This guide builds a small trace viewer with the production labkit.app
contract. The example keeps durable settings in a project, rebuilds transient
file data in a session, draws a plot, and exports a result. Each file has one
visible responsibility.
apps/examples/trace_viewer/
|-- labkit_TraceViewer_app.m
`-- +trace_viewer/
|-- definition.m
|-- projectSpec.m
|-- createSession.m
|-- +workbench/
| |-- buildLayout.m
| `-- present.m
|-- +sourceTrace/
| |-- readTrace.m
| |-- layoutSection.m
| |-- workspacePlot.m
| |-- present.m
| `-- draw.m
`-- +resultFiles/
|-- layoutSection.m
`-- exportTrace.m
There is no handler table, renderer registry, or +userInterface bucket.
+workbench is the product assembly boundary. Capability packages own their
part of the user workflow.
function varargout = labkit_TraceViewer_app(varargin)
[varargout{1:nargout}] = trace_viewer.definition().launch(varargin{:});
endThe entrypoint does not build state, controls, services, or figures.
function app = definition()
app = labkit.app.Definition( ...
Entrypoint="labkit_TraceViewer_app", ...
AppId="examples.trace-viewer", ...
Title="Trace Viewer", ...
Family="Examples", ...
AppVersion="1.0.0", ...
Updated="2026-07-19", ...
Requirements=labkit.contract.requirements("app", ">=2 <3"), ...
ProjectSchema=trace_viewer.projectSpec(), ...
CreateSession=@trace_viewer.createSession, ...
Workbench=trace_viewer.workbench.buildLayout(), ...
PresentWorkbench=@trace_viewer.workbench.present);
endDefinition is a readable inventory, not an execution script. It validates the static layout, direct callback signatures, plot renderers, target IDs, project schema, and presentation contract before native UI mutation.
function schema = projectSpec()
schema = labkit.app.project.Schema( ...
Version=1, ...
Create=@createProject, ...
Validate=@validateProject);
end
function project = createProject()
project = struct( ...
"inputs", struct("sources", struct([])), ...
"parameters", struct("gain", 1), ...
"results", struct("lastExport", []));
end
function accepted = validateProject(project)
accepted = isstruct(project) && isscalar(project) && ...
isfield(project, "inputs") && isfield(project.inputs, "sources") && ...
isfield(project, "parameters") && ...
isnumeric(project.parameters.gain) && ...
isscalar(project.parameters.gain) && ...
isfinite(project.parameters.gain);
endThe project contains only durable App meaning. External files are portable source records owned by the runtime, not raw paths.
function session = createSession(project, callbackContext)
paths = callbackContext.resolveSourcePaths(project.inputs.sources);
trace = struct("x", [], "y", []);
if ~isempty(paths)
trace = trace_viewer.sourceTrace.readTrace(paths(1));
end
session = struct("trace", trace);
endcreateSession(project,callbackContext) is the one reconstruction boundary.
The runtime calls it after project restore and file-list source changes.
Session data is reconstructible and is not written into the project envelope.
function layout = buildLayout()
controls = { ...
trace_viewer.sourceTrace.layoutSection(), ...
trace_viewer.resultFiles.layoutSection()};
workspace = labkit.app.layout.workspace( ...
trace_viewer.sourceTrace.workspacePlot());
layout = labkit.app.layout.workbench( ...
controls, Workspace=workspace);
endThe assembly reads in user order. A complex App can use tabs and workspace pages here without flattening every feature into one file.
The source capability owns its controls:
function section = layoutSection()
section = labkit.app.layout.section("sourceTrace", "Trace", { ...
labkit.app.layout.fileList("traceFiles", ...
Label="Trace file", ...
SelectionMode="single", ...
Bind="project.inputs.sources", ...
SourceRole="trace", ...
SourceIdPrefix="trace"), ...
labkit.app.layout.slider("gain", ...
Label="Gain", Limits=[0.1 10], ...
Bind="project.parameters.gain")});
endStandard fields and file lists need no App callback. Their bindings update canonical state transactionally.
The result capability binds a real business action directly:
function section = layoutSection()
section = labkit.app.layout.section("resultFiles", "Results", { ...
labkit.app.layout.button("exportTrace", ...
"Export trace", @trace_viewer.resultFiles.exportTrace, ...
Tooltip="Export the calibrated trace and its sampling metadata.")});
endfunction view = present(applicationState)
view = labkit.app.view.Snapshot();
view = view.include(trace_viewer.sourceTrace.present( ...
applicationState.session.trace, ...
applicationState.project.parameters.gain));
view = view.enabled("exportTrace", ...
~isempty(applicationState.session.trace.x));
end+workbench/present.m is a short assembly boundary. It extracts exact inputs
and composes feature fragments with Snapshot.include; it does not perform IO
or calculation.
function view = present(trace, gain)
model = struct("x", trace.x, "y", trace.y .* gain);
view = labkit.app.view.Snapshot().renderPlot("tracePlot", model);
endfunction node = workspacePlot()
node = labkit.app.layout.plotArea( ...
"tracePlot", @trace_viewer.sourceTrace.draw);
end
function draw(axesById, model)
ax = axesById.main;
cla(ax);
plot(ax, model.x, model.y);
xlabel(ax, "Time (s)");
ylabel(ax, "Signal");
grid(ax, "on");
endThe plot area references the concrete renderer. The model carries prepared App meaning; drawing does not read project state or dispatch workflow actions.
function applicationState = exportTrace( ...
applicationState, callbackContext)
trace = applicationState.session.trace;
gain = applicationState.project.parameters.gain;
choice = callbackContext.chooseOutputFile( ...
{"*.csv", "CSV files"}, "");
if choice.Cancelled
return;
end
tableValue = table(trace.x(:), trace.y(:) .* gain, ...
VariableNames=["Time", "Signal"]);
writetable(tableValue, choice.Value);
applicationState.project.results.lastExport = string(choice.Value);
endThe callback signature exposes its runtime boundary. For larger exports, delegate table construction and result packaging to functions that accept only the trace, gain, and destination they require.
Test readers, calculations, project validation, snapshot fragments, renderers,
and exports directly with synthetic values. Construct definition() in a
headless test to validate the whole static contract. Run the App's bounded
hidden-GUI workflow after smaller tests are stable; native dialogs and visual
quality still require developer-led interactive validation.
Before merge, update the App version, owning manual, structured component history, generated documentation site, and the final branch validation gates.