LabKit MATLAB Workbench

guide

Build A Complete App

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.

File Shape

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.

1. Thin Entrypoint

function varargout = labkit_TraceViewer_app(varargin)
[varargout{1:nargout}] = trace_viewer.definition().launch(varargin{:});
end

The entrypoint does not build state, controls, services, or figures.

2. One App Definition

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", ">=1 <2"), ...
    ProjectSchema=trace_viewer.projectSpec(), ...
    CreateSession=@trace_viewer.createSession, ...
    Workbench=trace_viewer.workbench.buildLayout(), ...
    PresentWorkbench=@trace_viewer.workbench.present);
end

Definition 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.

3. Durable Project

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);
end

The project contains only durable App meaning. External files are portable source records owned by the runtime, not raw paths.

4. Transient Session

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);
end

createSession(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.

5. Product Layout

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);
end

The 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")});
end

Standard 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.")});
end

6. Complete View Snapshot

function 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);
end

7. Feature-Owned Renderer

function 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");
end

The plot area references the concrete renderer. The model carries prepared App meaning; drawing does not read project state or dispatch workflow actions.

8. Direct Business Callback

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);
end

The 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.

Validation

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.