Skip to content

Commit 8d7c83b

Browse files
committed
feat: route app alerts through UI facade
1 parent a81853e commit 8d7c83b

35 files changed

Lines changed: 191 additions & 71 deletions

File tree

+labkit/+ui/+app/showAlert.m

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
function shown = showAlert(fig, message, titleText)
2+
%SHOWALERT Show an app alert, or record it during hidden GUI tests.
3+
%
4+
% App-facing contract:
5+
% shown = labkit.ui.app.showAlert(fig, message, titleText)
6+
%
7+
% Inputs:
8+
% fig - app uifigure that owns the alert.
9+
% message - user-facing alert message owned by the calling app.
10+
% titleText - user-facing alert title owned by the calling app.
11+
%
12+
% Output:
13+
% shown - true when a modal alert was shown, false when hidden GUI test
14+
% mode recorded the alert without opening a modal dialog.
15+
16+
if nargin < 3
17+
titleText = "";
18+
end
19+
recordAlert(fig, message, titleText);
20+
if isHiddenGuiTestMode()
21+
traceAlert(fig, message, titleText, "skipped-hidden-gui");
22+
shown = false;
23+
return;
24+
end
25+
traceAlert(fig, message, titleText, "shown");
26+
uialert(fig, message, titleText);
27+
shown = true;
28+
end
29+
30+
function tf = isHiddenGuiTestMode()
31+
tf = string(getenv('LABKIT_GUI_TEST_MODE')) == "hidden";
32+
end
33+
34+
function recordAlert(fig, message, titleText)
35+
if isempty(fig) || ~isvalid(fig)
36+
return;
37+
end
38+
alert = struct( ...
39+
'title', string(titleText), ...
40+
'message', string(message));
41+
if isappdata(fig, 'labkitUiAlerts')
42+
alerts = getappdata(fig, 'labkitUiAlerts');
43+
alerts(end + 1, 1) = alert;
44+
else
45+
alerts = alert;
46+
end
47+
setappdata(fig, 'labkitUiAlerts', alerts);
48+
end
49+
50+
function traceAlert(fig, message, titleText, reason)
51+
if isempty(fig) || ~isvalid(fig) || ~isappdata(fig, 'labkitUiDebugContext')
52+
return;
53+
end
54+
debug = getappdata(fig, 'labkitUiDebugContext');
55+
if isstruct(debug) && isfield(debug, 'trace') && isa(debug.trace, 'function_handle')
56+
debug.trace('alert', sprintf('%s: %s', char(string(titleText)), ...
57+
char(string(message))), reason);
58+
end
59+
end

+labkit/+ui/version.m

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@
1212
% compatible contract ranges implemented by this code, contract status,
1313
% and a short maintainer note.
1414

15-
info = labkit.contract.versionInfo("ui", "3.2.7", ">=3.0 <4", ...
16-
"stable", "UI 3.x app/spec/view/tool/diag contract with compact single-file panels, toolPanel file entry helpers, filePanel title context, debug trace, close guard, crash reports, output folder prompts, and default text fitting.");
15+
info = labkit.contract.versionInfo("ui", "3.2.8", ">=3.0 <4", ...
16+
"stable", "UI 3.x app/spec/view/tool/diag contract with compact single-file panels, toolPanel file entry helpers, filePanel title context, debug trace, hidden-test-safe alerts, close guard, crash reports, output folder prompts, and default text fitting.");
1717
end

.agents/migration_guide.md

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,10 @@ GUI workflow acceptance validation migration
4646
Current facts:
4747

4848
- MATLAB source inventory from tracked files:
49-
- total: 695 `.m` files, 58,328 lines
49+
- total: 696 `.m` files, 58,443 lines
5050
- `apps/`: 405 files, 23,817 lines, max 646 lines
51-
- `+labkit/`: 173 files, 16,410 lines, max 647 lines
52-
- `tests/`: 114 files, 15,824 lines, max 649 lines
51+
- `+labkit/`: 174 files, 16,469 lines, max 647 lines
52+
- `tests/`: 114 files, 15,880 lines, max 649 lines
5353
- `labkit_launcher.m`: 1,722 lines and intentionally exempt
5454
- Tracked files over the 650-line repository file budget:
5555
`labkit_launcher.m` only, by design, because it is the self-contained repair
@@ -97,6 +97,8 @@ Current facts:
9797
- App-owned save dialogs that need safe output-file handling use
9898
`labkit.ui.app.promptOutputFile`; app-owned output-folder dialogs now use
9999
`labkit.ui.app.promptOutputFolder`.
100+
- App-owned alerts use `labkit.ui.app.showAlert`, preserving normal modal
101+
behavior while recording and skipping the modal in hidden GUI test mode.
100102
- Package-root app runners that catch `MException` and continue report through
101103
the framework debug context before alerts or recovery logs.
102104
- Image workflow apps keep run/export/measurement task snapshots and
@@ -253,8 +255,8 @@ Completion criteria:
253255

254256
Objective:
255257

256-
Remove OS-dialog and callback edges that block hidden workflow acceptance tests
257-
while preserving normal public app behavior.
258+
Remove callback edges that block hidden workflow acceptance tests while
259+
preserving normal public app behavior.
258260

259261
Target shape:
260262

@@ -266,6 +268,8 @@ Target shape:
266268
`labkit.ui.app.promptOutputFolder(titleText, defaultFolder, "Chooser", f)`.
267269
- App-specific filenames, filters, export schemas, and user-facing messages
268270
stay app-owned.
271+
- App-specific alert titles and messages stay app-owned while modal mechanics
272+
route through `labkit.ui.app.showAlert`.
269273
- Caught callback exceptions that continue must reach the framework debug
270274
context before alerts or recovery logs are shown.
271275
- UI numeric control values must be normalized to finite scalar app state or
@@ -275,27 +279,19 @@ Target shape:
275279

276280
Current problem edges:
277281

278-
- Modal `uialert` is app-owned and expected, but workflow tests need an
279-
injectable or traceable way to avoid stalls where the path can be reached
280-
noninteractively.
281282
- Broad direct numeric UI control assignment audits still belong to app-local
282283
runner cleanup when those callbacks are touched.
283284

284285
Workstreams:
285286

286-
1. Audit modal alert paths that hidden workflow tests can reach. Add test hooks
287-
only where a real hidden GUI test would otherwise block; do not add fake app
288-
APIs that ordinary users can see or need.
289-
2. Continue auditing direct numeric UI control assignments during runner
287+
1. Continue auditing direct numeric UI control assignments during runner
290288
cleanup; promote only if multiple apps prove the same domain-neutral API is
291289
needed.
292-
3. Use the Route A helper rubric while doing this work. Do not solve a dialog
290+
2. Use the Route A helper rubric while doing this work. Do not solve a dialog
293291
migration by adding many single-use one-line wrappers.
294292

295293
Completion criteria:
296294

297-
- Hidden GUI workflow tests can avoid modal alert stalls without changing
298-
normal public app behavior.
299295
- Remaining direct numeric UI state assignments found during runner cleanup are
300296
finite-scalar normalized before reaching app state or task structs.
301297

@@ -311,8 +307,9 @@ proof. Correctness stays in app-owned GUI-free unit tests.
311307
Dependency:
312308

313309
Do not broadly roll out this route before Route B has a working folder-prompt
314-
hook and a clear modal-edge policy. Otherwise workflow tests will either stall
315-
on OS dialogs or grow app-specific test shims that later need removal.
310+
hook, hidden-test-safe alert helper, and a clear remaining-modal-edge policy.
311+
Otherwise workflow tests will either stall on OS dialogs or grow app-specific
312+
test shims that later need removal.
316313

317314
Target shape:
318315

apps/AGENTS.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ Apps are first-class deliverables. Do not treat them as examples for a hidden pl
1313

1414
## App Ownership
1515

16-
- Keep domain formulas, thresholds, integration rules, option defaults, plot labels, result fields, export columns, failed-row behavior, alerts, and log wording app-local unless the user explicitly approves a boundary change.
16+
- Keep domain formulas, thresholds, integration rules, option defaults, plot labels, result fields, export columns, failed-row behavior, alert wording/trigger decisions, and log wording app-local unless the user explicitly approves a boundary change.
1717
- For a new app cold start, create the standard app shape directly and use the
1818
smallest genuinely similar app as a reference. Keep the first version focused
1919
on the real workflow rather than placeholder behavior.
20-
- When a documented UI tool owns app-neutral controls or interaction mechanics, consume it instead of reimplementing widget state or normalization. Keep app calculations, summaries, alerts, and exports local.
20+
- When a documented UI tool owns app-neutral controls or interaction mechanics, consume it instead of reimplementing widget state or normalization. Keep app calculations, summaries, alert text, and exports local.
2121
- Use `labkit.ui.app.create` with `labkit.ui.spec.*` for app GUIs. Do not
2222
reintroduce the removed `labkit.ui.app.createShell` or legacy view helpers.
2323
- Use `labkit.ui.app.dispatchRequest` for debug launch routing and `labkit.ui.diag.createContext` only when an app has an app-specific nonstandard request path.
@@ -33,6 +33,9 @@ Apps are first-class deliverables. Do not treat them as examples for a hidden pl
3333
`debug.reportException(component, event, ME)` before showing an alert,
3434
logging a recovery message, or returning. Do not swallow import, export,
3535
preview, or tool errors without a framework debug/crash report.
36+
- App alerts must call `labkit.ui.app.showAlert(fig, message, titleText)`
37+
instead of raw `uialert`. Apps still own the title, message, and decision to
38+
alert; the framework owns hidden-test-safe modal mechanics.
3639
- Apps with custom preview scroll, drawing, ROI, scale-bar, or other axes interaction should create a `labkit.ui.tool.createRuntime` and pass that runtime into reusable tools. Do not set preview-tool `WindowScrollWheelFcn`, `WindowButtonMotionFcn`, `WindowButtonUpFcn`, or axes `ButtonDownFcn` directly in app code.
3740
- DTA-backed apps use `labkit.dta.*` for discovery, loading, pulse detection, and parsed curve/table access. Task queues, duplicate policy, current selection, analysis state, and export workflow stay app-owned.
3841
- RHS-backed apps use `labkit.rhs.*` for discovery, header inspection,
@@ -50,7 +53,7 @@ Apps are first-class deliverables. Do not treat them as examples for a hidden pl
5053
shared `+app` package name creates MATLAB package-resolution ambiguity.
5154
- Apps put the ordinary data-only spec in `+<app_slug>/+ui/buildSpec.m`.
5255
The public app entry point delegates to package-root `run.m`; that runner
53-
owns state, callback closures, alerts, log wording, and refresh order.
56+
owns state, callback closures, alert wording, log wording, and refresh order.
5457
`buildSpec.m` describes controls, sections, workspace, initial text/defaults,
5558
and callback handles only.
5659
- Package-root `run.m` files are allowed to contain app lifecycle

apps/dic/dic_postprocess/+dic_postprocess/run.m

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,12 @@ function onMaskCleared()
117117

118118
function onGenerate(~, ~)
119119
if strlength(S.matPath) == 0 || isempty(S.referenceImage) || isempty(S.maskImage)
120-
uialert(fig, 'Load the DIC MAT file, reference image, and mask image first.', ...
120+
labkit.ui.app.showAlert(fig, 'Load the DIC MAT file, reference image, and mask image first.', ...
121121
'Missing inputs');
122122
return;
123123
end
124124
if edMax.Value <= edMin.Value
125-
uialert(fig, 'Color max must be greater than color min.', 'Invalid color range');
125+
labkit.ui.app.showAlert(fig, 'Color max must be greater than color min.', 'Invalid color range');
126126
return;
127127
end
128128

@@ -132,14 +132,14 @@ function onGenerate(~, ~)
132132
addLog('Generated EXX/EYY overlays and ROI summary.');
133133
catch ME
134134
debugLog.reportException('dicPostprocess', 'Generate failed', ME);
135-
uialert(fig, ME.message, 'DIC postprocess error');
135+
labkit.ui.app.showAlert(fig, ME.message, 'DIC postprocess error');
136136
addLog(sprintf('Generate failed: %s', ME.message));
137137
end
138138
end
139139

140140
function onSaveOverlays(~, ~)
141141
if isempty(S.overlayExx) || isempty(S.overlayEyy)
142-
uialert(fig, 'Generate overlays before saving.', 'Save overlays');
142+
labkit.ui.app.showAlert(fig, 'Generate overlays before saving.', 'Save overlays');
143143
return;
144144
end
145145

@@ -160,7 +160,7 @@ function onSaveOverlays(~, ~)
160160

161161
function onExportSummary(~, ~)
162162
if isempty(S.summaryTable) || height(S.summaryTable) == 0
163-
uialert(fig, 'Generate a summary before exporting.', 'Export summary');
163+
labkit.ui.app.showAlert(fig, 'Generate a summary before exporting.', 'Export summary');
164164
return;
165165
end
166166

@@ -192,7 +192,7 @@ function onOptionsChanged(~, ~)
192192
function renderOverlays(showAlerts)
193193
if edMax.Value <= edMin.Value
194194
if showAlerts
195-
uialert(fig, 'Color max must be greater than color min.', 'Invalid color range');
195+
labkit.ui.app.showAlert(fig, 'Color max must be greater than color min.', 'Invalid color range');
196196
end
197197
return;
198198
end

apps/dic/dic_postprocess/+dic_postprocess/version.m

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@
88
"name", "labkit_DICPostprocess_app", ...
99
"displayName", "DIC Postprocess", ...
1010
"family", "DIC", ...
11-
"version", "1.2.2", ...
11+
"version", "1.2.3", ...
1212
"updated", "2026-06-30");
1313
end

apps/dic/dic_preprocess/+dic_preprocess/+ui/alertIfMissingImagePair.m

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77

88
shown = ~dic_preprocess.state.hasImagePair(S);
99
if shown
10-
uialert(fig, messageText, titleText);
10+
labkit.ui.app.showAlert(fig, messageText, titleText);
1111
end
1212
end

apps/dic/dic_preprocess/+dic_preprocess/run.m

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ function onAlign(~, ~)
110110
addLog('Opening point selector. Choose matching points, then accept.');
111111
[movingPoints, fixedPoints] = cpselect(S.currentMovingImage, S.currentReferenceImage, 'Wait', true);
112112
if size(movingPoints, 1) < 2
113-
uialert(fig, 'Rigid registration requires at least two point pairs.', 'Not enough points');
113+
labkit.ui.app.showAlert(fig, 'Rigid registration requires at least two point pairs.', 'Not enough points');
114114
addLog('Alignment cancelled: fewer than two point pairs.');
115115
return;
116116
end
@@ -139,7 +139,7 @@ function onAutoAlign(~, ~)
139139
[alignedImage, tform, method] = dic_preprocess.ops.autoAlignMovingToReference( ...
140140
S.currentReferenceImage, S.currentMovingImage);
141141
catch err
142-
uialert(fig, sprintf('Automatic alignment failed:\n%s', err.message), 'Auto align failed');
142+
labkit.ui.app.showAlert(fig, sprintf('Automatic alignment failed:\n%s', err.message), 'Auto align failed');
143143
addLog(sprintf('Automatic alignment failed: %s', err.message));
144144
return;
145145
end
@@ -183,7 +183,7 @@ function onStartCropRoi(~, ~)
183183

184184
function onApplyCropRoi(~, ~)
185185
if isempty(S.cropRoiTop) || ~isvalid(S.cropRoiTop)
186-
uialert(fig, 'Start a crop ROI before applying the crop.', 'No active ROI');
186+
labkit.ui.app.showAlert(fig, 'Start a crop ROI before applying the crop.', 'No active ROI');
187187
return;
188188
end
189189

@@ -227,7 +227,7 @@ function onCropRoiMoved(~, evt)
227227

228228
function onUndoEdit(~, ~)
229229
if isempty(S.history)
230-
uialert(fig, 'No align or crop operation is available to undo.', 'Undo');
230+
labkit.ui.app.showAlert(fig, 'No align or crop operation is available to undo.', 'Undo');
231231
return;
232232
end
233233

@@ -245,7 +245,7 @@ function onUndoEdit(~, ~)
245245

246246
function onResetToOriginals(~, ~)
247247
if isempty(S.referenceImage) || isempty(S.movingImage)
248-
uialert(fig, 'Load both images before resetting the working pair.', 'Reset');
248+
labkit.ui.app.showAlert(fig, 'Load both images before resetting the working pair.', 'Reset');
249249
return;
250250
end
251251
pushHistory('reset to originals');
@@ -280,7 +280,7 @@ function onSaveCurrentImages(~, ~)
280280

281281
function onStartMaskEdit(~, ~)
282282
if isempty(S.currentReferenceImage)
283-
uialert(fig, 'Load a reference image before drawing an ROI mask.', 'Missing image');
283+
labkit.ui.app.showAlert(fig, 'Load a reference image before drawing an ROI mask.', 'Missing image');
284284
return;
285285
end
286286

@@ -403,7 +403,7 @@ function onSaveMask(~, ~)
403403
if isempty(S.maskImage)
404404
[boundaryMask, ok] = currentBoundaryMask(false);
405405
if ~ok
406-
uialert(fig, 'Draw a mask ROI or add a boundary to the mask canvas before saving.', 'Save ROI mask');
406+
labkit.ui.app.showAlert(fig, 'Draw a mask ROI or add a boundary to the mask canvas before saving.', 'Save ROI mask');
407407
return;
408408
end
409409
S.maskImage = boundaryMask;
@@ -445,7 +445,7 @@ function onSaveMask(~, ~)
445445
S.maskPoints, size(S.currentReferenceImage), ...
446446
S.maskBoundaryStyle, S.maskEditor);
447447
if ~ok && showAlert
448-
uialert(fig, 'Mask ROI needs at least three anchors.', 'Not enough anchors');
448+
labkit.ui.app.showAlert(fig, 'Mask ROI needs at least three anchors.', 'Not enough anchors');
449449
end
450450
end
451451

apps/dic/dic_preprocess/+dic_preprocess/version.m

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@
88
"name", "labkit_DICPreprocess_app", ...
99
"displayName", "DIC Preprocess", ...
1010
"family", "DIC", ...
11-
"version", "1.2.1", ...
11+
"version", "1.2.2", ...
1212
"updated", "2026-06-30");
1313
end

apps/electrochem/chrono_overlay/+chrono_overlay/run.m

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ function loadFiles(filepaths)
7676

7777
if ~isempty(failed)
7878
firstError = failed(1);
79-
uialert(fig, sprintf('Failed to load:\n%s\n\n%s', firstError.filepath, firstError.message), 'Load error');
79+
labkit.ui.app.showAlert(fig, sprintf('Failed to load:\n%s\n\n%s', firstError.filepath, firstError.message), 'Load error');
8080
end
8181
end
8282

@@ -131,13 +131,13 @@ function refreshPlots()
131131

132132
function onExportCSV(~, ~)
133133
if isempty(S.items)
134-
uialert(fig, 'No files loaded.', 'Export');
134+
labkit.ui.app.showAlert(fig, 'No files loaded.', 'Export');
135135
return;
136136
end
137137

138138
items = selectedItems();
139139
if isempty(items)
140-
uialert(fig, 'No files selected for export.', 'Export');
140+
labkit.ui.app.showAlert(fig, 'No files selected for export.', 'Export');
141141
return;
142142
end
143143

0 commit comments

Comments
 (0)