Operator screen
Test Sequence
A test sequence with steps, limits, a run and its result record.
index.htm as it is written with the smart-industrial package; industrial-demos.css is the screens' shared layout
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>Test Sequence - Smart.Industrial</title>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.default.css" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.industrial.css" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.industrial-dark.css" />
<link rel="stylesheet" type="text/css" href="industrial-demos.css" />
<link rel="stylesheet" type="text/css" href="styles.css" />
</head>
<body class="viewport" theme="industrial-dark">
<div class="hmi-screen">
<header class="hmi-header">
<div class="hmi-identity">
<span class="hmi-plant">TEST SEQUENCE</span>
<span class="hmi-area">Station 4 · DAQ module functional test v3.2</span>
</div>
<div class="uut-block">
<span class="uut-label">Unit under test</span>
<span class="uut-serial" id="uutSerial">—</span>
</div>
<div class="hmi-header-actions">
<button class="hmi-button hmi-button-primary" id="startButton" type="button">Start</button>
<button class="hmi-button" id="abortButton" type="button" disabled>Abort</button>
<button class="hmi-theme-toggle" id="themeToggle" type="button">Light</button>
</div>
</header>
<section class="run-strip">
<div class="verdict" id="verdict" data-state="idle">
<span class="verdict-text" id="verdictText">READY</span>
</div>
<div class="run-progress">
<div class="run-progress-head">
<span id="currentStep">Press Start to run the sequence</span>
<span id="stepCounter">0 / 0</span>
</div>
<smart-progress-bar id="runProgress" min="0" max="100" value="0"></smart-progress-bar>
</div>
<div class="run-stat">
<span class="run-stat-value" id="elapsed">0.0<small>s</small></span>
<span class="run-stat-label">Elapsed</span>
</div>
<div class="run-stat">
<span class="run-stat-value" id="yieldValue">—</span>
<span class="run-stat-label">Session yield</span>
</div>
</section>
<section class="hmi-body">
<aside class="hmi-panel hmi-panel-steps">
<div class="hmi-panel-header"><span>Sequence</span></div>
<div class="hmi-panel-content">
<smart-tree id="stepTree" selection-mode="one"></smart-tree>
</div>
</aside>
<div class="hmi-panel hmi-panel-measurements">
<div class="hmi-panel-header">
<span>Measurements</span>
<span class="hmi-panel-note" id="measurementNote">—</span>
</div>
<div class="hmi-panel-content">
<smart-grid id="measurementGrid"></smart-grid>
</div>
</div>
</section>
</div>
<!-- scripts -->
<script type="module" src="node_modules/smart-webcomponents/source/modules/smart.tree.js"></script>
<script type="module" src="node_modules/smart-webcomponents/source/modules/smart.grid.js"></script>
<script type="module" src="node_modules/smart-webcomponents/source/modules/smart.progressbar.js"></script>
<script type="text/javascript" src="data.js"></script>
<script type="module" src="index.js"></script>
<script>
/* Shown once every element has rendered, so nothing moves on the way in (styles/demos.css). */
addEventListener('load', function () {
var all = Array.prototype.slice.call(document.querySelectorAll('*')),
ready = function () { document.body.classList.add('ready'); };
Promise.all(all.map(function (e) { return e.localName.indexOf('smart-') === 0 ? customElements.whenDefined(e.localName) : null; })).then(function () {
return Promise.all(all.filter(function (e) { return typeof e.whenRendered === 'function' && !e.isRendered; }).map(function (e) { return e.whenRendered(); }));
}).then(function () { requestAnimationFrame(function () { requestAnimationFrame(ready); }); }, ready);
});
</script>
<script>
/* The site's light/dark switch is the reader's choice: a screen opened from the light site
in a new tab opens light too. Framed, the site's page sets the theme itself. */
addEventListener('load', function () {
var light = false,
toggle = document.querySelector('.hmi-theme-toggle');
try { light = window.top === window && localStorage.getItem('smart-industrial-dark') === 'false'; } catch (e) { }
/* after the screen's own onload, which is where its button starts listening */
if (light && toggle) { setTimeout(function () { if (document.body.getAttribute('theme') === 'industrial-dark') { toggle.click(); } }, 0); }
});
</script>
</body>
</html>
index.js
/// <reference path="../../../source/typescript/smart.elements.d.ts" />
/* Test sequence execution screen.
*
* The shape a test executive screen takes: the sequence on the left, the
* measurements it produces on the right, and a verdict that is never ambiguous.
* Limits come from the sequence definition, so pass/fail is computed where the
* data is, not in the view. */
const MEASUREMENT_FIELDS = [
'id: number',
'step: string',
'measurement: string',
'value: string',
'low: string',
'high: string',
'unit: string',
'result: string'
];
let steps = TestData.flatSteps(),
measurements = [],
measurementId = 1,
session = { run: 0, passed: 0 },
runHandle = null,
startedAt = 0,
elapsedTimer = null;
function limitText(limit) {
return limit === null || limit === undefined ? '—' : String(limit);
}
function valueText(reading) {
const magnitude = Math.abs(reading.value);
return reading.value.toFixed(magnitude >= 100 ? 1 : magnitude >= 1 ? 2 : 3);
}
function chip(text, className) {
return '<span class="grid-chip ' + className + '">' + text + '</span>';
}
function adapter() {
return new window.Smart.DataAdapter({
dataSource: measurements,
dataFields: MEASUREMENT_FIELDS
});
}
window.Smart('#measurementGrid', class {
get properties() {
return {
appearance: { alternationCount: 0, showRowHeader: false },
behavior: { columnResizeMode: 'growAndShrink' },
sorting: { enabled: true },
dataSource: adapter(),
columns: [
{ label: 'Step', dataField: 'step', width: 220 },
{ label: 'Measurement', dataField: 'measurement' },
{ label: 'Value', dataField: 'value', width: 110, cellsAlign: 'right', align: 'right' },
{ label: 'Low limit', dataField: 'low', width: 100, cellsAlign: 'right', align: 'right' },
{ label: 'High limit', dataField: 'high', width: 100, cellsAlign: 'right', align: 'right' },
{ label: 'Unit', dataField: 'unit', width: 80, cellsAlign: 'center', align: 'center' },
{
label: 'Result', dataField: 'result', width: 100, cellsAlign: 'center', align: 'center',
formatFunction(settings) {
settings.template = chip(settings.value.toUpperCase(), 'chip-' + settings.value);
}
}
]
};
}
});
window.onload = function () {
const tree = document.getElementById('stepTree'),
grid = document.getElementById('measurementGrid'),
progress = document.getElementById('runProgress'),
verdict = document.getElementById('verdict'),
verdictText = document.getElementById('verdictText'),
currentStep = document.getElementById('currentStep'),
stepCounter = document.getElementById('stepCounter'),
elapsed = document.getElementById('elapsed'),
yieldValue = document.getElementById('yieldValue'),
measurementNote = document.getElementById('measurementNote'),
uutSerial = document.getElementById('uutSerial'),
startButton = document.getElementById('startButton'),
abortButton = document.getElementById('abortButton'),
themeToggle = document.getElementById('themeToggle');
/* ── Theme ───────────────────────────────────────────────────────── */
function applyTheme(theme) {
document.body.setAttribute('theme', theme);
const elements = document.querySelectorAll('smart-tree, smart-grid, smart-progress-bar');
for (let i = 0; i < elements.length; i++) {
elements[i].theme = theme;
}
themeToggle.textContent = theme === 'industrial-dark' ? 'Light' : 'Dark';
}
themeToggle.addEventListener('click', function () {
applyTheme(document.body.getAttribute('theme') === 'industrial-dark' ? 'industrial' : 'industrial-dark');
});
/* ── Step tree ───────────────────────────────────────────────────── */
function stepLabel(step) {
return '<span class="step-state state-' + step.state + '"></span>' +
'<span class="step-name">' + step.name + '</span>';
}
function groupState(groupSteps) {
if (groupSteps.some((step) => step.state === 'fail')) {
return 'fail';
}
if (groupSteps.some((step) => step.state === 'running')) {
return 'running';
}
if (groupSteps.every((step) => step.state === 'pass')) {
return 'pass';
}
if (groupSteps.every((step) => step.state === 'skipped')) {
return 'skipped';
}
return 'pending';
}
/* The tree is rebuilt rather than patched: a dozen steps is cheap, and it
keeps step state in one place instead of mirrored into tree items. */
function renderTree() {
const groups = [];
TestData.sequence.forEach(function (group) {
const groupSteps = steps.filter((step) => step.group === group.group);
groups.push({
label: '<span class="step-state state-' + groupState(groupSteps) + '"></span>' +
'<span class="step-group">' + group.group + '</span>',
expanded: true,
items: groupSteps.map(function (step) {
return { label: stepLabel(step) };
})
});
});
tree.dataSource = groups;
}
/* ── Measurements grid ───────────────────────────────────────────── */
function refreshGrid() {
grid.dataSource = adapter();
const failures = measurements.filter((row) => row.result === 'fail').length;
measurementNote.textContent = measurements.length + ' recorded' +
(failures ? ' · ' + failures + ' out of limit' : '');
}
/* ── Run state ───────────────────────────────────────────────────── */
function setVerdict(state, text) {
verdict.setAttribute('data-state', state);
verdictText.textContent = text;
}
function updateProgress() {
const done = steps.filter((step) => step.state === 'pass' || step.state === 'fail' || step.state === 'skipped').length;
progress.value = Math.round(done / steps.length * 100);
stepCounter.textContent = done + ' / ' + steps.length;
}
function tickElapsed() {
elapsed.innerHTML = ((Date.now() - startedAt) / 1000).toFixed(1) + '<small>s</small>';
}
function setRunning(running) {
startButton.disabled = running;
abortButton.disabled = !running;
}
function finish(result) {
if (elapsedTimer) {
clearInterval(elapsedTimer);
elapsedTimer = null;
}
tickElapsed();
setRunning(false);
runHandle = null;
session.run++;
if (result === 'pass') {
session.passed++;
}
yieldValue.textContent = session.passed + ' / ' + session.run +
' (' + Math.round(session.passed / session.run * 100) + '%)';
setVerdict(result, result === 'pass' ? 'PASS' : result === 'abort' ? 'ABORTED' : 'FAIL');
currentStep.textContent = result === 'pass'
? 'Sequence complete - unit within all limits'
: result === 'abort'
? 'Aborted by operator'
: 'Sequence stopped at first failing step';
updateProgress();
renderTree();
}
function start() {
steps = TestData.flatSteps();
measurements = [];
measurementId = 1;
startedAt = Date.now();
uutSerial.textContent = TestData.newSerial();
setVerdict('running', 'RUNNING');
setRunning(true);
renderTree();
refreshGrid();
updateProgress();
elapsedTimer = setInterval(tickElapsed, 100);
runHandle = TestData.start(steps, {
onStep: function (step) {
if (step.state === 'running') {
currentStep.textContent = step.group + ' — ' + step.name;
}
renderTree();
updateProgress();
},
onMeasurement: function (step, reading) {
measurements.unshift({
id: measurementId++,
step: step.name,
measurement: reading.name,
value: valueText(reading),
low: limitText(reading.low),
high: limitText(reading.high),
unit: reading.unit,
result: reading.result
});
refreshGrid();
},
onFinish: finish
}, 3);
}
startButton.addEventListener('click', start);
abortButton.addEventListener('click', function () {
if (!runHandle) {
return;
}
runHandle.abort();
steps.forEach(function (step) {
if (step.state === 'pending' || step.state === 'running') {
step.state = 'skipped';
}
});
finish('abort');
});
applyTheme('industrial-dark');
renderTree();
refreshGrid();
updateProgress();
/* Run one unit immediately so the screen is never empty on arrival. */
start();
};
styles.css
/* Test sequence execution screen. Colours come from the industrial theme
tokens - pass, fail and running are the only things allowed to carry any. */
html,
body {
height: 100%;
margin: 0;
}
body.viewport {
background: var(--smart-industrial-canvas, var(--smart-body-background-color));
color: var(--smart-text-color);
font-family: var(--smart-font-family);
font-size: 13px;
overflow: hidden;
}
.hmi-screen {
display: grid;
grid-template-rows: auto auto 1fr;
gap: 10px;
padding: 10px;
height: 100%;
box-sizing: border-box;
}
/* ── Header ──────────────────────────────────────────────────────────── */
.hmi-header {
display: flex;
align-items: center;
gap: 28px;
padding: 10px 14px;
background: var(--smart-industrial-panel-background);
border: 1px solid var(--smart-industrial-panel-border);
border-radius: 4px;
box-shadow: var(--smart-industrial-panel-shadow);
}
.hmi-identity {
display: flex;
flex-direction: column;
gap: 2px;
}
.hmi-plant {
font-size: 16px;
font-weight: 600;
letter-spacing: .08em;
}
.hmi-area {
color: var(--smart-text-tertiary-color);
font-size: 11px;
letter-spacing: .04em;
}
.uut-block {
display: flex;
flex-direction: column;
gap: 2px;
padding-left: 24px;
border-left: 1px solid var(--smart-border);
}
.uut-label {
color: var(--smart-text-tertiary-color);
font-size: 10px;
letter-spacing: .06em;
text-transform: uppercase;
}
.uut-serial {
font-family: Consolas, "Courier New", monospace;
font-size: 15px;
letter-spacing: .04em;
}
.hmi-header-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.hmi-button,
.hmi-theme-toggle {
background: transparent;
border: 1px solid var(--smart-border);
border-radius: var(--smart-border-radius);
color: var(--smart-text-secondary-color);
cursor: pointer;
font-family: inherit;
font-size: 11px;
height: 30px;
letter-spacing: .06em;
padding: 0 16px;
text-transform: uppercase;
}
.hmi-button:hover:not(:disabled),
.hmi-theme-toggle:hover {
background: var(--smart-ui-state-hover);
border-color: var(--smart-ui-state-border-hover);
color: var(--smart-ui-state-color-hover);
}
.hmi-button:disabled {
color: var(--smart-text-disabled-color);
cursor: default;
opacity: .55;
}
.hmi-button-primary:not(:disabled) {
border-color: var(--smart-primary);
/* The accent as text, not as a fill: the fill value is 2.6:1 on a light panel. */
color: var(--smart-industrial-accent-text, var(--smart-primary));
}
/* ── Run strip ───────────────────────────────────────────────────────── */
.run-strip {
display: grid;
grid-template-columns: 190px minmax(0, 1fr) 130px 160px;
gap: 12px;
align-items: stretch;
}
.verdict {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--smart-industrial-panel-border);
background: var(--smart-industrial-panel-background);
border-radius: 4px;
box-shadow: var(--smart-industrial-panel-shadow);
}
.verdict-text {
font-size: 26px;
font-weight: 600;
letter-spacing: .14em;
}
.verdict[data-state="idle"] .verdict-text {
color: var(--smart-text-tertiary-color);
}
.verdict[data-state="running"] {
border-color: var(--smart-primary);
}
.verdict[data-state="running"] .verdict-text {
color: var(--smart-primary);
}
.verdict[data-state="pass"] {
background: var(--smart-industrial-ok);
border-color: var(--smart-industrial-ok);
}
.verdict[data-state="pass"] .verdict-text {
color: var(--smart-industrial-ok-color);
}
.verdict[data-state="fail"] {
background: var(--smart-industrial-alarm-critical);
border-color: var(--smart-industrial-alarm-critical);
}
.verdict[data-state="fail"] .verdict-text {
color: var(--smart-industrial-alarm-critical-color);
}
.verdict[data-state="abort"] {
border-color: var(--smart-industrial-alarm-warning);
}
.verdict[data-state="abort"] .verdict-text {
color: var(--smart-industrial-alarm-warning-text, var(--smart-industrial-alarm-warning));
}
.run-progress {
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
padding: 10px 14px;
background: var(--smart-industrial-panel-background);
border: 1px solid var(--smart-industrial-panel-border);
border-radius: 4px;
box-shadow: var(--smart-industrial-panel-shadow);
}
.run-progress-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
}
#currentStep {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#stepCounter {
color: var(--smart-text-tertiary-color);
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
white-space: nowrap;
}
.run-progress smart-progress-bar {
width: 100%;
height: 8px;
}
.run-stat {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
padding: 10px 14px;
background: var(--smart-industrial-panel-background);
border: 1px solid var(--smart-industrial-panel-border);
border-radius: 4px;
box-shadow: var(--smart-industrial-panel-shadow);
}
.run-stat-value {
font-family: Consolas, "Courier New", monospace;
font-size: 22px;
line-height: 1;
}
.run-stat-value small {
font-size: 11px;
margin-left: 2px;
color: var(--smart-text-tertiary-color);
}
.run-stat-label {
color: var(--smart-text-tertiary-color);
font-size: 10px;
letter-spacing: .06em;
text-transform: uppercase;
}
/* ── Body ────────────────────────────────────────────────────────────── */
.hmi-body {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
gap: 10px;
min-height: 0;
}
.hmi-panel {
display: flex;
flex-direction: column;
background: var(--smart-industrial-panel-background);
border: 1px solid var(--smart-industrial-panel-border);
border-radius: 4px;
box-shadow: var(--smart-industrial-panel-shadow);
overflow: hidden;
min-width: 0;
min-height: 0;
}
.hmi-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--smart-industrial-panel-header-background);
border-bottom: 1px solid var(--smart-industrial-panel-border);
color: var(--smart-text-secondary-color);
font-size: 11.5px;
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
}
.hmi-panel-note {
color: var(--smart-text-tertiary-color);
letter-spacing: 0;
text-transform: none;
}
.hmi-panel-content {
flex: 1;
min-height: 0;
padding: 6px;
overflow: auto;
}
.hmi-panel-steps smart-tree {
width: 100%;
height: 100%;
border: none;
background: transparent;
}
.hmi-panel-measurements smart-grid {
width: 100%;
height: 100%;
}
/* ── Step state markers ──────────────────────────────────────────────── */
.step-state {
display: inline-block;
width: 9px;
height: 9px;
margin-right: 9px;
border-radius: 50%;
vertical-align: middle;
background: var(--smart-border);
}
.state-pending {
background: transparent;
border: 1px solid var(--smart-text-tertiary-color);
}
.state-running {
background: var(--smart-primary);
animation: step-pulse 1s ease-in-out infinite;
}
.state-pass {
background: var(--smart-industrial-ok);
}
.state-fail {
background: var(--smart-industrial-alarm-critical);
}
.state-skipped {
background: transparent;
border: 1px dashed var(--smart-text-disabled-color);
}
@keyframes step-pulse {
50% {
opacity: .3;
}
}
@media (prefers-reduced-motion: reduce) {
.state-running {
animation: none;
}
}
.step-group {
font-size: 11px;
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
}
.step-name {
vertical-align: middle;
}
/* ── Grid chips ──────────────────────────────────────────────────────── */
.grid-chip {
display: inline-block;
font-size: 10px;
font-weight: 600;
letter-spacing: .07em;
line-height: 1.6;
padding: 1px 10px;
border-radius: var(--smart-border-radius);
}
.chip-pass {
border: 1px solid var(--smart-industrial-ok);
color: var(--smart-industrial-ok-text, var(--smart-industrial-ok));
}
.chip-fail {
background: var(--smart-industrial-alarm-critical);
color: var(--smart-industrial-alarm-critical-color);
}
/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 1200px) {
body.viewport {
overflow: auto;
}
.run-strip {
grid-template-columns: 160px minmax(0, 1fr);
}
.hmi-body {
grid-template-columns: 1fr;
}
.hmi-panel-measurements smart-grid {
min-height: 400px;
}
}