Operator screen
Login
Badge and PIN login on a keypad with lockout, language and unit selection.
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>Operator Logon - 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-elements.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 lg-screen">
<header class="hmi-header">
<div class="hmi-identity">
<span class="hmi-plant">NORTHFIELD PLANT</span>
<span class="hmi-area">Area 2 · Operator station OS-2A</span>
</div>
<div class="hmi-header-actions">
<span class="hmi-conn"><span class="hmi-conn-dot" id="serverDot"></span><span id="serverLabel">directory · reachable</span></span>
<span class="hmi-clock" id="clock">--:--:--</span>
<button class="hmi-theme-toggle" id="themeToggle" type="button">Light</button>
</div>
</header>
<main class="lg-main">
<section class="hmi-panel lg-logon" aria-labelledby="logonTitle">
<div class="hmi-panel-header">
<span id="logonTitle">Operator logon</span>
<span class="hmi-panel-note" id="logonNote">Badge or user ID, then PIN</span>
</div>
<div class="hmi-panel-content lg-logon-body">
<form class="lg-form" id="logonForm" novalidate>
<label class="lg-field">
<span>User ID or badge</span>
<input id="userId" type="text" autocomplete="username" autocapitalize="off" spellcheck="false" />
</label>
<label class="lg-field">
<span>PIN</span>
<input id="pin" type="password" inputmode="numeric" autocomplete="current-password" readonly />
</label>
<div class="lg-message" id="message" role="alert"></div>
<div class="lg-actions">
<button class="hmi-button" id="badge" type="button">Simulate badge tap</button>
<button class="hmi-button hmi-button-primary" id="logon" type="submit">Log on</button>
</div>
</form>
<smart-numeric-keypad id="keypad" label="PIN" precision-digits="0" allow-negative="false" show-limits="false" show-current="false" density="touch"></smart-numeric-keypad>
</div>
</section>
<section class="lg-side">
<div class="hmi-panel">
<div class="hmi-panel-header"><span>Station</span></div>
<div class="hmi-panel-content lg-station">
<smart-selector-switch id="language" label="Language" appearance="segmented"></smart-selector-switch>
<smart-selector-switch id="units" label="Units" appearance="segmented"></smart-selector-switch>
<smart-selector-switch id="density" label="Density" appearance="segmented"></smart-selector-switch>
<div class="lg-station-facts">
<div class="lg-fact"><span>Station</span><b>OS-2A · 1920 × 1080 · touch</b></div>
<div class="lg-fact"><span>Last logon</span><b id="lastLogon">A. Smith · 05:58</b></div>
<div class="lg-fact"><span>Screen lock</span><b>15 min idle</b></div>
<div class="lg-fact"><span>Policy</span><b>3 attempts, then locked</b></div>
</div>
</div>
</div>
<div class="hmi-panel">
<div class="hmi-panel-header">
<span>Handover</span>
<span class="hmi-panel-note">Left by the previous shift</span>
</div>
<div class="hmi-panel-content lg-handover">
<p><b>A. Smith, night shift, 05:55.</b> FIC-101 oscillating since 04:30, suspected FCV-101 stiction,
gain reduced to 1.08 pending maintenance. PAH-102 shelved during CIP, unshelved 05:40.
R-201 in React phase of B-2026-0912, on plan. Nothing outstanding on the permit board.</p>
<smart-status-tile id="tileAlarms" label="Active alarms" precision-digits="0"></smart-status-tile>
<smart-status-tile id="tileUnack" label="Unacknowledged" precision-digits="0"></smart-status-tile>
</div>
</div>
<div class="hmi-panel lg-record">
<div class="hmi-panel-header">
<span>Logon record</span>
<span class="hmi-panel-note">Every attempt, this station</span>
</div>
<smart-audit-trail id="record" label="Logon record"></smart-audit-trail>
</div>
</section>
</main>
<section class="hmi-panel lg-welcome" id="welcome" hidden>
<div class="hmi-panel-content">
<h2 class="lg-welcome-title" id="welcomeTitle">Logged on</h2>
<p class="lg-welcome-text" id="welcomeText"></p>
<div class="lg-actions">
<a class="hmi-button hmi-button-primary" href="../overview/index.htm">Open plant overview</a>
<a class="hmi-button" href="../alarms/index.htm">Open alarms</a>
<button class="hmi-button" id="logoff" type="button">Log off</button>
</div>
</div>
</section>
</div>
<!-- scripts -->
<script type="module" src="node_modules/smart-industrial/source/modules/smart.industrial.locale.de.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.industrial.locale.fr.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.industrial.locale.es.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.industrial.locale.zh.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.industrial.units.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.industrial.audit.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.numerickeypad.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.selectorswitch.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.statustile.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.audittrail.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" />
/* Operator logon.
*
* The screen an operator meets at the start of a shift: who they are, in which language
* and units the station should speak to them, what the last shift left them, and a logon
* that is a Part 11 event - two components, attempts counted, a lock after three, and
* every attempt on the station's record whether it succeeded or not.
*
* The station never checks a PIN. It asks the directory - simulated here with a delay -
* and shows what the directory answers. Replace `directory.verify` with the identity
* provider; the record, the lock and the screen do not change. */
window.onload = function () {
const Industrial = window.Smart.Industrial,
Audit = Industrial.Audit,
Units = Industrial.Units,
el = function (id) {
return document.getElementById(id);
},
keypad = el('keypad'),
userId = el('userId'),
pin = el('pin'),
message = el('message'),
record = el('record');
const STATION = 'OS-2A';
/* ── The directory ──────────────────────────────────────────────────── */
const directory = {
users: {
jmoreau: { pin: '2468', name: 'J. Moreau', role: 'Operator', badge: '0042-1187', areas: 'Area 2' },
asmith: { pin: '1357', name: 'A. Smith', role: 'Shift supervisor', badge: '0042-0031', areas: 'Areas 1-3' },
mwilson: { pin: '9999', name: 'M. Wilson', role: 'Maintenance', badge: '0042-2210', areas: 'Area 2', locked: true }
},
byBadge: function (badge) {
const ids = Object.keys(directory.users);
for (let i = 0; i < ids.length; i++) {
if (directory.users[ids[i]].badge === badge) {
return ids[i];
}
}
return null;
},
verify: function (id, code) {
return new Promise(function (resolve) {
setTimeout(function () {
const user = directory.users[id];
if (!user) {
resolve({ ok: false, reason: 'unknown user' });
}
else if (user.locked) {
resolve({ ok: false, reason: 'account locked - contact the administrator' });
}
else if (user.pin !== code) {
resolve({ ok: false, reason: 'PIN not accepted' });
}
else {
resolve({ ok: true, user: Object.assign({ id: id }, user) });
}
}, 700);
});
}
};
/* ── The station record ─────────────────────────────────────────────── */
const trail = new Audit.Trail({ system: STATION });
trail.subscribe(function (entry) {
record.addEntry(entry);
});
record.showToolbar = false;
record.columns = ['at', 'user', 'action', 'newValue', 'reason'];
/* Attempts are counted per user ID, and the lock is the station's until the directory
is told; the directory's own lock is the one that protects the account. */
const attempts = {};
let station = { locale: 'en', units: '', density: 'touch', user: null };
/* ── The PIN pad ────────────────────────────────────────────────────── */
let typed = '';
keypad.addEventListener('input', function (event) {
typed = event.detail.text;
pin.value = typed;
message.textContent = '';
});
keypad.addEventListener('change', function () {
logon();
});
keypad.addEventListener('cancel', function () {
typed = '';
pin.value = '';
keypad.open();
});
pin.addEventListener('focus', function () {
keypad.open();
});
/* A physical keyboard types PINs too. */
userId.addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
event.preventDefault();
keypad.open();
}
});
el('badge').addEventListener('click', function () {
userId.value = '0042-1187';
typed = '';
pin.value = '';
keypad.open();
message.textContent = 'Badge read - enter your PIN.';
message.className = 'lg-message lg-message-plain';
});
el('logonForm').addEventListener('submit', function (event) {
event.preventDefault();
logon();
});
function logon() {
const entered = userId.value.trim(),
id = directory.byBadge(entered) || entered.toLowerCase(),
code = typed;
if (!entered) {
fail('Enter your user ID or tap your badge.');
userId.focus();
return;
}
if (!code) {
fail('Enter your PIN.');
keypad.open();
return;
}
if ((attempts[id] || 0) >= 3) {
fail('Logon is locked for ' + id + ' on this station after 3 failed attempts.');
return;
}
el('logon').disabled = true;
message.textContent = 'Checking with the directory…';
message.className = 'lg-message lg-message-plain';
/* The PIN leaves the pad before the answer comes back. */
typed = '';
pin.value = '';
directory.verify(id, code).then(function (answer) {
el('logon').disabled = false;
if (!answer.ok) {
attempts[id] = (attempts[id] || 0) + 1;
const remaining = 3 - attempts[id];
trail.append({ user: id, action: 'reject', record: STATION, field: 'logon', newValue: id, reason: answer.reason + (remaining > 0 ? ', ' + remaining + ' attempt(s) left' : '') });
fail('Logon refused: ' + answer.reason + (remaining > 0 ? '. ' + remaining + ' attempt' + (remaining === 1 ? '' : 's') + ' left.' : '.'));
if (remaining <= 0) {
trail.append({ user: id, action: 'lockout', record: STATION, field: 'logon', newValue: id, reason: '3 failed attempts - directory told' });
el('logonNote').textContent = 'Locked for ' + id;
}
keypad.open();
return;
}
attempts[id] = 0;
station.user = answer.user;
trail.append({ user: answer.user.id, userName: answer.user.name, action: 'login', record: STATION, field: 'logon', newValue: answer.user.role, reason: station.locale + ' · ' + (station.units || 'stored units') });
welcome(answer.user);
});
}
function fail(text) {
message.textContent = text;
message.className = 'lg-message';
}
function welcome(user) {
el('welcomeTitle').textContent = 'Logged on: ' + user.name;
el('welcomeText').textContent = user.role + ' · ' + user.areas + ' · ' + STATION + ' · ' +
new Date().toLocaleTimeString('en-GB', { hour12: false }) + '. Your session is continuous for 15 minutes: signatures in that ' +
'time ask for your PIN only. The station speaks ' + languageName(station.locale) + ' and reads ' +
(station.units ? station.units.toUpperCase() : 'the tags’ own') + ' units.';
el('welcome').hidden = false;
el('lastLogon').textContent = user.name + ' · ' + new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false });
document.querySelector('.lg-main').hidden = true;
}
el('logoff').addEventListener('click', function () {
trail.append({ user: station.user.id, userName: station.user.name, action: 'logout', record: STATION, field: 'logon', newValue: '' });
station.user = null;
el('welcome').hidden = true;
document.querySelector('.lg-main').hidden = false;
userId.value = '';
message.textContent = '';
keypad.open();
userId.focus();
});
/* ── Station preferences ────────────────────────────────────────────── */
const language = el('language'),
units = el('units'),
density = el('density');
language.positions = [{ id: 'en', label: 'English' }, { id: 'de', label: 'Deutsch' }, { id: 'fr', label: 'Français' }, { id: 'es', label: 'Español' }, { id: 'zh', label: '中文' }];
language.value = 'en';
units.positions = [{ id: '', label: 'As stored' }, { id: 'si', label: 'SI' }, { id: 'us', label: 'US' }];
units.value = '';
density.positions = [{ id: 'normal', label: 'Desk' }, { id: 'touch', label: 'Touch' }];
density.value = 'touch';
function languageName(code) {
const position = language.positions.filter(function (item) {
return item.id === code;
})[0];
return position ? position.label : code;
}
/* A station preference is the station's to set at once - no plant answers it. */
language.addEventListener('positionRequest', function (event) {
station.locale = event.detail.position;
language.value = station.locale;
Industrial.applyLocale(document.body, station.locale);
trail.append({ user: 'station', action: 'update', record: STATION, field: 'language', oldValue: event.detail.from, newValue: station.locale });
});
units.addEventListener('positionRequest', function (event) {
station.units = event.detail.position;
units.value = station.units;
Units.system = station.units;
trail.append({ user: 'station', action: 'update', record: STATION, field: 'units', oldValue: event.detail.from || 'stored', newValue: station.units || 'stored' });
});
density.addEventListener('positionRequest', function (event) {
station.density = event.detail.position;
density.value = station.density;
[keypad, language, units, density].forEach(function (element) {
element.density = station.density;
});
trail.append({ user: 'station', action: 'update', record: STATION, field: 'density', oldValue: event.detail.from, newValue: station.density });
});
/* ── The plant, waiting ─────────────────────────────────────────────── */
el('tileAlarms').value = 4;
el('tileAlarms').state = 'warning';
el('tileUnack').value = 2;
el('tileUnack').state = 'warning';
trail.append({ user: 'asmith', userName: 'A. Smith', action: 'logout', record: STATION, field: 'logon', newValue: '', reason: 'end of night shift', at: Audit.stamp(new Date(Date.now() - 4 * 60000)) });
setInterval(function () {
el('clock').textContent = new Date().toLocaleTimeString('en-GB', { hour12: false });
}, 1000);
const toggle = el('themeToggle');
toggle.addEventListener('click', function () {
const dark = document.body.getAttribute('theme') === 'industrial-dark';
document.body.setAttribute('theme', dark ? 'industrial' : 'industrial-dark');
toggle.textContent = dark ? 'Dark' : 'Light';
});
keypad.open();
userId.focus();
};
styles.css
/* Operator logon. A touch station at arm's length: the PIN pad is the biggest thing on
the screen, and the preferences that shape the shift sit beside it. */
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;
}
.lg-screen {
gap: 12px;
}
.lg-main {
display: grid;
grid-template-columns: minmax(520px, 1.2fr) minmax(360px, 1fr);
gap: 12px;
align-items: start;
}
.lg-main[hidden] {
display: none;
}
@media (max-width: 1000px) {
.lg-main {
grid-template-columns: 1fr;
}
}
/* ── Logon ───────────────────────────────────────────────────────────── */
.lg-logon-body {
display: grid;
grid-template-columns: minmax(240px, 1fr) auto;
gap: 20px;
align-items: start;
}
@media (max-width: 720px) {
.lg-logon-body {
grid-template-columns: 1fr;
}
}
.lg-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.lg-field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
opacity: 0.9;
}
.lg-field input {
min-height: 44px;
padding: 0 12px;
font: inherit;
font-size: 18px;
letter-spacing: 0.1em;
/* a field, in the theme of the screen: a display's black stayed black on the light theme */
color: var(--smart-text-color);
background: var(--smart-industrial-panel-header-background);
border: 1px solid var(--smart-industrial-panel-border);
border-radius: 4px;
}
.lg-field input:focus-visible {
outline: 2px solid var(--smart-industrial-focus);
outline-offset: 1px;
}
.lg-message {
min-height: 18px;
font-size: 13px;
font-weight: 600;
color: var(--smart-industrial-alarm-critical-text);
}
.lg-message-plain {
font-weight: 400;
color: var(--smart-text-color);
opacity: 0.8;
}
.lg-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.lg-actions .hmi-button {
min-height: 40px;
text-decoration: none;
}
/* ── Station ─────────────────────────────────────────────────────────── */
.lg-side {
display: flex;
flex-direction: column;
gap: 12px;
}
.lg-station {
display: flex;
flex-direction: column;
gap: 12px;
}
.lg-station {
align-items: flex-start;
}
.lg-station-facts {
align-self: stretch;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.lg-fact {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 4px 0;
border-bottom: 1px solid var(--smart-industrial-panel-border);
}
.lg-handover {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.lg-handover p {
grid-column: 1 / -1;
margin: 0 0 4px;
line-height: 1.55;
}
.lg-record {
--smart-audit-max-height: 220px;
}
/* ── Welcome ─────────────────────────────────────────────────────────── */
.lg-welcome[hidden] {
display: none;
}
.lg-welcome-title {
margin: 0 0 8px;
font-size: 22px;
}
.lg-welcome-text {
margin: 0 0 16px;
max-width: 80ch;
line-height: 1.55;
}