Operator screen
Copilot
An operator assistant that turns suggestions into signed requests.
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 Copilot - 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="styles.css" />
</head>
<body class="viewport" theme="industrial-dark">
<smart-hmi-shell id="shell" theme="industrial-dark" plant="Northfield plant" area="Area 2 · Utilities & Process"
screen="Operator copilot" user="J. Moreau" user-role="Operator" connection="connected" connection-detail="gw-02 · 40 ms"
show-theme-toggle>
<label slot="header" class="cp-provider">
<span>Model</span>
<select id="provider">
<option value="">Simulated (no model called)</option>
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
</select>
</label>
<input slot="header" class="cp-key" id="apiKey" type="password" placeholder="API key" autocomplete="off" aria-label="API key" />
<section class="cp-screen">
<div class="cp-chat hmi-panel">
<div class="cp-chat-head">
<span class="cp-chat-title">Copilot</span>
<span class="cp-chat-note" id="chatNote">The copilot advises; the operator decides; both are recorded.</span>
</div>
<div class="cp-messages" id="messages" role="log" aria-live="polite" aria-label="Conversation"></div>
<div class="cp-suggestions" id="suggestions" aria-label="Suggested questions"></div>
<form class="cp-input" id="askForm">
<input id="ask" type="text" autocomplete="off" placeholder="Ask about the plant - a tag, an alarm, a change, a handover" aria-label="Ask the copilot" />
<button type="submit" class="cp-send" id="send">Ask</button>
</form>
</div>
<aside class="cp-context">
<div class="hmi-panel">
<div class="cp-panel-head">In scope</div>
<div class="cp-tiles">
<smart-status-tile id="tileFic" label="FIC-101 feed flow" unit="m³/h" precision-digits="1" trend="flat"></smart-status-tile>
<smart-status-tile id="tilePic" label="PIC-102 header pressure" unit="bar" precision-digits="2"></smart-status-tile>
<smart-status-tile id="tileTic" label="TIC-204 jacket temp." unit="°C" precision-digits="1"></smart-status-tile>
<smart-status-tile id="tileR201" label="R-201 unit state"></smart-status-tile>
</div>
</div>
<div class="hmi-panel">
<div class="cp-panel-head">Before an action</div>
<div class="cp-panel-body">
<smart-permissive id="permissives" label="FIC-101 setpoint change" mode="permissive"></smart-permissive>
</div>
</div>
<div class="hmi-panel cp-record">
<div class="cp-panel-head">Advice and decisions</div>
<smart-audit-trail id="record" label="Copilot record" max-rows="50"></smart-audit-trail>
</div>
</aside>
</section>
<smart-esignature id="sign" label="Confirm the change" lock-meaning meaning="responsible" require-reason session-user-id="jmoreau" session-user-name="J. Moreau" auto-close="1500"></smart-esignature>
</smart-hmi-shell>
<!-- scripts -->
<script type="module" src="node_modules/smart-industrial/source/modules/smart.industrial.ai.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.hmishell.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.permissive.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.trend.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.momentarybutton.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.esignature.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.audittrail.js"></script>
<script type="text/javascript" src="responder.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;
try { light = window.top === window && localStorage.getItem('smart-industrial-dark') === 'false'; } catch (e) { }
/* after the screen's own onload, which is where its switch starts listening */
if (light) {
setTimeout(function () {
var shell = document.querySelector('smart-hmi-shell'),
toggle = document.querySelector('#themeToggle');
if (shell && typeof shell.setTheme === 'function') { shell.setTheme('industrial'); }
else if (toggle && document.body.getAttribute('theme') === 'industrial-dark') { toggle.click(); }
}, 0);
}
});
</script>
</body>
</html>
index.js
/// <reference path="../../../source/typescript/smart.elements.d.ts" />
/* Operator copilot.
*
* A conversation with the plant's data, inside the HMI shell. The copilot answers from a
* context the screen builds - the loops in scope, the last hour of alarms, the shift's
* recorded changes - and every answer says what it is: a summary, a suspicion with its
* evidence, or a suggested action. A suggested action is a request card: the operator
* presses it, the permissives are checked, the change is signed, and the controller
* answers. The copilot never writes to the plant, and the record shows what it advised
* next to what the operator did.
*
* With no provider configured the client's transport is a stand-in that reads the same
* context back out of the prompt (responder.js). Enter a key and the identical prompt
* goes to the provider; the JSON contract in the system prompt is what the screen renders. */
window.onload = function () {
const ai = window.Smart.Industrial.ai,
Audit = window.Smart.Industrial.Audit,
el = function (id) {
return document.getElementById(id);
},
shell = el('shell'),
messages = el('messages'),
sign = el('sign'),
record = el('record');
const OPERATOR = { id: 'jmoreau', name: 'J. Moreau' };
/* ── The plant, simulated ───────────────────────────────────────────── */
const plant = {
fic: { pv: 78, sp: 78, op: 46, gain: 1.8, unit: 'm³/h', oscillatingSince: Date.now() - 23 * 60000, history: [] },
pic: { pv: 5.42, sp: 5.5 },
tic: { pv: 66.3, sp: 65 },
r201: 'Running · React phase, 41 min',
alarms: [],
changes: [
{ at: '06:12', user: 'J. Moreau', what: 'logged on', reason: '' },
{ at: '06:40', user: 'J. Moreau', what: 'TIC-204 SP 63 → 65 °C', reason: 'recipe step 3' },
{ at: '07:55', user: 'A. Smith', what: 'PAH-102 shelved 30 min', reason: 'known transient during CIP' },
{ at: '08:31', user: 'J. Moreau', what: 'R-201 Start', reason: 'batch B-2026-0912' }
]
};
const ALARM_TAGS = [['LT-2202', 'Tank TK-02 level low', 2], ['PT-330', 'Transmitter communication lost', 2],
['PT-330', 'Transmitter communication lost', 2], ['PT-330', 'Transmitter communication lost', 2],
['CV-118', 'Control valve deviation', 3], ['TAH-204', 'Jacket temperature high', 1], ['PT-330', 'Transmitter communication lost', 2],
['FT-090', 'Flow transmitter under calibration', 3], ['PT-330', 'Transmitter communication lost', 2]];
ALARM_TAGS.forEach(function (row, index) {
plant.alarms.push({ id: 'a' + index, tag: row[0], message: row[1], priority: row[2], timestamp: Date.now() - (55 - index * 6) * 60000, acknowledged: index < 6, active: index > 3 });
});
/* Quarter-second physics: FIC-101 limit-cycles on a sticky valve until the gain drops. */
let t = 0;
setInterval(function () {
t += 0.25;
const amplitude = plant.fic.gain > 1.2 ? 4.2 : 0.6,
period = 48;
plant.fic.pv = plant.fic.sp + amplitude * Math.sin(t * 2 * Math.PI / period) + (Math.random() - 0.5) * 0.3;
plant.fic.op = 46 + (plant.fic.gain > 1.2 ? Math.round(Math.sin(t * 2 * Math.PI / period + 0.6) * 4) * 2 : Math.sin(t * 2 * Math.PI / period) * 0.8);
plant.pic.pv = 5.42 + (Math.random() - 0.5) * 0.02;
plant.tic.pv = 66.3 + Math.sin(t / 40) * 0.4 + (Math.random() - 0.5) * 0.1;
plant.fic.history.push({ timestamp: Date.now(), pv: plant.fic.pv, sp: plant.fic.sp, op: plant.fic.op });
if (plant.fic.history.length > 3600) {
plant.fic.history.shift();
}
el('tileFic').value = plant.fic.pv;
el('tileFic').state = amplitude > 2 ? 'warning' : 'ok';
if (Math.round(t * 4) % 8 === 0) {
el('tileFic').sparkline = plant.fic.history.slice(-240).filter(function (row, index) {
return index % 4 === 0;
}).map(function (row) {
return row.pv;
});
}
el('tilePic').value = plant.pic.pv;
el('tileTic').value = plant.tic.pv;
el('tileR201').value = plant.r201;
el('tileR201').state = 'ok';
document.querySelectorAll('smart-trend[data-live]').forEach(function (trend) {
trend.push({ timestamp: Date.now(), 'FIC-101.PV': plant.fic.pv, 'FIC-101.SP': plant.fic.sp, 'FIC-101.OP': plant.fic.op });
});
}, 250);
/* Seed the history so the first trend card has fifteen minutes on it. */
for (let i = 900 * 4; i > 0; i--) {
const tt = -i * 0.25;
plant.fic.history.push({ timestamp: Date.now() + tt * 1000, pv: 78 + 4.2 * Math.sin(tt * 2 * Math.PI / 48), sp: 78, op: 46 + Math.round(Math.sin(tt * 2 * Math.PI / 48 + 0.6) * 4) * 2 });
}
/* ── The shell ──────────────────────────────────────────────────────── */
shell.navigation = [
{ id: 'overview', label: 'Plant overview', level: 1 },
{ id: 'area2', label: 'Area 2', level: 2, alarms: { warning: 2 } },
{ id: 'copilot', label: 'Operator copilot', level: 2, active: true },
{ id: 'alarms', label: 'Alarm summary', level: 3 },
{ id: 'batch', label: 'R-201 batch', level: 3 }
];
shell.alarms = plant.alarms.filter(function (alarm) {
return alarm.active;
});
shell.addEventListener('navigate', function (event) {
const targets = { overview: '../overview/index.htm', alarms: '../alarms/index.htm', batch: '../batch/index.htm' };
if (targets[event.detail.id]) {
window.location.href = targets[event.detail.id];
}
});
shell.addEventListener('themeChange', function (event) {
document.body.setAttribute('theme', event.detail.theme);
});
/* ── The record ─────────────────────────────────────────────────────── */
const trail = new Audit.Trail({ system: 'Area 2 HMI' });
/* A narrow record: who, what, and what it became. Boolean attributes are presence-based,
so the toolbar is turned off here rather than in the markup. */
record.showToolbar = false;
record.showIntegrity = false;
record.columns = ['at', 'user', 'action', 'field', 'newValue'];
trail.subscribe(function (entry) {
record.addEntry(entry);
});
function log(user, userName, action, field, oldValue, newValue, reason, signature) {
trail.append({ user: user, userName: userName, action: action, record: 'FIC-101', field: field, oldValue: oldValue, newValue: newValue, reason: reason, signature: signature });
}
/* ── Permissives for the one action the copilot can suggest ─────────── */
el('permissives').conditions = [
{ id: 'c1', label: 'FIC-101 in Auto', satisfied: true },
{ id: 'c2', label: 'No open work order on FCV-101', satisfied: true },
{ id: 'c3', label: 'Operator authorised for tuning changes', satisfied: true },
{ id: 'c4', label: 'Change within 50 % of current gain', satisfied: true }
];
/* ── The model ──────────────────────────────────────────────────────── */
const SYSTEM = 'You are an operator copilot for a process plant. Answer only from the CONTEXT JSON in the ' +
'message. Use tags and units exactly as given. Never invent a measurement or a cause. Reply with JSON ' +
'only, shaped { "text": string, "card": { "type": "trend" | "alarms" | "changes" | "handover", "tags": ' +
'string[], "minutes": number } | null, "action": { "type": "setpoint", "tag": string, "property": string, ' +
'"from": number, "to": number, "label": string, "reason": string } | null, "confidence": "low" | "medium" | ' +
'"high" }. An action is a suggestion for the operator to request; you cannot change anything.';
function configure() {
const provider = el('provider').value,
key = el('apiKey').value.trim(),
live = !!(provider && key);
ai.configure({ provider: provider || 'anthropic', apiKey: live ? key : '', sendRequest: live ? null : window.CopilotResponder.respond, systemPrompt: SYSTEM });
el('chatNote').textContent = live ? 'Model: ' + provider + '. The context below the question is all that is sent.' :
'Simulated - no model is called. The copilot advises; the operator decides; both are recorded.';
}
el('provider').addEventListener('change', configure);
el('apiKey').addEventListener('input', configure);
configure();
/* The context the copilot reasons from: reduced, not raw. A shift of samples is a
statistic here, not a payload. */
function context() {
const recent = plant.fic.history.slice(-600),
values = recent.map(function (row) {
return row.pv;
}),
max = Math.max.apply(null, values),
min = Math.min.apply(null, values);
return {
time: new Date().toLocaleTimeString('en-GB', { hour12: false }),
loops: {
'FIC-101': {
pv: Number(plant.fic.pv.toFixed(1)), sp: plant.fic.sp, op: Number(plant.fic.op.toFixed(1)), unit: plant.fic.unit, gain: plant.fic.gain,
amplitude: Number((max - min).toFixed(1)), period: 48, outputStep: 2,
oscillatingFor: Math.round((Date.now() - plant.fic.oscillatingSince) / 60000) + ' min'
},
'PIC-102': { pv: Number(plant.pic.pv.toFixed(2)), sp: plant.pic.sp, unit: 'bar' },
'TIC-204': { pv: Number(plant.tic.pv.toFixed(1)), sp: plant.tic.sp, unit: '°C' }
},
r201: plant.r201,
alarms: plant.alarms.map(function (alarm) {
return { tag: alarm.tag, priority: alarm.priority, acknowledged: alarm.acknowledged, active: alarm.active };
}),
changes: plant.changes
};
}
/* ── The conversation ───────────────────────────────────────────────── */
function bubble(role, text) {
const node = document.createElement('div');
node.className = 'cp-message cp-message-' + role;
node.innerHTML = '<span class="cp-message-who">' + (role === 'user' ? OPERATOR.name : 'Copilot') + '</span>' +
'<div class="cp-message-text"></div>';
node.querySelector('.cp-message-text').textContent = text;
messages.appendChild(node);
messages.scrollTop = messages.scrollHeight;
return node;
}
function card(node, spec, action, confidence, payload) {
const host = document.createElement('div');
host.className = 'cp-card';
if (spec && spec.type === 'trend') {
const trend = document.createElement('smart-trend');
trend.className = 'cp-trend';
trend.setAttribute('data-live', '');
trend.timeSpan = (spec.minutes || 15) * 60000;
trend.live = true;
trend.showReadout = true;
trend.label = 'FIC-101';
trend.pens = [
{ field: 'FIC-101.PV', label: 'PV', unit: 'm³/h', min: 60, max: 100, precision: 1 },
{ field: 'FIC-101.SP', label: 'SP', unit: 'm³/h', min: 60, max: 100, precision: 1 },
{ field: 'FIC-101.OP', label: 'OP', unit: '%', min: 0, max: 100, precision: 0 }
];
host.appendChild(trend);
trend.pushMany(plant.fic.history.slice(-3600).map(function (row) {
return { timestamp: row.timestamp, 'FIC-101.PV': row.pv, 'FIC-101.SP': row.sp, 'FIC-101.OP': row.op };
}));
}
else if (spec && spec.type === 'alarms') {
const table = document.createElement('table');
table.className = 'cp-table';
table.innerHTML = '<thead><tr><th>Tag</th><th>Message</th><th>Pri</th><th>Ack</th></tr></thead><tbody>' +
plant.alarms.map(function (alarm) {
return '<tr><td>' + alarm.tag + '</td><td>' + alarm.message + '</td><td>' + alarm.priority + '</td><td>' + (alarm.acknowledged ? 'yes' : 'no') + '</td></tr>';
}).join('') + '</tbody>';
host.appendChild(table);
}
else if (spec && spec.type === 'changes') {
const list = document.createElement('ol');
list.className = 'cp-list';
plant.changes.forEach(function (change) {
const item = document.createElement('li');
item.textContent = change.at + ' ' + change.user + ' — ' + change.what + (change.reason ? ' (' + change.reason + ')' : '');
list.appendChild(item);
});
host.appendChild(list);
}
else if (spec && spec.type === 'handover') {
const button = document.createElement('button');
button.type = 'button';
button.className = 'cp-secondary';
button.textContent = 'Copy to shift log';
button.addEventListener('click', function () {
log(OPERATOR.id, OPERATOR.name, 'create', 'shift log', '', 'handover note from copilot', 'copied verbatim');
button.textContent = 'Copied to shift log';
button.disabled = true;
});
host.appendChild(button);
}
if (action && action.type === 'setpoint') {
const request = document.createElement('div');
request.className = 'cp-action';
request.innerHTML = '<span class="cp-action-text">Suggested action, ' + confidence + ' confidence: <b></b></span>';
request.querySelector('b').textContent = action.label;
const button = document.createElement('smart-momentary-button');
button.label = 'Request';
button.mode = 'confirm';
button.kind = 'normal';
request.appendChild(button);
host.appendChild(request);
log('copilot', 'Copilot', 'advise', action.property, action.from, action.to, action.reason);
button.addEventListener('activate', function () {
const permissive = el('permissives').summary();
if (!permissive.ready) {
button.interlocked = true;
button.interlockReason = 'permissives not met';
return;
}
sign.sign({ id: 'FIC-101.gain', title: 'FIC-101 controller gain', fields: [{ label: 'From', value: action.from }, { label: 'To', value: action.to }, { label: 'Advised by', value: 'Copilot' }] }, { reason: action.reason }).then(function (signature) {
/* The controller answers; only its answer moves the plant. */
setTimeout(function () {
plant.fic.gain = action.to;
plant.fic.oscillatingSince = Date.now();
log(signature.userId, signature.userName, 'update', action.property, action.from, action.to, signature.reason, signature);
request.querySelector('.cp-action-text').innerHTML = '<b>Gain ' + action.from + ' → ' + action.to + '</b> confirmed by the controller.';
button.state = 'done';
button.interlocked = true;
button.interlockReason = 'already applied';
bubble('assistant', 'The controller confirms gain ' + action.to + ' on FIC-101. Watch the next two minutes of the trend; the swing should settle to under 1 m³/h. Maintenance still needs to see FCV-101.');
}, 800);
}, function () { });
});
}
if (host.childNodes.length) {
node.appendChild(host);
}
const details = document.createElement('details');
details.className = 'cp-payload';
details.innerHTML = '<summary>What was sent</summary><pre></pre>';
details.querySelector('pre').textContent = JSON.stringify(payload, null, 2);
node.appendChild(details);
messages.scrollTop = messages.scrollHeight;
}
let busy = false;
function ask(text) {
if (busy || !text.trim()) {
return;
}
busy = true;
el('send').disabled = true;
bubble('user', text);
log(OPERATOR.id, OPERATOR.name, 'ask', 'copilot', '', text.slice(0, 80));
const payload = context(),
prompt = text + '\n\nCONTEXT: ' + JSON.stringify(payload),
thinking = bubble('assistant', '…');
ai.client().promptJSON(prompt).then(function (reply) {
thinking.querySelector('.cp-message-text').textContent = reply.text || '(no answer)';
card(thinking, reply.card, reply.action, reply.confidence || 'unknown', payload);
}, function (error) {
thinking.querySelector('.cp-message-text').textContent = 'No answer: ' + (error && error.message ? error.message : error);
}).then(function () {
busy = false;
el('send').disabled = false;
});
}
el('askForm').addEventListener('submit', function (event) {
event.preventDefault();
ask(el('ask').value);
el('ask').value = '';
});
['Why is FIC-101 oscillating?', 'Summarise the alarms of the last hour', 'What changed since the shift started?', 'Write a handover note'].forEach(function (text) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'cp-chip';
chip.textContent = text;
chip.addEventListener('click', function () {
ask(text);
});
el('suggestions').appendChild(chip);
});
/* ── Signing: the host's directory ─────────────────────────────────── */
sign.addEventListener('signRequest', function (event) {
setTimeout(function () {
if (event.detail.userId === 'jmoreau' && event.detail.password === 'operator1') {
sign.accept({ userName: 'J. Moreau' });
}
else {
sign.reject();
}
}, 500);
});
bubble('assistant', 'Area 2 is in scope: FIC-101, PIC-102, TIC-204 and R-201, the last hour of alarms and this shift\'s recorded changes. Ask a question, or pick one below. Anything I suggest is a request for you to make, not a change I can make.');
log(OPERATOR.id, OPERATOR.name, 'login', 'Area 2 HMI', '', OPERATOR.id);
};
styles.css
/* Operator copilot. Lives inside smart-hmi-shell, so the chrome is the shell's; this is the
conversation and its context. */
html,
body {
height: 100%;
margin: 0;
}
body.viewport {
overflow: hidden;
color: var(--smart-text-color);
font-family: var(--smart-font-family);
font-size: 13px;
}
smart-hmi-shell {
--smart-hmi-shell-height: 100vh;
}
/* ── Header slot ─────────────────────────────────────────────────────── */
.cp-provider {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.cp-provider select,
.cp-key {
min-height: var(--smart-hmi-shell-target, 28px);
padding: 0 8px;
font: inherit;
font-size: 12px;
color: var(--smart-text-color);
background: var(--smart-industrial-panel-background, var(--smart-surface));
border: 1px solid var(--smart-industrial-panel-border, var(--smart-border));
border-radius: 3px;
}
.cp-key {
width: 160px;
}
/* ── Layout ──────────────────────────────────────────────────────────── */
.cp-screen {
display: grid;
grid-template-columns: minmax(480px, 2fr) minmax(300px, 1fr);
gap: 10px;
height: 100%;
min-height: 0;
}
@media (max-width: 1000px) {
.cp-screen {
grid-template-columns: 1fr;
height: auto;
}
/* Stacked, the context panels come after the conversation and the page scrolls: a chat
sized to the window would push them a whole window further down at any height. */
/* .cp-screen in front: the base rules come later in this file */
.cp-screen .cp-chat {
height: 520px;
}
.cp-screen .cp-context {
overflow: visible;
}
/* and the shell grows with them: one window tall, it hid the context panels entirely */
body.viewport {
overflow: auto;
}
smart-hmi-shell {
--smart-hmi-shell-height: auto;
}
}
.hmi-panel {
display: flex;
flex-direction: column;
min-height: 0;
background: var(--smart-industrial-panel-background, var(--smart-surface));
border: 1px solid var(--smart-industrial-panel-border, var(--smart-border));
border-radius: 4px;
overflow: hidden;
}
.cp-panel-head {
padding: 8px 12px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
background: var(--smart-industrial-panel-header-background, var(--smart-surface));
border-bottom: 1px solid var(--smart-industrial-panel-border, var(--smart-border));
}
.cp-panel-body {
padding: 10px 12px;
}
/* ── Conversation ────────────────────────────────────────────────────── */
.cp-chat {
height: calc(100vh - var(--smart-hmi-shell-header-height, 52px) - 100px);
}
.cp-chat-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
background: var(--smart-industrial-panel-header-background, var(--smart-surface));
border-bottom: 1px solid var(--smart-industrial-panel-border, var(--smart-border));
}
.cp-chat-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.cp-chat-note {
font-size: 12px;
opacity: 0.75;
}
.cp-messages {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.cp-message {
display: flex;
flex-direction: column;
gap: 6px;
max-width: 92%;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid var(--smart-industrial-panel-border, var(--smart-border));
}
.cp-message-user {
align-self: flex-end;
background: var(--smart-industrial-panel-header-background, var(--smart-surface));
}
.cp-message-assistant {
align-self: flex-start;
background: var(--smart-industrial-panel-background, var(--smart-surface));
}
.cp-message-who {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
opacity: 0.7;
}
.cp-message-text {
line-height: 1.5;
white-space: pre-wrap;
}
.cp-card {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 4px;
}
.cp-trend {
width: 100%;
height: 220px;
}
.cp-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.cp-table th,
.cp-table td {
text-align: left;
padding: 4px 8px;
border-bottom: 1px solid var(--smart-industrial-panel-border, var(--smart-border));
}
.cp-list {
margin: 0;
padding-left: 20px;
font-size: 12px;
line-height: 1.6;
}
.cp-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border: 1px dashed var(--smart-industrial-panel-border, var(--smart-border));
border-radius: 4px;
}
.cp-action-text {
font-size: 12px;
}
.cp-payload {
font-size: 11px;
opacity: 0.8;
}
.cp-payload pre {
max-height: 160px;
overflow: auto;
margin: 6px 0 0;
padding: 8px;
font-size: 11px;
background: var(--smart-industrial-panel-header-background, var(--smart-background));
border-radius: 3px;
}
.cp-suggestions {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px 0;
}
.cp-chip,
.cp-secondary {
padding: 4px 10px;
font: inherit;
font-size: 12px;
color: var(--smart-text-color);
background: transparent;
border: 1px solid var(--smart-industrial-panel-border, var(--smart-border));
border-radius: 14px;
cursor: pointer;
}
.cp-secondary {
align-self: flex-start;
border-radius: 4px;
}
.cp-input {
display: flex;
gap: 8px;
padding: 10px 12px 12px;
}
.cp-input input {
flex: 1 1 auto;
min-height: 36px;
padding: 0 10px;
font: inherit;
color: var(--smart-text-color);
background: var(--smart-industrial-panel-header-background, var(--smart-background));
border: 1px solid var(--smart-industrial-panel-border, var(--smart-border));
border-radius: 4px;
}
.cp-send {
min-width: 72px;
font: inherit;
font-weight: 600;
color: var(--smart-primary-color, #fff);
background: var(--smart-primary);
border: 1px solid var(--smart-primary);
border-radius: 4px;
cursor: pointer;
}
.cp-send[disabled] {
opacity: 0.5;
}
/* ── Context column ──────────────────────────────────────────────────── */
.cp-context {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
overflow-y: auto;
}
/* each panel keeps its whole height and the column scrolls: shrunk to fit, a panel cut its
last tile and its last condition off with no way to reach them */
.cp-context > .hmi-panel {
flex: 0 0 auto;
}
.cp-tiles {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
padding: 10px;
}
.cp-record {
--smart-audit-max-height: 260px;
}
.cp-record smart-audit-trail {
font-size: 11px;
}
/* The signature panel floats over the shell when it opens. */
smart-esignature {
position: fixed;
right: 24px;
bottom: 60px;
z-index: 20;
}
Components on this screen
Audit TrailE-SignatureHMI ShellMomentary ButtonPermissiveStatus TileTrend