From c7dfa71f7b3b239de1f970817e7f3155090978d8 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Tue, 4 Aug 2026 10:25:06 -0500 Subject: [PATCH 01/12] feat: export ecg analysis timetable --- AGENTS.md | 5 +- .../+ecg_print/+analysisRun/analyze.m | 1 + .../+resultFiles/analysisRegionTimetable.m | 121 ++++++++++++++++++ .../+resultFiles/exportAnalysisRegionFile.m | 61 +++++++++ .../exportAnalysisRegionWorkspace.m | 27 ++++ .../+ecg_print/+resultFiles/layoutSection.m | 6 + .../+ecg_print/+resultFiles/present.m | 6 +- .../ecg_print/+ecg_print/definition.m | 2 +- .../ecg_print/+ecg_print/projectSpec.m | 10 +- docs/apps/README.md | 2 +- docs/apps/wearable/ecg-print/README.md | 14 +- ...04-ecg-analysis-region-timetable-export.md | 63 +++++++++ .../ecg_print/project/EcgPrintProjectSpec.m | 14 +- .../resultFiles/EcgPrintResultSpec.m | 34 +++++ .../workbench/EcgPrintWorkflowSpec.m | 22 +++- tests/specs/repository/TestArchitectureSpec.m | 14 +- 16 files changed, 387 insertions(+), 15 deletions(-) create mode 100644 apps/wearable/ecg_print/+ecg_print/+resultFiles/analysisRegionTimetable.m create mode 100644 apps/wearable/ecg_print/+ecg_print/+resultFiles/exportAnalysisRegionFile.m create mode 100644 apps/wearable/ecg_print/+ecg_print/+resultFiles/exportAnalysisRegionWorkspace.m create mode 100644 docs/history/records/2026/08/LK-20260804-ecg-analysis-region-timetable-export.md diff --git a/AGENTS.md b/AGENTS.md index c2e474de5..fb354b9fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,10 @@ under `docs/`. or `str2func` only at a genuinely dynamic extension or compatibility boundary with closed input validation, explicit ownership, and contract tests; never construct a callable symbol from untrusted project or user - data. + data. `assignin` is permitted only for an explicit result export to the + literal `base` workspace and a literal MATLAB variable name, with a + data-shaped value and contract tests; never use it to inject runtime + objects, handles, callbacks, or dynamically named state. - File budgets count nonblank, non-comment MATLAB code. They are review backstops, not extraction targets. Keep callback-local glue local when that makes workflow order clearer. diff --git a/apps/wearable/ecg_print/+ecg_print/+analysisRun/analyze.m b/apps/wearable/ecg_print/+ecg_print/+analysisRun/analyze.m index a0c5f5a68..e5eed1007 100644 --- a/apps/wearable/ecg_print/+ecg_print/+analysisRun/analyze.m +++ b/apps/wearable/ecg_print/+ecg_print/+analysisRun/analyze.m @@ -31,6 +31,7 @@ "segmentCount", size(cache.segments.values, 2), ... "summary", cache.measurements.summary, ... "perSegment", cache.measurements.perSegment); +applicationState.project.results.lastRegionExport = []; applicationState.project.results.lastSegmentExport = []; applicationState.project.results.lastWaveformExport = []; callbackContext.log("info", "ecg_print.analysisrun.analyze.completed", sprintf( ... diff --git a/apps/wearable/ecg_print/+ecg_print/+resultFiles/analysisRegionTimetable.m b/apps/wearable/ecg_print/+ecg_print/+resultFiles/analysisRegionTimetable.m new file mode 100644 index 000000000..94d03049f --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+resultFiles/analysisRegionTimetable.m @@ -0,0 +1,121 @@ +function region = analysisRegionTimetable(cache) +%ANALYSISREGIONTIMETABLE Build the most recent ECG ROI sample timetable. +% +% Usage: +% region = ecg_print.resultFiles.analysisRegionTimetable(cache) +% +% Description: +% Converts the raw and filtered signals from the most recent successful ECG +% analysis into one timetable. Row times match the analysis and preview time +% axis. SourceTimeSeconds retains the corresponding time in the decoded +% source when an ROI crop restarted analysis time at zero. DetectedPeak marks +% samples selected by the detector. +% +% Inputs: +% cache - Scalar ECG session cache containing signal, workingSignal, +% filteredSignal, and events from analyzeSignal. +% +% Outputs: +% region - Timetable with AnalysisTime row times and SourceTimeSeconds, +% RawSignal, FilteredSignal, and DetectedPeak variables. Time values are +% seconds. Signal variables retain the decoded channel unit in +% Properties.VariableUnits. Properties.UserData records channel, unit, +% sample rate, and requested source range. +% +% Errors: +% ecg_print:resultFiles:NoAnalysisRegion - No completed analysis is present. +% ecg_print:resultFiles:MismatchedAnalysisRegion - Raw and filtered sample +% vectors do not share one time base. +% ecg_print:resultFiles:InvalidEventIndex - A detected event does not name a +% sample in the current analysis region. +% +% See also ecg_print.analysisRun.analyzeSignal, timetable + + requireAnalysis(cache); + working = cache.workingSignal; + filtered = cache.filteredSignal; + analysisTime = double(working.time(:)); + raw = working.values(:); + filteredValues = filtered.values(:); + filteredTime = double(filtered.time(:)); + if numel(raw) ~= numel(analysisTime) || ... + numel(filteredValues) ~= numel(analysisTime) || ... + ~isequaln(filteredTime, analysisTime) + error("ecg_print:resultFiles:MismatchedAnalysisRegion", ... + "Raw and filtered ECG analysis samples must share one time base."); + end + + sourceTime = sourceTimes(cache, analysisTime); + detectedPeak = false(numel(analysisTime), 1); + if isfield(cache, "events") && ~isempty(cache.events) && ... + isfield(cache.events, "index") + indices = double(cache.events.index(:)); + if any(~isfinite(indices) | indices ~= round(indices) | ... + indices < 1 | indices > numel(analysisTime)) + error("ecg_print:resultFiles:InvalidEventIndex", ... + "Detected ECG events must index the current analysis region."); + end + detectedPeak(indices) = true; + end + + region = timetable(seconds(analysisTime), sourceTime, raw, ... + filteredValues, detectedPeak, VariableNames={ ... + 'SourceTimeSeconds', 'RawSignal', 'FilteredSignal', 'DetectedPeak'}); + region.Properties.DimensionNames{1} = 'AnalysisTime'; + unit = signalText(working, "unit"); + region.Properties.VariableUnits = {'s', char(unit), char(unit), ''}; + region.Properties.Description = ... + "Samples from the most recent ECG Print analysis region"; + region.Properties.UserData = struct( ... + "Channel", signalText(working, "displayName"), ... + "SignalUnit", unit, ... + "SampleRateHz", double(working.fs), ... + "RequestedSourceTimeRangeSeconds", requestedRange(working, sourceTime)); +end + +function requireAnalysis(cache) + if ~isstruct(cache) || ~isscalar(cache) || ... + ~all(isfield(cache, ["workingSignal", "filteredSignal"])) || ... + isempty(cache.workingSignal) || isempty(cache.filteredSignal) + error("ecg_print:resultFiles:NoAnalysisRegion", ... + "Analyze an ECG signal before exporting its current region."); + end +end + +function sourceTime = sourceTimes(cache, analysisTime) + sourceTime = analysisTime; + working = cache.workingSignal; + if ~isfield(working, "metadata") || ... + ~isfield(working.metadata, "cropTimeRangeSec") || ... + ~isfield(cache, "signal") || isempty(cache.signal) + return; + end + range = double(working.metadata.cropTimeRangeSec(:).'); + originalTime = double(cache.signal.time(:)); + keep = originalTime >= range(1) & originalTime <= range(2); + selected = originalTime(keep); + if numel(selected) == numel(analysisTime) + sourceTime = selected; + end +end + +function range = requestedRange(signal, sourceTime) + if isfield(signal, "metadata") && ... + isfield(signal.metadata, "cropTimeRangeSec") + range = double(signal.metadata.cropTimeRangeSec(:).'); + elseif isempty(sourceTime) + range = zeros(1, 0); + else + range = [sourceTime(1) sourceTime(end)]; + end +end + +function value = signalText(signal, field) + value = ""; + if isfield(signal, field) + candidate = string(signal.(field)); + if isscalar(candidate) && ~ismissing(candidate) + value = candidate; + end + end +end diff --git a/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportAnalysisRegionFile.m b/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportAnalysisRegionFile.m new file mode 100644 index 000000000..7c9579c2f --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportAnalysisRegionFile.m @@ -0,0 +1,61 @@ +% App-owned implementation for exporting the most recent ECG analysis region. +% Expected caller: exportRegionFile button. Side effects: chooses and writes +% one MAT file plus its LabKit result manifest. +function applicationState = exportAnalysisRegionFile( ... + applicationState, callbackContext) +%EXPORTANALYSISREGIONFILE Write the current ECG ROI timetable to MAT. + if isempty(applicationState.session.cache.workingSignal) + callbackContext.alert( ... + "Analyze a signal before exporting its current region.", ... + "No analysis region"); + return; + end + chosen = callbackContext.chooseOutputFile( ... + ["*.mat", "MAT files (*.mat)"], "ecg_analysis_region.mat"); + if chosen.Cancelled + callbackContext.log("info", ... + "ecg_print.resultfiles.exportanalysisregionfile.cancelled", ... + "ECG ROI timetable export cancelled."); + return; + end + filepath = ensureMatExtension(string(chosen.Value)); + try + ecgAnalysisRegion = ecg_print.resultFiles.analysisRegionTimetable( ... + applicationState.session.cache); + save(filepath, "ecgAnalysisRegion"); + [folder, name, extension] = fileparts(filepath); + if strlength(string(folder)) == 0 + folder = pwd; + end + output = labkit.app.result.File( ... + "ecgAnalysisRegion", "primary", string(name) + string(extension), ... + MediaType="application/x-matlab-data"); + package = labkit.app.result.Package(Outputs={output}, ... + Inputs=applicationState.project.inputs, ... + Parameters=applicationState.project.parameters, ... + Summary=ecg_print.resultFiles.manifestSummary( ... + applicationState.project.results.lastAnalysis), ... + ManifestName="ecg_analysis_region.labkit.json"); + written = callbackContext.writeResultPackage(folder, package); + applicationState.project.results.lastRegionExport = struct( ... + "matPath", filepath, "manifestPath", string(written.Value)); + catch cause + callbackContext.log("error", ... + "ecg_print.resultfiles.exportanalysisregionfile.exception", ... + "Could not export the ECG ROI timetable MAT file", ... + Category="failure", Audience="developer", Exception=cause); + callbackContext.alert(cause.message, "Could not export ROI timetable"); + return; + end + callbackContext.log("info", ... + "ecg_print.resultfiles.exportanalysisregionfile.completed", ... + sprintf("Exported %d ECG ROI samples to a MAT file.", ... + height(ecgAnalysisRegion))); +end + +function filepath = ensureMatExtension(filepath) + [folder, name, extension] = fileparts(filepath); + if strlength(string(extension)) == 0 + filepath = string(fullfile(folder, name + ".mat")); + end +end diff --git a/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportAnalysisRegionWorkspace.m b/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportAnalysisRegionWorkspace.m new file mode 100644 index 000000000..01d26ed7d --- /dev/null +++ b/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportAnalysisRegionWorkspace.m @@ -0,0 +1,27 @@ +% App-owned dynamic boundary for exporting one fixed, validated ECG result to +% the MATLAB base workspace. Expected caller: exportRegionWorkspace button. +function applicationState = exportAnalysisRegionWorkspace( ... + applicationState, callbackContext) +%EXPORTANALYSISREGIONWORKSPACE Assign the current ECG ROI timetable. + try + ecgAnalysisRegion = ecg_print.resultFiles.analysisRegionTimetable( ... + applicationState.session.cache); + % The literal base workspace and variable name keep this result export + % statically reviewable; only the validated timetable crosses it. + assignin("base", "ecgAnalysisRegion", ecgAnalysisRegion); + catch cause + callbackContext.log("error", ... + "ecg_print.resultfiles.exportanalysisregionworkspace.exception", ... + "Could not export the ECG ROI timetable to the workspace", ... + Category="failure", Audience="developer", Exception=cause); + callbackContext.alert(cause.message, "Could not export ROI timetable"); + return; + end + callbackContext.log("info", ... + "ecg_print.resultfiles.exportanalysisregionworkspace.completed", ... + sprintf("Assigned %d ECG ROI samples to ecgAnalysisRegion.", ... + height(ecgAnalysisRegion))); + callbackContext.alert( ... + "Assigned the current ECG ROI timetable to ecgAnalysisRegion.", ... + "ROI timetable exported"); +end diff --git a/apps/wearable/ecg_print/+ecg_print/+resultFiles/layoutSection.m b/apps/wearable/ecg_print/+ecg_print/+resultFiles/layoutSection.m index 159f713b6..5625d7af2 100644 --- a/apps/wearable/ecg_print/+ecg_print/+resultFiles/layoutSection.m +++ b/apps/wearable/ecg_print/+ecg_print/+resultFiles/layoutSection.m @@ -1,6 +1,12 @@ % App-owned implementation for ecg_print.resultFiles.layoutSection within the ecg_print product workflow. function section = layoutSection() section = labkit.app.layout.section("exportSection", "Exports", { ... + labkit.app.layout.button("exportRegionWorkspace", "Export ROI timetable to workspace", ... + @ecg_print.resultFiles.exportAnalysisRegionWorkspace, ... + Tooltip="Assign the raw, filtered, and detected-peak samples from the most recent ECG ROI analysis to ecgAnalysisRegion."), ... + labkit.app.layout.button("exportRegionFile", "Export ROI timetable MAT", ... + @ecg_print.resultFiles.exportAnalysisRegionFile, ... + Tooltip="Save the raw, filtered, and detected-peak samples from the most recent ECG ROI analysis as a MATLAB timetable."), ... labkit.app.layout.button("exportSegments", "Export segment SNR CSV", @ecg_print.resultFiles.exportSegments, ... Tooltip="Export beat-segment SNR measurements from the most recent ECG ROI analysis."), ... labkit.app.layout.button("exportWaveform", "Export waveform PNG", @ecg_print.resultFiles.exportWaveform, ... diff --git a/apps/wearable/ecg_print/+ecg_print/+resultFiles/present.m b/apps/wearable/ecg_print/+ecg_print/+resultFiles/present.m index 6ca3eb409..d8523728e 100644 --- a/apps/wearable/ecg_print/+ecg_print/+resultFiles/present.m +++ b/apps/wearable/ecg_print/+ecg_print/+resultFiles/present.m @@ -1,4 +1,8 @@ % App-owned implementation for ecg_print.resultFiles.present within the ecg_print product workflow. function view = present(hasMeasurements, hasWaveform) -view = labkit.app.view.Snapshot().enabled("exportSegments", hasMeasurements).enabled("exportWaveform", hasWaveform); +view = labkit.app.view.Snapshot() ... + .enabled("exportRegionWorkspace", hasWaveform) ... + .enabled("exportRegionFile", hasWaveform) ... + .enabled("exportSegments", hasMeasurements) ... + .enabled("exportWaveform", hasWaveform); end diff --git a/apps/wearable/ecg_print/+ecg_print/definition.m b/apps/wearable/ecg_print/+ecg_print/definition.m index 4811f5fbe..2c8f49faa 100644 --- a/apps/wearable/ecg_print/+ecg_print/definition.m +++ b/apps/wearable/ecg_print/+ecg_print/definition.m @@ -5,7 +5,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_ECGPrint_app", AppId="ecg_print", ... Title="ECG Signal Print + SNR Explorer", DisplayName="ECG Print", ... - Family="Wearable", AppVersion="1.6.1", Updated="2026-07-30", ... + Family="Wearable", AppVersion="1.6.2", Updated="2026-08-04", ... Requirements=labkit.contract.requirements( ... "app", ">=2 <3", "biosignal", ">=1.0 <2"), ... ProjectSchema=ecg_print.projectSpec(), ... diff --git a/apps/wearable/ecg_print/+ecg_print/projectSpec.m b/apps/wearable/ecg_print/+ecg_print/projectSpec.m index 1c7138e65..ba38c985c 100644 --- a/apps/wearable/ecg_print/+ecg_print/projectSpec.m +++ b/apps/wearable/ecg_print/+ecg_print/projectSpec.m @@ -1,8 +1,8 @@ % App-owned durable ECG Print contract. The App SDK applies the single -% migration entry until version 2, then validates sources, parameters, and +% migration entries until version 3, then validates sources, parameters, and % compact result records before rebuilding decoded signal state. function spec = projectSpec() - spec = labkit.app.project.Schema(Version=2, ... + spec = labkit.app.project.Schema(Version=3, ... Create=@createProject, Validate=@validateProject, Migrate=@migrateProject, ... SourceBindings="inputs.sources"); end @@ -22,6 +22,7 @@ project.annotations = struct(); project.results = struct( ... "lastAnalysis", struct(), ... + "lastRegionExport", [], ... "lastSegmentExport", [], ... "lastWaveformExport", []); project.extensions = struct(); @@ -32,6 +33,8 @@ case 1 project.inputs.sources = project.inputs.source; project.inputs = rmfield(project.inputs, 'source'); + case 2 + project.results.lastRegionExport = []; otherwise error('ecg_print:UnsupportedProjectMigration', ... 'ECG Print cannot migrate project version %d.', ... @@ -66,7 +69,8 @@ ["Pan-Tompkins", "Local peaks", "QRS streaming"]), ... 'ecg_print:InvalidProject', 'ECG peak method is invalid.'); assert(all(isfield(project.results, ... - {'lastAnalysis', 'lastSegmentExport', 'lastWaveformExport'})) && ... + {'lastAnalysis', 'lastRegionExport', 'lastSegmentExport', ... + 'lastWaveformExport'})) && ... isstruct(project.results.lastAnalysis) && ... isscalar(project.results.lastAnalysis), ... 'ecg_print:InvalidProject', 'ECG result state is invalid.'); diff --git a/docs/apps/README.md b/docs/apps/README.md index 4167bf172..704e16f7b 100644 --- a/docs/apps/README.md +++ b/docs/apps/README.md @@ -49,7 +49,7 @@ interactive and programmatic surface. | Detect trains and measure neural responses | [Nerve Response Analysis](neurophysiology/nerve-response-analysis/README.md) | Filter record and protocol JSON | Analysis JSON and CAP metrics | | Review aligned responses and statistics | [Response Review and Stats](neurophysiology/response-review-stats/README.md) | Analysis JSON or segment CSV | Metrics CSV and summary | | Compare multiple groups with the first using t-tests and one mean/SD plot | [T-Test Wizard](statistics/ttest-wizard/README.md) | CSV, TSV, workbook, or entered values | Result family, CSVs, and comparison plot | -| Inspect ECG and measure segment SNR | [ECG Print](wearable/ecg-print/README.md) | MAT or delimited table | Segment SNR CSV and waveform image | +| Inspect ECG and measure segment SNR | [ECG Print](wearable/ecg-print/README.md) | MAT or delimited table | ROI timetable, segment SNR CSV, and waveform image | ## Browse By Family diff --git a/docs/apps/wearable/ecg-print/README.md b/docs/apps/wearable/ecg-print/README.md index 2e1c38815..7075e29b7 100644 --- a/docs/apps/wearable/ecg-print/README.md +++ b/docs/apps/wearable/ecg-print/README.md @@ -2,8 +2,8 @@ ECG Print reads a wearable recording, filters one channel, detects beats, builds event-centered segments and a representative template, and reports -signal quality over time. It can export the segment measurements and a -printable waveform image. +signal quality over time. It can export the analyzed sample region as a +MATLAB timetable, the segment measurements, and a printable waveform image. ## Open ECG Print @@ -94,6 +94,16 @@ Peak polarity is selected automatically. The default detector threshold is ## Output Files +**Export ROI timetable to workspace** assigns `ecgAnalysisRegion` in the +MATLAB base workspace. **Export ROI timetable MAT** saves the same timetable +as `ecgAnalysisRegion` in `ecg_analysis_region.mat` and writes a matching +`ecg_analysis_region.labkit.json` manifest. Both commands use the most recent +successful analysis, not unapplied control edits. Timetable row times match +the analysis preview; its columns retain source time in seconds, raw and +filtered channel samples, and a logical detected-peak marker. Channel name, +signal unit, sample rate, and requested source range are stored in timetable +metadata. + **Export segment SNR CSV** writes `ecg_segment_snr.csv` and a matching `ecg_segment_snr.labkit.json` manifest. The CSV contains per-segment measurements and smoothed trends. diff --git a/docs/history/records/2026/08/LK-20260804-ecg-analysis-region-timetable-export.md b/docs/history/records/2026/08/LK-20260804-ecg-analysis-region-timetable-export.md new file mode 100644 index 000000000..9c65eb0b0 --- /dev/null +++ b/docs/history/records/2026/08/LK-20260804-ecg-analysis-region-timetable-export.md @@ -0,0 +1,63 @@ +# ECG Print exports the analyzed region as a timetable + +```labkit-change +id: LK-20260804-ecg-analysis-region-timetable-export +date: 2026-08-04 +sequence: 173 +type: feat +compatibility: compatible +component: `labkit_ECGPrint_app` | `1.6.1 -> 1.6.2` +scope: Current analysis-region timetable export +``` + +## Context + +ECG Print exposed the current waveform visually and could export segment SNR +measurements or a rendered image, but it did not provide the sample-aligned raw +and filtered analysis region for continued MATLAB work. + +## Decision and rationale + +Build one App-owned timetable from the most recent successful analysis cache +and use it for both export paths. Keep preview-relative analysis time as row +times, retain absolute decoded source time as a column, and include the raw +signal, filtered signal, and detected-peak mask. This preserves the scientific +selector and units without rerunning analysis or silently applying edited +controls. The fixed base-workspace assignment is a narrowly reviewed dynamic +boundary; it does not expand the shared App SDK for one consumer. + +## Changes + +- The Exports section adds actions for the MATLAB base workspace and a MAT + file. +- Both actions produce `ecgAnalysisRegion`, including channel, unit, sample +rate, and requested source-range metadata. +- The MAT export writes the normal LabKit result manifest beside the data. + +## User and data impact + +Users can continue analysis directly from the currently displayed ECG region +without reconstructing its crop, filter result, or peak indices. Existing +projects and existing CSV and PNG exports are unchanged. + +## Compatibility and migration + +The project schema adds an empty-or-compact MAT export record; existing +projects migrate by adding that field without changing analysis inputs or +results. The new timetable and buttons are otherwise additive. Repository +policy permits this kind of base-workspace export when both workspace and +variable names are literal and the exported value is validated data rather +than a runtime object. + +## Validation + +Focused ECG result and hidden-GUI workflow specifications cover timetable +shape, time semantics, units, peak markers, workspace assignment, MAT +round-trip, and manifest creation. The repository architecture specification +guards the single reviewed workspace-write boundary. + +## Known limitations and follow-up + +Automated tests do not assess whether downstream user scripts prefer different +column labels or additional derived measurements. The export intentionally +contains analyzed samples and detector markers, not segment SNR metrics. diff --git a/tests/specs/apps/wearable/ecg_print/project/EcgPrintProjectSpec.m b/tests/specs/apps/wearable/ecg_print/project/EcgPrintProjectSpec.m index ef043b354..78024e907 100644 --- a/tests/specs/apps/wearable/ecg_print/project/EcgPrintProjectSpec.m +++ b/tests/specs/apps/wearable/ecg_print/project/EcgPrintProjectSpec.m @@ -13,7 +13,19 @@ function migratesVersionOneSourceIntoTheCanonicalCollection(testCase) testCase.verifyEqual(migrated.inputs.sources, expected); testCase.verifyFalse(isfield(migrated.inputs, "source")); - testCase.verifyEqual(spec.Version, 2); + testCase.verifyEqual(spec.Version, 3); + end + + function migratesVersionTwoWithAnEmptyRegionExportRecord(testCase) + spec = ecg_print.projectSpec(); + project = spec.Create(); + project.results = rmfield(project.results, "lastRegionExport"); + + migrated = spec.Migrate(project, 2); + + testCase.verifyTrue(isfield(migrated.results, "lastRegionExport")); + testCase.verifyEmpty(migrated.results.lastRegionExport); + testCase.verifyTrue(spec.Validate(migrated)); end function rejectsUnknownPeakMethodWithoutChangingSupportedProjects(testCase) diff --git a/tests/specs/apps/wearable/ecg_print/resultFiles/EcgPrintResultSpec.m b/tests/specs/apps/wearable/ecg_print/resultFiles/EcgPrintResultSpec.m index f5cc53e34..9ddc6b1fa 100644 --- a/tests/specs/apps/wearable/ecg_print/resultFiles/EcgPrintResultSpec.m +++ b/tests/specs/apps/wearable/ecg_print/resultFiles/EcgPrintResultSpec.m @@ -28,5 +28,39 @@ function keepsManifestSummarySmallAndSerializable(testCase) testCase.verifyEqual(summary.channel, "ECG"); testCase.verifyEqual(summary.eventCount, 4); end + + function buildsAnalysisRegionTimetableWithSourceTimeAndPeaks(testCase) + original = signal([10; 10.5; 11; 11.5; 12], ... + [1; 2; 3; 4; 5]); + working = signal([0; 0.5; 1], [2; 3; 4]); + working.metadata.cropTimeRangeSec = [10.25 11.75]; + filtered = working; + filtered.values = [20; 30; 40]; + cache = struct("signal", original, "workingSignal", working, ... + "filteredSignal", filtered, "events", struct("index", 2)); + + actual = ecg_print.resultFiles.analysisRegionTimetable(cache); + + testCase.verifyClass(actual, "timetable"); + testCase.verifyEqual(actual.Properties.DimensionNames{1}, ... + 'AnalysisTime'); + testCase.verifyEqual(seconds(actual.Properties.RowTimes), ... + [0; 0.5; 1]); + testCase.verifyEqual(actual.SourceTimeSeconds, [10.5; 11; 11.5]); + testCase.verifyEqual(actual.RawSignal, [2; 3; 4]); + testCase.verifyEqual(actual.FilteredSignal, [20; 30; 40]); + testCase.verifyEqual(actual.DetectedPeak, [false; true; false]); + testCase.verifyEqual(actual.Properties.VariableUnits, ... + {'s', 'mV', 'mV', ''}); + testCase.verifyEqual(actual.Properties.UserData.Channel, "ECG"); + testCase.verifyEqual( ... + actual.Properties.UserData.RequestedSourceTimeRangeSeconds, ... + [10.25 11.75]); + end end end + +function value = signal(time, samples) +value = struct("time", time, "values", samples, "fs", 2, ... + "displayName", "ECG", "unit", "mV", "metadata", struct()); +end diff --git a/tests/specs/apps/wearable/ecg_print/workbench/EcgPrintWorkflowSpec.m b/tests/specs/apps/wearable/ecg_print/workbench/EcgPrintWorkflowSpec.m index f6e2076e9..395e33444 100644 --- a/tests/specs/apps/wearable/ecg_print/workbench/EcgPrintWorkflowSpec.m +++ b/tests/specs/apps/wearable/ecg_print/workbench/EcgPrintWorkflowSpec.m @@ -9,9 +9,10 @@ function analyzesExportsAndRestoresASyntheticRecording(testCase) pack = ecg_print.syntheticInputs.writeSamplePack(context); segmentPath = context.outputPath("segments.csv"); waveformPath = context.outputPath("waveform.png"); + regionPath = context.outputPath("analysis_region.mat"); backend = struct( ... "chooseOutputFile", @(~, defaultPath) chooseOutput( ... - defaultPath, segmentPath, waveformPath), ... + defaultPath, segmentPath, waveformPath, regionPath), ... "alert", @(~, ~) []); definition = ecg_print.definition(); journal = labkittest.temporarySessionJournal(definition, folder); @@ -23,6 +24,10 @@ function analyzesExportsAndRestoresASyntheticRecording(testCase) runtime.applyControlValue("peakMethod", "Local peaks"); runtime.invokeAction("analyze"); + clearRegion = onCleanup(@() evalin( ... + "base", "clear ecgAnalysisRegion")); + runtime.invokeAction("exportRegionWorkspace"); + runtime.invokeAction("exportRegionFile"); runtime.invokeAction("exportSegments"); runtime.invokeAction("exportWaveform"); @@ -34,20 +39,31 @@ function analyzesExportsAndRestoresASyntheticRecording(testCase) end testCase.verifyTrue(isfile(segmentPath)); testCase.verifyTrue(isfile(waveformPath)); + testCase.verifyTrue(isfile(regionPath)); + workspaceRegion = evalin("base", "ecgAnalysisRegion"); + fileRegion = load(regionPath, "ecgAnalysisRegion"); + testCase.verifyClass(workspaceRegion, "timetable"); + testCase.verifyEqual(fileRegion.ecgAnalysisRegion, workspaceRegion); + testCase.verifyEqual(height(workspaceRegion), ... + numel(runtime.State.session.cache.workingSignal.time)); + testCase.verifyTrue(isfile( ... + runtime.State.project.results.lastRegionExport.manifestPath)); testCase.verifyTrue(isfile(runtime.State.project.results.lastSegmentExport.manifestPath)); testCase.verifyTrue(isfile(runtime.State.project.results.lastWaveformExport.manifestPath)); saved = fullfile(folder, "ecg-project.mat"); runtime.saveProject(runtime.State, saved); runtime.restoreProject(saved); testCase.verifyNotEmpty(runtime.State.session.cache.measurements); - clear cleanup + clear clearRegion cleanup end end end -function choice = chooseOutput(defaultPath, segmentPath, waveformPath) +function choice = chooseOutput(defaultPath, segmentPath, waveformPath, regionPath) if contains(string(defaultPath), "segment", IgnoreCase=true) choice = labkit.app.dialog.Choice(segmentPath); +elseif contains(string(defaultPath), "analysis_region", IgnoreCase=true) + choice = labkit.app.dialog.Choice(regionPath); else choice = labkit.app.dialog.Choice(waveformPath); end diff --git a/tests/specs/repository/TestArchitectureSpec.m b/tests/specs/repository/TestArchitectureSpec.m index aa8e3f9a2..e737c7214 100644 --- a/tests/specs/repository/TestArchitectureSpec.m +++ b/tests/specs/repository/TestArchitectureSpec.m @@ -110,6 +110,7 @@ function productionDynamicInvocationIsClosedAndOwned(testCase) "+labkit/+app/+internal/+launcher/createLauncher.m" "+labkit/+app/+internal/+native/private/FigureInteractionHub.m" "tools/profiling/profileLabKitTarget.m"]; + allowedCalls = ["feval(" "feval(" "feval("]; markers = [ ... "Dynamic extension boundary" "Compatibility boundary" @@ -120,10 +121,19 @@ function productionDynamicInvocationIsClosedAndOwned(testCase) calls = regexp(source, ... '(? Date: Tue, 4 Aug 2026 13:21:29 -0500 Subject: [PATCH 02/12] chore: standardize release titles --- .github/workflows/release.yml | 2 +- AGENTS.md | 2 +- docs/development/maintain-and-release/release.md | 5 ++++- .../08/LK-20260804-ecg-analysis-region-timetable-export.md | 7 +++++++ tests/specs/tests/labkittest/TestCatalogSpec.m | 4 ++++ 5 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d6a0dcce..14bdca8be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -119,7 +119,7 @@ jobs: "${asset}#labkit_launcher.m" \ --repo "$GITHUB_REPOSITORY" \ --verify-tag \ - --title "LabKit MATLAB Workbench ${RELEASE_TAG}" \ + --title "V${RELEASE_TAG#v}" \ --generate-notes \ --draft local_digest="sha256:$(cut -d' ' -f1 \ diff --git a/AGENTS.md b/AGENTS.md index fb354b9fa..39ec90df3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,7 +281,7 @@ explicit compliant squash subject; do not rely on GitHub defaults. once for the net change. Update rationale, compatibility, user impact, and evidence to describe the final PR diff rather than its commit sequence. - New release tags are `vX.Y.Z`; do not rename published legacy tags. Release - titles are `LabKit MATLAB Workbench vX.Y.Z` with relevant `Highlights`, + titles contain only `VX.Y.Z` with an uppercase `V` and relevant `Highlights`, `Fixes`, `Upgrade Note`, and `Validation` sections. - Start the manual `Release` workflow only after developer-led interactive App validation, successful required PR validation, and a successful lightweight diff --git a/docs/development/maintain-and-release/release.md b/docs/development/maintain-and-release/release.md index 57bae983e..7c47f8198 100644 --- a/docs/development/maintain-and-release/release.md +++ b/docs/development/maintain-and-release/release.md @@ -80,9 +80,12 @@ preparing a release. Use the release title format: ```text -LabKit MATLAB Workbench vX.Y.Z +VX.Y.Z ``` +The title contains only the uppercase `V` and three-part semantic version. +The Git tag remains lowercase `vX.Y.Z`. + Use this note structure: ```text diff --git a/docs/history/records/2026/08/LK-20260804-ecg-analysis-region-timetable-export.md b/docs/history/records/2026/08/LK-20260804-ecg-analysis-region-timetable-export.md index 9c65eb0b0..0584b05b0 100644 --- a/docs/history/records/2026/08/LK-20260804-ecg-analysis-region-timetable-export.md +++ b/docs/history/records/2026/08/LK-20260804-ecg-analysis-region-timetable-export.md @@ -56,6 +56,13 @@ shape, time semantics, units, peak markers, workspace assignment, MAT round-trip, and manifest creation. The repository architecture specification guards the single reviewed workspace-write boundary. +## Evidence + +- Seven focused ECG result, workflow, and project identities passed. +- The exact dynamic-invocation architecture identity passed. +- The hidden-GUI workflow verified workspace assignment, MAT round-trip, + manifest creation, project save, and project restore. + ## Known limitations and follow-up Automated tests do not assess whether downstream user scripts prefer different diff --git a/tests/specs/tests/labkittest/TestCatalogSpec.m b/tests/specs/tests/labkittest/TestCatalogSpec.m index 9526c5b4b..618c419e7 100644 --- a/tests/specs/tests/labkittest/TestCatalogSpec.m +++ b/tests/specs/tests/labkittest/TestCatalogSpec.m @@ -598,6 +598,10 @@ function releaseAssetIntegrityUsesDigestAndByteCount(testCase) ".size')"); testCase.verifyFalse(contains(workflow, ... "gh release verify-asset")); + testCase.verifySubstring(workflow, ... + '--title "V${RELEASE_TAG#v}"'); + testCase.verifyFalse(contains(workflow, ... + '--title "LabKit MATLAB Workbench')); end function explainChangedReportsClassificationAndExactEvidence(testCase) From 8bc12457f958d1415b8564c83ae22723dc3b7333 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Tue, 4 Aug 2026 19:20:32 -0500 Subject: [PATCH 03/12] feat: persist app error diagnostics --- .../+diagnostics/RuntimeDiagnostics.m | 29 +++-- .../+diagnostics/SessionDiagnosticBundle.m | 4 +- .../+diagnostics/SessionDiagnostics.m | 8 +- .../+diagnostics/SessionEventStream.m | 13 ++- .../+runtime/@RuntimeKernel/RuntimeKernel.m | 11 +- .../+runtime/@RuntimeKernel/completeBackend.m | 5 + +labkit/+app/CallbackContext.m | 11 +- +labkit/+app/version.m | 2 +- apps/AGENTS.md | 5 +- .../exportAnalysisRegionWorkspace.m | 2 +- .../ecg_print/+ecg_print/definition.m | 4 +- docs/apps/wearable/ecg-print/README.md | 3 +- docs/framework/README.md | 8 +- docs/framework/guides/runtime.md | 11 +- docs/getting-started/README.md | 6 +- ...0804-automatic-error-diagnostic-bundles.md | 95 ++++++++++++++++ .../workbench/EcgPrintWorkflowSpec.m | 3 + tests/specs/labkit/app/AppSdkSpec.m | 26 +++++ .../labkit/app/SessionDiagnosticBundleSpec.m | 104 ++++++++++++++++-- 19 files changed, 310 insertions(+), 40 deletions(-) create mode 100644 docs/history/records/2026/08/LK-20260804-automatic-error-diagnostic-bundles.md diff --git a/+labkit/+app/+internal/+diagnostics/RuntimeDiagnostics.m b/+labkit/+app/+internal/+diagnostics/RuntimeDiagnostics.m index 4cf2482d9..9caca016c 100644 --- a/+labkit/+app/+internal/+diagnostics/RuntimeDiagnostics.m +++ b/+labkit/+app/+internal/+diagnostics/RuntimeDiagnostics.m @@ -61,7 +61,7 @@ function setTraceCapture(obj, enabled) function destination = exportBundle( ... obj, destination, state, stateMode) if nargin < 4 - stateMode = "exact"; + stateMode = "compact"; end stateMode = validateStateMode(stateMode); operation = obj.Recorder.begin( ... @@ -80,24 +80,37 @@ function setTraceCapture(obj, enabled) end end + function destination = exportAfterErrorOnClose(obj, state) + destination = ""; + if ~obj.Recorder.hasErrorOrCriticalEvent() + return + end + try + destination = obj.exportBundle( ... + obj.automaticDestination("compact"), state, "compact"); + catch + % Diagnostic persistence must not change Runtime close semantics. + end + end + function destination = exportInteractive(obj, state) selection = obj.Context.chooseOption( ... "Every bundle contains complete sensitive logs and App " + ... "state. Compact MAT replaces supported state values over " + ... "1 MiB with structural synthetic placeholders.", ... - ["Complete bundle (exact MAT)", ... - "Complete bundle (compact synthetic MAT)", ... + ["Complete bundle (compact synthetic MAT)", ... + "Complete bundle (exact MAT)", ... "Cancel"], ... Title="Export Diagnostic Bundle", ... - DefaultChoice="Complete bundle (exact MAT)", ... + DefaultChoice="Complete bundle (compact synthetic MAT)", ... CancelChoice="Cancel"); if selection.Cancelled || selection.Value == "Cancel" destination = ""; return end - stateMode = "exact"; - if selection.Value == "Complete bundle (compact synthetic MAT)" - stateMode = "compact"; + stateMode = "compact"; + if selection.Value == "Complete bundle (exact MAT)" + stateMode = "exact"; end destination = ""; try @@ -131,7 +144,7 @@ function setTraceCapture(obj, enabled) function destination = exportTextFallback( ... obj, preferredDestination, cause, stateMode) if nargin < 4 - stateMode = "exact"; + stateMode = "compact"; end stateMode = validateStateMode(stateMode); obj.Recorder.log( ... diff --git a/+labkit/+app/+internal/+diagnostics/SessionDiagnosticBundle.m b/+labkit/+app/+internal/+diagnostics/SessionDiagnosticBundle.m index 65ceb7527..d0c9ff88f 100644 --- a/+labkit/+app/+internal/+diagnostics/SessionDiagnosticBundle.m +++ b/+labkit/+app/+internal/+diagnostics/SessionDiagnosticBundle.m @@ -11,7 +11,7 @@ privateState = []; end if nargin < 4 - stateMode = "exact"; + stateMode = "compact"; end snapshot = validateSnapshot(snapshot); stateMode = ... @@ -79,7 +79,7 @@ function destination = writeFallback( ... snapshot, preferredDestination, stateMode) if nargin < 3 - stateMode = "exact"; + stateMode = "compact"; end snapshot = validateFallbackSnapshot(snapshot); stateMode = ... diff --git a/+labkit/+app/+internal/+diagnostics/SessionDiagnostics.m b/+labkit/+app/+internal/+diagnostics/SessionDiagnostics.m index 3d194caae..d21ba28d8 100644 --- a/+labkit/+app/+internal/+diagnostics/SessionDiagnostics.m +++ b/+labkit/+app/+internal/+diagnostics/SessionDiagnostics.m @@ -100,6 +100,10 @@ function setTraceEnabled(obj, enabled) obj.Stream.setTraceEnabled(enabled); end + function observed = hasErrorOrCriticalEvent(obj) + observed = obj.Stream.hasErrorOrCriticalEvent(); + end + function destination = exportBundle( ... obj, destination, excludeOperationId, ... privateState, stateMode) @@ -110,7 +114,7 @@ function setTraceEnabled(obj, enabled) privateState = []; end if nargin < 5 - stateMode = "exact"; + stateMode = "compact"; end obj.Journal.flush(); streamSnapshot = obj.Stream.captureSnapshot(); @@ -154,7 +158,7 @@ function setTraceEnabled(obj, enabled) function destination = exportTextFallback( ... obj, preferredDestination, failure, stateMode) if nargin < 4 - stateMode = "exact"; + stateMode = "compact"; end % Keep this path independent of the journal and ZIP staging so a % failure in either subsystem cannot consume the last evidence. diff --git a/+labkit/+app/+internal/+diagnostics/SessionEventStream.m b/+labkit/+app/+internal/+diagnostics/SessionEventStream.m index 77441777a..b93eea299 100644 --- a/+labkit/+app/+internal/+diagnostics/SessionEventStream.m +++ b/+labkit/+app/+internal/+diagnostics/SessionEventStream.m @@ -18,6 +18,7 @@ ProjectionHealthHook = [] ProjectionHealthUnavailableReported (1, 1) logical = false TraceEnabled (1, 1) logical = false + ErrorOrCriticalObserved (1, 1) logical = false ConsumerSequence (1, 1) double = 0 Consumers (1, :) struct = struct( ... "Id", strings(1, 0), "Callback", cell(1, 0)) @@ -182,12 +183,18 @@ function log(obj, severity, eventName, message, varargin) end record.exception = exception; obj.retain(record); - if any(severity == ["error", "critical"]) && ... - ~obj.TraceEnabled - obj.setTraceEnabled(true); + if any(severity == ["error", "critical"]) + obj.ErrorOrCriticalObserved = true; + if ~obj.TraceEnabled + obj.setTraceEnabled(true); + end end end + function observed = hasErrorOrCriticalEvent(obj) + observed = obj.ErrorOrCriticalObserved; + end + function records = records(obj) records = obj.Records; end diff --git a/+labkit/+app/+internal/+runtime/@RuntimeKernel/RuntimeKernel.m b/+labkit/+app/+internal/+runtime/@RuntimeKernel/RuntimeKernel.m index 8935aa60d..99cac0154 100644 --- a/+labkit/+app/+internal/+runtime/@RuntimeKernel/RuntimeKernel.m +++ b/+labkit/+app/+internal/+runtime/@RuntimeKernel/RuntimeKernel.m @@ -196,7 +196,7 @@ function setTraceCapture(obj, enabled) function destination = exportDiagnosticBundle( ... obj, destination, stateMode) if nargin < 3 - stateMode = "exact"; + stateMode = "compact"; end destination = obj.Diagnostics.exportBundle( ... destination, obj.State, stateMode); @@ -209,7 +209,7 @@ function setTraceCapture(obj, enabled) function destination = exportDiagnosticTextFallback( ... obj, preferredDestination, cause, stateMode) if nargin < 4 - stateMode = "exact"; + stateMode = "compact"; end destination = obj.Diagnostics.exportTextFallback( ... preferredDestination, cause, stateMode); @@ -247,7 +247,7 @@ function alertDiagnosticTextFallback(obj, destination) end folder = obj.uniqueSyntheticInputFolder(choice.Value); obj.generateSyntheticInputs(folder); - obj.Context.alert( ... + obj.Context.inform( ... "Synthetic inputs were written to the selected folder.", ... "Synthetic Inputs"); end @@ -583,6 +583,9 @@ function close(obj) obj.Recorder.finish( ... operation, "failed", "notApplicable", failure); end + if ~isempty(obj.Diagnostics) + obj.Diagnostics.exportAfterErrorOnClose(obj.State); + end if ~isempty(obj.Recorder) obj.Recorder.close(); end @@ -698,7 +701,7 @@ function notifyUser(obj, message, title) "labkit.app.internal.native.MatlabPlatformAdapter") obj.Adapter.alert(message, title, "info"); else - obj.Context.alert(message, title); + obj.Context.inform(message, title); end end diff --git a/+labkit/+app/+internal/+runtime/@RuntimeKernel/completeBackend.m b/+labkit/+app/+internal/+runtime/@RuntimeKernel/completeBackend.m index 88710398a..3de662bc3 100644 --- a/+labkit/+app/+internal/+runtime/@RuntimeKernel/completeBackend.m +++ b/+labkit/+app/+internal/+runtime/@RuntimeKernel/completeBackend.m @@ -24,6 +24,8 @@ obj.sourcePaths(sources, ids); if isa(obj.Adapter, "labkit.app.internal.native.MatlabPlatformAdapter") builtins.alert = @(message, title) obj.Adapter.alert(message, title); + builtins.inform = @(message, title) ... + obj.Adapter.alert(message, title, "info"); builtins.choose = @(prompt, choices, title, ... defaultChoice, cancelChoice) ... obj.Adapter.chooseOption(prompt, choices, title, ... @@ -36,6 +38,9 @@ obj.Adapter.chooseOutputFile(filters, startPath); builtins.chooseOutputFolder = @(startPath) ... obj.Adapter.chooseOutputFolder(startPath); + elseif isfield(backend, "alert") && ~isfield(backend, "inform") + % Headless test backends may use one nonvisual dialog observer. + builtins.inform = backend.alert; end names = string(fieldnames(builtins)); for k = 1:numel(names) diff --git a/+labkit/+app/CallbackContext.m b/+labkit/+app/CallbackContext.m index 0544b4322..4e1fd900d 100644 --- a/+labkit/+app/CallbackContext.m +++ b/+labkit/+app/CallbackContext.m @@ -3,6 +3,7 @@ % % Usage: % context.log(severity, eventName, message, Name=Value) + % context.inform(message, title) % context.alert(message, title) % result = context.chooseOption(prompt, choices, Name=Value) % result = context.chooseInputFile(filters, startPath) @@ -41,7 +42,9 @@ % Exception - Scalar MException associated with the event. Default: []. % id - Stable semantic diagnostic or resource identifier. % count - Nonnegative integer diagnostic count. - % title - Scalar reader-facing dialog title. + % title - Scalar reader-facing dialog title. inform presents non-error + % information with the native information icon; alert presents a + % blocking problem with the native error icon. % prompt - Scalar reader-facing choice prompt. % choices - Row string or cellstr array. % Title - Reader-facing choice-dialog title. Default: @@ -135,6 +138,12 @@ function alert(obj, message, title) scalarText(title, "title")}, 0); end + function inform(obj, message, title) + obj.invoke("inform", "dialogs", ... + {scalarText(message, "message"), ... + scalarText(title, "title")}, 0); + end + function result = chooseOption(obj, prompt, choices, varargin) choices = textRow(choices, "choices"); if isempty(choices) || numel(unique(choices)) ~= numel(choices) diff --git a/+labkit/+app/version.m b/+labkit/+app/version.m index 3e0d1b954..7f4b1f59f 100644 --- a/+labkit/+app/version.m +++ b/+labkit/+app/version.m @@ -30,6 +30,6 @@ % labkit.app.Definition info = labkit.contract.versionInfo( ... - "app", "2.2.0", ">=2 <3", "stable", ... + "app", "2.3.0", ">=2 <3", "stable", ... "Explicit LabKit App SDK contract for tracked production Apps."); end diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 7b4957fc1..9d88edcd6 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -114,7 +114,10 @@ find the exact owner and contract; App authors never invent test paths. valid for the smallest supported source geometry. - Placing or editing overlays must preserve the user's viewport unless the user explicitly requests fit/reset. -- File and folder dialogs outside `fileList` and alerts use CallbackContext. +- File and folder dialogs outside `fileList` use `CallbackContext`. Use + `CallbackContext.inform` for successful or neutral information and + `CallbackContext.alert` only for a blocking problem; never present an INFO + outcome through the error-style alert capability. - External files in saved projects use portable references and field-specific relinking. Current saves use the project envelope; compatibility importers are read-only. diff --git a/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportAnalysisRegionWorkspace.m b/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportAnalysisRegionWorkspace.m index 01d26ed7d..91a669880 100644 --- a/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportAnalysisRegionWorkspace.m +++ b/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportAnalysisRegionWorkspace.m @@ -21,7 +21,7 @@ "ecg_print.resultfiles.exportanalysisregionworkspace.completed", ... sprintf("Assigned %d ECG ROI samples to ecgAnalysisRegion.", ... height(ecgAnalysisRegion))); - callbackContext.alert( ... + callbackContext.inform( ... "Assigned the current ECG ROI timetable to ecgAnalysisRegion.", ... "ROI timetable exported"); end diff --git a/apps/wearable/ecg_print/+ecg_print/definition.m b/apps/wearable/ecg_print/+ecg_print/definition.m index 2c8f49faa..e3a41b1c1 100644 --- a/apps/wearable/ecg_print/+ecg_print/definition.m +++ b/apps/wearable/ecg_print/+ecg_print/definition.m @@ -5,9 +5,9 @@ app = labkit.app.Definition( ... Entrypoint="labkit_ECGPrint_app", AppId="ecg_print", ... Title="ECG Signal Print + SNR Explorer", DisplayName="ECG Print", ... - Family="Wearable", AppVersion="1.6.2", Updated="2026-08-04", ... + Family="Wearable", AppVersion="1.6.3", Updated="2026-08-04", ... Requirements=labkit.contract.requirements( ... - "app", ">=2 <3", "biosignal", ">=1.0 <2"), ... + "app", ">=2.3 <3", "biosignal", ">=1.0 <2"), ... ProjectSchema=ecg_print.projectSpec(), ... CreateSession=@ecg_print.createSession, ... Workbench=ecg_print.workbench.buildLayout(), ... diff --git a/docs/apps/wearable/ecg-print/README.md b/docs/apps/wearable/ecg-print/README.md index 7075e29b7..06cc18b27 100644 --- a/docs/apps/wearable/ecg-print/README.md +++ b/docs/apps/wearable/ecg-print/README.md @@ -95,7 +95,8 @@ Peak polarity is selected automatically. The default detector threshold is ## Output Files **Export ROI timetable to workspace** assigns `ecgAnalysisRegion` in the -MATLAB base workspace. **Export ROI timetable MAT** saves the same timetable +MATLAB base workspace and confirms success with an information notice. +**Export ROI timetable MAT** saves the same timetable as `ecgAnalysisRegion` in `ecg_analysis_region.mat` and writes a matching `ecg_analysis_region.labkit.json` manifest. Both commands use the most recent successful analysis, not unapplied control edits. Timetable row times match diff --git a/docs/framework/README.md b/docs/framework/README.md index 1c3b7d848..8c2386976 100644 --- a/docs/framework/README.md +++ b/docs/framework/README.md @@ -180,7 +180,9 @@ utilities do not compete with the App's workflow controls: `app-state-compact.mat` after replacing supported state leaves larger than 1 MiB with same-class, same-dimension, compressible synthetic placeholders. `bundle-report.json` records every replacement without storing its value. - Exact is the default. Selecting an event highlights its complete table row. + Compact is the default; exact remains an explicit choice. After the first + ERROR or CRITICAL event, closing the App automatically writes a compact + diagnostic bundle. Selecting an event highlights its complete table row. Text fallback retains complete events and reports that the selected MAT state could not be represented as text. @@ -188,6 +190,10 @@ These actions are framework-owned native behavior. Apps do not declare menu items, implement clipboard integration, or duplicate project persistence callbacks. +App callbacks use `CallbackContext.inform` for successful or neutral +information and reserve `CallbackContext.alert` for blocking problems. The two +capabilities map explicitly to native information and error icons. + Framework concepts and source names are versionless. Compatibility belongs to `labkit.app.version`; saved-data versions belong to `labkit.app.project.Schema`. diff --git a/docs/framework/guides/runtime.md b/docs/framework/guides/runtime.md index e58f0f964..9767a8ceb 100644 --- a/docs/framework/guides/runtime.md +++ b/docs/framework/guides/runtime.md @@ -277,6 +277,12 @@ documents, result packages, render surfaces, and managed resources. It does not expose figures, component registries, queues, lifecycle handles, or a nested service bag. +Use `callbackContext.inform(message,title)` for successful or neutral +information; it presents the native information icon. Reserve +`callbackContext.alert(message,title)` for a blocking problem; it presents the +native error icon. Keeping these operations distinct prevents completed INFO +outcomes from inheriting failure styling. + Use context methods only at a callback or reconstruction boundary. Pure readers, calculations, result builders, and render-model builders accept ordinary explicit values. @@ -349,7 +355,10 @@ replacement without retaining the replaced values; it also lists oversized unsupported leaf types that had to remain exact. Compact state is diagnostic evidence, not scientifically valid input. Both modes may contain sensitive paths, filenames, scientific values, and decoded data; neither is a privacy -filter. Exact is the default. +filter. Compact is the default; exact remains an explicit choice. If the +session records any ERROR or CRITICAL event, Runtime automatically writes a +compact bundle after the App closes, including the completed close lifecycle +event. A clean session does not create a bundle on close. If ZIP staging or publication fails, Runtime writes a generated complete-event text fallback beside that ZIP. Only when automatic output cannot be written diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index 9e1246c30..2fbf2055f 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -69,8 +69,10 @@ Every current LabKit app exposes one top-level **Tools** menu: - **Tools > Diagnostics > Open Session Log...** opens the current App's named live log with Full TRACE, DEBUG, and User views. - **Tools > Diagnostics > Export Diagnostic Bundle** asks for exact or compact - synthetic App state, then writes complete sensitive logs plus the selected - MAT in an automatically named ZIP beneath `artifacts/diagnostics/`. + synthetic App state, defaults to compact, then writes complete sensitive + logs plus the selected MAT in an automatically named ZIP beneath + `artifacts/diagnostics/`. After an ERROR or CRITICAL event, closing the App + automatically writes the compact bundle there. State files preserve app projects. They are different from exported result files and from ignored diagnostic manifests under `artifacts/diagnostics/`. diff --git a/docs/history/records/2026/08/LK-20260804-automatic-error-diagnostic-bundles.md b/docs/history/records/2026/08/LK-20260804-automatic-error-diagnostic-bundles.md new file mode 100644 index 000000000..51fb11c3f --- /dev/null +++ b/docs/history/records/2026/08/LK-20260804-automatic-error-diagnostic-bundles.md @@ -0,0 +1,95 @@ +# App diagnostics persist errors and distinguish informational dialogs + +```labkit-change +id: LK-20260804-automatic-error-diagnostic-bundles +date: 2026-08-04 +sequence: 174 +type: feat +compatibility: compatible +component: `labkit.app` | `2.2.0 -> 2.3.0` +component: `labkit_ECGPrint_app` | `1.6.2 -> 1.6.3` +scope: Error-triggered diagnostic persistence +scope: Compact diagnostic state default +scope: Informational callback dialogs +``` + +## Context + +Runtime already enabled TRACE after the first ERROR or CRITICAL event, but the +user still had to export the session before closing the App. Diagnostic export +also defaulted to the exact MAT option even though decoded caches can make that +bundle unnecessarily large. Separately, `CallbackContext.alert` always used +the native error icon, and one successful ECG timetable export used that +failure-oriented operation for its completion notice. + +## Decision and rationale + +Remember whether the session ever records ERROR or CRITICAL independently of +the bounded in-memory event window and the current TRACE toggle. After close +cleanup and its terminal lifecycle event are recorded, automatically export a +compact-state diagnostic bundle when that error flag is set. Keep clean closes +side-effect free and keep diagnostic persistence failure from changing Runtime +close semantics. Use compact state as the default for manual and automatic +exports while preserving exact state as an explicit manual choice. +Add the explicitly named `CallbackContext.inform` capability for successful or +neutral information and keep `CallbackContext.alert` error-styled for blocking +problems. The repository scan found 80 App alert calls: 79 describe missing +prerequisites or failures and remain alerts; the one successful ECG timetable +notice moves to `inform`. + +## Changes + +- Runtime remembers whether ERROR or CRITICAL occurred for the full session, + independent of the bounded event view and current TRACE toggle. +- Closing an affected App automatically writes one compact diagnostic bundle + after the close lifecycle result is recorded. +- Manual diagnostic export defaults to compact state and retains exact state + as an explicit choice. +- `CallbackContext.inform` presents successful and neutral information with an + information icon; ECG timetable workspace export now uses it. +- App authoring policy reserves error-style `CallbackContext.alert` for + blocking problems. + +## User and data impact + +Closing an App after an error writes one uniquely named ZIP beneath +`artifacts/diagnostics/`. It includes complete sensitive retained events and a +structurally compact `app-state-compact.mat`; it is diagnostic evidence rather +than scientifically valid saved state. Sessions without ERROR or CRITICAL +events do not create a close-time bundle. Users can still explicitly select an +exact-state bundle. +The ECG workspace timetable completion notice now uses an information icon; +its export data and workspace variable are unchanged. + +## Compatibility and migration + +The additive callback capability is compatible with existing version-2 App SDK +requirements. Existing `alert` calls keep their error styling. Project schemas +and result files do not change, and no project migration is required. ECG Print +1.6.3 raises its App SDK requirement to `>=2.3 <3` because it calls the new +`inform` capability. + +## Validation + +Focused headless App SDK diagnostics specifications cover compact defaults, +automatic export after an error, inclusion of the completed close event, and +the absence of automatic output for a clean close. Existing exact, compact, +journal-degradation, and text-fallback bundle contracts remain covered. +App SDK source evidence distinguishes `inform` and `alert` backend operations; +the ECG hidden-GUI workflow verifies the successful workspace export uses the +native information icon. + +## Evidence + +- `SessionDiagnosticBundleSpec` passed 8/8 focused headless identities. +- `AppSdkSpec` passed 23/23 focused App SDK identities. +- `EcgPrintWorkflowSpec` passed its focused hidden-GUI workflow identity. +- Authored-link validation checked 259 Markdown files with no unresolved + links; deterministic documentation validation compared 387 generated files. + +## Known limitations and follow-up + +A process termination that bypasses Runtime close cannot create the close-time +bundle; the durable session journal remains the surviving evidence boundary. +Both compact and exact bundles may contain sensitive paths, filenames, +scientific values, and exception details. diff --git a/tests/specs/apps/wearable/ecg_print/workbench/EcgPrintWorkflowSpec.m b/tests/specs/apps/wearable/ecg_print/workbench/EcgPrintWorkflowSpec.m index 395e33444..dba72d121 100644 --- a/tests/specs/apps/wearable/ecg_print/workbench/EcgPrintWorkflowSpec.m +++ b/tests/specs/apps/wearable/ecg_print/workbench/EcgPrintWorkflowSpec.m @@ -27,6 +27,9 @@ function analyzesExportsAndRestoresASyntheticRecording(testCase) clearRegion = onCleanup(@() evalin( ... "base", "clear ecgAnalysisRegion")); runtime.invokeAction("exportRegionWorkspace"); + notice = getappdata(figureValue, "labkitAppLastAlert"); + testCase.verifyEqual(notice.title, "ROI timetable exported"); + testCase.verifyEqual(notice.icon, "info"); runtime.invokeAction("exportRegionFile"); runtime.invokeAction("exportSegments"); runtime.invokeAction("exportWaveform"); diff --git a/tests/specs/labkit/app/AppSdkSpec.m b/tests/specs/labkit/app/AppSdkSpec.m index 60c45ec63..0713b9799 100644 --- a/tests/specs/labkit/app/AppSdkSpec.m +++ b/tests/specs/labkit/app/AppSdkSpec.m @@ -74,6 +74,27 @@ function callbackContextHasOnlyNamedRuntimeCapabilities(testCase) testCase.verifyFalse(any(string(properties(context)) == "Backend")); testCase.verifyError(@() context.alert("message", "title"), ... "labkit:app:runtime:InvariantFailure"); + testCase.verifyError(@() context.inform("message", "title"), ... + "labkit:app:runtime:InvariantFailure"); + end + + function separatesInformationalAndErrorDialogs(testCase) + observed = containers.Map("KeyType", "char", "ValueType", "any"); + backend = struct( ... + "inform", @(message, title) captureDialog( ... + observed, "info", message, title), ... + "alert", @(message, title) captureDialog( ... + observed, "error", message, title)); + context = ... + labkit.app.internal.runtime.CallbackContextFactory.create(backend); + + context.inform("Export completed.", "Exported"); + testCase.verifyEqual(observed("kind"), "info"); + testCase.verifyEqual(observed("message"), "Export completed."); + testCase.verifyEqual(observed("title"), "Exported"); + + context.alert("Export failed.", "Export error"); + testCase.verifyEqual(observed("kind"), "error"); end function nativeDialogFiltersContainOnlyLegacyCharacterCells(testCase) @@ -717,6 +738,11 @@ function captureAlert(store, message, title) store("title") = string(title); end +function captureDialog(store, kind, message, title) +store("kind") = string(kind); +captureAlert(store, message, title); +end + function accepted = validateProject(project) accepted = isstruct(project) && isscalar(project) && ... isfield(project, "parameters") && isstruct(project.parameters) && ... diff --git a/tests/specs/labkit/app/SessionDiagnosticBundleSpec.m b/tests/specs/labkit/app/SessionDiagnosticBundleSpec.m index f0621b1ac..aecbf0e33 100644 --- a/tests/specs/labkit/app/SessionDiagnosticBundleSpec.m +++ b/tests/specs/labkit/app/SessionDiagnosticBundleSpec.m @@ -1,6 +1,27 @@ classdef SessionDiagnosticBundleSpec < matlab.unittest.TestCase % SESSIONDIAGNOSTICBUNDLESPEC Specify complete exact and compact bundles. + properties (Access = private) + DiagnosticFilesBefore (1, :) string = strings(1, 0) + end + + methods (TestMethodSetup) + function rememberDiagnosticArtifacts(testCase) + testCase.DiagnosticFilesBefore = diagnosticFiles( ... + diagnosticArtifactsFolder()).'; + end + end + + methods (TestMethodTeardown) + function removeGeneratedDiagnosticArtifacts(testCase) + folder = diagnosticArtifactsFolder(); + created = setdiff( ... + diagnosticFiles(folder), ... + testCase.DiagnosticFilesBefore); + deleteDiagnostics(folder, created); + end + end + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) function exportsCompleteEventsAndExactState(testCase) folder = testCase.applyFixture( ... @@ -14,7 +35,7 @@ function exportsCompleteEventsAndExactState(testCase) runtime.invokeAction("run"); destination = runtime.exportDiagnosticBundle( ... - fullfile(folder, "diagnostics")); + fullfile(folder, "diagnostics"), "exact"); unpacked = fullfile(folder, "unpacked"); unzip(destination, unpacked); files = dir(unpacked); @@ -180,7 +201,7 @@ function writesOneReadableTextFallbackBesideTheAutomaticZip(testCase) testCase.verifyTrue(contains( ... fallback, "private-source.png")); testCase.verifyTrue(contains(fallback, ... - "app-state.mat could not be represented")); + "app-state-compact.mat could not be represented")); clear fileCleanup cleanup end @@ -215,8 +236,6 @@ function automaticExportUsesArtifactsAndDoesNotAskForAPath(testCase) definition = bundleDefinition(); selection = containers.Map("KeyType", "char", ... "ValueType", "any"); - selection("selection") = ... - "Complete bundle (compact synthetic MAT)"; backend = struct( ... "chooseOutputFile", @failOutputDialog, ... "choose", @(varargin) captureDiagnosticChoice( ... @@ -238,11 +257,11 @@ function automaticExportUsesArtifactsAndDoesNotAskForAPath(testCase) string(filename) + string(extension), ... "labkit-diagnostics-sensitive-compact-state-probe-diagnostic-bundle-")); testCase.verifyEqual(selection("choices"), ... - ["Complete bundle (exact MAT)", ... - "Complete bundle (compact synthetic MAT)", ... + ["Complete bundle (compact synthetic MAT)", ... + "Complete bundle (exact MAT)", ... "Cancel"]); testCase.verifyEqual(selection("default"), ... - "Complete bundle (exact MAT)"); + "Complete bundle (compact synthetic MAT)"); testCase.verifyEqual(selection("cancel"), "Cancel"); unpacked = fullfile(folder, "compact-interactive"); unzip(destination, unpacked); @@ -255,6 +274,50 @@ function automaticExportUsesArtifactsAndDoesNotAskForAPath(testCase) testCase.verifyEqual(string(report.stateReview.mode), "compact"); clear fileCleanup cleanup end + + function closeAfterErrorAutomaticallyExportsCompactBundle(testCase) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + artifacts = diagnosticArtifactsFolder(); + before = diagnosticFiles(artifacts); + runtime = labkit.app.internal.runtime.RuntimeFactory.createHeadless( ... + bundleDefinition(), [], struct(), [], JournalRoot=folder); + runtime.invokeAction("run"); + runtime.setTraceCapture(false); + + runtime.close(); + + created = setdiff(diagnosticFiles(artifacts), before); + fileCleanup = onCleanup(@() deleteDiagnostics(artifacts, created)); + testCase.verifyNumElements(created, 1); + testCase.verifyTrue(contains(created, ... + "diagnostics-sensitive-compact-state")); + unpacked = fullfile(folder, "automatic-close"); + unzip(fullfile(artifacts, created), unpacked); + testCase.verifyTrue(isfile( ... + fullfile(unpacked, "app-state-compact.mat"))); + events = readEvents(unpacked); + names = string({events.eventName}); + testCase.verifyTrue(any(names == "analysis.failed")); + testCase.verifyTrue(any(names == "runtime.close.completed")); + clear fileCleanup + end + + function cleanCloseDoesNotAutomaticallyExportBundle(testCase) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + artifacts = diagnosticArtifactsFolder(); + before = diagnosticFiles(artifacts); + runtime = labkit.app.internal.runtime.RuntimeFactory.createHeadless( ... + bundleDefinition(), [], struct(), [], JournalRoot=folder); + + runtime.close(); + + created = setdiff(diagnosticFiles(artifacts), before); + fileCleanup = onCleanup(@() deleteDiagnostics(artifacts, created)); + testCase.verifyEmpty(created); + clear fileCleanup + end end end @@ -275,11 +338,10 @@ function automaticExportUsesArtifactsAndDoesNotAskForAPath(testCase) end function applicationState = emitLargeState( ... - applicationState, callbackContext) -applicationState = emitBundleIncident(applicationState, callbackContext); + applicationState, ~) stream = RandStream("mt19937ar", "Seed", 41); applicationState.session.cache = struct( ... - "largePayload", uint8(randi(stream, 256, 1200, 1200) - 1), ... + "largePayload", uint8(randi(stream, 256, 1100, 1100) - 1), ... "smallDiagnosticValues", [2, 3, 5, 7]); end @@ -335,6 +397,28 @@ function deleteIfFile(filepath) end end +function folder = diagnosticArtifactsFolder() +versionPath = string(which("labkit.app.version")); +root = string(fileparts(fileparts(fileparts(versionPath)))); +folder = fullfile(root, "artifacts", "diagnostics"); +end + +function files = diagnosticFiles(folder) +files = strings(0, 1); +if exist(char(folder), "dir") ~= 7 + return +end +listing = dir(fullfile(folder, ... + "labkit-diagnostics-*-probe-diagnostic-bundle-*.zip")); +files = string({listing.name}).'; +end + +function deleteDiagnostics(folder, files) +for index = 1:numel(files) + deleteIfFile(fullfile(folder, files(index))); +end +end + function choice = failOutputDialog(varargin) choice = MException("labkit:test:OutputDialogFailure", ... "Intentional output dialog failure."); From bbbab8d8582797869ad13defd0bb50deea0a5686 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Thu, 6 Aug 2026 09:39:08 -0500 Subject: [PATCH 04/12] fix: harden app runtime input boundaries --- .../+internal/+contract/CompiledDefinition.m | 59 ++++ .../+internal/+launcher/launcherVersion.m | 4 +- .../MatlabPlatformAdapter.m | 5 +- .../+native/@MatlabPlatformAdapter/apply.m | 4 + .../@MatlabPlatformAdapter/installCallbacks.m | 14 +- .../+runtime/@RuntimeKernel/RuntimeKernel.m | 43 ++- .../@RuntimeKernel/applyBoundControl.m | 38 +-- .../+runtime/@RuntimeKernel/commitFilePanel.m | 44 ++- .../@RuntimeKernel/enqueueTransition.m | 41 +++ .../+runtime/@RuntimeKernel/execute.m | 88 +++--- +labkit/+app/+layout/dataTable.m | 6 +- +labkit/+app/+layout/field.m | 4 +- +labkit/+app/+layout/fileList.m | 5 +- +labkit/+app/+layout/plotArea.m | 6 +- +labkit/+app/+layout/rangeField.m | 4 +- +labkit/+app/+layout/slider.m | 4 +- +labkit/+app/+layout/workspace.m | 2 +- +labkit/+app/version.m | 2 +- docs/apps/labkit-core/launcher/README.md | 4 + docs/framework/README.md | 7 + ...K-20260806-app-runtime-input-boundaries.md | 93 +++++++ labkit_launcher.m | 16 ++ tests/specs/labkit/app/AppSdkSpec.m | 254 ++++++++++++++++++ .../labkit_launcher/LauncherBootstrapSpec.m | 18 ++ .../tools/deployment/VersionManagementSpec.m | 28 ++ tools/deployment/manageLabKitVersions.m | 16 ++ 26 files changed, 681 insertions(+), 128 deletions(-) create mode 100644 +labkit/+app/+internal/+runtime/@RuntimeKernel/enqueueTransition.m create mode 100644 docs/history/records/2026/08/LK-20260806-app-runtime-input-boundaries.md diff --git a/+labkit/+app/+internal/+contract/CompiledDefinition.m b/+labkit/+app/+internal/+contract/CompiledDefinition.m index a89a64038..16cfc04f1 100644 --- a/+labkit/+app/+internal/+contract/CompiledDefinition.m +++ b/+labkit/+app/+internal/+contract/CompiledDefinition.m @@ -31,6 +31,7 @@ interactionIds = string(cellfun(@(value) value.Id, ... interactions, "UniformOutput", false)); assertUnique([ids interactionIds], "Layout and interaction"); + validateLayoutContracts(nodes); targetMask = cellfun(@(value) ... ~isempty(value.Capabilities), nodes); obj.TargetNodes = [nodes(targetMask) interactions]; @@ -70,6 +71,13 @@ "Target %s does not declare a renderer.", ... operation.Target); end + if operation.Kind == "tableData" && ... + any(operation.Value.ColumnEditable) && ... + ~hasSignal(node, "cellEdited") + error("labkit:app:contract:InvalidValue", ... + "Editable dataTable %s must declare OnCellEdited.", ... + operation.Target); + end covered(index) = true; end missing = obj.TargetIds(~covered); @@ -97,6 +105,57 @@ end end +function validateLayoutContracts(nodes) +for k = 1:numel(nodes) + node = nodes{k}; + config = node.configurationForCompiler(); + switch node.Kind + case "field" + if config.Kind ~= "readonly" + requireValueOwner(node, config); + end + case {"rangeField", "slider"} + requireValueOwner(node, config); + case "fileList" + if strlength(config.Bind) == 0 + error("labkit:app:contract:InvalidValue", ... + "fileList %s must declare Bind.", node.Id); + end + case "plotArea" + if ~isempty(config.ViewModes) && ... + ~hasSignal(node, "valueChanged") + error("labkit:app:contract:InvalidValue", ... + "plotArea %s with ViewModes must declare OnValueChanged.", ... + node.Id); + end + case "dataTable" + if any(config.ColumnEditable) && ... + ~hasSignal(node, "cellEdited") + error("labkit:app:contract:InvalidValue", ... + "Editable dataTable %s must declare OnCellEdited.", ... + node.Id); + end + case "workspace" + if hasSignal(node, "pageChanged") && isempty(node.PageIds) + error("labkit:app:contract:InvalidValue", ... + "Workspace OnPageChanged requires named pages."); + end + end +end +end + +function requireValueOwner(node, config) +if strlength(config.Bind) == 0 && ~hasSignal(node, "valueChanged") + error("labkit:app:contract:InvalidValue", ... + "Interactive %s %s must declare Bind or OnValueChanged.", ... + node.Kind, node.Id); +end +end + +function tf = hasSignal(node, signal) +tf = any(cellfun(@(value) value.Signal == signal, node.Signals)); +end + function plan = compilePlatformPlan(nodes) compiled = repmat(struct( ... "Kind", "", "Id", "", "ChildIds", strings(1, 0), ... diff --git a/+labkit/+app/+internal/+launcher/launcherVersion.m b/+labkit/+app/+internal/+launcher/launcherVersion.m index eb89b7765..248d2b32b 100644 --- a/+labkit/+app/+internal/+launcher/launcherVersion.m +++ b/+labkit/+app/+internal/+launcher/launcherVersion.m @@ -3,6 +3,6 @@ info = struct( ... "name", "labkit_launcher", ... "displayName", "LabKit App Launcher", ... - "version", "1.8.3", ... - "updated", "2026-08-03"); + "version", "1.8.4", ... + "updated", "2026-08-06"); end diff --git a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/MatlabPlatformAdapter.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/MatlabPlatformAdapter.m index 360ff995f..5cbf80f8b 100644 --- a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/MatlabPlatformAdapter.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/MatlabPlatformAdapter.m @@ -48,6 +48,7 @@ policy = labkit.app.internal.native.NativeAdapterValues.layoutPolicy(); obj.Figure = uifigure(Visible="off", ... Name=char(string(title)), ... + Tag="labkitApp", ... Position=policy.InitialFigurePosition); obj.BusyLifecycle = ... labkit.app.internal.native.BusyLifecycle( ... @@ -421,7 +422,9 @@ function runUtility(obj, callback, title) try callback(); catch cause - obj.alert(cause.message, title); + obj.alert( ... + labkit.app.internal.native.NativeAdapterValues.deepestCauseMessage( ... + cause), title); end end diff --git a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/apply.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/apply.m index 4e104c8e5..fad71d867 100644 --- a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/apply.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/apply.m @@ -30,5 +30,9 @@ function apply(obj, operation) case "workspacePage" labkit.app.internal.native.NativeAdapterValues.setIfProperty(component, "Enable", labkit.app.internal.native.NativeAdapterValues.onOff(operation.Value.Enabled)); component.UserData = struct("Status", operation.Value.Status); + otherwise + error("labkit:app:runtime:InvariantFailure", ... + "No native presentation policy for operation %s.", ... + operation.Kind); end end diff --git a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installCallbacks.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installCallbacks.m index 689d56af4..4e79f01ad 100644 --- a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installCallbacks.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installCallbacks.m @@ -49,12 +49,22 @@ function installCallbacks(obj) node.Id, string(src.Value))); end case "workspace" - if ~isempty(node.PageIds) + hasPageChanged = any(cellfun( ... + @(signal) signal.Signal == "pageChanged", ... + node.Signals)); + if ~isempty(node.PageIds) && hasPageChanged component.SelectionChangedFcn = @(src, ~) ... obj.runUserInput(@() ... - obj.Runtime.applyControlValue( ... + obj.Runtime.applyWorkspacePage( ... node.Id, string(src.SelectedTab.Tag))); end + case {"workbench", "group", "section", "tab", ... + "statusPanel", "workspacePage"} + % These semantic nodes have no direct native input signal. + otherwise + error("labkit:app:runtime:InvariantFailure", ... + "No native callback policy for Layout kind %s.", ... + node.Kind); end end end diff --git a/+labkit/+app/+internal/+runtime/@RuntimeKernel/RuntimeKernel.m b/+labkit/+app/+internal/+runtime/@RuntimeKernel/RuntimeKernel.m index 99cac0154..d9057b791 100644 --- a/+labkit/+app/+internal/+runtime/@RuntimeKernel/RuntimeKernel.m +++ b/+labkit/+app/+internal/+runtime/@RuntimeKernel/RuntimeKernel.m @@ -110,27 +110,11 @@ methods function dispatch(obj, binding, payload) - obj.assertOpen(); - labkit.app.internal.runtime.RuntimeContractBoundary.validateDispatch( ... - obj.Contract, binding, payload); - obj.Queue{end + 1} = struct( ... - "Binding", binding, "Payload", {payload}); - if obj.Processing - return; - end - obj.Processing = true; - if isa(obj.Adapter, "labkit.app.internal.native.MatlabPlatformAdapter") - obj.Adapter.beginBusy( ... - labkit.app.internal.runtime.RuntimeContractBoundary.busyMessage( ... - obj.Contract, binding)); - end - cleanup = onCleanup(@() obj.finishProcessing()); - while ~isempty(obj.Queue) - item = obj.Queue{1}; - obj.Queue(1) = []; - obj.execute(item.Binding, item.Payload); - end - clear cleanup + obj.enqueueTransition( ... + binding, payload, @(state) state, ... + "Callback " + binding.Id, ... + labkit.app.internal.runtime.RuntimeContractBoundary.busyMessage( ... + obj.Contract, binding)); end function setResource(obj, scope, id, value, cleanup) @@ -486,6 +470,18 @@ function applyTableSelection(obj, target, cells) @() obj.dispatch(binding, selection)); end + function applyWorkspacePage(obj, target, pageId) + obj.assertOpen(); + binding = ... + labkit.app.internal.runtime.RuntimeContractBoundary.signalForTarget( ... + obj.Contract, target, "pageChanged"); + obj.recordOperation( ... + "runtime.interaction", "interaction.page_changed", ... + "Applying workspace page selection.", ... + "committed", "rolledBack", ... + @() obj.dispatch(binding, string(pageId))); + end + function applyInteraction(obj, interactionId, signal, payload) obj.assertOpen(); binding = ... @@ -614,7 +610,10 @@ function delete(obj) backend = completeBackend(obj, backend) - execute(obj, binding, payload) + execute(obj, binding, payload, prepareState, failureLabel) + + enqueueTransition(obj, binding, payload, prepareState, ... + failureLabel, busyMessage) view = present(obj, state) diff --git a/+labkit/+app/+internal/+runtime/@RuntimeKernel/applyBoundControl.m b/+labkit/+app/+internal/+runtime/@RuntimeKernel/applyBoundControl.m index a0485fc6e..93a1854fa 100644 --- a/+labkit/+app/+internal/+runtime/@RuntimeKernel/applyBoundControl.m +++ b/+labkit/+app/+internal/+runtime/@RuntimeKernel/applyBoundControl.m @@ -15,33 +15,15 @@ function applyBoundControl(obj, target, value, dispatchChanged) error("labkit:app:contract:UnknownReference", ... "Layout target has no state binding: %s.", target); end - previousState = obj.State; - previousPresentation = obj.Presentation; - try - candidate = labkit.app.internal.runtime.RuntimeStatePath.write( ... - previousState, path, value); - if dispatchChanged - binding = ... - labkit.app.internal.runtime.RuntimeContractBoundary.signalForTarget( ... - obj.Contract, target, "valueChanged", false); - if ~isempty(binding) - candidate = binding.UpdateState( ... - candidate, value, obj.Context); - end - end - labkit.app.internal.runtime.RuntimeContractBoundary.validateState( ... - obj.Application, candidate); - view = obj.present(candidate); - obj.Adapter.reconcile(previousPresentation, view); - obj.State = candidate; - obj.Presentation = view; - obj.markDocumentChanged(); - catch cause - obj.State = previousState; - obj.Presentation = previousPresentation; - failure = MException("labkit:app:runtime:ActionFailed", ... - "Bound update for %s failed transactionally.", target); - failure = addCause(failure, cause); - throwAsCaller(failure); + binding = []; + if dispatchChanged + binding = ... + labkit.app.internal.runtime.RuntimeContractBoundary.signalForTarget( ... + obj.Contract, target, "valueChanged", false); end + prepareState = @(state) ... + labkit.app.internal.runtime.RuntimeStatePath.write( ... + state, path, value); + obj.enqueueTransition(binding, value, prepareState, ... + "Bound update for " + string(target), string(target)); end diff --git a/+labkit/+app/+internal/+runtime/@RuntimeKernel/commitFilePanel.m b/+labkit/+app/+internal/+runtime/@RuntimeKernel/commitFilePanel.m index 562fb6e81..64c5127a5 100644 --- a/+labkit/+app/+internal/+runtime/@RuntimeKernel/commitFilePanel.m +++ b/+labkit/+app/+internal/+runtime/@RuntimeKernel/commitFilePanel.m @@ -12,12 +12,6 @@ function commitFilePanel(obj, target, config, sources, indices, rebuildSession) error("labkit:app:contract:InvalidValue", ... "fileList selection indices are invalid."); end - candidate = labkit.app.internal.runtime.RuntimeStatePath.write( ... - obj.State, config.Bind, sources); - if rebuildSession && ~isempty(obj.Application.CreateSession) - candidate.session = obj.Application.CreateSession( ... - candidate.project, obj.Context); - end if strlength(config.SelectionBind) > 0 ids = strings(1, 0); if ~isempty(indices) @@ -25,34 +19,26 @@ function commitFilePanel(obj, target, config, sources, indices, rebuildSession) end selection = labkit.app.event.ListSelection( ... Ids=ids, Indices=indices); - candidate = labkit.app.internal.runtime.RuntimeStatePath.write( ... - candidate, config.SelectionBind, selection); else selection = labkit.app.event.ListSelection(Indices=indices); end binding = ... labkit.app.internal.runtime.RuntimeContractBoundary.signalForTarget( ... obj.Contract, target, "listSelectionChanged", false); - if ~isempty(binding) - candidate = binding.UpdateState( ... - candidate, selection, obj.Context); - end - previousState = obj.State; - previousPresentation = obj.Presentation; - try - labkit.app.internal.runtime.RuntimeContractBoundary.validateState( ... - obj.Application, candidate); - view = obj.present(candidate); - obj.Adapter.reconcile(previousPresentation, view); - obj.State = candidate; - obj.Presentation = view; - obj.markDocumentChanged(); - catch cause - obj.State = previousState; - obj.Presentation = previousPresentation; - failure = MException("labkit:app:runtime:ActionFailed", ... - "fileList update for %s failed transactionally.", target); - failure = addCause(failure, cause); - throwAsCaller(failure); + obj.enqueueTransition(binding, selection, @prepareState, ... + "fileList update for " + string(target), string(target)); + + function candidate = prepareState(current) + candidate = labkit.app.internal.runtime.RuntimeStatePath.write( ... + current, config.Bind, sources); + if rebuildSession && ~isempty(obj.Application.CreateSession) + candidate.session = obj.Application.CreateSession( ... + candidate.project, obj.Context); + end + if strlength(config.SelectionBind) > 0 + candidate = ... + labkit.app.internal.runtime.RuntimeStatePath.write( ... + candidate, config.SelectionBind, selection); + end end end diff --git a/+labkit/+app/+internal/+runtime/@RuntimeKernel/enqueueTransition.m b/+labkit/+app/+internal/+runtime/@RuntimeKernel/enqueueTransition.m new file mode 100644 index 000000000..05329b1b1 --- /dev/null +++ b/+labkit/+app/+internal/+runtime/@RuntimeKernel/enqueueTransition.m @@ -0,0 +1,41 @@ +function enqueueTransition( ... + obj, binding, payload, prepareState, failureLabel, busyMessage) +%ENQUEUETRANSITION Queue one state preparation and optional App callback. +% Caller: RuntimeKernel input boundaries. PREPARESTATE receives the latest +% committed state when the queued item executes, so reentrant transitions do +% not retain stale candidates. BINDING may be empty for binding-only commits. + + obj.assertOpen(); + if ~isempty(binding) + labkit.app.internal.runtime.RuntimeContractBoundary.validateDispatch( ... + obj.Contract, binding, payload); + end + if ~isa(prepareState, "function_handle") || ~isscalar(prepareState) + error("labkit:app:runtime:InvariantFailure", ... + "Runtime transition preparation must be one function handle."); + end + obj.Queue{end + 1} = struct( ... + "Binding", binding, "Payload", {payload}, ... + "PrepareState", prepareState, ... + "FailureLabel", string(failureLabel)); + if obj.Processing + return; + end + obj.Processing = true; + if isa(obj.Adapter, "labkit.app.internal.native.MatlabPlatformAdapter") + obj.Adapter.beginBusy(busyMessage); + end + cleanup = onCleanup(@() obj.finishProcessing()); + try + while ~isempty(obj.Queue) + item = obj.Queue{1}; + obj.Queue(1) = []; + obj.execute(item.Binding, item.Payload, ... + item.PrepareState, item.FailureLabel); + end + catch cause + obj.Queue = {}; + rethrow(cause); + end + clear cleanup +end diff --git a/+labkit/+app/+internal/+runtime/@RuntimeKernel/execute.m b/+labkit/+app/+internal/+runtime/@RuntimeKernel/execute.m index 380ddc859..7a61d3dc9 100644 --- a/+labkit/+app/+internal/+runtime/@RuntimeKernel/execute.m +++ b/+labkit/+app/+internal/+runtime/@RuntimeKernel/execute.m @@ -1,37 +1,55 @@ -function execute(obj, binding, payload) -%EXECUTE Run one callback transaction with presentation rollback. +function execute(obj, binding, payload, prepareState, failureLabel) +%EXECUTE Run one prepared callback transaction with presentation rollback. + if nargin < 4 + prepareState = @(state) state; + end + if nargin < 5 + failureLabel = "Callback " + binding.Id; + end previousState = obj.State; previousPresentation = obj.Presentation; obj.PendingDocumentMetadata = []; - operation = obj.Recorder.begin( ... - "runtime.callback", "callback." + binding.Signal, ... - "Dispatching callback.", Attributes=struct("runtimeAlias", binding.Id)); + hasCallback = ~isempty(binding); + operation = []; + if hasCallback + operation = obj.Recorder.begin( ... + "runtime.callback", "callback." + binding.Signal, ... + "Dispatching callback.", ... + Attributes=struct("runtimeAlias", binding.Id)); + end try - if ~binding.AcceptsPayload - candidate = binding.UpdateState(previousState, obj.Context); - else - candidate = binding.UpdateState( ... - previousState, payload, obj.Context); + candidate = prepareState(previousState); + if hasCallback + if ~binding.AcceptsPayload + candidate = binding.UpdateState(candidate, obj.Context); + else + candidate = binding.UpdateState( ... + candidate, payload, obj.Context); + end + obj.Recorder.log( ... + "trace", "callback.state_updated", ... + "Callback state update completed.", ... + Category="runtime.callback", Audience="developer", ... + Attributes=struct("runtimeAlias", binding.Id)); end - obj.Recorder.log( ... - "trace", "callback.state_updated", ... - "Callback state update completed.", ... - Category="runtime.callback", Audience="developer", ... - Attributes=struct("runtimeAlias", binding.Id)); labkit.app.internal.runtime.RuntimeContractBoundary.validateState( ... obj.Application, candidate); - obj.Recorder.log( ... - "trace", "callback.state_validated", ... - "Callback state validation completed.", ... - Category="runtime.callback", Audience="developer", ... - Attributes=struct("runtimeAlias", binding.Id)); + if hasCallback + obj.Recorder.log( ... + "trace", "callback.state_validated", ... + "Callback state validation completed.", ... + Category="runtime.callback", Audience="developer", ... + Attributes=struct("runtimeAlias", binding.Id)); + end view = obj.present(candidate); obj.Adapter.reconcile(previousPresentation, view); - obj.Recorder.log( ... - "trace", "callback.presentation_committed", ... - "Native presentation commit completed.", ... - Category="runtime.callback", Audience="developer", ... - Attributes=struct("runtimeAlias", binding.Id)); + if hasCallback + obj.Recorder.log( ... + "trace", "callback.presentation_committed", ... + "Native presentation commit completed.", ... + Category="runtime.callback", Audience="developer", ... + Attributes=struct("runtimeAlias", binding.Id)); + end obj.State = candidate; obj.Presentation = view; if isempty(obj.PendingDocumentMetadata) @@ -40,20 +58,24 @@ function execute(obj, binding, payload) obj.Documents.acceptRestore(obj.PendingDocumentMetadata); obj.refreshWindowTitle(); end - obj.Recorder.finish(operation, "completed", "committed", []); + if hasCallback + obj.Recorder.finish(operation, "completed", "committed", []); + end catch cause obj.State = previousState; obj.Presentation = previousPresentation; obj.PendingDocumentMetadata = []; obj.Resources.clearScope("event"); - obj.Recorder.finish(operation, "failed", "rolledBack", cause); - obj.Recorder.log( ... - "trace", "callback.rollback_completed", ... - "Callback rollback cleanup completed.", ... - Category="runtime.callback", Audience="developer", ... - Attributes=struct("runtimeAlias", binding.Id)); + if hasCallback + obj.Recorder.finish(operation, "failed", "rolledBack", cause); + obj.Recorder.log( ... + "trace", "callback.rollback_completed", ... + "Callback rollback cleanup completed.", ... + Category="runtime.callback", Audience="developer", ... + Attributes=struct("runtimeAlias", binding.Id)); + end failure = MException("labkit:app:runtime:ActionFailed", ... - "Callback %s failed transactionally.", binding.Id); + "%s failed transactionally.", failureLabel); failure = addCause(failure, cause); throwAsCaller(failure); end diff --git a/+labkit/+app/+layout/dataTable.m b/+labkit/+app/+layout/dataTable.m index 161375877..90fd8bd41 100644 --- a/+labkit/+app/+layout/dataTable.m +++ b/+labkit/+app/+layout/dataTable.m @@ -15,7 +15,8 @@ % supplies its title when this is blank. Default: blank. % Columns - Column-label text row. Default: strings(1,0). % RowNames - Row-label text row. Default: strings(1,0). -% ColumnEditable - Logical scalar or row matching Columns. Default: false. +% ColumnEditable - Logical scalar or row matching Columns. Any editable +% column requires OnCellEdited. Default: false. % OnCellEdited - Scalar callback % state = callback(state,edit,context), where edit is a % labkit.app.event.TableCellEdit. Default: []. @@ -27,7 +28,8 @@ % node - Immutable internal layout node accepted by layout containers. % % Errors: -% Throws labkit:app:contract:* for invalid options or callback signatures. +% Throws labkit:app:contract:* for invalid options, callback signatures, +% or editable columns without an OnCellEdited owner. % % Typical Call: % node = labkit.app.layout.dataTable("results", Columns=["Name" "Value"]); diff --git a/+labkit/+app/+layout/field.m b/+labkit/+app/+layout/field.m index c608a96dc..8074e927b 100644 --- a/+labkit/+app/+layout/field.m +++ b/+labkit/+app/+layout/field.m @@ -24,12 +24,14 @@ % Enabled - Initial logical enabled state. Default: true. % OnValueChanged - Scalar callback % state = callback(state,value,context). Default: []. +% Every non-readonly field must declare Bind or OnValueChanged. % % Outputs: % node - Immutable internal layout node accepted by layout containers. % % Errors: -% Throws labkit:app:contract:* for invalid IDs, options, or handlers. +% Throws labkit:app:contract:* for invalid IDs, options, handlers, or an +% editable field with no declared state or callback owner. % % Typical Call: % node = labkit.app.layout.field("gain", Kind="numeric", ... diff --git a/+labkit/+app/+layout/fileList.m b/+labkit/+app/+layout/fileList.m index 715a46f43..197e1334c 100644 --- a/+labkit/+app/+layout/fileList.m +++ b/+labkit/+app/+layout/fileList.m @@ -46,7 +46,7 @@ % PathFilterDescription - Reader-facing description of files accepted by % PathFilter, used in the aggregate filtering notice. Default: % "supported". -% Bind - Project source-record field path. Default: "". +% Bind - Required project or session source-record field path. % SelectionBind - ListSelection field path. Default: "". % OnSelectionChanged - Optional callback % applicationState = callback(applicationState,selection,callbackContext) @@ -61,7 +61,8 @@ % node - Immutable internal layout node accepted by layout containers. % % Errors: -% Throws labkit:app:contract:* for invalid options, paths, or callbacks. +% Throws labkit:app:contract:* for invalid options, paths, callbacks, or +% a missing Bind owner. % In a native App, an unhandled file-panel validation or parsing exception % is rolled back and shown in an alert. % diff --git a/+labkit/+app/+layout/plotArea.m b/+labkit/+app/+layout/plotArea.m index 96a5757f4..e5dcbacf7 100644 --- a/+labkit/+app/+layout/plotArea.m +++ b/+labkit/+app/+layout/plotArea.m @@ -27,7 +27,8 @@ % RowHeights - One positive pixel, "fit", or "1x" value per axis for % stack layout. Default: equal flexible heights. % ScrollZoomAxes - One "xy", "x", or "y" value per axis. Default: "xy". -% ViewModes - App-owned mode labels. Default: strings(1,0). +% ViewModes - App-owned mode labels. When nonempty, OnValueChanged is +% required. Default: strings(1,0). % OnValueChanged - Scalar callback % state = callback(state,value,context). Default: []. % Interactions - Row cell array returned by named @@ -37,7 +38,8 @@ % node - Immutable internal layout node accepted by workspace. % % Errors: -% Throws labkit:app:contract:* for invalid IDs, options, or handlers. +% Throws labkit:app:contract:* for invalid IDs, options, handlers, or +% ViewModes without an OnValueChanged owner. % % Typical Call: % node = labkit.app.layout.plotArea("preview", @drawTrace, ... diff --git a/+labkit/+app/+layout/rangeField.m b/+labkit/+app/+layout/rangeField.m index a7f2a46dc..ffbb137fd 100644 --- a/+labkit/+app/+layout/rangeField.m +++ b/+labkit/+app/+layout/rangeField.m @@ -18,6 +18,7 @@ % Bind - Project or session field path. Default: "". % OnValueChanged - Scalar callback % state = callback(state,value,context). Default: []. +% Every range field must declare Bind or OnValueChanged. % % Outputs: % node - Immutable internal layout node accepted by layout containers. @@ -26,7 +27,8 @@ % Throws labkit:app:contract:* for invalid IDs, options, or callbacks. % % Typical Call: -% node = labkit.app.layout.rangeField("window", Limits=[0 10]); +% node = labkit.app.layout.rangeField("window", Limits=[0 10], ... +% Bind="session.window"); % % See also labkit.app.layout.field, labkit.app.layout.slider node = labkit.app.internal.contract.LayoutNode.rangeField(id, varargin{:}); diff --git a/+labkit/+app/+layout/slider.m b/+labkit/+app/+layout/slider.m index d73601191..44e08c554 100644 --- a/+labkit/+app/+layout/slider.m +++ b/+labkit/+app/+layout/slider.m @@ -22,6 +22,7 @@ % Enabled - Initial logical enabled state. Default: true. % OnValueChanged - Scalar callback % state = callback(state,value,context). Default: []. +% Every slider must declare Bind or OnValueChanged. % % Outputs: % node - Immutable internal layout node accepted by layout containers. @@ -30,7 +31,8 @@ % Throws labkit:app:contract:* for invalid IDs, options, or callbacks. % % Typical Call: -% node = labkit.app.layout.slider("frame", Limits=[1 100], Step=1); +% node = labkit.app.layout.slider("frame", Limits=[1 100], Step=1, ... +% Bind="session.frame"); % % See also labkit.app.layout.field, labkit.app.layout.rangeField node = labkit.app.internal.contract.LayoutNode.slider(id, varargin{:}); diff --git a/+labkit/+app/+layout/workspace.m b/+labkit/+app/+layout/workspace.m index 143b15e9f..ef4173dcc 100644 --- a/+labkit/+app/+layout/workspace.m +++ b/+labkit/+app/+layout/workspace.m @@ -19,7 +19,7 @@ % Options: % Title - Reader-facing workspace title. Default: "Workspace". % OnPageChanged - Callback state = callback(state,pageId,context). -% Default: []. +% Requires at least one named page. Default: []. % % Outputs: % node - Immutable workspace node accepted by layout.workbench. diff --git a/+labkit/+app/version.m b/+labkit/+app/version.m index 7f4b1f59f..40a5bbaa5 100644 --- a/+labkit/+app/version.m +++ b/+labkit/+app/version.m @@ -30,6 +30,6 @@ % labkit.app.Definition info = labkit.contract.versionInfo( ... - "app", "2.3.0", ">=2 <3", "stable", ... + "app", "2.3.1", ">=2 <3", "stable", ... "Explicit LabKit App SDK contract for tracked production Apps."); end diff --git a/docs/apps/labkit-core/launcher/README.md b/docs/apps/labkit-core/launcher/README.md index 57bf1eb1d..111f917f8 100644 --- a/docs/apps/labkit-core/launcher/README.md +++ b/docs/apps/labkit-core/launcher/README.md @@ -126,6 +126,10 @@ After LabKit is installed, **Latest**, **Release**, and **Versions** in the full Launcher provide the richer source-checkout version workflow. Every downloaded archive is validated before replacement. Existing repairs preserve a recovery copy transactionally and retain known local workspace folders when required. +Close every running LabKit App before replacing an installation. Standalone +repair and the full Launcher's version update workflows refuse to replace +framework files while an App remains open so live callbacks and delayed UI +work cannot lose their MATLAB class definitions mid-operation. Keep experimental data and exports outside the runtime folder because installed code is replaceable. diff --git a/docs/framework/README.md b/docs/framework/README.md index 8c2386976..e1449506e 100644 --- a/docs/framework/README.md +++ b/docs/framework/README.md @@ -76,6 +76,13 @@ and renderer signatures, and builds one private native platform plan. ## Paved Road - Bind ordinary state with `Bind="project..."` or `Bind="session..."`. +- Give every editable semantic surface one declared behavior owner before + launch: editable fields, ranges, and sliders use `Bind` or + `OnValueChanged`; file lists require `Bind`; plot view modes require + `OnValueChanged`; and editable table columns require `OnCellEdited`. + Workspace page callbacks require named pages. Runtime applies binding and + callback effects through the same queued transaction, validates the final + state, and rolls back state and presentation together on failure. - Use `labkit.app.layout.fileList` for portable file records and selection. Multi-file collections use native multi-selection; a semantic single-file slot declares `MaxFiles=1` and single selection. File buttons describe only diff --git a/docs/history/records/2026/08/LK-20260806-app-runtime-input-boundaries.md b/docs/history/records/2026/08/LK-20260806-app-runtime-input-boundaries.md new file mode 100644 index 000000000..fece6fa5b --- /dev/null +++ b/docs/history/records/2026/08/LK-20260806-app-runtime-input-boundaries.md @@ -0,0 +1,93 @@ +# App runtime input boundaries fail closed and repair preserves live sessions + +```labkit-change +id: LK-20260806-app-runtime-input-boundaries +date: 2026-08-06 +sequence: 175 +type: fix +compatibility: compatible +component: `labkit.app` | `2.3.0 -> 2.3.1` +component: `labkit_launcher` | `1.8.3 -> 1.8.4` +scope: Native semantic input callback ownership +scope: Queued binding and callback transactions +scope: Live App installation repair protection +``` + +## Context + +The native workspace adapter installed a selection callback even when the App +declared no workspace page behavior. Selecting a page therefore routed a valid +native interaction into the generic value path, which rejected the workspace +as having no value behavior. Related input paths for bound controls and file +lists also performed their state work outside the ordinary callback queue, so +they did not share one lifecycle for serialization, diagnostics, event +resources, and rollback. Installation repair could meanwhile remove framework +classes from the MATLAB path while a running App still owned delayed UI work. + +## Decision and rationale + +Make declared semantic behavior the source of truth for every native callback. +Compile-time validation rejects editable surfaces with no state or callback +owner, and native callback and presentation switches fail closed when a future +kind has no explicit policy. Route binding preparation and optional App +callbacks through one queue item that prepares against the latest committed +state. Mark native App figures and refuse installation replacement while any +marked or structurally recognized LabKit App remains open. + +## Changes + +- Workspace selection installs only for a declared page callback and dispatches + through a page-specific runtime entry point. +- Bound controls and file-list selection now use the shared queued transaction, + callback diagnostics, event-resource cleanup, state validation, presentation + commit, and rollback lifecycle. +- Definition compilation rejects unowned editable controls, file lists, plot + view modes, editable tables, and page callbacks without named pages; dynamic + table edits receive the same presentation-time check. +- Native layout and presentation switches now report missing policies instead + of silently accepting an unsupported future kind. +- Standalone repair and full Launcher version updates check for live LabKit + figures before preparation and again immediately before replacing the + installation. + +## User and data impact + +Changing workspace pages without an App callback remains a native visual +operation and no longer raises an error. Declared callbacks receive their typed +semantic values, and bound changes commit atomically with callback effects. +Failures retain the deepest actionable message in the native alert. Repair +does not move or replace an installation while an App is open, preventing +running callbacks from losing class definitions. Project and result formats +are unchanged. + +## Compatibility and migration + +Tracked Apps already give every editable surface a behavior owner, so no App or +saved-project migration is required. Previously accepted definitions containing +an inert editable surface now fail during Definition compilation with an +actionable ownership error; those surfaces must add the documented binding or +callback, or become read-only. The version-2 compatibility range is unchanged. + +## Validation + +Focused App SDK specifications exercise compile-time and dynamic ownership +failures, real native events for fields, ranges, sliders, plot modes, table +edits and table selections, workspace pages with and without callbacks, +transaction diagnostics, rollback alerts, and busy lifecycle behavior. +Focused Launcher and version-management specifications exercise live-App +refusal together with the existing replacement, rollback, path restoration, +and local-data preservation contracts. + +## Evidence + +- `AppSdkSpec` passed 28/28 focused identities. +- `LauncherBootstrapSpec` passed 28/28 focused identities. +- Public App definition inventory found no tracked editable surface without a + declared behavior owner. + +## Known limitations and follow-up + +Hidden-GUI tests invoke real installed native callback functions but do not +prove pointer feel or rendering on every MATLAB release and operating system. +Repair cannot identify unrelated figures that imitate neither the stable App +tag nor the legacy workbench marker. diff --git a/labkit_launcher.m b/labkit_launcher.m index c57b069ee..d5b8ffe92 100644 --- a/labkit_launcher.m +++ b/labkit_launcher.m @@ -256,6 +256,7 @@ function runRepair(~, ~) setRepairStatus("Step 1 of 4 — Preparing target", ... "Preparing " + plan.action + " target..."); try + assertNoRunningLabKitApps(); prepareBootstrapTarget(target, root); result = repairFromZip(target, string(sourceChoice.Value), ... string(releaseChoice.Value), @setRepairStatus); @@ -691,6 +692,7 @@ function assertRepairRoot(root) end function replacement = replaceInstall(root, candidate, failAfterBackup) +assertNoRunningLabKitApps(); parent = fileparts(root); [~, name] = fileparts(root); backup = fullfile(parent, name + ".repair-backup-" + string(java.util.UUID.randomUUID())); @@ -738,6 +740,20 @@ function assertRepairRoot(root) delete(cleanup) end +function assertNoRunningLabKitApps() +figures = findall(groot, "Type", "figure"); +for index = 1:numel(figures) + figureHandle = figures(index); + isMarkedApp = strcmp(string(figureHandle.Tag), "labkitApp"); + hasWorkbench = ~isempty(findall( ... + figureHandle, "Tag", "labkitAppWorkbenchGrid")); + if isMarkedApp || hasWorkbench + error("labkit_launcher:AppsStillRunning", ... + "Close every running LabKit App before installing or repairing LabKit."); + end +end +end + function preservation = copyPreservedLocalContent(backup, root) relativePaths = preservedLocalPaths(); present = false(size(relativePaths)); diff --git a/tests/specs/labkit/app/AppSdkSpec.m b/tests/specs/labkit/app/AppSdkSpec.m index 0713b9799..8b6891368 100644 --- a/tests/specs/labkit/app/AppSdkSpec.m +++ b/tests/specs/labkit/app/AppSdkSpec.m @@ -281,6 +281,45 @@ function rejectsMalformedFilePathFilters(testCase) "labkit:app:contract:CallbackRoleMismatch"); end + function rejectsInteractiveLayoutsWithoutBehaviorOwners(testCase) + nodes = { ... + labkit.app.layout.field("field", Kind="numeric"), ... + labkit.app.layout.rangeField("range"), ... + labkit.app.layout.slider("slider"), ... + labkit.app.layout.fileList("files"), ... + labkit.app.layout.plotArea("plot", @drawNothing, ... + ViewModes=["First", "Second"]), ... + labkit.app.layout.dataTable("table", ... + Columns="Value", ColumnEditable=true)}; + for index = 1:numel(nodes) + node = nodes{index}; + testCase.verifyError(@() AppSdkSpec.definition( ... + labkit.app.layout.workbench({node})), ... + "labkit:app:contract:InvalidValue"); + end + workspace = labkit.app.layout.workspace( ... + OnPageChanged=@recordWorkspacePage); + testCase.verifyError(@() AppSdkSpec.definition( ... + labkit.app.layout.workbench({}, Workspace=workspace)), ... + "labkit:app:contract:InvalidValue"); + end + + function rejectsDynamicallyEditableTableWithoutEditCallback(testCase) + tableNode = labkit.app.layout.dataTable( ... + "table", Columns="Value"); + app = AppSdkSpec.definition( ... + labkit.app.layout.workbench({tableNode}), ... + "PresentWorkbench", @presentEditableTable); + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + journal = labkittest.temporarySessionJournal(app, root); + + testCase.verifyError(@() ... + labkit.app.internal.runtime.RuntimeFactory.createHeadless( ... + app, [], struct(), journal), ... + "labkit:app:contract:InvalidValue"); + end + function syntheticInputsAreDeliberateAndDoNotChangeTheRuntime(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; @@ -420,6 +459,133 @@ function insertsOpenAnchorsByVisiblePathLocation(testCase) end methods (Test, TestTags = {'Contract:source', 'Env:hidden-gui'}) + function nativeInputBridgeDispatchesEverySemanticControl(testCase) + layout = nativeBridgeLayout(); + app = AppSdkSpec.definition(layout, ... + "CreateSession", @createNativeBridgeSession); + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + journal = labkittest.temporarySessionJournal(app, root); + runtime = labkit.app.internal.runtime.RuntimeFactory.createMatlab( ... + app, [], struct(), journal); + cleanup = onCleanup(@() runtime.close()); + figureValue = runtime.figureHandle(); + + field = oneTagged(figureValue, "nativeField"); + field.Value = 2; + invokeNativeCallback(field.ValueChangedFcn, field, struct()); + + rangeStart = oneTagged(figureValue, "nativeRange"); + rangeEnd = oneTagged(figureValue, "nativeRange.end"); + rangeStart.Value = 0.2; + rangeEnd.Value = 0.8; + invokeNativeCallback( ... + rangeEnd.ValueChangedFcn, rangeEnd, struct()); + + spinner = oneTagged(figureValue, "nativeSlider"); + slider = oneTagged(figureValue, "nativeSlider.slider"); + spinner.Value = 0.4; + invokeNativeCallback( ... + spinner.ValueChangedFcn, spinner, struct()); + invokeNativeCallback( ... + slider.ValueChangingFcn, slider, struct("Value", 0.6)); + + mode = oneTagged(figureValue, "nativePlot.viewMode"); + mode.Value = "Second"; + invokeNativeCallback(mode.ValueChangedFcn, mode, struct()); + + tableHandle = oneTagged(figureValue, "nativeTable"); + tableHandle.Data = {2}; + editEvent = struct("Indices", [1 1], ... + "PreviousData", 1, "NewData", 2, "EditData", 2); + invokeNativeCallback( ... + tableHandle.CellEditCallback, tableHandle, editEvent); + if isprop(tableHandle, "SelectionChangedFcn") && ... + ~isempty(tableHandle.SelectionChangedFcn) + selectionCallback = tableHandle.SelectionChangedFcn; + else + selectionCallback = tableHandle.CellSelectionCallback; + end + selectionEvent = struct( ... + "Selection", [1 1], "Indices", [1 1]); + invokeNativeCallback( ... + selectionCallback, tableHandle, selectionEvent); + + state = runtime.State.session; + testCase.verifyEqual(state.fieldValue, 2); + testCase.verifyEqual(state.rangeValue, [0.2 0.8]); + testCase.verifyEqual(state.sliderValue, 0.6); + testCase.verifyEqual(state.plotMode, "Second"); + testCase.verifyEqual(state.editedValue, 2); + testCase.verifyEqual(state.selectedCells, [1 1]); + testCase.verifyEqual(string(figureValue.Tag), "labkitApp"); + events = runtime.diagnosticEvents(); + aliases = callbackStartAliases(events); + testCase.verifyTrue(all(ismember([ ... + "nativeField__valueChanged", ... + "nativeRange__valueChanged", ... + "nativeSlider__valueChanged", ... + "nativePlot__valueChanged", ... + "nativeTable__cellEdited", ... + "nativeTable__cellSelectionChanged"], aliases))); + clear cleanup + end + + function workspacePagesWithoutCallbackUseNativeSelectionOnly(testCase) + workspace = labkit.app.layout.workspace(); + workspace = workspace.page("firstPage", "First", ... + labkit.app.layout.statusPanel("firstStatus")); + workspace = workspace.page("secondPage", "Second", ... + labkit.app.layout.statusPanel("secondStatus")); + layout = labkit.app.layout.workbench({}, Workspace=workspace); + app = AppSdkSpec.definition(layout); + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + journal = labkittest.temporarySessionJournal(app, root); + runtime = labkit.app.internal.runtime.RuntimeFactory.createMatlab( ... + app, [], struct(), journal); + cleanup = onCleanup(@() runtime.close()); + figureValue = runtime.figureHandle(); + group = oneTagged(figureValue, "workspace"); + secondPage = oneTagged(figureValue, "secondPage"); + + testCase.verifyEmpty(group.SelectionChangedFcn); + group.SelectedTab = secondPage; + drawnow; + + testCase.verifyEqual(string(group.SelectedTab.Tag), "secondPage"); + clear cleanup + end + + function workspacePageCallbackReceivesSelectedPageId(testCase) + workspace = labkit.app.layout.workspace( ... + OnPageChanged=@recordWorkspacePage); + workspace = workspace.page("firstPage", "First", ... + labkit.app.layout.statusPanel("firstStatus")); + workspace = workspace.page("secondPage", "Second", ... + labkit.app.layout.statusPanel("secondStatus")); + layout = labkit.app.layout.workbench({}, Workspace=workspace); + app = AppSdkSpec.definition(layout, ... + "CreateSession", @createWorkspaceSession); + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + journal = labkittest.temporarySessionJournal(app, root); + runtime = labkit.app.internal.runtime.RuntimeFactory.createMatlab( ... + app, [], struct(), journal); + cleanup = onCleanup(@() runtime.close()); + figureValue = runtime.figureHandle(); + group = oneTagged(figureValue, "workspace"); + secondPage = oneTagged(figureValue, "secondPage"); + + group.SelectedTab = secondPage; + group.SelectionChangedFcn(group, []); + drawnow; + + testCase.verifyEqual( ... + runtime.State.session.selectedPage, "secondPage"); + clear cleanup + end + function updatesAFieldAndItsCachedLabelWithoutTreeDiscovery(testCase) layout = labkit.app.layout.workbench({ ... labkit.app.layout.field("gain", Kind="numeric", ... @@ -676,6 +842,28 @@ function invokeMenu(menu) drawnow; end +function invokeNativeCallback(callback, source, event) +if isa(callback, "function_handle") + callback(source, event); +else + callbackFunction = callback{1}; + callbackFunction(source, event, callback{2:end}); +end +drawnow; +end + +function aliases = callbackStartAliases(events) +aliases = strings(1, 0); +for index = 1:numel(events) + event = events(index); + if event.category == "runtime.callback" && ... + endsWith(event.eventName, ".started") && ... + isfield(event.attributes, "runtimeAlias") + aliases(end + 1) = string(event.attributes.runtimeAlias); + end +end +end + function folder = sdkArtifactFolder(category) versionPath = string(which("labkit.app.version")); root = string(fileparts(fileparts(fileparts(versionPath)))); @@ -765,10 +953,76 @@ function removeBusyProbe() session = struct(); end +function session = createWorkspaceSession(~, ~) +session = struct("selectedPage", ""); +end + +function applicationState = recordWorkspacePage( ... + applicationState, pageId, ~) +applicationState.session.selectedPage = pageId; +end + function view = presentProbe(~) view = labkit.app.view.Snapshot(); end +function view = presentEditableTable(~) +view = labkit.app.view.Snapshot().tableData( ... + "table", {1}, Columns="Value", ColumnEditable=true); +end + +function layout = nativeBridgeLayout() +controls = { ... + labkit.app.layout.field("nativeField", Kind="numeric", ... + Bind="session.fieldValue", OnValueChanged=@recordNativeField), ... + labkit.app.layout.rangeField("nativeRange", ... + Bind="session.rangeValue", OnValueChanged=@recordNativeRange), ... + labkit.app.layout.slider("nativeSlider", ... + Bind="session.sliderValue", OnValueChanged=@recordNativeSlider), ... + labkit.app.layout.plotArea("nativePlot", @drawNothing, ... + ViewModes=["First", "Second"], ... + OnValueChanged=@recordNativePlotMode), ... + labkit.app.layout.dataTable("nativeTable", ... + Columns="Value", ColumnEditable=true, ... + OnCellEdited=@recordNativeTableEdit, ... + OnCellSelectionChanged=@recordNativeTableSelection)}; +layout = labkit.app.layout.workbench(controls); +end + +function session = createNativeBridgeSession(~, ~) +session = struct( ... + "fieldValue", 0, "rangeValue", [0 1], ... + "sliderValue", 0, "plotMode", "First", ... + "editedValue", 0, "selectedCells", zeros(0, 2)); +end + +function state = recordNativeField(state, value, ~) +state.session.fieldValue = value; +end + +function state = recordNativeRange(state, value, ~) +state.session.rangeValue = value; +end + +function state = recordNativeSlider(state, value, ~) +state.session.sliderValue = value; +end + +function state = recordNativePlotMode(state, value, ~) +state.session.plotMode = value; +end + +function state = recordNativeTableEdit(state, edit, ~) +state.session.editedValue = edit.NewValue; +end + +function state = recordNativeTableSelection(state, selection, ~) +state.session.selectedCells = selection.CellIndices; +end + +function drawNothing(~, ~) +end + function pack = syntheticSample(~) pack = struct(); end diff --git a/tests/specs/labkit_launcher/LauncherBootstrapSpec.m b/tests/specs/labkit_launcher/LauncherBootstrapSpec.m index e5adf9632..25fb5fa12 100644 --- a/tests/specs/labkit_launcher/LauncherBootstrapSpec.m +++ b/tests/specs/labkit_launcher/LauncherBootstrapSpec.m @@ -338,6 +338,24 @@ function repairUiReplacesDamagedInstallAndRestoresFolder(testCase) delete(folderCleanup); delete(hookCleanup); delete(cleanup) end + function repairUiRefusesReplacementWhileALabKitAppIsOpen(testCase) + root = damagedRepairRoot(testCase, "old-marker", false); + candidate = validRepairCandidate(testCase, "new-marker", false); + [repairFigure, cleanup] = openRepairFixture(root); + hookCleanup = setRepairHook(struct("CandidateRoot", candidate)); + appFigure = uifigure("Visible", "off", "Tag", "labkitApp"); + appCleanup = onCleanup(@() delete(appFigure)); + + clickRepair(repairFigure); + + testCase.verifyTrue(contains(repairStatus(repairFigure), ... + "labkit_launcher:AppsStillRunning")); + testCase.verifyEqual(readMarker(root), "old-marker"); + testCase.verifyEmpty(dir(fullfile( ... + fileparts(root), "*.repair-backup-*"))); + delete(appCleanup); delete(hookCleanup); delete(cleanup) + end + function repairMigratesLocalDataAndRetainsARecoveryBackup(testCase) root = damagedRepairRoot(testCase, "old-marker", false); candidate = validRepairCandidate(testCase, "new-marker", false); diff --git a/tests/specs/tools/deployment/VersionManagementSpec.m b/tests/specs/tools/deployment/VersionManagementSpec.m index 89f2049a1..b446852bc 100644 --- a/tests/specs/tools/deployment/VersionManagementSpec.m +++ b/tests/specs/tools/deployment/VersionManagementSpec.m @@ -66,6 +66,34 @@ function installationWithoutLocalDataDeletesTheSiblingBackup(testCase) delete(cleanup); delete(toolCleanup) end + function updateRefusesTaggedAndLegacyRunningApps(testCase) + root = fixtureRoot(testCase, "old"); + candidate = fixtureCandidate(testCase, "new"); + toolCleanup = isolatedTool(""); + hookCleanup = setHook(struct( ... + "CandidateRoot", candidate, "Confirm", true)); + + for marker = ["figure", "workbench"] + appFigure = uifigure("Visible", "off"); + if marker == "figure" + appFigure.Tag = "labkitApp"; + else + workbench = uigridlayout(appFigure, [1 1]); + workbench.Tag = "labkitAppWorkbenchGrid"; + end + appCleanup = onCleanup(@() delete(appFigure)); + + testCase.verifyError(@() manageLabKitVersions( ... + root, "install", "Source", selectedSource()), ... + "LabKit:Deployment:AppsStillRunning"); + testCase.verifyEqual(readMarker(root), "old"); + testCase.verifyEmpty(dir(fullfile( ... + fileparts(root), "*.version-backup-*"))); + delete(appCleanup) + end + delete(hookCleanup); delete(toolCleanup) + end + function replacementAndRollbackRestoreOnlyValidRootPathSubtrees(testCase) root = fixtureRoot(testCase, "old"); candidate = fixtureCandidate(testCase, "new"); diff --git a/tools/deployment/manageLabKitVersions.m b/tools/deployment/manageLabKitVersions.m index 5b1e650d6..14ea74bc9 100644 --- a/tools/deployment/manageLabKitVersions.m +++ b/tools/deployment/manageLabKitVersions.m @@ -372,6 +372,7 @@ function configureTable(tableHandle, selectionCallback, doubleClickCallback) "", false, 0); return; end + assertNoRunningLabKitApps(); notify(opts.ProgressFcn, "Preparing update workspace...", .10); temporary = string(tempname); cleanup = onCleanup(@() removeFolder(temporary)); @@ -408,6 +409,7 @@ function configureTable(tableHandle, selectionCallback, doubleClickCallback) end function replacement = replaceInstallation(root, candidate, failAfterBackup) + assertNoRunningLabKitApps(); parent = fileparts(root); [~, name] = fileparts(root); backup = fullfile(parent, name + ".version-backup-" + string(java.util.UUID.randomUUID())); @@ -454,6 +456,20 @@ function configureTable(tableHandle, selectionCallback, doubleClickCallback) delete(cleanup) end +function assertNoRunningLabKitApps() + figures = findall(groot, "Type", "figure"); + for index = 1:numel(figures) + figureHandle = figures(index); + isMarkedApp = strcmp(string(figureHandle.Tag), "labkitApp"); + hasWorkbench = ~isempty(findall( ... + figureHandle, "Tag", "labkitAppWorkbenchGrid")); + if isMarkedApp || hasWorkbench + error("LabKit:Deployment:AppsStillRunning", ... + "Close every running LabKit App before updating LabKit."); + end + end +end + function rollback(root, backup, cause) if exist(root, "dir") == 7 [removed, message] = rmdir(root, "s"); From c3899888712d948eabe934e5b4d7266b5769231c Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Thu, 6 Aug 2026 11:30:09 -0500 Subject: [PATCH 05/12] fix: unify native app control layout --- .../MatlabPlatformAdapter.m | 15 ++- .../@MatlabPlatformAdapter/applyText.m | 11 ++- .../@MatlabPlatformAdapter/applyValue.m | 8 +- .../@MatlabPlatformAdapter/createComponent.m | 5 +- .../@MatlabPlatformAdapter/createField.m | 24 ++++- .../@MatlabPlatformAdapter/createFilePanel.m | 5 + .../installContentGrid.m | 19 +++- .../preferredRowHeight.m | 16 ++-- .../refreshReadonlySurfaces.m | 18 ++++ .../updateReadonlyHeight.m | 73 +++++++++++++++ .../+internal/+native/NativeAdapterValues.m | 35 +++++++ .../+native/private/installColumnResize.m | 4 + .../+native/private/installRowResize.m | 2 +- .../+native/private/nativeLayoutPolicy.m | 15 ++- +labkit/+app/+layout/field.m | 3 +- +labkit/+app/version.m | 2 +- AGENTS.md | 17 +++- docs/framework/README.md | 6 ++ ...20260806-consistent-app-control-density.md | 92 +++++++++++++++++++ tests/specs/labkit/app/AppSdkSpec.m | 55 +++++++++++ 20 files changed, 393 insertions(+), 32 deletions(-) create mode 100644 +labkit/+app/+internal/+native/@MatlabPlatformAdapter/refreshReadonlySurfaces.m create mode 100644 +labkit/+app/+internal/+native/@MatlabPlatformAdapter/updateReadonlyHeight.m create mode 100644 docs/history/records/2026/08/LK-20260806-consistent-app-control-density.md diff --git a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/MatlabPlatformAdapter.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/MatlabPlatformAdapter.m index 5cbf80f8b..8c20d7928 100644 --- a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/MatlabPlatformAdapter.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/MatlabPlatformAdapter.m @@ -22,6 +22,7 @@ PriorPointer (1, 1) string = "arrow" ClosePrompt DialogFolders + ReadonlyHeights Starting (1, 1) logical = false StartupStarted StartupPanel @@ -45,6 +46,8 @@ "ValueType", "any"); obj.DialogFolders = containers.Map("KeyType", "char", ... "ValueType", "char"); + obj.ReadonlyHeights = containers.Map("KeyType", "char", ... + "ValueType", "double"); policy = labkit.app.internal.native.NativeAdapterValues.layoutPolicy(); obj.Figure = uifigure(Visible="off", ... Name=char(string(title)), ... @@ -61,6 +64,8 @@ obj.Figure.Pointer = "watch"; setappdata(obj.Figure, "labkitAppBusy", true); obj.buildTree(); + setappdata(obj.Figure, "labkitAppLayoutChanged", ... + @() obj.refreshReadonlySurfaces()); obj.createStartupSurface(); obj.startupUpdate("Preparing runtime..."); end @@ -137,6 +142,8 @@ function show(obj, title) return end obj.Figure.Visible = "on"; + drawnow limitrate nocallbacks + obj.refreshReadonlySurfaces(); if mode == "minimized" && isprop(obj.Figure, "WindowState") obj.Figure.WindowState = "minimized"; end @@ -347,9 +354,13 @@ function createStartupSurface(obj) applyTableData(~, component, model) - applyText(~, component, value) + applyText(obj, component, value) + + applyValue(obj, component, value) + + updateReadonlyHeight(obj, component, value) - applyValue(~, component, value) + refreshReadonlySurfaces(obj) applyLimits(~, component, limits) diff --git a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyText.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyText.m index f8e924524..b0439f56c 100644 --- a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyText.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyText.m @@ -1,4 +1,4 @@ -function applyText(~, component, value) +function applyText(obj, component, value) % Class-folder implementation of MatlabPlatformAdapter.applyText. if isstruct(component.UserData) && ... isfield(component.UserData, "Status") && ... @@ -19,6 +19,15 @@ function applyText(~, component, value) elseif isprop(component, "Title") component.Title = value; end + if isstruct(component.UserData) && ... + isfield(component.UserData, "Readonly") && ... + component.UserData.Readonly && isprop(component, "Tooltip") + component.Tooltip = char(join( ... + labkit.app.internal.native.NativeAdapterValues.readonlyLines(value), ... + newline)); + obj.updateReadonlyHeight(component, value); + return + end labkit.app.internal.native.NativeAdapterValues.fitText(component); if isappdata(component, "labkitAppLogFollowLatest") && ... getappdata(component, "labkitAppLogFollowLatest") diff --git a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyValue.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyValue.m index 6d245eb68..b61ddecaf 100644 --- a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyValue.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/applyValue.m @@ -1,5 +1,11 @@ -function applyValue(~, component, value) +function applyValue(obj, component, value) % Class-folder implementation of MatlabPlatformAdapter.applyValue. + if isstruct(component.UserData) && ... + isfield(component.UserData, "Readonly") && ... + component.UserData.Readonly + obj.applyText(component, value); + return + end mode = labkit.app.internal.native.NativeAdapterValues.linkedPlotMode(component); if ~isempty(mode) mode.Value = value; diff --git a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createComponent.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createComponent.m index 64510d5f4..4a5ff0513 100644 --- a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createComponent.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createComponent.m @@ -35,7 +35,10 @@ Enable=labkit.app.internal.native.NativeAdapterValues.onOff(config.Enabled), ... Tooltip=char(config.Tooltip)); labkit.app.internal.native.NativeAdapterValues.fitText(component, ... - CharsPerStep=18, MaxShrinkSteps=3); + CharsPerStep=28, MaxShrinkSteps=2); + if isprop(component, "WordWrap") + component.WordWrap = "off"; + end case "field" component = obj.createField(parent, config, node.Id); case "rangeField" diff --git a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createField.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createField.m index 61a34657a..7455a6457 100644 --- a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createField.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createField.m @@ -20,13 +20,31 @@ component = uidropdown(parent, Items=choices, ... Value=value, Enable=labkit.app.internal.native.NativeAdapterValues.onOff(config.Enabled)); case "readonly" - component = uitextarea(parent, Editable="off", ... - Value=char(string(value)), ... + policy = labkit.app.internal.native.NativeAdapterValues.layoutPolicy(); + valuePanel = uipanel(parent, BorderType="none", ... + AutoResizeChildren="off"); + initialHeight = labkit.app.internal.native.NativeAdapterValues.readonlyHeight( ... + value, policy.ReadonlyDefaultWidth, policy.ReadonlyFontSize); + lines = labkit.app.internal.native.NativeAdapterValues.readonlyLines(value); + component = uitextarea(valuePanel, Editable="off", ... + Value=cellstr(lines), ... + WordWrap="on", ... + Position=[0 0 policy.ReadonlyDefaultWidth initialHeight], ... Enable=labkit.app.internal.native.NativeAdapterValues.onOff(config.Enabled)); + if isprop(component, "WordWrap") + component.WordWrap = "on"; + end otherwise component = uieditfield(parent, "text", ... Value=string(value), Enable=labkit.app.internal.native.NativeAdapterValues.onOff(config.Enabled)); end component.UserData = struct( ... - "LayoutContainer", layoutContainer); + "LayoutContainer", layoutContainer, ... + "Readonly", config.Kind == "readonly", ... + "NodeId", string(id)); + if config.Kind == "readonly" && isprop(component, "Tooltip") + component.Tooltip = char(join( ... + labkit.app.internal.native.NativeAdapterValues.readonlyLines(value), ... + newline)); + end end diff --git a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createFilePanel.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createFilePanel.m index 684086441..5626d920f 100644 --- a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createFilePanel.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/createFilePanel.m @@ -4,13 +4,18 @@ panel = uipanel(parent, BorderType="line", ... Title=char(config.Label)); if config.SelectionMode == "single" && config.MaxFiles == 1 + policy = labkit.app.internal.native.NativeAdapterValues.layoutPolicy(); grid = uigridlayout(panel, [1 2], Padding=[7 6 7 6], ... + RowHeight={policy.ButtonHeight}, ... ColumnWidth={140, '1x'}, ColumnSpacing=7); grid.Tag = char(node.Id + ".layout"); choose = uibutton(grid, Text=config.ChooseLabel, ... Tag=char(node.Id + ".choose"), ... Tooltip=char(config.ChooseTooltip)); labkit.app.internal.native.NativeAdapterValues.fitText(choose, CharsPerStep=18, MaxShrinkSteps=3); + if isprop(choose, "WordWrap") + choose.WordWrap = "off"; + end status = uieditfield(grid, Editable="off", ... Value=char(config.EmptyText), ... Tag=char(node.Id + ".status")); diff --git a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installContentGrid.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installContentGrid.m index 26285af09..76c176e50 100644 --- a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installContentGrid.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/installContentGrid.m @@ -18,18 +18,25 @@ function installContentGrid(obj, node, component) Padding=padding, ... RowSpacing=policy.ContentSpacing, ... ColumnSpacing=policy.ContentSpacing); - grid.RowHeight = repmat({'fit'}, 1, rows); + grid.RowHeight = repmat({policy.ButtonHeight}, 1, rows); grid.ColumnWidth = repmat({'1x'}, 1, columns); elseif horizontal grid = uigridlayout(component, [1 numel(node.ChildIds)], ... Padding=padding, ... RowSpacing=policy.ContentSpacing, ... ColumnSpacing=policy.ContentSpacing); + allButtons = true; + for k = 1:numel(node.ChildIds) + allButtons = allButtons && obj.node(node.ChildIds(k)).Kind == "button"; + end + if allButtons + grid.RowHeight = {policy.ButtonHeight}; + end grid.ColumnWidth = repmat({'1x'}, 1, numel(node.ChildIds)); else rowCount = numel(node.ChildIds); if node.Kind == "tab" - rowCount = 2 * rowCount; + rowCount = max(1, 2 * rowCount - 1); end grid = uigridlayout(component, [rowCount 1], ... Padding=padding, ... @@ -48,9 +55,11 @@ function installContentGrid(obj, node, component) heights{1} = "1x"; end if node.Kind == "tab" - expanded = cell(1, 2 * numel(heights)); + expanded = cell(1, max(1, 2 * numel(heights) - 1)); expanded(1:2:end) = heights; - expanded(2:2:end) = {policy.SplitterThickness}; + if numel(heights) > 1 + expanded(2:2:end) = {policy.SplitterThickness}; + end heights = expanded; end grid.RowHeight = heights; @@ -61,7 +70,7 @@ function installContentGrid(obj, node, component) grid.Tag = char(node.Id + ".layout"); obj.Layouts(char(node.Id)) = grid; if node.Kind == "tab" - for k = 1:numel(node.ChildIds) + for k = 1:max(0, numel(node.ChildIds) - 1) labkit.app.internal.native.NativeAdapterValues.installRowDivider(obj.Figure, grid, 2 * k - 1, 2 * k); end end diff --git a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/preferredRowHeight.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/preferredRowHeight.m index ebba76c5e..a15ef1459 100644 --- a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/preferredRowHeight.m +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/preferredRowHeight.m @@ -23,17 +23,19 @@ node.Configuration.Lines * policy.StatusLineHeight; end case "button" - height = labkit.app.internal.native.NativeAdapterValues.estimatedControlHeight( ... - node.Configuration.Label, 22, 2, ... - policy.ButtonHeight); + height = policy.ButtonHeight; case "slider" height = policy.SliderHeight; case "field" if node.Configuration.Kind == "readonly" - text = [string(node.Configuration.Label), ... - string(node.Configuration.Value)]; - height = labkit.app.internal.native.NativeAdapterValues.estimatedControlHeight( ... - text, 34, 3, policy.FieldHeight); + key = char(node.Id); + if isKey(obj.ReadonlyHeights, key) + height = obj.ReadonlyHeights(key); + else + height = labkit.app.internal.native.NativeAdapterValues.readonlyHeight( ... + node.Configuration.Value, policy.ReadonlyDefaultWidth, ... + policy.ReadonlyFontSize); + end elseif node.Configuration.Kind == "logical" height = labkit.app.internal.native.NativeAdapterValues.estimatedControlHeight( ... node.Configuration.Label, 42, 2, ... diff --git a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/refreshReadonlySurfaces.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/refreshReadonlySurfaces.m new file mode 100644 index 000000000..1ae45a131 --- /dev/null +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/refreshReadonlySurfaces.m @@ -0,0 +1,18 @@ +function refreshReadonlySurfaces(obj) +% Class-folder implementation of MatlabPlatformAdapter.refreshReadonlySurfaces. + if isempty(obj.Components) + return + end + components = values(obj.Components); + for index = 1:numel(components) + component = components{index}; + if isempty(component) || ~isvalid(component) || ... + ~isprop(component, "UserData") || ... + ~isstruct(component.UserData) || ... + ~isfield(component.UserData, "Readonly") || ... + ~component.UserData.Readonly + continue + end + obj.updateReadonlyHeight(component, component.Value); + end +end diff --git a/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/updateReadonlyHeight.m b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/updateReadonlyHeight.m new file mode 100644 index 000000000..f7704110e --- /dev/null +++ b/+labkit/+app/+internal/+native/@MatlabPlatformAdapter/updateReadonlyHeight.m @@ -0,0 +1,73 @@ +function updateReadonlyHeight(obj, component, value) +% Class-folder implementation of MatlabPlatformAdapter.updateReadonlyHeight. + if isempty(component) || ~isvalid(component) || ... + ~isstruct(component.UserData) || ... + ~isfield(component.UserData, "NodeId") + return + end + id = string(component.UserData.NodeId); + policy = labkit.app.internal.native.NativeAdapterValues.layoutPolicy(); + width = policy.ReadonlyDefaultWidth; + figureHandle = ancestor(component, "figure"); + if ~isempty(figureHandle) && isvalid(figureHandle) && ... + figureHandle.Visible == "on" && component.Position(3) > 0 + width = component.Position(3); + end + height = labkit.app.internal.native.NativeAdapterValues.readonlyHeight( ... + value, width, component.FontSize); + key = char(id); + node = obj.node(id); + chain = node; + owner = obj.owningNode(id); + while ~isempty(owner) && owner.Kind ~= "workbench" + chain(end + 1) = owner; + owner = obj.owningNode(owner.Id); + end + before = cell(1, numel(chain)); + for index = 1:numel(chain) + before{index} = obj.preferredRowHeight(chain(index)); + end + if isKey(obj.ReadonlyHeights, key) && ... + abs(obj.ReadonlyHeights(key) - height) < 0.5 + component.Position = [0 0 width height]; + return + end + obj.ReadonlyHeights(key) = height; + for index = 1:numel(chain) + after = obj.preferredRowHeight(chain(index)); + if ~(isnumeric(before{index}) && isnumeric(after)) + continue + end + delta = after - before{index}; + if abs(delta) < 0.5 + continue + end + if index == 1 + handle = component.UserData.LayoutContainer; + else + handle = obj.component(chain(index).Id); + handle = labkit.app.internal.native.NativeAdapterValues.layoutHandle(handle); + end + adjustOwningRow(handle, delta); + end + component.Position = [0 0 width height]; +end + +function adjustOwningRow(handle, delta) +if isempty(handle) || ~isvalid(handle) || ... + ~isprop(handle, "Layout") || ... + ~isa(handle.Parent, "matlab.ui.container.GridLayout") + return +end +row = handle.Layout.Row; +if ~isscalar(row) || row < 1 || row > numel(handle.Parent.RowHeight) + return +end +heights = handle.Parent.RowHeight; +current = heights{row}; +if ~(isnumeric(current) && isscalar(current) && isfinite(current)) + return +end +heights{row} = max(1, current + delta); +handle.Parent.RowHeight = heights; +end diff --git a/+labkit/+app/+internal/+native/NativeAdapterValues.m b/+labkit/+app/+internal/+native/NativeAdapterValues.m index d34aad5d9..13011dd63 100644 --- a/+labkit/+app/+internal/+native/NativeAdapterValues.m +++ b/+labkit/+app/+internal/+native/NativeAdapterValues.m @@ -266,6 +266,41 @@ function applyChoices(component, choices) end end + function height = readonlyHeight(value, width, fontSize) + policy = nativeLayoutPolicy(); + width = double(width); + if ~isscalar(width) || ~isfinite(width) || width <= 0 + width = policy.ReadonlyDefaultWidth; + end + fontSize = double(fontSize); + if ~isscalar(fontSize) || ~isfinite(fontSize) || fontSize <= 0 + fontSize = policy.ReadonlyFontSize; + end + charactersPerLine = max(8, floor( ... + (width - 12) / (0.58 * fontSize))); + lines = labkit.app.internal.native.NativeAdapterValues.readonlyLines(value); + if isempty(lines) + lineCount = 1; + else + lineCount = 0; + for index = 1:numel(lines) + units = sum(1 + (double(char(lines(index))) > 255)); + lineCount = lineCount + max(1, ceil(units / charactersPerLine)); + end + end + height = max(policy.ReadonlyMinimumHeight, ... + policy.ReadonlyChromeHeight + ... + max(1, lineCount) * policy.ReadonlyLineHeight); + end + + function lines = readonlyLines(value) + if ischar(value) + lines = string(value); + else + lines = string(value(:)); + end + end + function key = axisKey(target, axisId) key = string(target) + "." + string(axisId); end diff --git a/+labkit/+app/+internal/+native/private/installColumnResize.m b/+labkit/+app/+internal/+native/private/installColumnResize.m index fc4664b48..86f2d0328 100644 --- a/+labkit/+app/+internal/+native/private/installColumnResize.m +++ b/+labkit/+app/+internal/+native/private/installColumnResize.m @@ -85,5 +85,9 @@ function finishDrag() figureHandle.WindowKeyPressFcn = drag.Key; figureHandle.Pointer = drag.Pointer; drag.Active = false; + if isappdata(figureHandle, "labkitAppLayoutChanged") + callback = getappdata(figureHandle, "labkitAppLayoutChanged"); + callback(); + end end end diff --git a/+labkit/+app/+internal/+native/private/installRowResize.m b/+labkit/+app/+internal/+native/private/installRowResize.m index 873f46e8f..efe1b8c66 100644 --- a/+labkit/+app/+internal/+native/private/installRowResize.m +++ b/+labkit/+app/+internal/+native/private/installRowResize.m @@ -20,7 +20,7 @@ heights{separatorRow} = options.SeparatorHeight; grid.RowHeight = heights; separator = uipanel(grid, BorderType="none", ... - BackgroundColor=[0.72 0.72 0.72], ... + BackgroundColor=[0.86 0.86 0.86], ... Tag="labkitAppRowResize"); separator.Layout.Row = separatorRow; separator.Layout.Column = 1; diff --git a/+labkit/+app/+internal/+native/private/nativeLayoutPolicy.m b/+labkit/+app/+internal/+native/private/nativeLayoutPolicy.m index 9123ef3ab..e32e29da3 100644 --- a/+labkit/+app/+internal/+native/private/nativeLayoutPolicy.m +++ b/+labkit/+app/+internal/+native/private/nativeLayoutPolicy.m @@ -11,12 +11,12 @@ "FormLabelWidth", 145, ... "MinimumControlPaneWidth", 260, ... "MinimumWorkspaceWidth", 360, ... - "SplitterThickness", 6, ... + "SplitterThickness", 5, ... "SplitterSpacing", 2, ... "MinimumResizableRowHeight", 80, ... "OuterPadding", [8 8 8 8], ... "ContentPadding", [8 8 8 8], ... - "ContentSpacing", 8, ... + "ContentSpacing", 6, ... "CompactFileHeight", 72, ... "FileListHeight", 236, ... "FileListNoStatusHeight", 174, ... @@ -26,9 +26,14 @@ "StatusLineHeight", 22, ... "SummaryFontSize", 14, ... "UsageHeight", 124, ... - "ButtonHeight", 26, ... - "SliderHeight", 26, ... - "FieldHeight", 26, ... + "ButtonHeight", 32, ... + "SliderHeight", 30, ... + "FieldHeight", 30, ... + "ReadonlyDefaultWidth", 210, ... + "ReadonlyFontSize", 12, ... + "ReadonlyMinimumHeight", 60, ... + "ReadonlyLineHeight", 20, ... + "ReadonlyChromeHeight", 6, ... "SectionChromeHeight", 44, ... "UntitledSectionChromeHeight", 16, ... "GroupChromeHeight", 0); diff --git a/+labkit/+app/+layout/field.m b/+labkit/+app/+layout/field.m index 8074e927b..640b84e9a 100644 --- a/+labkit/+app/+layout/field.m +++ b/+labkit/+app/+layout/field.m @@ -13,7 +13,8 @@ % Options: % Label - Display text. Default: id. % Kind - "text", "numeric", "choice", "logical", or "readonly". -% Readonly fields display one labeled result value. Default: "text". +% Readonly fields automatically wrap and grow with their current text +% and available width. Default: "text". % Value - Initial value. Default: []. % Choices - Text row for choice fields. Default: strings(1,0). % Limits - Increasing finite numeric 1-by-2 row. Default: []. diff --git a/+labkit/+app/version.m b/+labkit/+app/version.m index 40a5bbaa5..aa05e502f 100644 --- a/+labkit/+app/version.m +++ b/+labkit/+app/version.m @@ -30,6 +30,6 @@ % labkit.app.Definition info = labkit.contract.versionInfo( ... - "app", "2.3.1", ">=2 <3", "stable", ... + "app", "2.4.0", ">=2 <3", "stable", ... "Explicit LabKit App SDK contract for tracked production Apps."); end diff --git a/AGENTS.md b/AGENTS.md index 39ec90df3..d0e12acfa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,7 +207,16 @@ tests, history, and details out of the public repository. coherent checkpoint merely to accumulate a larger batch. Once a `develop -> main` PR opens, freeze `develop` until the PR is merged or closed; do not mix later work into its moving head. -4. Keep branch work stable with purpose-based commits and focused validation. +4. Before every commit, inspect the complete intended diff against its + baseline and account for every changed file and meaningful hunk. Keep only + changes necessary for the requested outcome, its owned contract, and + proportionate evidence. Remove speculative APIs, types, options, App-owned + declarations, compatibility work, documentation, tests, and incidental + cleanup introduced while pursuing a narrower symptom. If the net diff is + substantially larger or more conceptual than the user outcome, stop and + revisit the design boundary before committing; do not preserve iteration + history or a discarded design merely because it has already been written. +5. Keep branch work stable with purpose-based commits and focused validation. Intermediate commit count is not a merge criterion. Before opening or merging the final PR, inspect the complete base-to-head diff, user docs, component versions, structured history, validation evidence, and remaining @@ -217,17 +226,17 @@ tests, history, and details out of the public repository. rewrites them from the `origin/main` baseline: remove intermediate version transitions, merge related checkpoint records, and leave exactly one changed structured history record for each versioned component. -5. Main accepts PRs only from the repository-owned `develop` branch. Run +6. Main accepts PRs only from the repository-owned `develop` branch. Run `changedFast` once before final review, inspect required PR CI, and read only failing logs. Squash-merge with an explicit compliant subject. -6. After the merge, inspect the exact lightweight main-push policy gate once +7. After the merge, inspect the exact lightweight main-push policy gate once and complete any authorized release from that exact commit. Do not repeat the MATLAB matrix already required on the up-to-date PR. Before deleting `develop`, verify its PR is merged, it has no unmerged commits, and no open PR depends on it. Delete local and remote `develop`, recreate both at the exact new `origin/main` commit, restore branch protection, and verify `develop == origin/main` before new work starts. Never create a sync commit. -7. Never force-push without explicit approval. Stop and report permission, +8. Never force-push without explicit approval. Stop and report permission, protection, review, CI, sync, or cleanup blockers rather than bypassing them. Branch protection must require `CI Gate`, PR review flow, linear main history, and conversation resolution for administrators as well as ordinary diff --git a/docs/framework/README.md b/docs/framework/README.md index e1449506e..5b30fee7f 100644 --- a/docs/framework/README.md +++ b/docs/framework/README.md @@ -76,6 +76,12 @@ and renderer signatures, and builds one private native platform plan. ## Paved Road - Bind ordinary state with `Bind="project..."` or `Bind="session..."`. +- Keep ordinary actions on the framework's consistent single-line native + button rhythm. A readonly `field` automatically wraps its current text and + grows or shrinks with the available value-column width; Apps do not declare + line counts or a separate message type. Control-group dividers appear only + between adjacent resizable sections, never as a trailing bar after the final + section. - Give every editable semantic surface one declared behavior owner before launch: editable fields, ranges, and sliders use `Bind` or `OnValueChanged`; file lists require `Bind`; plot view modes require diff --git a/docs/history/records/2026/08/LK-20260806-consistent-app-control-density.md b/docs/history/records/2026/08/LK-20260806-consistent-app-control-density.md new file mode 100644 index 000000000..efccc4339 --- /dev/null +++ b/docs/history/records/2026/08/LK-20260806-consistent-app-control-density.md @@ -0,0 +1,92 @@ +# App controls use consistent action rhythm and adaptive readonly height + +```labkit-change +id: LK-20260806-consistent-app-control-density +date: 2026-08-06 +sequence: 176 +type: fix +compatibility: compatible +component: `labkit.app` | `2.3.1 -> 2.4.0` +scope: Consistent native action sizing +scope: Adaptive readonly fields +scope: Control-panel divider density +``` + +## Context + +The native adapter estimated button height from label character count without +knowing the available width. Several single-line actions therefore occupied a +two-line row while neighboring actions retained the native single-line height. +Readonly values always used text areas, which put scroll affordances into +compact one-line status rows. Every control-tab section also received a heavy +divider after it, including the final section where the bar resembled an +unnecessary horizontal scrollbar. + +## Decision and rationale + +Keep workflow buttons single-line and use a consistent framework-owned action +row instead of guessing line count from text length. Keep complete action text +in the tooltip and permit bounded font fitting only for unusually long labels. +Render readonly values as framework-owned wrapped text surfaces that recompute +their height from current content and available value-column width. Apps keep +using `Kind="readonly"` without declaring line counts or a second field type. +Retain row resizing only between adjacent sections and present its separator +with lower visual contrast. + +## Changes + +- Native workflow buttons no longer wrap into label-dependent heights; + adaptive action grids and compact single-file selectors use the same action + row policy. +- Readonly fields use the existing App-facing kind while the native adapter + recomputes their wrapped height when text or available width changes. +- Readonly text surfaces avoid textarea scrollbars and retain the complete + current value in their tooltip. +- Control tabs omit the trailing resize bar after their final section and use + a lighter separator only between adjacent resizable sections. +- Existing Apps retain their declarations, versions, and documentation; no + App-specific type, line count, or geometry option is required. + +## User and data impact + +Buttons within a workflow now share a predictable visual rhythm instead of +changing height because one label crosses a character threshold. Compact +status values no longer show textarea scrollbars, while longer guidance remains +readable without transient clipping. Control panels retain scrolling and +between-section resizing with less visual weight. Scientific values, workflow +order, project state, calculations, plots, and exports are unchanged. + +## Compatibility and migration + +Readonly field syntax remains compatible within the LabKit App SDK 2 range. +Existing Apps receive the improved native presentation without source or +metadata changes. Saved projects and result files do not change and require no +migration. + +## Validation + +Framework specifications cover automatic readonly growth without geometry +options, equal single-line action heights, and bounded divider count. +All public App definitions and native construction are checked after the shared +layout change. A same-size hidden-GUI audit compares every public App before +and after the change. + +## Evidence + +- `AppSdkSpec` passed 29/29 focused identities with native button-height and + no-wrap assertions, adaptive-readonly coverage, and divider bounds. +- `AppDefinitionConformanceSpec` passed 42/42 identities across all 21 public + Apps without App-owned layout or requirement changes. +- The 21-App 1180-by-760 baseline found eight workflow actions at an unintended + 46-pixel height and 138 row dividers. The final audit launched and exported + all 21 Apps with 217 buttons, no button above 32 pixels, 88 between-section + dividers, and 113 text areas within a 60-to-110.234375-pixel range; its + screenshots remain ignored temporary visual evidence for final review. +- Documentation link validation covered 261 authored files with no unresolved + link, and `docsCheck` produced two byte-identical 389-file render trees. + +## Known limitations and follow-up + +Hidden-GUI exports prove native structure and geometry but not pointer feel, +text rendering at every display scale, or visual quality on every supported +MATLAB release. Final subjective inspection remains a manual GUI boundary. diff --git a/tests/specs/labkit/app/AppSdkSpec.m b/tests/specs/labkit/app/AppSdkSpec.m index 8b6891368..b19751c78 100644 --- a/tests/specs/labkit/app/AppSdkSpec.m +++ b/tests/specs/labkit/app/AppSdkSpec.m @@ -459,6 +459,61 @@ function insertsOpenAnchorsByVisiblePathLocation(testCase) end methods (Test, TestTags = {'Contract:source', 'Env:hidden-gui'}) + function nativeLayoutUsesConsistentButtonsAndBoundedDividers(testCase) + controls = { ... + labkit.app.layout.tab("controls", "Controls", { ... + labkit.app.layout.section("first", "First", { ... + labkit.app.layout.button("shortAction", ... + "Run", @runProbe), ... + labkit.app.layout.button("longAction", ... + "Measure length + curvature", @runProbe), ... + labkit.app.layout.field("summary", ... + Kind="readonly", ... + Value="Reader-facing status grows naturally as current content wraps across several lines in the available value column, without any App-owned line count, alternate control type, or layout-specific presentation option.")}), ... + labkit.app.layout.section("second", "Second", { ... + labkit.app.layout.button("exportAction", ... + "Export result CSV", @runProbe), ... + labkit.app.layout.field("compactSummary", ... + Kind="readonly", Value="Ready")})})}; + app = AppSdkSpec.definition( ... + labkit.app.layout.workbench(controls)); + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + journal = labkittest.temporarySessionJournal(app, root); + runtime = labkit.app.internal.runtime.RuntimeFactory.createMatlab( ... + app, [], struct(), journal); + cleanup = onCleanup(@() runtime.close()); + figureValue = runtime.figureHandle(); + + short = oneTagged(figureValue, "shortAction"); + long = oneTagged(figureValue, "longAction"); + export = oneTagged(figureValue, "exportAction"); + testCase.verifyEqual(short.Position(4), long.Position(4), ... + AbsTol=0.5); + testCase.verifyEqual(short.Position(4), export.Position(4), ... + AbsTol=0.5); + testCase.verifyGreaterThanOrEqual(short.Position(4), 22); + testCase.verifyLessThanOrEqual(short.Position(4), 32); + if isprop(long, "WordWrap") + testCase.verifyEqual(string(long.WordWrap), "off"); + end + testCase.verifyNumElements(findall( ... + figureValue, "Tag", "labkitAppRowResize"), 1); + summary = oneTagged(figureValue, "summary"); + compact = oneTagged(figureValue, "compactSummary"); + testCase.verifyClass(summary, "matlab.ui.control.TextArea"); + testCase.verifyClass(compact, "matlab.ui.control.TextArea"); + testCase.verifyEqual(string(compact.Tooltip), "Ready"); + testCase.verifyGreaterThan(summary.Position(4), ... + compact.Position(4)); + charHeight = labkit.app.internal.native.NativeAdapterValues.readonlyHeight( ... + 'Ready', 210, 12); + stringHeight = labkit.app.internal.native.NativeAdapterValues.readonlyHeight( ... + "Ready", 210, 12); + testCase.verifyEqual(charHeight, stringHeight); + clear cleanup + end + function nativeInputBridgeDispatchesEverySemanticControl(testCase) layout = nativeBridgeLayout(); app = AppSdkSpec.definition(layout, ... From d50e193202d7c1e7a3453f172a151f0951494628 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Thu, 6 Aug 2026 11:36:59 -0500 Subject: [PATCH 06/12] test: remove obsolete and duplicate guards --- tests/AGENTS.md | 18 ++++ .../workbench/BatchCropPresentationSpec.m | 11 +-- .../workbench/FlirThermalPresentationSpec.m | 11 +-- .../workbench/ImageEnhancePresentationSpec.m | 11 +-- .../workbench/ImageMatchPresentationSpec.m | 11 +-- .../workbench/VideoMarkerPresentationSpec.m | 12 +-- .../workbench/FigureStudioPresentationSpec.m | 11 +-- tests/specs/labkit/app/AppSdkSpec.m | 12 +-- .../labkit/app/SessionLoggingContractSpec.m | 4 +- .../app/SessionLoggingPrivacyContractSpec.m | 2 +- tests/specs/repository/TestArchitectureSpec.m | 90 ++----------------- 11 files changed, 43 insertions(+), 150 deletions(-) diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 27976c0c1..7831f4d16 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -13,6 +13,24 @@ only synthetic inputs reused by more than one specification owner; a fixture used by one owner stays beside that specification. Do not create a generic shared, support, or helper directory. +Tests protect current observable contracts and costly regressions, not the +history of how the repository reached its present design. Remove a test when +its behavior is retired; do not keep rejection tests for already-removed +options, absence checks for completed migration paths, exact implementation +file maps, arbitrary size/count limits, or assertions whose only purpose is to +enforce taste. When compatibility remains supported, test the saved input and +current output rather than the migration project or intermediate shape that +introduced it. Before adding a narrow regression, first extend an existing +owner-level behavior proof when that preserves a clear failure identity. + +Place a rule that applies uniformly to every App in `apps/conformance/`, or at +the narrowest family root when it truly applies to that whole family. Do not +copy the same guard into individual Apps, because that both duplicates upkeep +and leaves unlisted Apps unprotected. Keep App-local tests when scientific +meaning, fixtures, workflow state, output schema, or failure semantics are +genuinely App-owned; do not force abstraction when it would hide those +differences or require a parallel test-only product model. + Production Apps and downstream App specifications, including accepted private repositories, never call `labkit.app.internal`. Use the focused `labkittest` test seams for runtime construction, callback contexts, compiled diff --git a/tests/specs/apps/image_measurement/batch_crop/workbench/BatchCropPresentationSpec.m b/tests/specs/apps/image_measurement/batch_crop/workbench/BatchCropPresentationSpec.m index 279d585d8..8d0909bbe 100644 --- a/tests/specs/apps/image_measurement/batch_crop/workbench/BatchCropPresentationSpec.m +++ b/tests/specs/apps/image_measurement/batch_crop/workbench/BatchCropPresentationSpec.m @@ -3,18 +3,11 @@ methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) function declaresSourceCropScaleAndExportControls(testCase) - ids = nodeIds(batch_crop.workbench.buildLayout()); + plan = labkittest.inspectDefinition(batch_crop.definition()); + ids = string({plan.Nodes.Id}); testCase.verifyTrue(all(ismember( ... ["images" "cropWidth" "exportCrops" "resultTable"], ids))); end end end - -function ids = nodeIds(node) -ids = string(node.Id); -if ~isempty(node.Children) - childIds = cellfun(@nodeIds, node.Children, UniformOutput=false); - ids = [ids; vertcat(childIds{:})]; -end -end diff --git a/tests/specs/apps/image_measurement/flir_thermal/workbench/FlirThermalPresentationSpec.m b/tests/specs/apps/image_measurement/flir_thermal/workbench/FlirThermalPresentationSpec.m index 3f75882d6..288afddc3 100644 --- a/tests/specs/apps/image_measurement/flir_thermal/workbench/FlirThermalPresentationSpec.m +++ b/tests/specs/apps/image_measurement/flir_thermal/workbench/FlirThermalPresentationSpec.m @@ -3,7 +3,8 @@ methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) function declaresFileDisplayReadingAndExportControls(testCase) - ids = nodeIds(flir_thermal.workbench.buildLayout()); + plan = labkittest.inspectDefinition(flir_thermal.definition()); + ids = string({plan.Nodes.Id}); testCase.verifyTrue(all(ismember( ... ["thermalFiles" "summaryTable" "exportCurrent" "exportAll"], ids))); @@ -22,11 +23,3 @@ function rendersNonlinearColorsWithoutMutatingTemperatures(testCase) end end end - -function ids = nodeIds(node) -ids = string(node.Id); -if ~isempty(node.Children) - childIds = cellfun(@nodeIds, node.Children, UniformOutput=false); - ids = [ids; vertcat(childIds{:})]; -end -end diff --git a/tests/specs/apps/image_measurement/image_enhance/workbench/ImageEnhancePresentationSpec.m b/tests/specs/apps/image_measurement/image_enhance/workbench/ImageEnhancePresentationSpec.m index d52e3814b..af7b5f772 100644 --- a/tests/specs/apps/image_measurement/image_enhance/workbench/ImageEnhancePresentationSpec.m +++ b/tests/specs/apps/image_measurement/image_enhance/workbench/ImageEnhancePresentationSpec.m @@ -3,7 +3,8 @@ methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) function declaresSourceHistoryPreviewAndExportWorkflow(testCase) - ids = nodeIds(image_enhance.workbench.buildLayout()); + plan = labkittest.inspectDefinition(image_enhance.definition()); + ids = string({plan.Nodes.Id}); testCase.verifyTrue(all(ismember( ... ["sourceImages" "applyTool" "historyTable" "exportImages"], ids))); @@ -60,11 +61,3 @@ function defaultWhiteRoiStartsAtTheImageCornerAndClampsToSmallImages(testCase) end end end - -function ids = nodeIds(node) -ids = string(node.Id); -if ~isempty(node.Children) - childIds = cellfun(@nodeIds, node.Children, UniformOutput=false); - ids = [ids; vertcat(childIds{:})]; -end -end diff --git a/tests/specs/apps/image_measurement/image_match/workbench/ImageMatchPresentationSpec.m b/tests/specs/apps/image_measurement/image_match/workbench/ImageMatchPresentationSpec.m index 79f0a8538..a3d2220eb 100644 --- a/tests/specs/apps/image_measurement/image_match/workbench/ImageMatchPresentationSpec.m +++ b/tests/specs/apps/image_measurement/image_match/workbench/ImageMatchPresentationSpec.m @@ -3,7 +3,8 @@ methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) function declaresReferenceSourceHistoryAndExportControls(testCase) - ids = nodeIds(image_match.workbench.buildLayout()); + plan = labkittest.inspectDefinition(image_match.definition()); + ids = string({plan.Nodes.Id}); testCase.verifyTrue(all(ismember( ... ["referenceImage" "sourceImages" "applyMatch" "historyTable" "exportImages"], ids))); @@ -21,11 +22,3 @@ function reportsOriginalOutputDimensionsRatherThanPreviewDimensions(testCase) end end end - -function ids = nodeIds(node) -ids = string(node.Id); -if ~isempty(node.Children) - childIds = cellfun(@nodeIds, node.Children, UniformOutput=false); - ids = [ids; vertcat(childIds{:})]; -end -end diff --git a/tests/specs/apps/image_measurement/video_marker/workbench/VideoMarkerPresentationSpec.m b/tests/specs/apps/image_measurement/video_marker/workbench/VideoMarkerPresentationSpec.m index f12e5f62b..f883699df 100644 --- a/tests/specs/apps/image_measurement/video_marker/workbench/VideoMarkerPresentationSpec.m +++ b/tests/specs/apps/image_measurement/video_marker/workbench/VideoMarkerPresentationSpec.m @@ -3,8 +3,8 @@ methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) function declaresTheVideoMarkingAndCoordinateExportWorkflow(testCase) - layout = video_marker.workbench.buildLayout(); - ids = nodeIds(layout); + plan = labkittest.inspectDefinition(video_marker.definition()); + ids = string({plan.Nodes.Id}); testCase.verifyTrue(all(ismember( ... ["skeletonPreset" "videoFile" "currentFrame" ... @@ -12,11 +12,3 @@ function declaresTheVideoMarkingAndCoordinateExportWorkflow(testCase) end end end - -function ids = nodeIds(node) -ids = string(node.Id); -if ~isempty(node.Children) - childIds = cellfun(@nodeIds, node.Children, UniformOutput=false); - ids = [ids; vertcat(childIds{:})]; -end -end diff --git a/tests/specs/apps/labkit_core/figure_studio/workbench/FigureStudioPresentationSpec.m b/tests/specs/apps/labkit_core/figure_studio/workbench/FigureStudioPresentationSpec.m index 63107ca9f..13aaa914f 100644 --- a/tests/specs/apps/labkit_core/figure_studio/workbench/FigureStudioPresentationSpec.m +++ b/tests/specs/apps/labkit_core/figure_studio/workbench/FigureStudioPresentationSpec.m @@ -3,7 +3,8 @@ methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) function declaresFigureSourceStyleAndExportControls(testCase) - ids = nodeIds(figure_studio.workbench.buildLayout()); + plan = labkittest.inspectDefinition(figure_studio.definition()); + ids = string({plan.Nodes.Id}); testCase.verifyTrue(all(ismember( ... ["preview" "outputFolder" "exportCurrent"], ids))); @@ -11,11 +12,3 @@ function declaresFigureSourceStyleAndExportControls(testCase) end end end - -function ids = nodeIds(node) -ids = string(node.Id); -if ~isempty(node.Children) - childIds = cellfun(@nodeIds, node.Children, UniformOutput=false); - ids = [ids; vertcat(childIds{:})]; -end -end diff --git a/tests/specs/labkit/app/AppSdkSpec.m b/tests/specs/labkit/app/AppSdkSpec.m index b19751c78..faecc974e 100644 --- a/tests/specs/labkit/app/AppSdkSpec.m +++ b/tests/specs/labkit/app/AppSdkSpec.m @@ -41,14 +41,6 @@ function validatesDefinitionMetadataAndCallbackRoles(testCase) "CreateSession", @wrongSession), "labkit:app:contract:CallbackRoleMismatch"); end - function rejectsRetiredLaunchDiagnosticsOption(testCase) - app = AppSdkSpec.definition(labkit.app.layout.workbench({})); - - testCase.verifyError(@() app.launch( ... - Diagnostics=struct()), ... - "labkit:app:contract:UnknownArgument"); - end - function exposesTypedEventsRatherThanAmbiguousTransport(testCase) edit = labkit.app.event.TableCellEdit( ... RowId="row-a", RowIndex=1, ColumnId="group", ColumnIndex=2, ... @@ -97,7 +89,7 @@ function separatesInformationalAndErrorDialogs(testCase) testCase.verifyEqual(observed("kind"), "error"); end - function nativeDialogFiltersContainOnlyLegacyCharacterCells(testCase) + function nativeDialogFiltersUseMatlabCharacterCells(testCase) filters = labkit.app.internal.native.NativeAdapterValues.dialogFilters( ... {"*.zip", "Diagnostic bundle (*.zip)"}); scalarFilter = ... @@ -417,7 +409,7 @@ function preventsExternalProjectOverwriteAndAllowsSaveAs(testCase) clear cleanup end - function explicitSourceBindingsPreserveLegacyInference(testCase) + function explicitSourceBindingsOverrideDefaultInference(testCase) inferred = labkit.app.project.Schema(Version=1, ... Create=@createSourceProject, Validate=@validateSourceProject); explicit = labkit.app.project.Schema(Version=1, ... diff --git a/tests/specs/labkit/app/SessionLoggingContractSpec.m b/tests/specs/labkit/app/SessionLoggingContractSpec.m index fb38bf59f..fe303da93 100644 --- a/tests/specs/labkit/app/SessionLoggingContractSpec.m +++ b/tests/specs/labkit/app/SessionLoggingContractSpec.m @@ -1,8 +1,8 @@ classdef SessionLoggingContractSpec < matlab.unittest.TestCase - %SESSIONLOGGINGCONTRACTSPEC Freeze the v1 retained-event migration target. + %SESSIONLOGGINGCONTRACTSPEC Specify the canonical retained-event schema. methods (Test, TestTags = {'Contract:source', 'Env:headless'}) - function retainsOnlyTheMinimalV1EventSchema(testCase) + function retainsOnlyTheCanonicalEventFields(testCase) root = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; definition = loggingProbeDefinition(); diff --git a/tests/specs/labkit/app/SessionLoggingPrivacyContractSpec.m b/tests/specs/labkit/app/SessionLoggingPrivacyContractSpec.m index f17fcbf61..4ec7e4941 100644 --- a/tests/specs/labkit/app/SessionLoggingPrivacyContractSpec.m +++ b/tests/specs/labkit/app/SessionLoggingPrivacyContractSpec.m @@ -1,5 +1,5 @@ classdef SessionLoggingPrivacyContractSpec < matlab.unittest.TestCase - %SESSIONLOGGINGPRIVACYCONTRACTSPEC Freeze full-detail logging boundary. + %SESSIONLOGGINGPRIVACYCONTRACTSPEC Specify the full-detail logging boundary. methods (Test, TestTags = {'Contract:source', 'Env:headless'}) function passesCompleteDetailsToTheLoggingBackend(testCase) diff --git a/tests/specs/repository/TestArchitectureSpec.m b/tests/specs/repository/TestArchitectureSpec.m index e737c7214..80fd88c94 100644 --- a/tests/specs/repository/TestArchitectureSpec.m +++ b/tests/specs/repository/TestArchitectureSpec.m @@ -2,14 +2,6 @@ %TESTARCHITECTURESPEC Specify one active owner/contract test architecture. methods (Test, TestTags = {'Contract:system', 'Env:headless'}) - function generatedApiExcludesInternalPackages(testCase) - root = labkittest.setup(); - - testCase.verifyFalse(isfolder(fullfile( ... - root, "site", "reference", "api", "labkit", ... - "app", "internal"))); - end - function appSdkInternalRootContainsNoImplementationTypes(testCase) root = labkittest.setup(); internalRoot = fullfile(root, "+labkit", "+app", "+internal"); @@ -40,49 +32,7 @@ function launcherDispatchRemainsCompositionOnly(testCase) end end - function runtimeKernelKeepsCompleteWorkflowsInClassFolder(testCase) - root = labkittest.setup(); - folder = fullfile(root, "+labkit", "+app", "+internal", ... - "+runtime", "@RuntimeKernel"); - required = ["RuntimeKernel" "applyBoundControl" ... - "applyFileSelection" "commitFilePanel" "completeBackend" ... - "execute" "present" "restoreProject" ... - "wrapDialogOperations"] + ".m"; - - testCase.verifyTrue(all(arrayfun(@(name) ... - isfile(fullfile(folder, name)), required)), ... - "RuntimeKernel workflow methods must remain separately owned."); - main = string(fileread(fullfile(folder, "RuntimeKernel.m"))); - testCase.verifyLessThanOrEqual(numel(splitlines(main)), 800, ... - "RuntimeKernel definition is accumulating workflow bodies again."); - end - - function activeEntryPointsDescribeOnlyTheCatalogModel(testCase) - root = labkittest.setup(); - build = text(root, "buildfile.m"); - guide = text(root, "docs/development/maintain-and-release/testing.md"); - testsGuide = text(root, "tests/AGENTS.md"); - migrationGuide = text(root, ".agents/migration_guide.md"); - skillFiles = activeSkillFiles(root, "labkit-test-planner"); - - testCase.verifySubstring(build, "labkittest.run"); - testCase.verifyFalse(contains(build, "runLabKitTests")); - testCase.verifyFalse(contains(build, "tests/runner")); - testCase.verifySubstring(migrationGuide, ... - "tests/+labkittest/toolboxDebt.m"); - testCase.verifyFalse(contains(migrationGuide, ... - "tests/runner/labkitToolboxDebt.m")); - activeTexts = [guide; testsGuide; ... - arrayfun(@(file) string(fileread(file)), skillFiles(:))]; - for active = activeTexts.' - testCase.verifyFalse(contains(active, "tests/cases")); - testCase.verifyFalse(contains(active, "runLabKitTests")); - testCase.verifyFalse(contains(active, "tests/runner")); - end - end - - function catalogOwnsTheOnlyRunnableSpecificationRoot(testCase) - root = labkittest.setup(); + function catalogDescriptorsUseCurrentOwnerRoots(testCase) descriptors = labkittest.catalog(); testCase.verifyNotEmpty(descriptors); @@ -91,13 +41,6 @@ function catalogOwnsTheOnlyRunnableSpecificationRoot(testCase) ismember(string({descriptors.Owner}), ... ["labkit_launcher" "repository"]) | ... string({descriptors.Owner}) == "")); - testCase.verifyFalse(isfile(fullfile(root, "tests", "runLabKitTests.m")) && ... - isfile(fullfile(root, "buildfile.m")) && ... - contains(text(root, "buildfile.m"), "runLabKitTests")); - testCase.verifyTrue(isfolder(fullfile(root, "tests", "+testfixtures"))); - testCase.verifyFalse(isfolder(fullfile(root, "tests", "shared"))); - testCase.verifyFalse(isfolder(fullfile(root, "tests", "cases"))); - testCase.verifyFalse(isfolder(fullfile(root, "tests", "runner"))); end function productionDynamicInvocationIsClosedAndOwned(testCase) @@ -171,18 +114,11 @@ function ciRoutesDocumentationWithoutWeakeningAggregateGate(testCase) "needs.change-scope.outputs.docs == 'true'"); testCase.verifySubstring(workflow, "docs-check:"); testCase.verifySubstring(workflow, "tasks: docsCheck"); - testCase.verifyEqual(count(workflow, ... - "release: R2022b"), 2); - testCase.verifyEqual(count(workflow, ... - "release: latest"), 6); - testCase.verifyEqual(count(workflow, ... - "shard: All profiles"), 3); - testCase.verifyEqual(count(workflow, ... - "shard: Core"), 2); - testCase.verifyEqual(count(workflow, ... - "shard: Hidden GUI"), 2); - testCase.verifyEqual(count(workflow, ... - " - os: "), 7); + testCase.verifySubstring(workflow, "release: R2022b"); + testCase.verifySubstring(workflow, "release: latest"); + testCase.verifySubstring(workflow, "shard: All profiles"); + testCase.verifySubstring(workflow, "shard: Core"); + testCase.verifySubstring(workflow, "shard: Hidden GUI"); testCase.verifySubstring(workflow, "os: ubuntu-22.04"); testCase.verifySubstring(workflow, "os: windows-2022"); testCase.verifySubstring(workflow, "os: macos-14"); @@ -192,10 +128,8 @@ function ciRoutesDocumentationWithoutWeakeningAggregateGate(testCase) "Xvfb :99 -screen 0 1920x1080x24"); testCase.verifySubstring(workflow, ... "Documents/MATLAB"); - testCase.verifyEqual(count(workflow, ... - "release: ${{ matrix.release }}"), 1); - testCase.verifyEqual(count(workflow, ... - "continue-on-error: true"), 3); + testCase.verifySubstring(workflow, ... + "release: ${{ matrix.release }}"); testCase.verifySubstring(workflow, ... "name: matlab-${{ matrix.id }}-${{ matrix.release }}-" + ... "${{ matrix.shard_id }}"); @@ -468,14 +402,6 @@ function runtimeFactoryParserAcceptsExplicitJournalRootWithEmptyJournal(testCase value = string(fileread(fullfile(root, relative))); end -function files = activeSkillFiles(root, skillName) -listing = dir(fullfile(root, ".agents", "skills", skillName, "**", "*")); -listing = listing(~[listing.isdir]); -extensions = [".m" ".md"]; -paths = string(fullfile({listing.folder}, {listing.name})); -files = paths(endsWith(lower(paths), extensions)); -end - function files = repositoryTextFiles(root) [status, output] = system("git -C " + shellQuote(root) + ... " ls-files --cached --others --exclude-standard"); From c2247c48b352e89a941f093f9773e05e4ef7f306 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Thu, 6 Aug 2026 11:42:45 -0500 Subject: [PATCH 07/12] docs: focus guidance on reader tasks --- AGENTS.md | 9 + docs/apps/statistics/README.md | 1 - docs/apps/statistics/ttest-wizard/README.md | 1 - docs/development/README.md | 1 - docs/development/build-apps/architecture.md | 4 - .../group-comparison-app-design.md | 227 ------------------ .../scientific-csv-interchange.md | 2 - .../maintain-and-release/testing.md | 86 ++----- ...260718-ttest-wizard-and-table-workspace.md | 1 - .../07/LK-20260720-app-action-tooltips.md | 6 +- ...app-tools-menu-and-screenshot-clipboard.md | 6 +- ...260720-figure-studio-semantic-restyling.md | 6 +- ...K-20260720-launcher-app-sdk-diagnostics.md | 6 +- ...LK-20260720-public-api-escalation-order.md | 6 +- ...r-oriented-app-manuals-and-test-wrapper.md | 6 +- ...0260720-ttest-plot-viewport-and-density.md | 6 +- 16 files changed, 59 insertions(+), 315 deletions(-) delete mode 100644 docs/development/data-and-designs/group-comparison-app-design.md diff --git a/AGENTS.md b/AGENTS.md index d0e12acfa..354850a87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,15 @@ under `docs/`. - Update human docs for user behavior or public contracts, scoped AGENTS for execution/ownership rules, and both only when both changed. Do not duplicate agent workflow in human manuals. +- Treat documentation as a reader interface, not a diff narrative or an + accumulation sink. Every addition must help a reader perform a supported + task, call a public API, understand current behavior, interpret an output, + or recover from a documented failure. Do not mechanically restate private + source structure, implementation order, test inventories, commit evidence, + or completed migration plans in current manuals. Put durable change + rationale and compatibility evidence in component history, and delete a + delivered design page once the current manual and API reference own its + useful behavior. - Every public library function documents syntax, inputs, outputs, options, defaults, legal values, errors, and related APIs immediately after its declaration. Cataloged scientific app APIs also document units, assumptions, diff --git a/docs/apps/statistics/README.md b/docs/apps/statistics/README.md index e9696e3fc..a306d5c7a 100644 --- a/docs/apps/statistics/README.md +++ b/docs/apps/statistics/README.md @@ -28,5 +28,4 @@ experiment schema. - [T-Test Wizard manual](ttest-wizard/README.md) - [Simple Scientific CSV Exchange](../../development/data-and-designs/scientific-csv-interchange.md) -- [T-Test Wizard design](../../development/data-and-designs/group-comparison-app-design.md) - [All Apps](../README.md) diff --git a/docs/apps/statistics/ttest-wizard/README.md b/docs/apps/statistics/ttest-wizard/README.md index b9be38c29..12b565b8c 100644 --- a/docs/apps/statistics/ttest-wizard/README.md +++ b/docs/apps/statistics/ttest-wizard/README.md @@ -240,7 +240,6 @@ migrate by preserving A as the first group and B as the second group. ## Related Documentation - [Statistics Apps](../README.md) -- [T-Test Wizard design](../../../development/data-and-designs/group-comparison-app-design.md) - [Simple Scientific CSV Exchange](../../../development/data-and-designs/scientific-csv-interchange.md) - [Figure Studio](../../labkit-core/figure-studio/README.md) - [App Framework](../../../framework/README.md) diff --git a/docs/development/README.md b/docs/development/README.md index 717c87aed..3e66b551d 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -11,7 +11,6 @@ only [Getting started](../getting-started/README.md) and the | Understand repository and package ownership | [Architecture](build-apps/architecture.md) | | Create or refactor an app | [App development](build-apps/app-development.md) and [complete app tutorial](build-apps/complete-app.md) | | Exchange simple scientific tables or gradually improve an App CSV export | [Simple scientific CSV exchange](data-and-designs/scientific-csv-interchange.md) | -| Develop the proposed cell-selection-based t-test App | [T-Test Wizard design](data-and-designs/group-comparison-app-design.md) | | Work with an ignored private app repository | [Private apps](maintain-and-release/private-apps.md) | | Select tests, GUI checks, or profiling | [Testing](maintain-and-release/testing.md) | | Call source-checkout packaging, profiling, codecheck, or documentation tools | [Maintainer tools](tools/README.md) | diff --git a/docs/development/build-apps/architecture.md b/docs/development/build-apps/architecture.md index dcb6ac540..c3f4a197b 100644 --- a/docs/development/build-apps/architecture.md +++ b/docs/development/build-apps/architecture.md @@ -171,10 +171,6 @@ generic `+actions`, `+renderers`, `+ops`, `+io`, `+ui`, `+userInterface`, family-level `private/` helpers, `*Workflow.m` string dispatchers, and `+core/dispatch.m` routers. -`+state`, `+actions`, `+ui`, `+view`, `+ops`, `+io`, and `+export` packages -were retired with the workflow-first migration. Current app work should follow -the workflow-first shape. - ## App SDK Boundary App GUIs use the explicit `labkit.app` SDK: diff --git a/docs/development/data-and-designs/group-comparison-app-design.md b/docs/development/data-and-designs/group-comparison-app-design.md deleted file mode 100644 index 6520ca924..000000000 --- a/docs/development/data-and-designs/group-comparison-app-design.md +++ /dev/null @@ -1,227 +0,0 @@ -# T-Test Wizard Design - -[Development index](../README.md) | -[App manual](../../apps/statistics/ttest-wizard/README.md) | -[App development](../build-apps/app-development.md) | -[Simple scientific CSV exchange](scientific-csv-interchange.md) - -## Product Boundary - -T-Test Wizard answers: - -> Given two or more ordered numeric groups, what are the configured t-test -> results for every later group versus the first, and how should that completed -> family be visualized? - -The App owns visible group selection, order, test settings, result rows, plot -meaning, export schemas, and failure wording. It does not choose the -scientifically correct test, infer independence or pairing, apply automatic -multiple-comparison correction, or replace ANOVA and repeated-measures models. - -| Field | Value | -| --- | --- | -| Display name | T-Test Wizard | -| Command | `labkit_TTestWizard_app` | -| App ID | `ttest_wizard` | -| Family | Statistics | -| Version | 1.0.0 | - -No new `+labkit` facade is involved. The multi-group policy stays App-local. - -## Workflow - -```text -Select or enter groups - -> order groups with reference first - -> choose one test specification - -> run group 2..N versus group 1 - -> review result family - -> draw bar or box comparison plot - -> export group and result CSVs -``` - -The left controls are Data, Test & Plot, Export, and Log. The right workspace -has native Data and Plot pages. Data stacks the opened source table and -editable analysis table at equal height; Plot gives the figure the full -workspace. A compact freshness field stays beside the plot controls. Result -rows stay beside the test controls, removing a result-only navigation step. - -## Data Boundary - -The source grid accepts readable CSV, TSV, XLSX, and XLS tables. Selection is -schema-free: finite numeric cells and numeric text are copied in visible -spreadsheet order; blanks and labels are skipped with counts; nonfinite numeric -cells block the selected range. - -Each capture appends one durable group: - -```text -group.label -group.values -group.sourceDisplayName -group.sheet -group.cellAddresses -``` - -Only `label` and `values` participate in calculation identity. Source metadata -supports traceability and remains App-owned. A source can be replaced without -discarding captured groups. - -The ordered group editor has `Group` and `Value` columns with one observation -per row. Repeated labels form one group, and first label appearance defines -group order. The first group is the reference. Blank rows support manual entry. -The capture dropdown contains `(new group)` plus current labels, so a source -selection can create a group or append to an existing one without an -unexplained count column. - -For new groups, the App scans upward from the selected numeric cells and -combines at most two nonnumeric header levels. Looking left on the same header -row supports merged-style spreadsheet headings. Dates, numeric summaries, and -row numbers are excluded. The result is only an editable suggestion, not a -calculation identity inferred from the source. - -The editor supports batch group changes and deletion of selected observation -rows. Deleting the final observation removes the empty group; clearing all -data remains a distinct action. Table selection is delivered after the -completed selection changes so pointer dragging does not trigger repeated -presentation commits. - -## Statistical Contract - -`runTTest` remains the two-vector primitive. `runGroupTTests` calls it once for -each later group: - -```matlab -for k = 2:numel(groups) - results(k - 1) = runTTest( ... - groups(1).values, groups(k).values, optionsForTheseLabels); -end -``` - -Common rules: - -- difference direction is reference minus comparison; -- result order matches group order after the reference; -- the same method, alternative, and alpha apply to the family; -- Welch uses Welch-Satterthwaite degrees of freedom; -- pooled testing uses the selected equal-variance model; -- paired testing analyzes displayed reference(k)-comparison(k) differences; -- paired length is checked independently for each later group; -- Student-t probabilities and confidence intervals use the Base MATLAB - implementation already owned by the App; -- each comparison preserves stable failure status instead of fabricating a - number; -- no p-value correction is applied. - -The completed result family stores copies of the reference vector and each -comparison vector. Plot callbacks consume those copies and cannot recalculate -the tests. - -## Result And Plot Model - -Each result row includes the method, alternative, alpha, labels, sample counts, -means, SDs, reference-minus-comparison estimate and interval, standard error, -t statistic, degrees of freedom, exact p-value, status, and message. - -Significance shorthand is presentation metadata: - -```text -p < 0.0001 -> **** -p < 0.001 -> *** -p < 0.01 -> ** -p < alpha -> * -otherwise -> NS -``` - -The plot contract follows the supplied laboratory reference figure: - -- white axes and canvas; -- black box and ticks, no grid; -- explicit numeric y ticks and ordinary horizontal x-axis tick labels; -- modest default typography intended for later Figure Studio adjustment; -- either one pastel mean bar with sample-SD error bar or one colored box per - group; -- black edge, cap, and annotation strokes; -- stacked brackets from x=1 to x=2..N; -- shorthand label centered above each bracket; -- optional deterministic raw-value overlay. - -The first four colors are: - -```text -#9DD39C #F5C38A #A9D8E8 #FFF89A -``` - -They cycle deterministically for additional groups. Plot limits reserve space -for every successful bracket. Failed comparisons remain visible as group bars -but do not receive a misleading significance annotation. - -## Export Contract - -The data CSV has `Row` followed by one uniquely named column per group. Shorter -groups are padded with blank cells. - -The result CSV has one row per later group and columns for: - -```text -method, alternative, alpha, reference label, comparison label, -sample counts, means and SDs, difference and interval, -SE, t, df, p, shorthand, status, message -``` - -CSV remains the portable boundary. The project MAT file is for recovery, not -the only recoverable copy. - -## Persistence And Migration - -Project schema version 2 replaces: - -```text -inputs.vectorA -inputs.vectorB -``` - -with: - -```text -inputs.groups -``` - -Migration from version 1 preserves vector A as group 1 and vector B as group 2, -including labels, values, and source metadata. A not-run scalar result becomes -an empty result family; an existing completed A/B result remains the one-row -family for those migrated groups. - -## Validation - -Unit evidence covers: - -- table-cell extraction and source preservation; -- Welch, pooled, paired, and directional reference values; -- ordered first-versus-each result generation; -- result-family identity after group mutation; -- multi-column group CSV and multi-row result CSV; -- stable individual failure statuses. - -Hidden GUI evidence covers: - -- capture of three selections; -- ordered group state; -- two comparisons against the first group; -- equal-height source/editor tables and native Data/Plot workspace pages; -- stale-plot messaging after a manual data edit; -- boxed, grid-free rendered axes; -- group and result exports. - -Manual validation remains required for: - -- native dialogs; -- large and irregular workbooks; -- group-table editing feel; -- long label wrapping; -- visual bracket spacing with many groups; -- publication suitability and scientific interpretation. - -Private laboratory files may be used only as local evidence. No real paths, -filenames, identifiers, timestamps, or recognizable values enter tracked -fixtures or documentation. diff --git a/docs/development/data-and-designs/scientific-csv-interchange.md b/docs/development/data-and-designs/scientific-csv-interchange.md index 1bbda6aa8..0a0596cf7 100644 --- a/docs/development/data-and-designs/scientific-csv-interchange.md +++ b/docs/development/data-and-designs/scientific-csv-interchange.md @@ -2,7 +2,6 @@ [Development index](../README.md) | [T-Test Wizard](../../apps/statistics/ttest-wizard/README.md) | -[T-Test Wizard design](group-comparison-app-design.md) | [App development](../build-apps/app-development.md) | [Architecture](../build-apps/architecture.md) ## Status And Purpose @@ -315,7 +314,6 @@ their names, paths, identifiers, or values into the repository. ## Related Guidance -- [T-Test Wizard Design](group-comparison-app-design.md) - [T-Test Wizard](../../apps/statistics/ttest-wizard/README.md) - [App Development](../build-apps/app-development.md) - [Architecture](../build-apps/architecture.md) diff --git a/docs/development/maintain-and-release/testing.md b/docs/development/maintain-and-release/testing.md index 9d97e7810..2a0bf3f04 100644 --- a/docs/development/maintain-and-release/testing.md +++ b/docs/development/maintain-and-release/testing.md @@ -202,68 +202,26 @@ CI includes each profile's `visual-evidence/` folder in the platform artifact. ## CI and Manual Evidence -Continuous Integration runs `headless`, `gui`, and `isolated` on Linux, macOS, -and Windows against R2022b and the latest release available to -`matlab-actions/setup-matlab`. R2022b is the LabKit minimum supported release -and also the first release with MATLAB Build Tool. macOS runs only the latest -release as an Apple Silicon and native-platform sentinel; Linux and Windows -cover both release boundaries. This matrix is compatibility evidence for the -supported product boundary. CI uses clean MATLAB runtimes without optional -Toolboxes. The R2022b entries use the fixed Ubuntu 22.04 and Windows Server -2022 runner images supported by that MATLAB release; latest MATLAB uses the -current runner images. Each matrix job installs MATLAB once and runs its -scheduled profiles in separate batch sessions, so a shard shares setup cost -without sharing MATLAB session state. Historical -timings showed that the latest Windows and macOS GUI sessions formed the CI -critical path. Those two sentinels therefore run a `gui` shard in parallel -with a `headless` plus `isolated` shard. Linux and the R2022b Windows floor -retain one all-profile job because splitting their shorter sessions would -mostly duplicate setup. This selective sharding keeps complete evidence while -avoiding both the long fully serial critical path and the setup cost of a -15-job Cartesian matrix. Linux jobs provide an X virtual framebuffer before -running MATLAB so native graphics tests have a real display service instead -of relying on release-specific no-display behavior. CI runs `docsCheck` once -on the latest release, then reports one aggregate `CI Gate` result that depends -on every required shard. Configure repository branch protection to require -`CI Gate`; the workflow does not silently replace repository protection -policy. It uploads the catalog artifacts even after failure. Coverage is an -explicit report, not a duplicate CI gate. - -`main` is release-only and accepts pull requests only from the -repository-owned `develop` branch. The lightweight policy job rejects every -other PR source before MATLAB setup. It also compares -the PR base and head for App, facade, and launcher source ownership, direct -semantic version steps, and matching component-history transitions. Branch -protection separately rejects direct pushes, including administrator pushes. -Because the required PR check runs against an up-to-date merge result, the -squash-merged `main` commit has the same file tree that the PR matrix already -validated. A `main` push therefore repeats only the lightweight policy and -aggregate gate for the exact commit SHA; it does not pay for a second MATLAB -matrix or documentation render. This optimization depends on required strict -PR checks, administrator enforcement, and the direct-push prohibition. If any -of those protections are relaxed, restore full validation on `main` pushes. -Documentation delivery is separate from the tracked source tree: relevant -`main` changes trigger the Documentation Pages workflow, which installs MATLAB, -generates ignored `site/` output from that exact commit, and deploys the Pages -artifact. No workflow commits generated HTML back to a protected branch. - -Every matrix shard publishes one evidence-oriented job summary after its -scheduled independent MATLAB sessions finish. All-profile summaries make the -platform compatibility claim; split summaries name `Core` or `Hidden GUI` and -claim only that shard's evidence. Their profile tables state what the scheduled -profiles prove instead of treating intentionally unscheduled profiles as -failures. A successful summary records the scoped claim, clean runtime -assumptions, display configuration, slowest tests, artifact name, and the -manual boundaries that automation does not prove. A failed summary preserves -any profiles that still passed, separates a missing JUnit report from a -reported test failure, names failed test identities, includes recorded MATLAB -diagnostics, and collapses active-test and log-tail evidence below the primary -failure. Build tasks define descriptions so the upstream MATLAB Build Results -table is meaningful as well. The repository-owned summary helper has no -third-party Python dependency and is regression-tested by the lightweight -change-policy job. A cancelled or skipped scheduled profile makes that shard -`incomplete`, not `failed`; passing profiles remain valid evidence, but the -unfinished job cannot establish its scoped claim. +Continuous Integration runs the `headless`, `gui`, and `isolated` profiles on +the supported Linux, Windows, and macOS boundaries using clean MATLAB runtimes +without optional Toolboxes. R2022b is the minimum supported release; the +latest available release supplies the current boundary. Linux GUI jobs use a +real virtual display service. CI also runs `docsCheck` once and uploads catalog +artifacts after failures. Coverage is an explicit report, not a duplicate +merge gate. + +`CI Gate` is the required aggregate result. `main` accepts pull requests only +from the repository-owned `develop` branch, and policy checks verify source +ownership, direct semantic version steps, and matching component history. +Branch protection rejects direct pushes. Because the pull request validates an +up-to-date merge result, the resulting `main` push repeats only policy and the +aggregate gate. If those protection assumptions change, restore full +validation on `main` pushes. + +Job summaries identify the profiles actually run, failed test identities, +available diagnostics, artifacts, and manual boundaries. A cancelled or +skipped required profile is incomplete rather than passing. Read the summary +first, then inspect only the named failing artifact or log. CI classifies the exact pushed or pull-request diff before scheduling MATLAB. Source, test, build, workflow, and tool changes run the complete platform @@ -271,8 +229,8 @@ matrix. Human documentation-only changes run `docsCheck` without the platform matrix. Agent guidance and GitHub contribution-template-only changes run the lightweight change-policy check without starting MATLAB. Mixed changes run the union of their required profiles, and `CI Gate` verifies every profile selected -by the classifier. Changing the classifier or workflow is itself a full-matrix -change. +by the classifier. Documentation Pages independently generates ignored `site/` +output from the accepted `main` source; generated HTML is never committed. Manual App validation remains required for native file dialogs, visual design, pointer interaction, real-data suitability, and scientific interpretation. diff --git a/docs/history/records/2026/07/LK-20260718-ttest-wizard-and-table-workspace.md b/docs/history/records/2026/07/LK-20260718-ttest-wizard-and-table-workspace.md index 587bfbc50..c81f03d49 100644 --- a/docs/history/records/2026/07/LK-20260718-ttest-wizard-and-table-workspace.md +++ b/docs/history/records/2026/07/LK-20260718-ttest-wizard-and-table-workspace.md @@ -122,7 +122,6 @@ repository. - [T-Test Wizard](../../../../apps/statistics/ttest-wizard/README.md) - [Statistics Apps](../../../../apps/statistics/README.md) - [Simple Scientific CSV Exchange](../../../../development/data-and-designs/scientific-csv-interchange.md) -- [T-Test Wizard design](../../../../development/data-and-designs/group-comparison-app-design.md) - [Runtime and Lifecycle](../../../../framework/guides/runtime.md) ## Known limitations and follow-up diff --git a/docs/history/records/2026/07/LK-20260720-app-action-tooltips.md b/docs/history/records/2026/07/LK-20260720-app-action-tooltips.md index b4ea57d35..6de25fa28 100644 --- a/docs/history/records/2026/07/LK-20260720-app-action-tooltips.md +++ b/docs/history/records/2026/07/LK-20260720-app-action-tooltips.md @@ -74,7 +74,8 @@ carry domain-specific explanations. ## Compatibility and migration -No additional migration applies beyond the compatibility information in the preceding impact section. +Tooltip additions do not change action callbacks, calculations, projects, or +results. Existing layouts remain source compatible and need no data migration. ## Validation @@ -86,7 +87,8 @@ No additional migration applies beyond the compatibility information in the prec ## Evidence -The validation details above are the supporting evidence for this record. +App SDK, cross-App definition, native-adapter, and DIC Postprocess GUI +specifications passed with the new tooltip contract. ## Known limitations and follow-up diff --git a/docs/history/records/2026/07/LK-20260720-app-tools-menu-and-screenshot-clipboard.md b/docs/history/records/2026/07/LK-20260720-app-tools-menu-and-screenshot-clipboard.md index 9d553bcf9..c02f96fe8 100644 --- a/docs/history/records/2026/07/LK-20260720-app-tools-menu-and-screenshot-clipboard.md +++ b/docs/history/records/2026/07/LK-20260720-app-tools-menu-and-screenshot-clipboard.md @@ -55,7 +55,8 @@ noninteractive MATLAB session. ## Compatibility and migration -No additional migration applies beyond the compatibility information in the preceding impact section. +The menu reorganization is compatible with existing projects, calculations, +plots, and result files. No saved-data migration is required. ## Validation @@ -68,7 +69,8 @@ No additional migration applies beyond the compatibility information in the prec ## Evidence -The validation details above are the supporting evidence for this record. +The focused App SDK and hidden native-adapter specifications passed, and the +updated documentation rendered deterministically. ## Known limitations and follow-up diff --git a/docs/history/records/2026/07/LK-20260720-figure-studio-semantic-restyling.md b/docs/history/records/2026/07/LK-20260720-figure-studio-semantic-restyling.md index d68888448..84340d86e 100644 --- a/docs/history/records/2026/07/LK-20260720-figure-studio-semantic-restyling.md +++ b/docs/history/records/2026/07/LK-20260720-figure-studio-semantic-restyling.md @@ -86,7 +86,8 @@ window presentation. ## Compatibility and migration -No additional migration applies beyond the compatibility information in the preceding impact section. +Schema 1 Figure Studio projects migrate automatically to schema 2 while +preserving saved values. Other App projects and result contracts are unchanged. ## Validation @@ -106,7 +107,8 @@ No additional migration applies beyond the compatibility information in the prec ## Evidence -The validation details above are the supporting evidence for this record. +Figure import, result reconstruction, semantic styling, hidden-GUI, and +single-process runner specifications passed with the recorded timing evidence. ## Known limitations and follow-up diff --git a/docs/history/records/2026/07/LK-20260720-launcher-app-sdk-diagnostics.md b/docs/history/records/2026/07/LK-20260720-launcher-app-sdk-diagnostics.md index 1fd4f0879..887da5705 100644 --- a/docs/history/records/2026/07/LK-20260720-launcher-app-sdk-diagnostics.md +++ b/docs/history/records/2026/07/LK-20260720-launcher-app-sdk-diagnostics.md @@ -55,7 +55,8 @@ the retired string-mode or request-adapter contracts. ## Compatibility and migration -No additional migration applies beyond the compatibility information in the preceding impact section. +Public Launcher commands, ordinary App launch, projects, and result files +remain compatible. The retired integration seam requires no data migration. ## Validation @@ -67,7 +68,8 @@ No additional migration applies beyond the compatibility information in the prec ## Evidence -The validation details above are the supporting evidence for this record. +Focused Launcher and App SDK specifications passed, diagnostic artifacts were +inspected through DIC Preprocess, and documentation guardrails passed. ## Known limitations and follow-up diff --git a/docs/history/records/2026/07/LK-20260720-public-api-escalation-order.md b/docs/history/records/2026/07/LK-20260720-public-api-escalation-order.md index c0ee5859c..16245a3b9 100644 --- a/docs/history/records/2026/07/LK-20260720-public-api-escalation-order.md +++ b/docs/history/records/2026/07/LK-20260720-public-api-escalation-order.md @@ -47,7 +47,8 @@ are unchanged. The rule affects future design choices and review evidence. ## Compatibility and migration -No additional migration applies beyond the compatibility information in the preceding impact section. +This guidance-only change adds no public API, runtime behavior, saved-data +format, or migration requirement. ## Validation @@ -58,7 +59,8 @@ No additional migration applies beyond the compatibility information in the prec ## Evidence -The validation details above are the supporting evidence for this record. +Skill validation and deterministic documentation rendering passed; repository +inspection confirmed the escalation order at each intended ownership layer. ## Known limitations and follow-up diff --git a/docs/history/records/2026/07/LK-20260720-reader-oriented-app-manuals-and-test-wrapper.md b/docs/history/records/2026/07/LK-20260720-reader-oriented-app-manuals-and-test-wrapper.md index 04b8e16b7..a7e14604e 100644 --- a/docs/history/records/2026/07/LK-20260720-reader-oriented-app-manuals-and-test-wrapper.md +++ b/docs/history/records/2026/07/LK-20260720-reader-oriented-app-manuals-and-test-wrapper.md @@ -82,7 +82,8 @@ official test runner and does not add another CI or public build interface. ## Compatibility and migration -No additional migration applies beyond the compatibility information in the preceding impact section. +This documentation and test-tooling change does not alter App runtime behavior, +saved data, public APIs, or result files. ## Validation @@ -102,7 +103,8 @@ No additional migration applies beyond the compatibility information in the prec ## Evidence -The validation details above are the supporting evidence for this record. +The App-manual boilerplate guard, deterministic documentation render, and +focused test-wrapper specifications passed. ## Known limitations and follow-up diff --git a/docs/history/records/2026/07/LK-20260720-ttest-plot-viewport-and-density.md b/docs/history/records/2026/07/LK-20260720-ttest-plot-viewport-and-density.md index 158106a6d..32299ae27 100644 --- a/docs/history/records/2026/07/LK-20260720-ttest-plot-viewport-and-density.md +++ b/docs/history/records/2026/07/LK-20260720-ttest-plot-viewport-and-density.md @@ -54,7 +54,8 @@ open without migration. ## Compatibility and migration -No additional migration applies beyond the compatibility information in the preceding impact section. +Existing T-Test projects and exported results remain compatible. The App needs +the stated framework version but no project payload migration. ## Validation @@ -66,7 +67,8 @@ No additional migration applies beyond the compatibility information in the prec ## Evidence -The validation details above are the supporting evidence for this record. +The T-Test hidden-GUI workflow and framework viewport specifications passed, +and the updated documentation rendered deterministically. ## Known limitations and follow-up From 2b72e4128ef0753b7ceffe408b2c020ff3a7cca4 Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Thu, 6 Aug 2026 11:53:09 -0500 Subject: [PATCH 08/12] docs: rewrite manuals around user tasks --- docs/apps/dic/dic-preprocess/README.md | 25 +++----- docs/apps/electrochemistry/README.md | 17 +++-- docs/apps/electrochemistry/cic/README.md | 13 ++-- docs/apps/electrochemistry/csc/README.md | 8 +-- docs/apps/electrochemistry/eis/README.md | 5 +- .../electrochemistry/vt-resistance/README.md | 9 ++- docs/apps/gait/gait-analysis/README.md | 64 ++++++++----------- .../image-measurement/batch-crop/README.md | 9 ++- .../image-measurement/flir-thermal/README.md | 9 ++- .../image-measurement/video-marker/README.md | 14 ++-- docs/apps/labkit-core/README.md | 5 +- docs/apps/labkit-core/launcher/README.md | 31 ++++----- .../nerve-response-analysis/README.md | 19 +++--- .../response-review-stats/README.md | 6 +- .../neurophysiology/rhs-preview/README.md | 15 ++--- docs/apps/statistics/ttest-wizard/README.md | 8 +-- docs/apps/wearable/ecg-print/README.md | 22 +++---- docs/framework/README.md | 10 ++- docs/getting-started/README.md | 9 +-- 19 files changed, 127 insertions(+), 171 deletions(-) diff --git a/docs/apps/dic/dic-preprocess/README.md b/docs/apps/dic/dic-preprocess/README.md index 941ca7846..9b9a26c65 100644 --- a/docs/apps/dic/dic-preprocess/README.md +++ b/docs/apps/dic/dic-preprocess/README.md @@ -76,18 +76,11 @@ clustered points provide weak rotational leverage. ## Automatic Alignment -**Auto align current pair** runs the app-owned base-MATLAB rigid-registration -path. It searches the complete rotation circle in global, one-degree, and -quarter-degree stages; translation candidates may span up to 75% of either -preview dimension. It estimates translation with anti-aliased, zero-padded -amplitude-weighted phase correlation and subpixel peak refinement, then ranks candidate angles using robust, -overlap-aware oriented structure on a finer preview. Its diagnostic event -records the accepted angle, translation, overlap, structural score, score -margin, and translation-peak margin. It returns the same -aligned image and rigid-transform fields as manual alignment. Automatic -alignment remains a starting estimate, not a guarantee of DIC-quality -correspondence. Always inspect the false-color overlay and prefer manual points -when the image has repeated texture, extremely small overlap, scale change, +**Auto align current pair** estimates a rigid rotation and translation without +changing scale or shear. It can search large rotations and displacements, but +the result is only a starting estimate, not a guarantee of DIC-quality +correspondence. Always inspect the false-color overlay. Prefer manual points +when the images have repeated texture, very little overlap, scale change, deformation, large occlusion, or weak contrast. ## Crop ROI @@ -124,10 +117,10 @@ the selected file. LabKit result manifests record source references, parameters, output roles, and generated filenames; the manifest JSON is provenance metadata, not an additional scientific result. -The saved project stores portable source references, preview choices, edit -steps, crop and mask annotations, and result references. Decoded source images -and intermediate working images are loaded or recomputed when the project is -opened; they are not duplicated inside the project file. +The saved project stores source references, preview choices, applied edits, +crop and mask annotations, and result references. Source and intermediate +images are read or recreated when the project opens rather than copied into +the project file. ## Use Without The GUI diff --git a/docs/apps/electrochemistry/README.md b/docs/apps/electrochemistry/README.md index e41c97136..230256e75 100644 --- a/docs/apps/electrochemistry/README.md +++ b/docs/apps/electrochemistry/README.md @@ -14,13 +14,12 @@ the owning app. Source DTA files are never modified. | Impedance inspection and export | [EIS](eis/README.md) | `ZCURVE` | configurable Nyquist/Bode-style overlay | | Steady pulse resistance | [VT Resistance](vt-resistance/README.md) | biphasic chrono transient | cathodic, anodic, and mean resistance | -## Shared DTA Contract +## Supported DTA Data -The DTA library returns structured items, curve tables, headers, units, -metadata, parser messages, and status. Apps use exact required columns for -scientific calculations and do not infer missing physical quantities from a -plot label. The Apps require the version 3 unit-explicit DTA contract rather -than branching between duplicate legacy and canonical item fields. +Each App checks for the measurements it needs, including their units, before +running a calculation. A plotted label is not used to guess a missing physical +quantity. See the individual App manual for its required experiment type and +columns. ## Units And Traceability @@ -30,9 +29,9 @@ or CSC is reported in the unit shown by the app. UI display conversions do not change stored base-unit calculations. Export tables include the source identity and analysis settings needed to -interpret the result. Runtime-generated `.labkit.json` files are provenance -manifests describing inputs, parameters, and output roles; they are not a -second numerical result and can be kept with the corresponding CSV. +interpret the result. Keep the accompanying `.labkit.json` file with its CSV; +it records which inputs and settings produced the export but contains no +additional numerical result. ## Use Without The GUI diff --git a/docs/apps/electrochemistry/cic/README.md b/docs/apps/electrochemistry/cic/README.md index dc82555e3..0dfd07fa0 100644 --- a/docs/apps/electrochemistry/cic/README.md +++ b/docs/apps/electrochemistry/cic/README.md @@ -14,12 +14,11 @@ labkit_CIC_app ## Inputs And Batch Behavior -The Files list retains chrono `.DTA` transients and omits other Gamry -experiment kinds before session reconstruction. The selected row is decoded -for immediate preview; batch calculation uses the same analysis settings when -results are exported. This avoids repeatedly decoding every large file while -the user is only switching previews. Saved projects preserve file order and -portable source identity through removal, later additions, and reopen. +The Files list accepts chrono `.DTA` transients and omits other Gamry +experiment kinds. Selecting a row updates the preview. Export applies the +current analysis settings to every accepted file in the displayed order. +Saved projects preserve that order through removal, later additions, and +reopen. Electrode area comes from a positive UI override when supplied, otherwise from the parsed DTA metadata. Without a valid positive area, charge in coulombs can @@ -68,7 +67,7 @@ Maximum cathodic and anodic polarization potentials are interpolated at `phase end + delay`. The app does not extrapolate: if either requested time is outside the recorded range, that file fails with an explicit delay message. Baseline candidates are selected from pre-pulse, interpulse, and post-pulse -windows, with documented fallbacks recorded in the result struct. +windows. The exported result identifies the baseline used. Water-window status compares the calculated polarization potentials with the selected cathodic and anodic limits. The limits are an app policy and do not diff --git a/docs/apps/electrochemistry/csc/README.md b/docs/apps/electrochemistry/csc/README.md index 0eb6eccc6..e6e6059e5 100644 --- a/docs/apps/electrochemistry/csc/README.md +++ b/docs/apps/electrochemistry/csc/README.md @@ -15,12 +15,12 @@ labkit_CSC_app ## Inputs And Selection -The Files list retains CV/CT `.DTA` sources and omits other Gamry experiment -kinds before session reconstruction. The selected file determines the current +The Files list accepts CV/CT `.DTA` sources and omits other Gamry experiment +kinds. The selected file determines the current curve list, readout, and plots. Selecting another file resets the curve selection and default plot quantities to that file; it does not silently keep -a cycle from the previous source. Saved projects preserve the successfully -decoded file order through removal, later additions, and reopen. +a cycle from the previous source. Saved projects preserve the accepted file +order through removal, later additions, and reopen. The default curve selection is **All cycles**. Individual cycle selection updates the comparison readout for that cycle. diff --git a/docs/apps/electrochemistry/eis/README.md b/docs/apps/electrochemistry/eis/README.md index a36e30433..468eadf4f 100644 --- a/docs/apps/electrochemistry/eis/README.md +++ b/docs/apps/electrochemistry/eis/README.md @@ -14,9 +14,8 @@ labkit_EIS_app The Files list retains `.DTA` sources containing a readable EIS `ZCURVE`. Other Gamry experiment kinds and files without the required curve are omitted -before plotting. The -successfully decoded source list and its order are preserved in project state -through portable references. +before plotting. Saved projects preserve the accepted source order and ask you +to locate a source if it has moved. ## Basic Workflow diff --git a/docs/apps/electrochemistry/vt-resistance/README.md b/docs/apps/electrochemistry/vt-resistance/README.md index 60da0599b..05f73e1fd 100644 --- a/docs/apps/electrochemistry/vt-resistance/README.md +++ b/docs/apps/electrochemistry/vt-resistance/README.md @@ -14,12 +14,11 @@ labkit_VTResistance_app ## Inputs And Batch Behavior -The Files list retains chrono `.DTA` transients and omits other Gamry -experiment kinds before session reconstruction. The transient session decodes -and analyzes the registered batch so shared setting changes update every result together. +The Files list accepts chrono `.DTA` transients and omits other Gamry +experiment kinds. Changing a shared analysis setting updates the whole batch. No electrode-area normalization is performed because the reported quantity is -electrical resistance in ohms. Saved projects preserve source order and -portable identity through removal, later additions, and reopen. +electrical resistance in ohms. Saved projects preserve source order and ask +you to locate a source if it has moved. ## Basic Workflow diff --git a/docs/apps/gait/gait-analysis/README.md b/docs/apps/gait/gait-analysis/README.md index c1b611cae..1ecfee372 100644 --- a/docs/apps/gait/gait-analysis/README.md +++ b/docs/apps/gait/gait-analysis/README.md @@ -1,6 +1,6 @@ # Gait Analysis -Gait Analysis 2 converts a current Video Marker project into independently +Gait Analysis converts a current Video Marker project into independently segmented treadmill swing steps, per-frame kinematics, per-step gait parameters, visual step reports, and reproducible CSV outputs. Loading and analysis are deliberately separate: loading first shows the complete tracked @@ -15,14 +15,10 @@ source checkout, run: labkit_GaitAnalysis_app ``` -## Input Contract +## Supported Input -The only file input is a current Video Marker `labkitProject` MAT document or -autosave. The MAT file is the analysis source of truth and the cross-App data -contract; Gait Analysis does not call the Video Marker package at runtime. The -saved fields are described by -[Video Marker project and session state](../../image-measurement/video-marker/README.md#project-and-session-state). -Gait Analysis reads: +The only file input is a current Video Marker project MAT file or autosave. +It must contain: - frames-by-points-by-2 pixel coordinates; - point IDs, point names, and skeleton edges; @@ -30,13 +26,11 @@ Gait Analysis reads: - video frame count, frame rate, duration, width, and height; - scale calibration and physical unit when calibrated. -The app does not reopen the original video to obtain scientific metadata. -Video Marker payloads older than the embedded-video-metadata contract must be -opened and saved with the current Video Marker before analysis. Gait Analysis -rejects generic coordinate tables and arbitrary MAT variables because they do -not jointly own timing, skeleton, calibration, and annotation provenance. -`computeGait` still accepts an in-memory normalized pose for deterministic -tests and programmatic calculations. +The App uses the timing, skeleton, calibration, and annotation information in +the Video Marker project; it does not reopen the video to infer missing +metadata. Generic coordinate tables and unrelated MAT files are therefore not +accepted. If an older Video Marker project lacks the required metadata, open +and save it with the current Video Marker before analysis. The Video Marker document is stored as a portable project source. Older Gait Analysis projects are upgraded on load. @@ -45,15 +39,13 @@ Coordinates use image convention: the origin is at the upper left and Y increases downward. The skeleton preview preserves that convention. Angle and length time series use conventional plot axes. -## Project And Session State +## Saving And Reopening A Project -The pose source, analysis options, computed tables/events, and export record -are saved. Decoded pose data and the currently selected step are reconstructed -after load. Projects from the older stride-naming contract invalidate results -whose scientific meaning changed, so rerun the analysis before export. -Current projects validate finite numeric ranges, integer frame/window fields, -the swing-frame ordering, nonempty role names, and logical origin settings -before reconstructing a session. +The project saves the Video Marker source reference, analysis options, +completed results, and export record. If an older project contains results +from before the step-length correction, Gait Analysis asks you to rerun the +analysis before export. If the Video Marker source has moved, the App asks you +to locate it. ## Two-Stage Workflow @@ -85,9 +77,8 @@ changing the selected step fits the new data once; later redraws preserve the user's zoom. The skeleton plot annotates swing duration, step length, iliac/hip/knee/ankle/ -foot translations, and each joint's minimum, maximum, and range of motion. This -is the app version of the earlier per-step gait figure; the figures no longer -need to be generated as an intermediate image set merely to inspect each step. +foot translations, and each joint's minimum, maximum, and range of motion. You +can inspect each step directly without first exporting intermediate figures. ## Step Segmentation @@ -99,10 +90,9 @@ interval retain the higher peak. Every accepted lift-off is independently paired subsequent foot X value before the next lift-off, or before the recording ends. That following minimum is the landing event. -This pairing is important: a completed last swing remains a valid step even -when the recording ends before another lift-off. Earlier contact-to-contact -implementations lost that final step. These are image-kinematic treadmill -events, not force-plate contact measurements. +This pairing keeps a completed last swing valid even when the recording ends +before another lift-off. These are image-kinematic treadmill events, not +force-plate contact measurements. When a later lift-off exists, the app additionally derives cycle time, stance time, cadence, and duty factor. Those values are legitimately unavailable for @@ -118,17 +108,16 @@ the final independently complete swing if no next lift-off was recorded. | Unit name | `px` | Physical unit label associated with pixels per unit. | | First-frame first point as origin | Off | Shifts scaled coordinate exports; raw pixel coordinates remain unchanged. | | Smooth window | 5 frames | Centered finite-value mean applied independently to each point and axis. | -| Minimum foot-X prominence | 20 source units | Minimum lift-off peak prominence inherited from the legacy treadmill workflow. | -| Peak-height sigma | 2 | Requires a lift-off peak to be at least mean foot X minus this many standard deviations, matching the legacy treadmill detector. | +| Minimum foot-X prominence | 20 source units | Minimum prominence required for a lift-off peak. | +| Peak-height sigma | 2 | Requires a lift-off peak to be at least mean foot X minus this many standard deviations. | | Minimum interval | 0.2 s | Minimum time between lift-off peaks; converted to frames from source timing. | | Minimum swing | 3 frames | Minimum accepted inclusive lift-off-to-landing span and timing-free separation fallback. | | Maximum swing | 300 frames | Maximum accepted inclusive lift-off-to-landing span. | | Minimum step length | 1 output unit | Minimum two-dimensional foot endpoint displacement. | | Maximum hip translation | 1,000,000 output units | Upper endpoint-displacement QC rule; the default normally leaves it inactive. | -Changing any parameter invalidates the previous result. Repeating a run with -the same source and parameters is skipped when its deterministic fingerprint -is unchanged. +Changing any parameter makes the previous result out of date. Running analysis +again with unchanged source and parameters reuses the completed result. ## Calculations @@ -148,9 +137,8 @@ For each lift-off-to-landing step it calculates: - validity and a concrete rejection reason for duration, step length, or hip translation rules. -The former scripts sometimes called foot displacement `stride_length` even -though only one active swing was measured. Version 2 uses `step_length` so the -column name matches the calculation. +Foot displacement is exported as `step_length` because each row describes one +active swing rather than a complete stride. ## Outputs diff --git a/docs/apps/image-measurement/batch-crop/README.md b/docs/apps/image-measurement/batch-crop/README.md index b45105865..074b7e910 100644 --- a/docs/apps/image-measurement/batch-crop/README.md +++ b/docs/apps/image-measurement/batch-crop/README.md @@ -16,8 +16,8 @@ Use **Add images** for selected files, **Add folder** for one directory, or **Add folder tree** for nested sources. Each list row stores its own crop center, rotation, padding, and optional scale calibration. **Duplicate image** creates another task for the same source so multiple ROIs can be exported without -loading duplicate files. Duplicate tasks remain aligned with their source and -cached preview when the native file list supplies row- or column-shaped state. +loading duplicate files. Duplicate tasks stay linked to the same source while +each keeps its own crop and calibration settings. ## Basic Workflow @@ -76,9 +76,8 @@ parameters and identifies each output file. Saved projects preserve one independent task per list row, so duplicated tasks can resolve to the same image while retaining separate crop geometry and calibration. Crop dimensions, physical-scale settings, output format, output -folder, and scale-bar choices are also saved. Image pixels and preview caches -are reconstructed from the sources after load rather than embedded in the -project. +folder, and scale-bar choices are also saved. Images remain external source +files and are read again when the project opens. Older projects are upgraded on open while preserving each task's center, rotation, padding, and calibration. If a source moved, the standard relinking diff --git a/docs/apps/image-measurement/flir-thermal/README.md b/docs/apps/image-measurement/flir-thermal/README.md index f09193b68..1f2744ff4 100644 --- a/docs/apps/image-measurement/flir-thermal/README.md +++ b/docs/apps/image-measurement/flir-thermal/README.md @@ -78,11 +78,10 @@ outputs remain Celsius regardless of palette or mapping mode. ## Project And State -Saved projects keep portable source references, display parameters, export -settings, and lightweight per-image ranges and readings. Raw sensor matrices -and decoded Celsius matrices are loaded again from the selected source when a -project opens. Missing source files use the standard relinking flow rather -than relying on a local absolute path. +Saved projects keep source references, display parameters, export settings, +and per-image ranges and readings. Thermal data remains in the source files +and is read again when a project opens. If a source has moved, the App asks you +to locate it. An existing source that is no longer a readable radiometric file aborts the restore and preserves the current document. Batch import may still report and skip rejected selections before they become project sources. diff --git a/docs/apps/image-measurement/video-marker/README.md b/docs/apps/image-measurement/video-marker/README.md index 78e39263b..8ded6fd93 100644 --- a/docs/apps/image-measurement/video-marker/README.md +++ b/docs/apps/image-measurement/video-marker/README.md @@ -88,14 +88,12 @@ user to locate the video without discarding skeleton or annotations. A compatible old Video Marker project or autosave opens through **Open MAT** or **Tools > Project State > Load State...**. -## Project And Session State - -Durable state consists of video metadata, the portable video source, skeleton, -frame annotations and provenance, calibration, export parameters, and output -manifest paths. The video reader, decoded frame, current interaction, -selection, preview graphics, and frame cache are transient session resources. -Only the current frame number is saved as navigation convenience; annotations -remain the authoritative scientific data. +## What The Project Saves + +The project saves the video reference and metadata, skeleton, frame +annotations, calibration, export settings, and current frame number. It does +not copy the video or a frame cache. When the project is reopened, Video Marker +reads frames from the source video and preserves the saved annotations. Older compatible project and autosave formats are upgraded when opened. Save the upgraded project before moving it into another workflow. diff --git a/docs/apps/labkit-core/README.md b/docs/apps/labkit-core/README.md index 57a280353..7e05b15b8 100644 --- a/docs/apps/labkit-core/README.md +++ b/docs/apps/labkit-core/README.md @@ -13,9 +13,8 @@ experiment-specific formula. ## Workbench Entry Point -The launcher is documented here because users operate it as the first LabKit -application. Its implementation remains a self-contained root file so it can -repair a damaged installation without depending on the app framework. +The launcher is documented here because it is the first LabKit application +most users open. It can also install LabKit or repair a damaged installation. ## Figure Handoff diff --git a/docs/apps/labkit-core/launcher/README.md b/docs/apps/labkit-core/launcher/README.md index 111f917f8..663012502 100644 --- a/docs/apps/labkit-core/launcher/README.md +++ b/docs/apps/labkit-core/launcher/README.md @@ -1,10 +1,9 @@ # LabKit Launcher -The LabKit Launcher is the installed workbench entry point. It discovers apps, -prepares their MATLAB paths, checks requirements, starts App SDK sessions, -manages installed versions, opens app documentation, and exposes -source-checkout maintenance tools. It is intentionally self-contained so a -single surviving `labkit_launcher.m` can repair an incomplete ZIP installation. +The LabKit Launcher is the main place to find and open Apps, check their +requirements, manage installed versions, open documentation, and use +source-checkout maintenance tools. A downloaded `labkit_launcher.m` can also +install LabKit or repair an incomplete installation. ## Start The Launcher @@ -20,7 +19,7 @@ tool availability, or the active maintenance operation. | Group | Action | Behavior | | --- | --- | --- | -| Run Apps | **Open Selected App** | Checks the selected app requirements, adds the app root, and calls its App SDK entrypoint without retired runtime launch arguments. | +| Run Apps | **Open Selected App** | Checks the selected App's requirements and opens it. | | Run Apps | **Refresh App List** | Repeats public and configured private-app discovery without restarting the launcher. | | Run Apps | **Documentation and History** | Opens the current online manual for the selected app. | | Versions and Install | **Latest** | Installs the current `main` branch archive. | @@ -39,21 +38,19 @@ selection does not change the checked set. The application table places **Family** immediately before **App** so related tools remain visually grouped while their individual names stay easy to scan. -When startup begins, the launcher immediately disables its App table and -actions, changes the open button to **Starting App...**, shows a wait pointer, -and reports two explicit stages: preparing the selected App path, then -initializing its window through the named entry point. This feedback is painted -before the potentially slow App entry point runs. Duplicate clicks are ignored -while startup is active. Completion restores the controls and reports the -opened command; failure reports the failing identifier and message, with repair -guidance only for structural installation failures. +When startup begins, the launcher disables its App table and actions, changes +the open button to **Starting App...**, and reports whether it is preparing the +App or opening its window. Duplicate clicks are ignored until startup +finishes. Success restores the controls and reports the opened command; +failure reports the identifier and message and offers repair guidance when the +installation itself is incomplete. Every launch uses the same clean App path. Use the App's **Tools > Diagnostics** menu to inspect its live session log or export a diagnostic bundle after a problem occurs. The Session Log window owns manual TRACE capture when earlier detail is needed. Apps that declare a synthetic input pack expose **Tools > Developer Tools > Generate Synthetic Inputs...**. -Generation writes anonymous fixture files and a manifest into a new folder but +Generation writes anonymous example files and a manifest into a new folder but does not load them or mutate the running project. ## Programmatic Calls @@ -128,8 +125,8 @@ archive is validated before replacement. Existing repairs preserve a recovery copy transactionally and retain known local workspace folders when required. Close every running LabKit App before replacing an installation. Standalone repair and the full Launcher's version update workflows refuse to replace -framework files while an App remains open so live callbacks and delayed UI -work cannot lose their MATLAB class definitions mid-operation. +LabKit while an App is open, preventing a partial update from disrupting a +running session. Keep experimental data and exports outside the runtime folder because installed code is replaceable. diff --git a/docs/apps/neurophysiology/nerve-response-analysis/README.md b/docs/apps/neurophysiology/nerve-response-analysis/README.md index a5c2f9fc8..aeea67c93 100644 --- a/docs/apps/neurophysiology/nerve-response-analysis/README.md +++ b/docs/apps/neurophysiology/nerve-response-analysis/README.md @@ -31,17 +31,14 @@ an export cannot silently use outdated settings. The filter record and optional protocol are saved as distinct portable project sources. Older projects with separate source fields are upgraded on load. -## Project And Session State - -The durable project stores portable references for the filter record and -optional protocol, the two run limits, and the last export paths. - -Parsed JSON, analysis tables, issue details, preview selection, log messages, -and output-folder convenience are transient session state. Opening a project -reparses its JSON sources but does not persist or silently reuse old analysis -tables; choose **Analyze Filtered Files** to calculate them again. -If an existing selected JSON file is malformed, project restore stops and -preserves the current document. An absent optional protocol remains valid. +## Saving And Reopening A Project + +The project stores references to the filter record and optional protocol, the +two run limits, and the last export locations. It does not save a completed +analysis. After reopening, choose **Analyze Filtered Files** to calculate the +results again from the referenced recordings. If a required JSON file is +malformed, the project remains unchanged so you can correct or replace that +file. A missing optional protocol is allowed. ## What The Analysis Does diff --git a/docs/apps/neurophysiology/response-review-stats/README.md b/docs/apps/neurophysiology/response-review-stats/README.md index bbde07f8c..79faa8f20 100644 --- a/docs/apps/neurophysiology/response-review-stats/README.md +++ b/docs/apps/neurophysiology/response-review-stats/README.md @@ -45,11 +45,11 @@ segment's original range remain `NaN`. The selected input is saved as a portable project source. Older projects are upgraded on load. -## Project And Session State +## Saving And Reopening A Project The project saves the source, baseline/noise windows, and last export -reference. Metric tables, aligned waveforms, and summaries are recalculated -from that source after load. +location. When the project is reopened, metric tables, aligned waveforms, and +summaries are recalculated from that source. ## Measurements And Summary diff --git a/docs/apps/neurophysiology/rhs-preview/README.md b/docs/apps/neurophysiology/rhs-preview/README.md index 672e1f114..0f89bbc9b 100644 --- a/docs/apps/neurophysiology/rhs-preview/README.md +++ b/docs/apps/neurophysiology/rhs-preview/README.md @@ -60,16 +60,13 @@ The preview recording, optional protocol, and filter recordings are saved as distinct portable project sources. Older projects that stored these sources separately are combined automatically on load. -## Project And Session State +## Saving And Reopening A Project -The durable project stores portable references for one preview recording, one -optional protocol, and an ordered collection of filter recordings. It also -stores preview settings, channel-role drafts, manual filter labels/comments, -and compact export records. - -Header indices, decoded preview windows, table presentation state, current ROI -and window position, status text, and log messages are transient session data. -They are reconstructed from the project sources when a project is opened. +The project stores references to the preview recording, optional protocol, and +ordered filter recordings together with preview settings, channel assignments, +labels, and comments. Waveform samples are not copied into the project. When a +project is reopened, the App reads the required window from the source file; +if a source has moved, it asks you to locate it. ## Review Recording Information diff --git a/docs/apps/statistics/ttest-wizard/README.md b/docs/apps/statistics/ttest-wizard/README.md index 12b565b8c..9cfe7e2bc 100644 --- a/docs/apps/statistics/ttest-wizard/README.md +++ b/docs/apps/statistics/ttest-wizard/README.md @@ -219,11 +219,11 @@ returns one canonical [`runTTest`](../../../reference/api/ttest_wizard/testRun/runTTest.html) result per later group. -## Project Recovery +## Opening Existing Projects -Project schema version 2 stores ordered groups, test settings, plot settings, -completed comparison results, and source references. Version-1 A/B projects -migrate by preserving A as the first group and B as the second group. +Projects preserve the ordered groups, test and plot settings, completed +comparisons, and source references. When an older two-group project is opened, +group A remains the first or reference group and group B remains the second. ## Assumptions And Limitations diff --git a/docs/apps/wearable/ecg-print/README.md b/docs/apps/wearable/ecg-print/README.md index 06cc18b27..4c22f9d81 100644 --- a/docs/apps/wearable/ecg-print/README.md +++ b/docs/apps/wearable/ecg-print/README.md @@ -35,18 +35,13 @@ the detected channels and lets you select one for analysis. The recording is saved as a portable project source. Older projects are upgraded on load. -## Project And Session State +## Saving And Reopening A Project -ECG Print stores only durable workflow state in its project file: - -- the portable recording source; -- import, channel, ROI, filter, detector, segment, template, and view settings; -- the compact last-analysis summary and export records. - -Decoded recordings, signal arrays, events, segments, templates, measurements, -header previews, and plot models are transient session data. They are rebuilt -from the recording and durable parameters when a project is opened. This keeps -saved projects portable and avoids duplicating large waveform caches. +The project stores a reference to the recording, the import and analysis +settings, the last analysis summary, and export records. It does not copy the +full waveform or derived signal arrays. When the project is reopened, ECG +Print reads the recording again and rebuilds the preview and analysis data. If +the recording has moved, the App asks you to locate it. ## Analyze ECG @@ -85,9 +80,8 @@ on every tab. | Smooth beats | 15 | Smoothing span used in exported per-segment trends | | Template plot | Template + residual band | Alternative view is template plus individual segments | -Saved projects accept exactly the three displayed peak-method labels. A -corrupt or hand-edited project containing another label is rejected instead -of silently falling back to a detector. +Saved projects accept the three peak methods shown in the App. An unrecognized +value is reported as an error rather than silently selecting another method. Peak polarity is selected automatically. The default detector threshold is 2.8 standard deviations inside the app calculation. diff --git a/docs/framework/README.md b/docs/framework/README.md index 5b30fee7f..62b6757f0 100644 --- a/docs/framework/README.md +++ b/docs/framework/README.md @@ -78,17 +78,15 @@ and renderer signatures, and builds one private native platform plan. - Bind ordinary state with `Bind="project..."` or `Bind="session..."`. - Keep ordinary actions on the framework's consistent single-line native button rhythm. A readonly `field` automatically wraps its current text and - grows or shrinks with the available value-column width; Apps do not declare - line counts or a separate message type. Control-group dividers appear only - between adjacent resizable sections, never as a trailing bar after the final + adjusts to the available width; Apps do not declare line counts or a separate + message type. Dividers belong between resizable sections, not after the final section. - Give every editable semantic surface one declared behavior owner before launch: editable fields, ranges, and sliders use `Bind` or `OnValueChanged`; file lists require `Bind`; plot view modes require `OnValueChanged`; and editable table columns require `OnCellEdited`. - Workspace page callbacks require named pages. Runtime applies binding and - callback effects through the same queued transaction, validates the final - state, and rolls back state and presentation together on failure. + Workspace page callbacks require named pages. Binding and callback changes + use the same validation and rollback behavior. - Use `labkit.app.layout.fileList` for portable file records and selection. Multi-file collections use native multi-selection; a semantic single-file slot declares `MaxFiles=1` and single selection. File buttons describe only diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index 2fbf2055f..1266c2b44 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -69,10 +69,11 @@ Every current LabKit app exposes one top-level **Tools** menu: - **Tools > Diagnostics > Open Session Log...** opens the current App's named live log with Full TRACE, DEBUG, and User views. - **Tools > Diagnostics > Export Diagnostic Bundle** asks for exact or compact - synthetic App state, defaults to compact, then writes complete sensitive - logs plus the selected MAT in an automatically named ZIP beneath - `artifacts/diagnostics/`. After an ERROR or CRITICAL event, closing the App - automatically writes the compact bundle there. + App state, defaults to compact, and writes the logs plus selected state to an + automatically named ZIP beneath `artifacts/diagnostics/`. Diagnostic bundles + may contain sensitive paths and data; review them before sharing. After an + ERROR or CRITICAL event, closing the App automatically writes a compact + bundle there. State files preserve app projects. They are different from exported result files and from ignored diagnostic manifests under `artifacts/diagnostics/`. From da7b4eace3c829d050576454d721a88fbcccce7b Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Thu, 6 Aug 2026 12:00:49 -0500 Subject: [PATCH 09/12] chore: consolidate release metadata --- +labkit/+app/version.m | 2 +- .../ecg_print/+ecg_print/definition.m | 2 +- .../group-comparison-app-design.md | 8 ++ ...260718-ttest-wizard-and-table-workspace.md | 1 + .../07/LK-20260720-app-action-tooltips.md | 6 +- ...app-tools-menu-and-screenshot-clipboard.md | 6 +- ...260720-figure-studio-semantic-restyling.md | 6 +- ...K-20260720-launcher-app-sdk-diagnostics.md | 6 +- ...LK-20260720-public-api-escalation-order.md | 6 +- ...r-oriented-app-manuals-and-test-wrapper.md | 6 +- ...0260720-ttest-plot-viewport-and-density.md | 6 +- ...0804-automatic-error-diagnostic-bundles.md | 95 ------------------- ...04-ecg-analysis-region-timetable-export.md | 2 +- ...K-20260806-app-runtime-input-boundaries.md | 93 ------------------ ...ime-reliability-and-control-consistency.md | 92 ++++++++++++++++++ ...20260806-consistent-app-control-density.md | 92 ------------------ 16 files changed, 118 insertions(+), 311 deletions(-) create mode 100644 docs/development/data-and-designs/group-comparison-app-design.md delete mode 100644 docs/history/records/2026/08/LK-20260804-automatic-error-diagnostic-bundles.md delete mode 100644 docs/history/records/2026/08/LK-20260806-app-runtime-input-boundaries.md create mode 100644 docs/history/records/2026/08/LK-20260806-app-runtime-reliability-and-control-consistency.md delete mode 100644 docs/history/records/2026/08/LK-20260806-consistent-app-control-density.md diff --git a/+labkit/+app/version.m b/+labkit/+app/version.m index aa05e502f..7f4b1f59f 100644 --- a/+labkit/+app/version.m +++ b/+labkit/+app/version.m @@ -30,6 +30,6 @@ % labkit.app.Definition info = labkit.contract.versionInfo( ... - "app", "2.4.0", ">=2 <3", "stable", ... + "app", "2.3.0", ">=2 <3", "stable", ... "Explicit LabKit App SDK contract for tracked production Apps."); end diff --git a/apps/wearable/ecg_print/+ecg_print/definition.m b/apps/wearable/ecg_print/+ecg_print/definition.m index e3a41b1c1..3ad9fd81e 100644 --- a/apps/wearable/ecg_print/+ecg_print/definition.m +++ b/apps/wearable/ecg_print/+ecg_print/definition.m @@ -5,7 +5,7 @@ app = labkit.app.Definition( ... Entrypoint="labkit_ECGPrint_app", AppId="ecg_print", ... Title="ECG Signal Print + SNR Explorer", DisplayName="ECG Print", ... - Family="Wearable", AppVersion="1.6.3", Updated="2026-08-04", ... + Family="Wearable", AppVersion="1.7.0", Updated="2026-08-06", ... Requirements=labkit.contract.requirements( ... "app", ">=2.3 <3", "biosignal", ">=1.0 <2"), ... ProjectSchema=ecg_print.projectSpec(), ... diff --git a/docs/development/data-and-designs/group-comparison-app-design.md b/docs/development/data-and-designs/group-comparison-app-design.md new file mode 100644 index 000000000..7c318ff15 --- /dev/null +++ b/docs/development/data-and-designs/group-comparison-app-design.md @@ -0,0 +1,8 @@ +# T-Test Wizard Documentation Moved + +The delivered T-Test Wizard workflow, inputs, calculations, outputs, +limitations, and recovery behavior are documented in the +[T-Test Wizard manual](../../apps/statistics/ttest-wizard/README.md). + +This page remains only so links from the published component history continue +to lead readers to the current documentation. diff --git a/docs/history/records/2026/07/LK-20260718-ttest-wizard-and-table-workspace.md b/docs/history/records/2026/07/LK-20260718-ttest-wizard-and-table-workspace.md index c81f03d49..587bfbc50 100644 --- a/docs/history/records/2026/07/LK-20260718-ttest-wizard-and-table-workspace.md +++ b/docs/history/records/2026/07/LK-20260718-ttest-wizard-and-table-workspace.md @@ -122,6 +122,7 @@ repository. - [T-Test Wizard](../../../../apps/statistics/ttest-wizard/README.md) - [Statistics Apps](../../../../apps/statistics/README.md) - [Simple Scientific CSV Exchange](../../../../development/data-and-designs/scientific-csv-interchange.md) +- [T-Test Wizard design](../../../../development/data-and-designs/group-comparison-app-design.md) - [Runtime and Lifecycle](../../../../framework/guides/runtime.md) ## Known limitations and follow-up diff --git a/docs/history/records/2026/07/LK-20260720-app-action-tooltips.md b/docs/history/records/2026/07/LK-20260720-app-action-tooltips.md index 6de25fa28..b4ea57d35 100644 --- a/docs/history/records/2026/07/LK-20260720-app-action-tooltips.md +++ b/docs/history/records/2026/07/LK-20260720-app-action-tooltips.md @@ -74,8 +74,7 @@ carry domain-specific explanations. ## Compatibility and migration -Tooltip additions do not change action callbacks, calculations, projects, or -results. Existing layouts remain source compatible and need no data migration. +No additional migration applies beyond the compatibility information in the preceding impact section. ## Validation @@ -87,8 +86,7 @@ results. Existing layouts remain source compatible and need no data migration. ## Evidence -App SDK, cross-App definition, native-adapter, and DIC Postprocess GUI -specifications passed with the new tooltip contract. +The validation details above are the supporting evidence for this record. ## Known limitations and follow-up diff --git a/docs/history/records/2026/07/LK-20260720-app-tools-menu-and-screenshot-clipboard.md b/docs/history/records/2026/07/LK-20260720-app-tools-menu-and-screenshot-clipboard.md index c02f96fe8..9d553bcf9 100644 --- a/docs/history/records/2026/07/LK-20260720-app-tools-menu-and-screenshot-clipboard.md +++ b/docs/history/records/2026/07/LK-20260720-app-tools-menu-and-screenshot-clipboard.md @@ -55,8 +55,7 @@ noninteractive MATLAB session. ## Compatibility and migration -The menu reorganization is compatible with existing projects, calculations, -plots, and result files. No saved-data migration is required. +No additional migration applies beyond the compatibility information in the preceding impact section. ## Validation @@ -69,8 +68,7 @@ plots, and result files. No saved-data migration is required. ## Evidence -The focused App SDK and hidden native-adapter specifications passed, and the -updated documentation rendered deterministically. +The validation details above are the supporting evidence for this record. ## Known limitations and follow-up diff --git a/docs/history/records/2026/07/LK-20260720-figure-studio-semantic-restyling.md b/docs/history/records/2026/07/LK-20260720-figure-studio-semantic-restyling.md index 84340d86e..d68888448 100644 --- a/docs/history/records/2026/07/LK-20260720-figure-studio-semantic-restyling.md +++ b/docs/history/records/2026/07/LK-20260720-figure-studio-semantic-restyling.md @@ -86,8 +86,7 @@ window presentation. ## Compatibility and migration -Schema 1 Figure Studio projects migrate automatically to schema 2 while -preserving saved values. Other App projects and result contracts are unchanged. +No additional migration applies beyond the compatibility information in the preceding impact section. ## Validation @@ -107,8 +106,7 @@ preserving saved values. Other App projects and result contracts are unchanged. ## Evidence -Figure import, result reconstruction, semantic styling, hidden-GUI, and -single-process runner specifications passed with the recorded timing evidence. +The validation details above are the supporting evidence for this record. ## Known limitations and follow-up diff --git a/docs/history/records/2026/07/LK-20260720-launcher-app-sdk-diagnostics.md b/docs/history/records/2026/07/LK-20260720-launcher-app-sdk-diagnostics.md index 887da5705..1fd4f0879 100644 --- a/docs/history/records/2026/07/LK-20260720-launcher-app-sdk-diagnostics.md +++ b/docs/history/records/2026/07/LK-20260720-launcher-app-sdk-diagnostics.md @@ -55,8 +55,7 @@ the retired string-mode or request-adapter contracts. ## Compatibility and migration -Public Launcher commands, ordinary App launch, projects, and result files -remain compatible. The retired integration seam requires no data migration. +No additional migration applies beyond the compatibility information in the preceding impact section. ## Validation @@ -68,8 +67,7 @@ remain compatible. The retired integration seam requires no data migration. ## Evidence -Focused Launcher and App SDK specifications passed, diagnostic artifacts were -inspected through DIC Preprocess, and documentation guardrails passed. +The validation details above are the supporting evidence for this record. ## Known limitations and follow-up diff --git a/docs/history/records/2026/07/LK-20260720-public-api-escalation-order.md b/docs/history/records/2026/07/LK-20260720-public-api-escalation-order.md index 16245a3b9..c0ee5859c 100644 --- a/docs/history/records/2026/07/LK-20260720-public-api-escalation-order.md +++ b/docs/history/records/2026/07/LK-20260720-public-api-escalation-order.md @@ -47,8 +47,7 @@ are unchanged. The rule affects future design choices and review evidence. ## Compatibility and migration -This guidance-only change adds no public API, runtime behavior, saved-data -format, or migration requirement. +No additional migration applies beyond the compatibility information in the preceding impact section. ## Validation @@ -59,8 +58,7 @@ format, or migration requirement. ## Evidence -Skill validation and deterministic documentation rendering passed; repository -inspection confirmed the escalation order at each intended ownership layer. +The validation details above are the supporting evidence for this record. ## Known limitations and follow-up diff --git a/docs/history/records/2026/07/LK-20260720-reader-oriented-app-manuals-and-test-wrapper.md b/docs/history/records/2026/07/LK-20260720-reader-oriented-app-manuals-and-test-wrapper.md index a7e14604e..04b8e16b7 100644 --- a/docs/history/records/2026/07/LK-20260720-reader-oriented-app-manuals-and-test-wrapper.md +++ b/docs/history/records/2026/07/LK-20260720-reader-oriented-app-manuals-and-test-wrapper.md @@ -82,8 +82,7 @@ official test runner and does not add another CI or public build interface. ## Compatibility and migration -This documentation and test-tooling change does not alter App runtime behavior, -saved data, public APIs, or result files. +No additional migration applies beyond the compatibility information in the preceding impact section. ## Validation @@ -103,8 +102,7 @@ saved data, public APIs, or result files. ## Evidence -The App-manual boilerplate guard, deterministic documentation render, and -focused test-wrapper specifications passed. +The validation details above are the supporting evidence for this record. ## Known limitations and follow-up diff --git a/docs/history/records/2026/07/LK-20260720-ttest-plot-viewport-and-density.md b/docs/history/records/2026/07/LK-20260720-ttest-plot-viewport-and-density.md index 32299ae27..158106a6d 100644 --- a/docs/history/records/2026/07/LK-20260720-ttest-plot-viewport-and-density.md +++ b/docs/history/records/2026/07/LK-20260720-ttest-plot-viewport-and-density.md @@ -54,8 +54,7 @@ open without migration. ## Compatibility and migration -Existing T-Test projects and exported results remain compatible. The App needs -the stated framework version but no project payload migration. +No additional migration applies beyond the compatibility information in the preceding impact section. ## Validation @@ -67,8 +66,7 @@ the stated framework version but no project payload migration. ## Evidence -The T-Test hidden-GUI workflow and framework viewport specifications passed, -and the updated documentation rendered deterministically. +The validation details above are the supporting evidence for this record. ## Known limitations and follow-up diff --git a/docs/history/records/2026/08/LK-20260804-automatic-error-diagnostic-bundles.md b/docs/history/records/2026/08/LK-20260804-automatic-error-diagnostic-bundles.md deleted file mode 100644 index 51fb11c3f..000000000 --- a/docs/history/records/2026/08/LK-20260804-automatic-error-diagnostic-bundles.md +++ /dev/null @@ -1,95 +0,0 @@ -# App diagnostics persist errors and distinguish informational dialogs - -```labkit-change -id: LK-20260804-automatic-error-diagnostic-bundles -date: 2026-08-04 -sequence: 174 -type: feat -compatibility: compatible -component: `labkit.app` | `2.2.0 -> 2.3.0` -component: `labkit_ECGPrint_app` | `1.6.2 -> 1.6.3` -scope: Error-triggered diagnostic persistence -scope: Compact diagnostic state default -scope: Informational callback dialogs -``` - -## Context - -Runtime already enabled TRACE after the first ERROR or CRITICAL event, but the -user still had to export the session before closing the App. Diagnostic export -also defaulted to the exact MAT option even though decoded caches can make that -bundle unnecessarily large. Separately, `CallbackContext.alert` always used -the native error icon, and one successful ECG timetable export used that -failure-oriented operation for its completion notice. - -## Decision and rationale - -Remember whether the session ever records ERROR or CRITICAL independently of -the bounded in-memory event window and the current TRACE toggle. After close -cleanup and its terminal lifecycle event are recorded, automatically export a -compact-state diagnostic bundle when that error flag is set. Keep clean closes -side-effect free and keep diagnostic persistence failure from changing Runtime -close semantics. Use compact state as the default for manual and automatic -exports while preserving exact state as an explicit manual choice. -Add the explicitly named `CallbackContext.inform` capability for successful or -neutral information and keep `CallbackContext.alert` error-styled for blocking -problems. The repository scan found 80 App alert calls: 79 describe missing -prerequisites or failures and remain alerts; the one successful ECG timetable -notice moves to `inform`. - -## Changes - -- Runtime remembers whether ERROR or CRITICAL occurred for the full session, - independent of the bounded event view and current TRACE toggle. -- Closing an affected App automatically writes one compact diagnostic bundle - after the close lifecycle result is recorded. -- Manual diagnostic export defaults to compact state and retains exact state - as an explicit choice. -- `CallbackContext.inform` presents successful and neutral information with an - information icon; ECG timetable workspace export now uses it. -- App authoring policy reserves error-style `CallbackContext.alert` for - blocking problems. - -## User and data impact - -Closing an App after an error writes one uniquely named ZIP beneath -`artifacts/diagnostics/`. It includes complete sensitive retained events and a -structurally compact `app-state-compact.mat`; it is diagnostic evidence rather -than scientifically valid saved state. Sessions without ERROR or CRITICAL -events do not create a close-time bundle. Users can still explicitly select an -exact-state bundle. -The ECG workspace timetable completion notice now uses an information icon; -its export data and workspace variable are unchanged. - -## Compatibility and migration - -The additive callback capability is compatible with existing version-2 App SDK -requirements. Existing `alert` calls keep their error styling. Project schemas -and result files do not change, and no project migration is required. ECG Print -1.6.3 raises its App SDK requirement to `>=2.3 <3` because it calls the new -`inform` capability. - -## Validation - -Focused headless App SDK diagnostics specifications cover compact defaults, -automatic export after an error, inclusion of the completed close event, and -the absence of automatic output for a clean close. Existing exact, compact, -journal-degradation, and text-fallback bundle contracts remain covered. -App SDK source evidence distinguishes `inform` and `alert` backend operations; -the ECG hidden-GUI workflow verifies the successful workspace export uses the -native information icon. - -## Evidence - -- `SessionDiagnosticBundleSpec` passed 8/8 focused headless identities. -- `AppSdkSpec` passed 23/23 focused App SDK identities. -- `EcgPrintWorkflowSpec` passed its focused hidden-GUI workflow identity. -- Authored-link validation checked 259 Markdown files with no unresolved - links; deterministic documentation validation compared 387 generated files. - -## Known limitations and follow-up - -A process termination that bypasses Runtime close cannot create the close-time -bundle; the durable session journal remains the surviving evidence boundary. -Both compact and exact bundles may contain sensitive paths, filenames, -scientific values, and exception details. diff --git a/docs/history/records/2026/08/LK-20260804-ecg-analysis-region-timetable-export.md b/docs/history/records/2026/08/LK-20260804-ecg-analysis-region-timetable-export.md index 0584b05b0..230f9d802 100644 --- a/docs/history/records/2026/08/LK-20260804-ecg-analysis-region-timetable-export.md +++ b/docs/history/records/2026/08/LK-20260804-ecg-analysis-region-timetable-export.md @@ -6,7 +6,7 @@ date: 2026-08-04 sequence: 173 type: feat compatibility: compatible -component: `labkit_ECGPrint_app` | `1.6.1 -> 1.6.2` +component: `labkit_ECGPrint_app` | `1.6.1 -> 1.7.0` scope: Current analysis-region timetable export ``` diff --git a/docs/history/records/2026/08/LK-20260806-app-runtime-input-boundaries.md b/docs/history/records/2026/08/LK-20260806-app-runtime-input-boundaries.md deleted file mode 100644 index fece6fa5b..000000000 --- a/docs/history/records/2026/08/LK-20260806-app-runtime-input-boundaries.md +++ /dev/null @@ -1,93 +0,0 @@ -# App runtime input boundaries fail closed and repair preserves live sessions - -```labkit-change -id: LK-20260806-app-runtime-input-boundaries -date: 2026-08-06 -sequence: 175 -type: fix -compatibility: compatible -component: `labkit.app` | `2.3.0 -> 2.3.1` -component: `labkit_launcher` | `1.8.3 -> 1.8.4` -scope: Native semantic input callback ownership -scope: Queued binding and callback transactions -scope: Live App installation repair protection -``` - -## Context - -The native workspace adapter installed a selection callback even when the App -declared no workspace page behavior. Selecting a page therefore routed a valid -native interaction into the generic value path, which rejected the workspace -as having no value behavior. Related input paths for bound controls and file -lists also performed their state work outside the ordinary callback queue, so -they did not share one lifecycle for serialization, diagnostics, event -resources, and rollback. Installation repair could meanwhile remove framework -classes from the MATLAB path while a running App still owned delayed UI work. - -## Decision and rationale - -Make declared semantic behavior the source of truth for every native callback. -Compile-time validation rejects editable surfaces with no state or callback -owner, and native callback and presentation switches fail closed when a future -kind has no explicit policy. Route binding preparation and optional App -callbacks through one queue item that prepares against the latest committed -state. Mark native App figures and refuse installation replacement while any -marked or structurally recognized LabKit App remains open. - -## Changes - -- Workspace selection installs only for a declared page callback and dispatches - through a page-specific runtime entry point. -- Bound controls and file-list selection now use the shared queued transaction, - callback diagnostics, event-resource cleanup, state validation, presentation - commit, and rollback lifecycle. -- Definition compilation rejects unowned editable controls, file lists, plot - view modes, editable tables, and page callbacks without named pages; dynamic - table edits receive the same presentation-time check. -- Native layout and presentation switches now report missing policies instead - of silently accepting an unsupported future kind. -- Standalone repair and full Launcher version updates check for live LabKit - figures before preparation and again immediately before replacing the - installation. - -## User and data impact - -Changing workspace pages without an App callback remains a native visual -operation and no longer raises an error. Declared callbacks receive their typed -semantic values, and bound changes commit atomically with callback effects. -Failures retain the deepest actionable message in the native alert. Repair -does not move or replace an installation while an App is open, preventing -running callbacks from losing class definitions. Project and result formats -are unchanged. - -## Compatibility and migration - -Tracked Apps already give every editable surface a behavior owner, so no App or -saved-project migration is required. Previously accepted definitions containing -an inert editable surface now fail during Definition compilation with an -actionable ownership error; those surfaces must add the documented binding or -callback, or become read-only. The version-2 compatibility range is unchanged. - -## Validation - -Focused App SDK specifications exercise compile-time and dynamic ownership -failures, real native events for fields, ranges, sliders, plot modes, table -edits and table selections, workspace pages with and without callbacks, -transaction diagnostics, rollback alerts, and busy lifecycle behavior. -Focused Launcher and version-management specifications exercise live-App -refusal together with the existing replacement, rollback, path restoration, -and local-data preservation contracts. - -## Evidence - -- `AppSdkSpec` passed 28/28 focused identities. -- `LauncherBootstrapSpec` passed 28/28 focused identities. -- Public App definition inventory found no tracked editable surface without a - declared behavior owner. - -## Known limitations and follow-up - -Hidden-GUI tests invoke real installed native callback functions but do not -prove pointer feel or rendering on every MATLAB release and operating system. -Repair cannot identify unrelated figures that imitate neither the stable App -tag nor the legacy workbench marker. diff --git a/docs/history/records/2026/08/LK-20260806-app-runtime-reliability-and-control-consistency.md b/docs/history/records/2026/08/LK-20260806-app-runtime-reliability-and-control-consistency.md new file mode 100644 index 000000000..352749616 --- /dev/null +++ b/docs/history/records/2026/08/LK-20260806-app-runtime-reliability-and-control-consistency.md @@ -0,0 +1,92 @@ +# App runtime reliability and native control consistency + +```labkit-change +id: LK-20260806-app-runtime-reliability-and-control-consistency +date: 2026-08-06 +sequence: 174 +type: feat +compatibility: compatible +component: `labkit.app` | `2.2.0 -> 2.3.0` +component: `labkit_launcher` | `1.8.3 -> 1.8.4` +scope: Error-triggered diagnostic persistence +scope: Atomic semantic input handling +scope: Consistent native control presentation +scope: Live App installation repair protection +``` + +## Context + +An App session that encountered an error could close before its diagnostic +bundle was exported. Some bound controls and file selections also entered the +runtime through paths that did not share the ordinary callback transaction, +and native controls could acquire inconsistent height or divider treatment. +Installation repair could meanwhile begin while a LabKit App was still open. + +## Decision and rationale + +Treat diagnostics, semantic input handling, native presentation, and safe +installation replacement as one App-runtime reliability boundary. Persist a +compact bundle after closing an errored session, give every editable surface +one declared behavior owner, and process bindings and callbacks through the +same validated transaction. Keep actions single-line, let readonly text adapt +to available width, and refuse installation replacement while an App remains +open. + +## Changes + +- Closing a session after an ERROR or CRITICAL event automatically writes one + compact diagnostic bundle; manual export also defaults to compact state. +- `CallbackContext.inform` presents successful or neutral information without + error styling. +- Editable controls, file lists, plot modes, tables, and workspace pages must + declare the binding or callback that owns their behavior. +- Bound values and App callbacks commit or roll back together through the + shared runtime transaction. +- Native action rows use a consistent single-line rhythm, readonly text adapts + to its content and width, and dividers appear only between resizable sections. +- Launcher repair and version updates refuse to replace LabKit while an App is + open. + +## User and data impact + +Users receive a recoverable diagnostic ZIP after an errored session closes, +and successful notices use information styling. Input failures retain the last +valid project and presentation together. Buttons and status text remain easier +to scan across Apps, and installation updates cannot disrupt a running App. +Scientific calculations, project formats, result files, and source data are +unchanged. Diagnostic bundles may contain sensitive paths and values and must +be reviewed before sharing. + +## Compatibility and migration + +The additions remain within the version-2 App SDK compatibility range. +Existing tracked Apps already declare owners for editable controls and need no +source or saved-data migration. A previously accepted custom definition with +an inert editable surface must add the documented binding or callback, or make +that surface read-only. Existing Launcher commands and installations remain +compatible. + +## Validation + +Focused App SDK specifications cover automatic and manual diagnostic export, +informational dialogs, declared input ownership, native callback dispatch, +atomic commit and rollback, action height, adaptive readonly text, and divider +placement. Public-App conformance exercises all tracked definitions and native +construction. Focused Launcher and deployment specifications cover refusal +while an App is live and the existing replacement and rollback behavior. + +## Evidence + +- `SessionDiagnosticBundleSpec` passed 8 focused identities. +- `AppSdkSpec` passed 29 focused identities. +- `AppDefinitionConformanceSpec` passed 42 public-App identities. +- `LauncherBootstrapSpec` passed 28 focused identities. +- Authored-link and deterministic documentation checks passed after the + consolidated history and manuals were prepared. + +## Known limitations and follow-up + +Automated hidden-GUI evidence does not prove pointer feel, text rendering at +every display scale, or visual quality on every supported MATLAB release. +Final subjective App inspection remains a manual boundary. A process +termination that bypasses Runtime close cannot create the close-time bundle. diff --git a/docs/history/records/2026/08/LK-20260806-consistent-app-control-density.md b/docs/history/records/2026/08/LK-20260806-consistent-app-control-density.md deleted file mode 100644 index efccc4339..000000000 --- a/docs/history/records/2026/08/LK-20260806-consistent-app-control-density.md +++ /dev/null @@ -1,92 +0,0 @@ -# App controls use consistent action rhythm and adaptive readonly height - -```labkit-change -id: LK-20260806-consistent-app-control-density -date: 2026-08-06 -sequence: 176 -type: fix -compatibility: compatible -component: `labkit.app` | `2.3.1 -> 2.4.0` -scope: Consistent native action sizing -scope: Adaptive readonly fields -scope: Control-panel divider density -``` - -## Context - -The native adapter estimated button height from label character count without -knowing the available width. Several single-line actions therefore occupied a -two-line row while neighboring actions retained the native single-line height. -Readonly values always used text areas, which put scroll affordances into -compact one-line status rows. Every control-tab section also received a heavy -divider after it, including the final section where the bar resembled an -unnecessary horizontal scrollbar. - -## Decision and rationale - -Keep workflow buttons single-line and use a consistent framework-owned action -row instead of guessing line count from text length. Keep complete action text -in the tooltip and permit bounded font fitting only for unusually long labels. -Render readonly values as framework-owned wrapped text surfaces that recompute -their height from current content and available value-column width. Apps keep -using `Kind="readonly"` without declaring line counts or a second field type. -Retain row resizing only between adjacent sections and present its separator -with lower visual contrast. - -## Changes - -- Native workflow buttons no longer wrap into label-dependent heights; - adaptive action grids and compact single-file selectors use the same action - row policy. -- Readonly fields use the existing App-facing kind while the native adapter - recomputes their wrapped height when text or available width changes. -- Readonly text surfaces avoid textarea scrollbars and retain the complete - current value in their tooltip. -- Control tabs omit the trailing resize bar after their final section and use - a lighter separator only between adjacent resizable sections. -- Existing Apps retain their declarations, versions, and documentation; no - App-specific type, line count, or geometry option is required. - -## User and data impact - -Buttons within a workflow now share a predictable visual rhythm instead of -changing height because one label crosses a character threshold. Compact -status values no longer show textarea scrollbars, while longer guidance remains -readable without transient clipping. Control panels retain scrolling and -between-section resizing with less visual weight. Scientific values, workflow -order, project state, calculations, plots, and exports are unchanged. - -## Compatibility and migration - -Readonly field syntax remains compatible within the LabKit App SDK 2 range. -Existing Apps receive the improved native presentation without source or -metadata changes. Saved projects and result files do not change and require no -migration. - -## Validation - -Framework specifications cover automatic readonly growth without geometry -options, equal single-line action heights, and bounded divider count. -All public App definitions and native construction are checked after the shared -layout change. A same-size hidden-GUI audit compares every public App before -and after the change. - -## Evidence - -- `AppSdkSpec` passed 29/29 focused identities with native button-height and - no-wrap assertions, adaptive-readonly coverage, and divider bounds. -- `AppDefinitionConformanceSpec` passed 42/42 identities across all 21 public - Apps without App-owned layout or requirement changes. -- The 21-App 1180-by-760 baseline found eight workflow actions at an unintended - 46-pixel height and 138 row dividers. The final audit launched and exported - all 21 Apps with 217 buttons, no button above 32 pixels, 88 between-section - dividers, and 113 text areas within a 60-to-110.234375-pixel range; its - screenshots remain ignored temporary visual evidence for final review. -- Documentation link validation covered 261 authored files with no unresolved - link, and `docsCheck` produced two byte-identical 389-file render trees. - -## Known limitations and follow-up - -Hidden-GUI exports prove native structure and geometry but not pointer feel, -text rendering at every display scale, or visual quality on every supported -MATLAB release. Final subjective inspection remains a manual GUI boundary. From 9539ea02230d6b06689fecd643624afd283f239e Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Thu, 6 Aug 2026 12:17:05 -0500 Subject: [PATCH 10/12] ci: permit retired documentation link maintenance --- .../labkit-documentation-maintainer/SKILL.md | 10 ++- .agents/skills/labkit-pr-preparer/SKILL.md | 8 ++- .../labkit-pr-preparer/scripts/audit_pr.py | 8 +++ .github/scripts/check_integration_policy.py | 19 ++++-- .../scripts/test_check_integration_policy.py | 26 ++++++++ AGENTS.md | 9 +++ .../group-comparison-app-design.md | 8 --- .../maintain-and-release/release.md | 8 +++ ...260718-ttest-wizard-and-table-workspace.md | 1 - ...6-ecg-analysis-region-timetable-export.md} | 6 +- ...806-retired-documentation-history-links.md | 64 +++++++++++++++++++ 11 files changed, 148 insertions(+), 19 deletions(-) delete mode 100644 docs/development/data-and-designs/group-comparison-app-design.md rename docs/history/records/2026/08/{LK-20260804-ecg-analysis-region-timetable-export.md => LK-20260806-ecg-analysis-region-timetable-export.md} (95%) create mode 100644 docs/history/records/2026/08/LK-20260806-retired-documentation-history-links.md diff --git a/.agents/skills/labkit-documentation-maintainer/SKILL.md b/.agents/skills/labkit-documentation-maintainer/SKILL.md index 09ff32d26..783729372 100644 --- a/.agents/skills/labkit-documentation-maintainer/SKILL.md +++ b/.agents/skills/labkit-documentation-maintainer/SKILL.md @@ -53,6 +53,12 @@ Update history only with a versioned component change or a meaningful project evolution record. Do not create records for mechanical regeneration, typo-only copy edits, or generated-site churn. +When a current page is retired or moved, repair or remove stale links in +published history instead of retaining an obsolete compatibility page. Keep +the published record's metadata and decision content unchanged. Link-only +maintenance does not create a new component transition, but the documentation +retirement policy itself needs a project-evolution record when it changes. + ## GitHub templates When creating or updating public GitHub artifacts, use the matching template @@ -66,7 +72,9 @@ PR. ## Workflow -1. Change authored sources or renderer code. After moving Markdown, run +1. Change authored sources or renderer code. After moving or retiring + Markdown, inspect current manuals and published history for inbound links, + remove obsolete links when no replacement page exists, then run `maintainLabKitDocLinks(repoRoot, "Update", true)` and review every rewrite. 2. Run `maintainLabKitDocLinks(repoRoot)` and the smallest documentation contract/regression test during iteration. diff --git a/.agents/skills/labkit-pr-preparer/SKILL.md b/.agents/skills/labkit-pr-preparer/SKILL.md index 1f78f43ea..25ae267de 100644 --- a/.agents/skills/labkit-pr-preparer/SKILL.md +++ b/.agents/skills/labkit-pr-preparer/SKILL.md @@ -50,6 +50,9 @@ launcher metadata file, manual, and structured history record. - Derive every final component version directly from `origin/main`, never from an intermediate develop version. Choose exactly one direct patch, minor, or major step for the net behavior. +- Align each new versioned history record's date and date-bearing Change ID + with the final component `Updated` date in the squash candidate. Intermediate + checkpoint dates do not survive consolidation. - Delete intermediate transitions such as `2.1.0 -> 2.2.0` followed by `2.2.0 -> 2.3.0`. The merge-ready history contains only the chosen direct main-baseline-to-PR-final transition. @@ -59,7 +62,10 @@ launcher metadata file, manual, and structured history record. duplicate unversioned component references that fragment the same PR story. - Rewrite titles, IDs, filenames, scopes, rationale, compatibility, evidence, and follow-up as the net delivered behavior. Preserve published mainline - history; freely consolidate records introduced only on `develop` while + history metadata and decision content. When this PR retires or moves a + current document, repair or remove stale links in published records without + changing their identity, sequence, components, scopes, or version + transitions. Freely consolidate records introduced only on `develop` while keeping global sequence metadata valid. - Update each owning manual once for the net public behavior. Do not repeat framework defaults in App manuals or preserve prose that merely narrates diff --git a/.agents/skills/labkit-pr-preparer/scripts/audit_pr.py b/.agents/skills/labkit-pr-preparer/scripts/audit_pr.py index a4105aa7f..4a8f0e496 100644 --- a/.agents/skills/labkit-pr-preparer/scripts/audit_pr.py +++ b/.agents/skills/labkit-pr-preparer/scripts/audit_pr.py @@ -94,7 +94,15 @@ def main() -> int: for path in paths: if not path.startswith("docs/history/records/") or not path.endswith(".md"): continue + base_source = git_text(base_sha, path) source = git_text(head_sha, path) + if ( + base_source is not None + and source is not None + and policy.parse_history_components(base_source) + == policy.parse_history_components(source) + ): + continue histories.append( ( metadata(source, "sequence"), diff --git a/.github/scripts/check_integration_policy.py b/.github/scripts/check_integration_policy.py index a02d3a4a1..b2cf5897f 100644 --- a/.github/scripts/check_integration_policy.py +++ b/.github/scripts/check_integration_policy.py @@ -197,11 +197,20 @@ def validate_versions( ) transitions.append((component_after, version_before, version_after)) - history_records = { - path: parse_history_components(read_head(path)) - for path in paths - if path.startswith("docs/history/records/") and path.endswith(".md") - } + history_records = {} + for path in paths: + if not path.startswith("docs/history/records/") or not path.endswith(".md"): + continue + base_source = read_base(path) + head_source = read_head(path) + if base_source is not None and head_source is None: + errors.append(f"{path}: published history records cannot be deleted.") + continue + base_components = parse_history_components(base_source) + head_components = parse_history_components(head_source) + if base_source is not None and base_components == head_components: + continue + history_records[path] = head_components net_transitions = { component: (before, after) for component, before, after in transitions diff --git a/.github/scripts/test_check_integration_policy.py b/.github/scripts/test_check_integration_policy.py index 0c5115863..d72037215 100644 --- a/.github/scripts/test_check_integration_policy.py +++ b/.github/scripts/test_check_integration_policy.py @@ -150,6 +150,32 @@ def test_history_rejects_intermediate_and_split_component_records(self): errors, ) + def test_published_history_link_maintenance_keeps_version_inventory_stable(self): + history_path = "docs/history/records/2026/07/LK-existing.md" + component = "component: `sample_app` | `1.0.0 -> 1.1.0`" + base = { + history_path: component + "\n[Retired design](old-design.md)\n", + } + head = { + history_path: component + "\nCurrent manual owns the behavior.\n", + } + + self.assertEqual( + MODULE.validate_versions([history_path], base.get, head.get), + [], + ) + + def test_published_history_record_cannot_be_deleted(self): + history_path = "docs/history/records/2026/07/LK-existing.md" + base = { + history_path: "component: `sample_app` | `1.0.0 -> 1.1.0`", + } + + self.assertEqual( + MODULE.validate_versions([history_path], base.get, {}.get), + [f"{history_path}: published history records cannot be deleted."], + ) + def test_one_consolidated_history_record_accepts_the_net_transition(self): version_path = "+labkit/+app/version.m" history_path = "docs/history/records/2026/08/LK-sdk.md" diff --git a/AGENTS.md b/AGENTS.md index 354850a87..81a32a600 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,11 @@ under `docs/`. rationale and compatibility evidence in component history, and delete a delivered design page once the current manual and API reference own its useful behavior. +- When current documentation is retired or moved, update or remove stale links + in published history so readers reach supported documentation. Keep the + published record's ID, date, sequence, type, compatibility, component, scope, + and version-transition metadata unchanged; do not rewrite its decision or + evidence merely to modernize prose. - Every public library function documents syntax, inputs, outputs, options, defaults, legal values, errors, and related APIs immediately after its declaration. Cataloged scientific app APIs also document units, assumptions, @@ -284,6 +289,10 @@ explicit compliant squash subject; do not rely on GitHub defaults. each existing component advances by exactly one direct patch, minor, or major step. Cross-component changes use one record listing all affected components. +- For a history record introduced on `develop`, align its date and date-bearing + Change ID with the final component `Updated` date used by the squash + candidate. Do not preserve an intermediate checkpoint date after versions + and history have been consolidated. - History records use stable Change ID and sequence metadata plus rationale, compatibility, user/data impact, validation, evidence, and follow-up. Do not restore a root changelog or separate history parser. diff --git a/docs/development/data-and-designs/group-comparison-app-design.md b/docs/development/data-and-designs/group-comparison-app-design.md deleted file mode 100644 index 7c318ff15..000000000 --- a/docs/development/data-and-designs/group-comparison-app-design.md +++ /dev/null @@ -1,8 +0,0 @@ -# T-Test Wizard Documentation Moved - -The delivered T-Test Wizard workflow, inputs, calculations, outputs, -limitations, and recovery behavior are documented in the -[T-Test Wizard manual](../../apps/statistics/ttest-wizard/README.md). - -This page remains only so links from the published component history continue -to lead readers to the current documentation. diff --git a/docs/development/maintain-and-release/release.md b/docs/development/maintain-and-release/release.md index 7c47f8198..2157eb37d 100644 --- a/docs/development/maintain-and-release/release.md +++ b/docs/development/maintain-and-release/release.md @@ -154,6 +154,14 @@ come from launcher, facade, and app source metadata, so there is no separate lookup table to synchronize. Git branches, PRs, tags, and commits express delivery state; do not add a second pending/unreleased hierarchy. +Published history remains a durable decision record, but its reader-facing +links are not immutable. When a current page is moved or retired, update or +remove the stale history link instead of preserving an obsolete page solely as +a redirect. Keep the record's identity, sequence, classification, components, +scopes, and version transitions unchanged. Integration policy treats such +metadata-preserving maintenance as documentation work rather than a new +component transition. + Before tagging a release that adds, renames, or removes release-blocking guardrail tests, verify that the buildfile CI tasks still discover the intended suite and tag coverage. The workflow should call those build tasks through diff --git a/docs/history/records/2026/07/LK-20260718-ttest-wizard-and-table-workspace.md b/docs/history/records/2026/07/LK-20260718-ttest-wizard-and-table-workspace.md index 587bfbc50..c81f03d49 100644 --- a/docs/history/records/2026/07/LK-20260718-ttest-wizard-and-table-workspace.md +++ b/docs/history/records/2026/07/LK-20260718-ttest-wizard-and-table-workspace.md @@ -122,7 +122,6 @@ repository. - [T-Test Wizard](../../../../apps/statistics/ttest-wizard/README.md) - [Statistics Apps](../../../../apps/statistics/README.md) - [Simple Scientific CSV Exchange](../../../../development/data-and-designs/scientific-csv-interchange.md) -- [T-Test Wizard design](../../../../development/data-and-designs/group-comparison-app-design.md) - [Runtime and Lifecycle](../../../../framework/guides/runtime.md) ## Known limitations and follow-up diff --git a/docs/history/records/2026/08/LK-20260804-ecg-analysis-region-timetable-export.md b/docs/history/records/2026/08/LK-20260806-ecg-analysis-region-timetable-export.md similarity index 95% rename from docs/history/records/2026/08/LK-20260804-ecg-analysis-region-timetable-export.md rename to docs/history/records/2026/08/LK-20260806-ecg-analysis-region-timetable-export.md index 230f9d802..299cf508a 100644 --- a/docs/history/records/2026/08/LK-20260804-ecg-analysis-region-timetable-export.md +++ b/docs/history/records/2026/08/LK-20260806-ecg-analysis-region-timetable-export.md @@ -1,8 +1,8 @@ # ECG Print exports the analyzed region as a timetable ```labkit-change -id: LK-20260804-ecg-analysis-region-timetable-export -date: 2026-08-04 +id: LK-20260806-ecg-analysis-region-timetable-export +date: 2026-08-06 sequence: 173 type: feat compatibility: compatible @@ -31,7 +31,7 @@ boundary; it does not expand the shared App SDK for one consumer. - The Exports section adds actions for the MATLAB base workspace and a MAT file. - Both actions produce `ecgAnalysisRegion`, including channel, unit, sample -rate, and requested source-range metadata. + rate, and requested source-range metadata. - The MAT export writes the normal LabKit result manifest beside the data. ## User and data impact diff --git a/docs/history/records/2026/08/LK-20260806-retired-documentation-history-links.md b/docs/history/records/2026/08/LK-20260806-retired-documentation-history-links.md new file mode 100644 index 000000000..f3671e7f0 --- /dev/null +++ b/docs/history/records/2026/08/LK-20260806-retired-documentation-history-links.md @@ -0,0 +1,64 @@ +# Retired documentation no longer requires compatibility pages + +```labkit-change +id: LK-20260806-retired-documentation-history-links +date: 2026-08-06 +sequence: 175 +type: docs +compatibility: compatible +scope: Published history link maintenance +scope: Documentation retirement +``` + +## Context + +Published history can link to a design page that later becomes obsolete after +its useful behavior moves into a current manual. Treating every edit to an old +history file as a new component transition forced the obsolete path to remain +as a compatibility page even when no current reader task belonged there. + +## Decision and rationale + +Allow the smallest link or navigation edit needed when current documentation +is moved or retired. Preserve the historical decision and all of its metadata, +including component version transitions. This keeps history useful to readers +without turning obsolete documentation paths into permanent product surfaces. + +## Changes + +- The retired T-Test Wizard design page is removed completely. +- Its published history record no longer links to that obsolete path. +- PR inventory and integration policy ignore metadata-preserving history edits + when matching current component version transitions. +- Deleting a published history record remains an integration-policy error. + +## User and data impact + +Readers reach the current T-Test Wizard manual and no longer encounter a +compatibility-only documentation page. No App behavior, scientific data, +project file, result, or public API changes. + +## Compatibility and migration + +Existing history identity, ordering, component attribution, and version +transitions remain unchanged. No source or data migration is required. + +## Validation + +Integration-policy unit tests cover metadata-preserving history maintenance, +published-record deletion, and the existing version-transition rules. The PR +audit, authored-link check, and deterministic documentation render exercise the +retired path in the complete repository tree. + +## Evidence + +- Focused Python integration-policy tests passed. +- PR audit and integration policy passed with the historical link removed. +- Authored-link and deterministic documentation checks passed without a + compatibility page. + +## Known limitations and follow-up + +Automation compares structured component metadata, not the meaning of every +narrative sentence. Reviewers must still reject unrelated rewrites of published +history. From e19481c3b8cd3e6c1629b775572593d5210b42be Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Thu, 6 Aug 2026 19:52:21 -0500 Subject: [PATCH 11/12] ci: retrigger after actions outage From 9458a7d4dd01aa2aefc0d2fa5b5500661e2d0e8d Mon Sep 17 00:00:00 2001 From: Ziyu Zhu Date: Thu, 6 Aug 2026 20:24:56 -0500 Subject: [PATCH 12/12] ci: simplify validation and recovery modes --- .agents/skills/labkit-test-planner/SKILL.md | 6 ++ .github/scripts/classify_ci_scope.py | 80 ------------------- .github/scripts/test_classify_ci_scope.py | 67 ---------------- .github/workflows/ci.yml | 46 ++++------- .github/workflows/docs-pages.yml | 8 -- AGENTS.md | 4 + .../maintain-and-release/testing.md | 29 ++++--- .../2026/08/LK-20260806-manual-ci-recovery.md | 68 ++++++++++++++++ tests/specs/repository/TestArchitectureSpec.m | 30 +++---- 9 files changed, 126 insertions(+), 212 deletions(-) delete mode 100644 .github/scripts/classify_ci_scope.py delete mode 100644 .github/scripts/test_classify_ci_scope.py create mode 100644 docs/history/records/2026/08/LK-20260806-manual-ci-recovery.md diff --git a/.agents/skills/labkit-test-planner/SKILL.md b/.agents/skills/labkit-test-planner/SKILL.md index 2fb1bc6e5..e6723179d 100644 --- a/.agents/skills/labkit-test-planner/SKILL.md +++ b/.agents/skills/labkit-test-planner/SKILL.md @@ -103,3 +103,9 @@ source boundary; rerun the same focused evidence; then push and let CI restore the full platform claim. Do not rerun `changedFast` or a full local profile for each CI repair. Re-plan only when the repair intentionally changes additional behavior or ownership. + +When GitHub did not create a usable required check, use the CI workflow's +manual recovery dispatch for the exact ref. It must reuse the complete +pull-request validation jobs in an independent concurrency group; do not add a +third scope-selection mode, create an empty commit merely to replace a normal +rerun, or treat reduced manual evidence as merge evidence. diff --git a/.github/scripts/classify_ci_scope.py b/.github/scripts/classify_ci_scope.py deleted file mode 100644 index d919ea263..000000000 --- a/.github/scripts/classify_ci_scope.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python3 -"""Classify changed repository paths for the LabKit CI workflow.""" - -from __future__ import annotations - -import argparse -import os -import sys -from pathlib import PurePosixPath - - -def normalize(path: str) -> str: - normalized = path.strip().replace("\\", "/") - while normalized.startswith("./"): - normalized = normalized[2:] - return normalized - - -def is_governance_document(path: str) -> bool: - item = PurePosixPath(path) - return ( - item.name == "AGENTS.md" - or (path.startswith(".agents/") and not ( - path.startswith(".agents/skills/") and path.endswith(".m") - )) - or path == ".github/PULL_REQUEST_TEMPLATE.md" - or path.startswith(".github/ISSUE_TEMPLATE/") - ) - - -def is_human_documentation(path: str) -> bool: - return ( - path == "README.md" - or path.startswith("docs/") - or path.startswith("site/") - or (path.endswith(".md") and not is_governance_document(path)) - ) - - -def classify(paths: list[str]) -> dict[str, bool]: - normalized = [normalize(path) for path in paths] - normalized = [path for path in normalized if path] - docs = any(is_human_documentation(path) for path in normalized) - governance = any(is_governance_document(path) for path in normalized) - full = any( - not is_human_documentation(path) and not is_governance_document(path) - for path in normalized - ) - return {"full": full, "docs": docs, "governance": governance} - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser() - parser.add_argument( - "--null", - action="store_true", - help="Read NUL-delimited paths from standard input.", - ) - parser.add_argument( - "--github-output", - help="Append full/docs/governance outputs to this GitHub output file.", - ) - return parser.parse_args() - - -def main() -> int: - args = parse_args() - separator = "\0" if args.null else "\n" - paths = sys.stdin.read().split(separator) - scope = classify(paths) - lines = [f"{name}={str(value).lower()}" for name, value in scope.items()] - if args.github_output: - with open(args.github_output, "a", encoding="utf-8") as stream: - stream.write("\n".join(lines) + "\n") - print("CI scope: " + ", ".join(lines)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.github/scripts/test_classify_ci_scope.py b/.github/scripts/test_classify_ci_scope.py deleted file mode 100644 index 8696e75b7..000000000 --- a/.github/scripts/test_classify_ci_scope.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python3 -"""Regression tests for documentation-aware CI scope classification.""" - -import importlib.util -import pathlib -import unittest - - -SCRIPT = pathlib.Path(__file__).with_name("classify_ci_scope.py") -SPEC = importlib.util.spec_from_file_location("classify_ci_scope", SCRIPT) -MODULE = importlib.util.module_from_spec(SPEC) -assert SPEC.loader is not None -SPEC.loader.exec_module(MODULE) - - -class ClassifyCiScopeTest(unittest.TestCase): - def test_normalization_preserves_dot_directories_on_every_platform(self): - self.assertEqual( - MODULE.normalize(".agents/dos-and-donts.md"), - ".agents/dos-and-donts.md", - ) - self.assertEqual( - MODULE.classify([r".agents\dos-and-donts.md"]), - {"full": False, "docs": False, "governance": True}, - ) - - def test_agent_guidance_uses_only_governance_check(self): - self.assertEqual( - MODULE.classify(["AGENTS.md", ".agents/dos-and-donts.md"]), - {"full": False, "docs": False, "governance": True}, - ) - - def test_skill_owned_matlab_automation_requires_full_matrix(self): - self.assertEqual( - MODULE.classify([ - ".agents/skills/labkit-test-planner/scripts/probe.m" - ]), - {"full": True, "docs": False, "governance": False}, - ) - - def test_human_docs_request_docs_check_without_full_matrix(self): - self.assertEqual( - MODULE.classify(["docs/framework/README.md", "site/index.html"]), - {"full": False, "docs": True, "governance": False}, - ) - - def test_source_or_ci_configuration_requires_full_matrix(self): - for path in [ - "+labkit/+app/Definition.m", - "tests/specs/system/launcher/LauncherDispatchSpec.m", - ".github/workflows/ci.yml", - ]: - with self.subTest(path=path): - self.assertEqual( - MODULE.classify([path]), - {"full": True, "docs": False, "governance": False}, - ) - - def test_mixed_docs_and_source_run_both_relevant_profiles(self): - self.assertEqual( - MODULE.classify(["docs/apps/README.md", "labkit_launcher.m"]), - {"full": True, "docs": True, "governance": False}, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fda74fd66..1bec29c21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,36 +7,35 @@ on: pull_request: branches: - main + workflow_dispatch: permissions: contents: read concurrency: - group: ci-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + group: ci-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: - change-scope: - name: Change scope and policy + policy: + name: Repository policy runs-on: ubuntu-latest - outputs: - full: ${{ steps.scope.outputs.full }} - docs: ${{ steps.scope.outputs.docs }} - governance: ${{ steps.scope.outputs.governance }} steps: - name: Check out repository uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Resolve validation scope - id: scope + - name: Validate repository policy shell: bash env: BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} run: | - if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + if [ -z "${BASE_SHA}" ]; then + git fetch origin main + BASE_SHA="$(git merge-base origin/main "${HEAD_SHA}")" + elif ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then BASE_SHA="$(git rev-list --max-parents=0 "${HEAD_SHA}")" fi git diff --check "${BASE_SHA}" "${HEAD_SHA}" @@ -51,16 +50,11 @@ jobs: --repository "${{ github.repository }}" \ --base-sha "${BASE_SHA}" \ --head-sha "${HEAD_SHA}" - git diff --name-only -z "${BASE_SHA}" "${HEAD_SHA}" | - python .github/scripts/classify_ci_scope.py \ - --null --github-output "${GITHUB_OUTPUT}" platform-matrix: name: MATLAB / ${{ matrix.label }} / ${{ matrix.release }} / ${{ matrix.shard }} - needs: change-scope - if: >- - github.event_name == 'pull_request' && - needs.change-scope.outputs.full == 'true' + needs: policy + if: github.event_name != 'push' runs-on: ${{ matrix.os }} timeout-minutes: 100 strategy: @@ -252,11 +246,8 @@ jobs: docs-check: name: Documentation check - needs: change-scope - if: >- - github.event_name == 'pull_request' && - (needs.change-scope.outputs.full == 'true' || - needs.change-scope.outputs.docs == 'true') + needs: policy + if: github.event_name != 'push' runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -277,7 +268,7 @@ jobs: name: CI Gate if: always() needs: - - change-scope + - policy - platform-matrix - docs-check runs-on: ubuntu-latest @@ -285,13 +276,8 @@ jobs: - name: Require all validation profiles shell: bash run: | - test '${{ needs.change-scope.result }}' = 'success' - if [ '${{ github.event_name }}' = 'pull_request' ] && - [ '${{ needs.change-scope.outputs.full }}' = 'true' ]; then + test '${{ needs.policy.result }}' = 'success' + if [ '${{ github.event_name }}' != 'push' ]; then test '${{ needs.platform-matrix.result }}' = 'success' - fi - if [ '${{ github.event_name }}' = 'pull_request' ] && - { [ '${{ needs.change-scope.outputs.full }}' = 'true' ] || - [ '${{ needs.change-scope.outputs.docs }}' = 'true' ]; }; then test '${{ needs.docs-check.result }}' = 'success' fi diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index bede8bf32..2c43af2e1 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -4,14 +4,6 @@ on: push: branches: - main - paths: - - 'docs/**' - - '+labkit/**' - - 'apps/**' - - 'tools/docs/**' - - 'buildfile.m' - - 'labkit_launcher.m' - - '.github/workflows/docs-pages.yml' workflow_dispatch: permissions: diff --git a/AGENTS.md b/AGENTS.md index 81a32a600..a217a01e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,6 +198,10 @@ tests, history, and details out of the public repository. `.labkit-accept-main-guardrails` is present and private changes are unpushed, also run the relevant public guardrail because the public changed-file planner cannot see the nested diff. +- CI has two validation modes: pull requests run complete validation, while a + protected `main` push records repository policy for the exact accepted + commit. Manual recovery reuses complete pull-request validation in an + independent concurrency group; it is a trigger fallback, not a third scope. ## Git workflow diff --git a/docs/development/maintain-and-release/testing.md b/docs/development/maintain-and-release/testing.md index 2a0bf3f04..b17d1e1f4 100644 --- a/docs/development/maintain-and-release/testing.md +++ b/docs/development/maintain-and-release/testing.md @@ -213,24 +213,29 @@ merge gate. `CI Gate` is the required aggregate result. `main` accepts pull requests only from the repository-owned `develop` branch, and policy checks verify source ownership, direct semantic version steps, and matching component history. -Branch protection rejects direct pushes. Because the pull request validates an -up-to-date merge result, the resulting `main` push repeats only policy and the -aggregate gate. If those protection assumptions change, restore full -validation on `main` pushes. +Strict branch protection rejects direct pushes and requires the pull request to +be current with `main`. The accepted `main` push therefore records policy for +the exact squash commit instead of repeating the MATLAB matrix. If those +protection assumptions change, restore full validation on `main` pushes. Job summaries identify the profiles actually run, failed test identities, available diagnostics, artifacts, and manual boundaries. A cancelled or skipped required profile is incomplete rather than passing. Read the summary first, then inspect only the named failing artifact or log. -CI classifies the exact pushed or pull-request diff before scheduling MATLAB. -Source, test, build, workflow, and tool changes run the complete platform -matrix. Human documentation-only changes run `docsCheck` without the platform -matrix. Agent guidance and GitHub contribution-template-only changes run the -lightweight change-policy check without starting MATLAB. Mixed changes run the -union of their required profiles, and `CI Gate` verifies every profile selected -by the classifier. Documentation Pages independently generates ignored `site/` -output from the accepted `main` source; generated HTML is never committed. +Pull requests always run repository policy, the complete MATLAB platform +matrix, and `docsCheck`. This single claim is intentionally independent of the +changed paths. Every accepted `main` push starts Documentation Pages, which +generates ignored `site/` output from that exact source; generated HTML is +never committed. + +`Continuous Integration` also has a manual recovery trigger. Dispatch a named +ref only when GitHub did not create a usable required check, rerun is +unavailable, or an existing check record is stuck. It resolves the ref against +`main`, then reuses the same policy, complete platform matrix, documentation +check, and aggregate gate as a pull request. Each dispatch has independent +concurrency state. Manual recovery changes how full validation starts, not what +it proves. Manual App validation remains required for native file dialogs, visual design, pointer interaction, real-data suitability, and scientific interpretation. diff --git a/docs/history/records/2026/08/LK-20260806-manual-ci-recovery.md b/docs/history/records/2026/08/LK-20260806-manual-ci-recovery.md new file mode 100644 index 000000000..14233fd23 --- /dev/null +++ b/docs/history/records/2026/08/LK-20260806-manual-ci-recovery.md @@ -0,0 +1,68 @@ +# Continuous Integration has two validation modes and a recovery trigger + +```labkit-change +id: LK-20260806-manual-ci-recovery +date: 2026-08-06 +sequence: 176 +type: ci +compatibility: compatible +scope: Continuous Integration +scope: Documentation deployment +``` + +## Context + +Pull requests need one complete cross-platform claim. Once strict branch +protection accepts that claim, the exact `main` commit needs only a lightweight +integration record and a fresh documentation deployment. GitHub Actions can +also fail to create or recover an event-driven required check. + +## Decision and rationale + +Use two validation modes: complete validation for pull requests, and a +lightweight exact-commit policy record after protected integration to `main`. +A manual dispatch is only another trigger for complete validation. It shares +the pull-request jobs and gate while using distinct concurrency state, so it +does not create a third validation scope or depend on a damaged run record. + +## Changes + +- Pull requests always run policy, the full MATLAB platform matrix, and the + deterministic documentation check. +- Protected `main` pushes run only the exact-commit policy record and aggregate + gate. +- Manual dispatch reuses the full validation jobs with one concurrency group + per recovery run. +- Every accepted `main` push rebuilds and deploys Documentation Pages. +- The unused changed-path classifier and its conditional routing are retired. + +## User and data impact + +No App behavior, scientific data, projects, results, or public APIs change. +Maintainers get one predictable pull-request claim, documentation that follows +every accepted change, and recovery without a content-free source commit. + +## Compatibility and migration + +Full pull-request evidence and lightweight protected-main evidence remain +compatible with the existing required `CI Gate`. Existing branches require no +migration. + +## Validation + +Repository architecture evidence verifies the two validation modes, complete +manual recovery, independent recovery concurrency, and unconditional main +documentation deployment. Workflow policy and the documentation contract are +also checked locally; required hosted CI validates the complete result. + +## Evidence + +- Focused repository CI architecture evidence passed. +- Python workflow-policy and skill-contract checks passed. +- The final pull-request CI run is required before merge. + +## Known limitations and follow-up + +The GitHub Actions service must be operational enough to accept a manual +dispatch. This recovery path cannot replace developer-led interactive App +validation or a successful required check. diff --git a/tests/specs/repository/TestArchitectureSpec.m b/tests/specs/repository/TestArchitectureSpec.m index 80fd88c94..4ac2bf523 100644 --- a/tests/specs/repository/TestArchitectureSpec.m +++ b/tests/specs/repository/TestArchitectureSpec.m @@ -85,14 +85,15 @@ function productionDynamicInvocationIsClosedAndOwned(testCase) end end - function ciRoutesDocumentationWithoutWeakeningAggregateGate(testCase) + function ciUsesTwoModesWithoutWeakeningManualRecovery(testCase) root = labkittest.setup(); workflow = text(root, ".github/workflows/ci.yml"); - testCase.verifySubstring(workflow, "change-scope:"); + testCase.verifySubstring(workflow, "policy:"); + testCase.verifySubstring(workflow, "name: Repository policy"); + testCase.verifySubstring(workflow, "workflow_dispatch:"); testCase.verifySubstring(workflow, ... - "group: ci-${{ github.event_name }}-" + ... - "${{ github.event.pull_request.number || github.ref }}"); + "github.event_name == 'workflow_dispatch' && github.run_id"); testCase.verifyFalse(contains(workflow, ... "group: ci-${{ github.event.pull_request.head.sha || github.sha }}")); testCase.verifySubstring(workflow, ... @@ -107,11 +108,13 @@ function ciRoutesDocumentationWithoutWeakeningAggregateGate(testCase) "--head-repository"); testCase.verifySubstring(workflow, ... "--base-sha ""${BASE_SHA}"""); - testCase.verifySubstring(workflow, "fetch-depth: 0"); testCase.verifySubstring(workflow, ... - "needs.change-scope.outputs.full == 'true'"); + "[ -z ""${BASE_SHA}"" ]"); testCase.verifySubstring(workflow, ... - "needs.change-scope.outputs.docs == 'true'"); + "git merge-base origin/main ""${HEAD_SHA}"""); + testCase.verifySubstring(workflow, "fetch-depth: 0"); + testCase.verifyFalse(contains(workflow, "classify_ci_scope")); + testCase.verifyEqual(count(workflow, "needs: policy"), 2); testCase.verifySubstring(workflow, "docs-check:"); testCase.verifySubstring(workflow, "tasks: docsCheck"); testCase.verifySubstring(workflow, "release: R2022b"); @@ -147,13 +150,13 @@ function ciRoutesDocumentationWithoutWeakeningAggregateGate(testCase) "if: matrix.run_gui"); testCase.verifySubstring(workflow, ... "if: matrix.run_isolated"); - testCase.verifyGreaterThanOrEqual(count(workflow, ... - "github.event_name == 'pull_request'"), 2); + testCase.verifyEqual(count(workflow, ... + "if: github.event_name != 'push'"), 2); testCase.verifySubstring(workflow, ... "needs.platform-matrix.result"); testCase.verifySubstring(workflow, "ci-gate:"); testCase.verifySubstring(workflow, "name: CI Gate"); - testCase.verifySubstring(workflow, "needs.change-scope.result"); + testCase.verifySubstring(workflow, "needs.policy.result"); testCase.verifySubstring(workflow, "docs-check.result"); end @@ -181,11 +184,8 @@ function documentationSiteIsBuiltByPagesAndNotTracked(testCase) ignore = splitlines(text(root, ".gitignore")); testCase.verifyTrue(any(strip(ignore) == "site/")); - testCase.verifySubstring(workflow, "- 'docs/**'"); - testCase.verifySubstring(workflow, "- '+labkit/**'"); - testCase.verifySubstring(workflow, "- 'apps/**'"); - testCase.verifySubstring(workflow, "- 'tools/docs/**'"); - testCase.verifyFalse(contains(workflow, "- 'site/**'")); + testCase.verifySubstring(workflow, "workflow_dispatch:"); + testCase.verifyFalse(contains(workflow, " paths:")); testCase.verifySubstring(workflow, ... "name: Generate documentation from the exact main source"); testCase.verifySubstring(workflow, ...