Component demo · Recipes, orders and scheduled jobs
Recipe Panel
index.htm as it is written with the smart-industrial package
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>Recipe Panel Overview Demo</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">
<div class="smart-demo-container">
<section id="overview">
<h2>Smart.RecipePanel is the control an operator uses to change what the line is making.</h2>
<p>A recipe is a named set of values — “Product A” is 180 °C and 42 bar
— and loading one writes them all in a single action, instead of typing six numbers into six
fields and getting the fifth wrong. The values are listed with the names an operator knows and the
units they are in, so the person pressing <b>Load</b> can read what is about to happen to the plant.</p>
</section>
<section>
<h2>Choosing a product</h2>
<p>Pick a recipe to see what loading it would write. <code>confirm</code> is on by default, so a load
names the values first — a recipe that moves six setpoints at once deserves that.</p>
<div class="demo-row">
<smart-recipe-panel id="line3" label="Line 3 recipes"></smart-recipe-panel>
</div>
<div class="demo-actions">
<button class="demo-button" id="straight">Load without asking</button>
<button class="demo-button" id="refuse">Make the plant refuse one</button>
<button class="demo-button" id="clear">Clear the outcome</button>
</div>
<p class="hint" id="log">The panel raises <code>recipeApply</code>; this page does the writing and hands back a result.</p>
</section>
<section>
<h2>It never writes</h2>
<p>Loading raises <code>recipeApply</code> and the host performs the writes — through the station,
which refuses a tag that is not writable and records who asked — then sets <code>result</code>.
The element cannot know who is pressing the button or how the writes should be audited, so it does
not pretend to. A load that half-succeeds says so: the entries the plant refused stay struck through
in the values table.</p>
</section>
<section>
<h2>Read-only</h2>
<p><code>readOnly</code> removes the button rather than disabling it: a control that cannot be used
should not invite a press. An operator with view rights still reads what each product is.</p>
<div class="demo-row">
<smart-recipe-panel id="viewer" label="Products (view only)" read-only></smart-recipe-panel>
</div>
</section>
</div>
<!-- scripts -->
<script type="module" src="node_modules/smart-industrial/source/modules/smart.recipepanel.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>
</body>
</html>
index.js
/// <reference path="../../../source/typescript/smart.elements.d.ts" />
/* The recipe panel: the page holds the recipes and does the writing, as a real host would. */
window.onload = function () {
const RECIPES = [
{
id: 'product-a', name: 'Product A', note: 'the hot one',
entries: [
{ tag: 'plc.TIC-101.SP', label: 'Reactor temperature', value: 180, unit: '°C' },
{ tag: 'plc.PIC-201.SP', label: 'Header pressure', value: 42, unit: 'bar' },
{ tag: 'plc.FIC-104.SP', label: 'Feed flow', value: 430, unit: 'L/min' },
{ tag: 'plc.AG-212.SPD', label: 'Agitator speed', value: 1450, unit: 'rpm' },
{ tag: 'plc.SEQ.DWELL', label: 'Dwell', value: 90, unit: 's' }
]
},
{
id: 'product-b', name: 'Product B', note: 'the cold one',
entries: [
{ tag: 'plc.TIC-101.SP', label: 'Reactor temperature', value: 64, unit: '°C' },
{ tag: 'plc.PIC-201.SP', label: 'Header pressure', value: 18, unit: 'bar' },
{ tag: 'plc.FIC-104.SP', label: 'Feed flow', value: 210, unit: 'L/min' },
{ tag: 'plc.AG-212.SPD', label: 'Agitator speed', value: 900, unit: 'rpm' }
]
},
{
id: 'cip', name: 'Clean in place', note: 'between products',
entries: [
{ tag: 'plc.TIC-101.SP', label: 'Reactor temperature', value: 85, unit: '°C' },
{ tag: 'plc.FIC-104.SP', label: 'Feed flow', value: 600, unit: 'L/min' },
{ tag: 'plc.SEQ.DWELL', label: 'Dwell', value: 1200, unit: 's' }
]
},
{
id: 'standby', name: 'Standby', note: 'overnight',
entries: [
{ tag: 'plc.TIC-101.SP', label: 'Reactor temperature', value: 18, unit: '°C' },
{ tag: 'plc.AG-212.SPD', label: 'Agitator speed', value: 0, unit: 'rpm' }
]
}
];
const panel = document.getElementById('line3'),
viewer = document.getElementById('viewer'),
log = document.getElementById('log');
panel.recipes = RECIPES;
viewer.recipes = RECIPES;
viewer.selected = 'cip';
/* the plant, such as it is: one tag this page will refuse, to show a half-applied load */
let refuseTag = '';
/* The host writes. The panel asked; it did not act. */
panel.addEventListener('recipeApply', function (event) {
const asked = event.detail;
panel.busy = true;
log.textContent = 'Writing ' + asked.entries.length + ' values of "' + asked.name + '"…';
/* a real host would write through the station and wait for the confirmations */
setTimeout(function () {
const failed = asked.entries
.filter((entry) => entry.tag === refuseTag)
.map((entry) => ({ tag: entry.tag, error: entry.tag + ' is not writable' }));
panel.busy = false;
panel.result = {
name: asked.name,
applied: asked.entries.length - failed.length,
of: asked.entries.length,
failed: failed,
at: new Date().toISOString()
};
log.textContent = failed.length
? 'The plant refused ' + failed.length + ' of ' + asked.entries.length + ': ' + failed.map((f) => f.tag).join(', ')
: 'All ' + asked.entries.length + ' values written.';
}, 700);
});
panel.addEventListener('recipeSelect', function (event) {
log.textContent = 'Chose ' + event.detail.name + '.';
});
document.getElementById('straight').addEventListener('click', function () {
panel.confirm = !panel.confirm;
this.textContent = panel.confirm ? 'Load without asking' : 'Ask before loading';
log.textContent = panel.confirm ? 'Load asks first.' : 'Load writes straight away.';
});
document.getElementById('refuse').addEventListener('click', function () {
refuseTag = refuseTag ? '' : 'plc.AG-212.SPD';
this.textContent = refuseTag ? 'Let the plant take them all' : 'Make the plant refuse one';
log.textContent = refuseTag
? 'The agitator speed will be refused on the next load.'
: 'The plant will take every value again.';
});
document.getElementById('clear').addEventListener('click', function () {
panel.result = null;
log.textContent = 'Cleared.';
});
};
styles.css
/* The panel is a real control on a screen, so it is given the room one would have. */
.demo-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
smart-recipe-panel {
width: 520px;
max-width: 100%;
height: 260px;
}
/* ── Demo chrome, shared by the Industrial demos ─────────────────────────
Headings, prose, buttons and notes are the same on every demo page. This block
is appended by scripts and comes last, so it wins over the rules above. */
.smart-demo-container {
padding: 4px 0 32px;
}
.smart-demo-container section {
margin-bottom: 36px;
}
.smart-demo-container section h2 {
margin: 0 0 8px;
font-size: 17px;
font-weight: 600;
line-height: 1.35;
color: var(--smart-text-color);
}
.smart-demo-container #overview h2 {
margin-bottom: 10px;
font-size: 20px;
max-width: 78ch;
}
.smart-demo-container section > p {
margin: 0 0 14px;
max-width: 84ch;
font-size: 14px;
line-height: 1.55;
color: var(--smart-text-secondary-color);
}
.smart-demo-container code {
padding: 1px 5px;
border-radius: 2px;
background: var(--smart-ui-state-hover);
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
}
.smart-demo-container .demo-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-top: 12px;
}
.smart-demo-container .demo-actions label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--smart-text-color);
}
.smart-demo-container .demo-actions button,
.smart-demo-container .demo-button {
min-height: 30px;
padding: 0 12px;
border: 1px solid var(--smart-border);
border-radius: 3px;
background: var(--smart-industrial-panel-background, var(--smart-surface));
color: var(--smart-text-color);
font: inherit;
font-size: 13px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
cursor: pointer;
}
.smart-demo-container .demo-actions button:hover,
.smart-demo-container .demo-button:hover {
border-color: var(--smart-ui-state-border-hover);
background: var(--smart-ui-state-hover);
color: var(--smart-ui-state-color-hover);
}
.smart-demo-container .demo-actions button.demo-button-primary,
.smart-demo-container .demo-button-primary {
border-color: var(--smart-primary);
background: var(--smart-primary);
color: var(--smart-primary-color, #fff);
}
.smart-demo-container .demo-actions button[disabled],
.smart-demo-container .demo-button[disabled] {
opacity: 0.5;
cursor: default;
}
.smart-demo-container .demo-actions select,
.smart-demo-container .demo-actions input[type="text"],
.smart-demo-container .demo-actions input[type="number"] {
min-height: 30px;
padding: 2px 6px;
border: 1px solid var(--smart-border);
border-radius: 3px;
background: var(--smart-background);
color: var(--smart-text-color);
font: inherit;
font-size: 13px;
}
.smart-demo-container .hint,
.smart-demo-container .demo-log,
.smart-demo-container .demo-counts {
margin: 10px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--smart-text-secondary-color);
opacity: 1;
}
.smart-demo-container .demo-log,
.smart-demo-container .demo-counts {
font-family: Consolas, "Courier New", monospace;
}
.smart-demo-container .log {
list-style: none;
margin: 10px 0 0;
padding: 0;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--smart-text-secondary-color);
opacity: 1;
}
.smart-demo-container .log li {
padding: 2px 0;
}
The same demo in Angular, generated from the HTML demo. The frameworks guide has the project setup.
angular/app.component.html
<div class="angular-root" theme="industrial">
<div class="smart-demo-container">
<section id="overview">
<h2>Smart.RecipePanel is the control an operator uses to change what the line is making.</h2>
<p>A recipe is a named set of values — “Product A” is 180 °C and 42 bar
— and loading one writes them all in a single action, instead of typing six numbers into six
fields and getting the fifth wrong. The values are listed with the names an operator knows and the
units they are in, so the person pressing <b>Load</b> can read what is about to happen to the plant.</p>
</section>
<section>
<h2>Choosing a product</h2>
<p>Pick a recipe to see what loading it would write. <code>confirm</code> is on by default, so a load
names the values first — a recipe that moves six setpoints at once deserves that.</p>
<div class="demo-row">
<smart-recipe-panel id="line3" label="Line 3 recipes"></smart-recipe-panel>
</div>
<div class="demo-actions">
<button class="demo-button" id="straight">Load without asking</button>
<button class="demo-button" id="refuse">Make the plant refuse one</button>
<button class="demo-button" id="clear">Clear the outcome</button>
</div>
<p class="hint" id="log">The panel raises <code>recipeApply</code>; this page does the writing and hands back a result.</p>
</section>
<section>
<h2>It never writes</h2>
<p>Loading raises <code>recipeApply</code> and the host performs the writes — through the station,
which refuses a tag that is not writable and records who asked — then sets <code>result</code>.
The element cannot know who is pressing the button or how the writes should be audited, so it does
not pretend to. A load that half-succeeds says so: the entries the plant refused stay struck through
in the values table.</p>
</section>
<section>
<h2>Read-only</h2>
<p><code>readOnly</code> removes the button rather than disabling it: a control that cannot be used
should not invite a press. An operator with view rights still reads what each product is.</p>
<div class="demo-row">
<smart-recipe-panel id="viewer" label="Products (view only)" [readOnly]="true"></smart-recipe-panel>
</div>
</section>
</div>
</div>
angular/app.component.ts
import { Component, AfterViewInit, OnDestroy } from '@angular/core';
import { RecipePanelModule } from 'smart-industrial/angular/recipepanel';
import { runDemo } from './demo';
/**
* Smart.UI Industrial - RecipePanel in Angular.
*
* The template is the HTML demo's markup: the elements are the wrappers, so what is
* written on them is checked against their inputs. demo.ts drives them the way the
* plant's data would, once every element in the view has rendered.
*/
@Component({
selector: 'app-root',
standalone: true,
imports: [RecipePanelModule],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent implements AfterViewInit, OnDestroy {
private unmounted = false;
ngAfterViewInit(): void {
const root = document.querySelector('.angular-root') as HTMLElement,
pending: Promise<void>[] = [];
root.querySelectorAll('*').forEach((element: any) => {
if (typeof element.whenRendered === 'function' && !element.isRendered) {
pending.push(element.whenRendered());
}
});
Promise.all(pending).then(() => {
if (!this.unmounted) {
runDemo();
}
});
}
ngOnDestroy(): void {
this.unmounted = true;
}
}
angular/demo.ts
// @ts-nocheck
/* The HTML demo's script, as it was written: page code that feeds the elements, not a typed
API. The types are on the wrappers in app.component.ts. */
/* The recipe panel: the page holds the recipes and does the writing, as a real host would. */
export function runDemo(): void {
const RECIPES = [
{
id: 'product-a', name: 'Product A', note: 'the hot one',
entries: [
{ tag: 'plc.TIC-101.SP', label: 'Reactor temperature', value: 180, unit: '°C' },
{ tag: 'plc.PIC-201.SP', label: 'Header pressure', value: 42, unit: 'bar' },
{ tag: 'plc.FIC-104.SP', label: 'Feed flow', value: 430, unit: 'L/min' },
{ tag: 'plc.AG-212.SPD', label: 'Agitator speed', value: 1450, unit: 'rpm' },
{ tag: 'plc.SEQ.DWELL', label: 'Dwell', value: 90, unit: 's' }
]
},
{
id: 'product-b', name: 'Product B', note: 'the cold one',
entries: [
{ tag: 'plc.TIC-101.SP', label: 'Reactor temperature', value: 64, unit: '°C' },
{ tag: 'plc.PIC-201.SP', label: 'Header pressure', value: 18, unit: 'bar' },
{ tag: 'plc.FIC-104.SP', label: 'Feed flow', value: 210, unit: 'L/min' },
{ tag: 'plc.AG-212.SPD', label: 'Agitator speed', value: 900, unit: 'rpm' }
]
},
{
id: 'cip', name: 'Clean in place', note: 'between products',
entries: [
{ tag: 'plc.TIC-101.SP', label: 'Reactor temperature', value: 85, unit: '°C' },
{ tag: 'plc.FIC-104.SP', label: 'Feed flow', value: 600, unit: 'L/min' },
{ tag: 'plc.SEQ.DWELL', label: 'Dwell', value: 1200, unit: 's' }
]
},
{
id: 'standby', name: 'Standby', note: 'overnight',
entries: [
{ tag: 'plc.TIC-101.SP', label: 'Reactor temperature', value: 18, unit: '°C' },
{ tag: 'plc.AG-212.SPD', label: 'Agitator speed', value: 0, unit: 'rpm' }
]
}
];
const panel = document.getElementById('line3'),
viewer = document.getElementById('viewer'),
log = document.getElementById('log');
panel.recipes = RECIPES;
viewer.recipes = RECIPES;
viewer.selected = 'cip';
/* the plant, such as it is: one tag this page will refuse, to show a half-applied load */
let refuseTag = '';
/* The host writes. The panel asked; it did not act. */
panel.addEventListener('recipeApply', function (event) {
const asked = event.detail;
panel.busy = true;
log.textContent = 'Writing ' + asked.entries.length + ' values of "' + asked.name + '"…';
/* a real host would write through the station and wait for the confirmations */
setTimeout(function () {
const failed = asked.entries
.filter((entry) => entry.tag === refuseTag)
.map((entry) => ({ tag: entry.tag, error: entry.tag + ' is not writable' }));
panel.busy = false;
panel.result = {
name: asked.name,
applied: asked.entries.length - failed.length,
of: asked.entries.length,
failed: failed,
at: new Date().toISOString()
};
log.textContent = failed.length
? 'The plant refused ' + failed.length + ' of ' + asked.entries.length + ': ' + failed.map((f) => f.tag).join(', ')
: 'All ' + asked.entries.length + ' values written.';
}, 700);
});
panel.addEventListener('recipeSelect', function (event) {
log.textContent = 'Chose ' + event.detail.name + '.';
});
document.getElementById('straight').addEventListener('click', function () {
panel.confirm = !panel.confirm;
this.textContent = panel.confirm ? 'Load without asking' : 'Ask before loading';
log.textContent = panel.confirm ? 'Load asks first.' : 'Load writes straight away.';
});
document.getElementById('refuse').addEventListener('click', function () {
refuseTag = refuseTag ? '' : 'plc.AG-212.SPD';
this.textContent = refuseTag ? 'Let the plant take them all' : 'Make the plant refuse one';
log.textContent = refuseTag
? 'The agitator speed will be refused on the next load.'
: 'The plant will take every value again.';
});
document.getElementById('clear').addEventListener('click', function () {
panel.result = null;
log.textContent = 'Cleared.';
});
}
angular/app.component.css
/* The panel is a real control on a screen, so it is given the room one would have. */
.demo-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
smart-recipe-panel {
width: 520px;
max-width: 100%;
height: 260px;
}
/* ── Demo chrome, shared by the Industrial demos ─────────────────────────
Headings, prose, buttons and notes are the same on every demo page. This block
is appended by scripts and comes last, so it wins over the rules above. */
.smart-demo-container {
padding: 4px 0 32px;
}
.smart-demo-container section {
margin-bottom: 36px;
}
.smart-demo-container section h2 {
margin: 0 0 8px;
font-size: 17px;
font-weight: 600;
line-height: 1.35;
color: var(--smart-text-color);
}
.smart-demo-container #overview h2 {
margin-bottom: 10px;
font-size: 20px;
max-width: 78ch;
}
.smart-demo-container section > p {
margin: 0 0 14px;
max-width: 84ch;
font-size: 14px;
line-height: 1.55;
color: var(--smart-text-secondary-color);
}
.smart-demo-container code {
padding: 1px 5px;
border-radius: 2px;
background: var(--smart-ui-state-hover);
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
}
.smart-demo-container .demo-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-top: 12px;
}
.smart-demo-container .demo-actions label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--smart-text-color);
}
.smart-demo-container .demo-actions button,
.smart-demo-container .demo-button {
min-height: 30px;
padding: 0 12px;
border: 1px solid var(--smart-border);
border-radius: 3px;
background: var(--smart-industrial-panel-background, var(--smart-surface));
color: var(--smart-text-color);
font: inherit;
font-size: 13px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
cursor: pointer;
}
.smart-demo-container .demo-actions button:hover,
.smart-demo-container .demo-button:hover {
border-color: var(--smart-ui-state-border-hover);
background: var(--smart-ui-state-hover);
color: var(--smart-ui-state-color-hover);
}
.smart-demo-container .demo-actions button.demo-button-primary,
.smart-demo-container .demo-button-primary {
border-color: var(--smart-primary);
background: var(--smart-primary);
color: var(--smart-primary-color, #fff);
}
.smart-demo-container .demo-actions button[disabled],
.smart-demo-container .demo-button[disabled] {
opacity: 0.5;
cursor: default;
}
.smart-demo-container .demo-actions select,
.smart-demo-container .demo-actions input[type="text"],
.smart-demo-container .demo-actions input[type="number"] {
min-height: 30px;
padding: 2px 6px;
border: 1px solid var(--smart-border);
border-radius: 3px;
background: var(--smart-background);
color: var(--smart-text-color);
font: inherit;
font-size: 13px;
}
.smart-demo-container .hint,
.smart-demo-container .demo-log,
.smart-demo-container .demo-counts {
margin: 10px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--smart-text-secondary-color);
opacity: 1;
}
.smart-demo-container .demo-log,
.smart-demo-container .demo-counts {
font-family: Consolas, "Courier New", monospace;
}
.smart-demo-container .log {
list-style: none;
margin: 10px 0 0;
padding: 0;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--smart-text-secondary-color);
opacity: 1;
}
.smart-demo-container .log li {
padding: 2px 0;
}
The same demo in React, generated from the HTML demo. The frameworks guide has the project setup.
react/App.jsx
import 'smart-industrial/source/styles/smart.default.css';
import 'smart-industrial/source/styles/smart.industrial-elements.css';
import 'smart-industrial/source/styles/smart.industrial.css';
import 'smart-industrial/source/styles/smart.industrial-dark.css';
import './App.css';
import React from 'react';
import { RecipePanel } from 'smart-industrial/react/recipepanel';
/* The demo logic is the HTML demo's: it feeds the elements the way a plant's data would. */
/* The recipe panel: the page holds the recipes and does the writing, as a real host would. */
const App = () => {
const root = React.useRef(null);
React.useEffect(() => {
/* The elements render after React has mounted them, and the demo's logic - written
for a page's load event - reads their parts, so it waits for every element in the
tree to have rendered first. */
const pending = Array.from(root.current.querySelectorAll('*'))
.filter((element) => typeof element.whenRendered === 'function' && !element.isRendered)
.map((element) => element.whenRendered());
let unmounted = false;
Promise.all(pending).then(() => {
if (unmounted) {
return;
}
const RECIPES = [
{
id: 'product-a', name: 'Product A', note: 'the hot one',
entries: [
{ tag: 'plc.TIC-101.SP', label: 'Reactor temperature', value: 180, unit: '°C' },
{ tag: 'plc.PIC-201.SP', label: 'Header pressure', value: 42, unit: 'bar' },
{ tag: 'plc.FIC-104.SP', label: 'Feed flow', value: 430, unit: 'L/min' },
{ tag: 'plc.AG-212.SPD', label: 'Agitator speed', value: 1450, unit: 'rpm' },
{ tag: 'plc.SEQ.DWELL', label: 'Dwell', value: 90, unit: 's' }
]
},
{
id: 'product-b', name: 'Product B', note: 'the cold one',
entries: [
{ tag: 'plc.TIC-101.SP', label: 'Reactor temperature', value: 64, unit: '°C' },
{ tag: 'plc.PIC-201.SP', label: 'Header pressure', value: 18, unit: 'bar' },
{ tag: 'plc.FIC-104.SP', label: 'Feed flow', value: 210, unit: 'L/min' },
{ tag: 'plc.AG-212.SPD', label: 'Agitator speed', value: 900, unit: 'rpm' }
]
},
{
id: 'cip', name: 'Clean in place', note: 'between products',
entries: [
{ tag: 'plc.TIC-101.SP', label: 'Reactor temperature', value: 85, unit: '°C' },
{ tag: 'plc.FIC-104.SP', label: 'Feed flow', value: 600, unit: 'L/min' },
{ tag: 'plc.SEQ.DWELL', label: 'Dwell', value: 1200, unit: 's' }
]
},
{
id: 'standby', name: 'Standby', note: 'overnight',
entries: [
{ tag: 'plc.TIC-101.SP', label: 'Reactor temperature', value: 18, unit: '°C' },
{ tag: 'plc.AG-212.SPD', label: 'Agitator speed', value: 0, unit: 'rpm' }
]
}
];
const panel = document.getElementById('line3'),
viewer = document.getElementById('viewer'),
log = document.getElementById('log');
panel.recipes = RECIPES;
viewer.recipes = RECIPES;
viewer.selected = 'cip';
/* the plant, such as it is: one tag this page will refuse, to show a half-applied load */
let refuseTag = '';
/* The host writes. The panel asked; it did not act. */
panel.addEventListener('recipeApply', function (event) {
const asked = event.detail;
panel.busy = true;
log.textContent = 'Writing ' + asked.entries.length + ' values of "' + asked.name + '"…';
/* a real host would write through the station and wait for the confirmations */
setTimeout(function () {
const failed = asked.entries
.filter((entry) => entry.tag === refuseTag)
.map((entry) => ({ tag: entry.tag, error: entry.tag + ' is not writable' }));
panel.busy = false;
panel.result = {
name: asked.name,
applied: asked.entries.length - failed.length,
of: asked.entries.length,
failed: failed,
at: new Date().toISOString()
};
log.textContent = failed.length
? 'The plant refused ' + failed.length + ' of ' + asked.entries.length + ': ' + failed.map((f) => f.tag).join(', ')
: 'All ' + asked.entries.length + ' values written.';
}, 700);
});
panel.addEventListener('recipeSelect', function (event) {
log.textContent = 'Chose ' + event.detail.name + '.';
});
document.getElementById('straight').addEventListener('click', function () {
panel.confirm = !panel.confirm;
this.textContent = panel.confirm ? 'Load without asking' : 'Ask before loading';
log.textContent = panel.confirm ? 'Load asks first.' : 'Load writes straight away.';
});
document.getElementById('refuse').addEventListener('click', function () {
refuseTag = refuseTag ? '' : 'plc.AG-212.SPD';
this.textContent = refuseTag ? 'Let the plant take them all' : 'Make the plant refuse one';
log.textContent = refuseTag
? 'The agitator speed will be refused on the next load.'
: 'The plant will take every value again.';
});
document.getElementById('clear').addEventListener('click', function () {
panel.result = null;
log.textContent = 'Cleared.';
});
});
return () => {
unmounted = true;
};
}, []);
return (
<div className="react-root" ref={root} theme="industrial">
<div className="smart-demo-container">
<section id="overview">
<h2>Smart.RecipePanel is the control an operator uses to change what the line is making.</h2>
<p>A recipe is a named set of values — “Product A” is 180 °C and 42 bar — and loading one writes them all in a single action, instead of typing six numbers into six fields and getting the fifth wrong. The values are listed with the names an operator knows and the units they are in, so the person pressing <b>Load</b> can read what is about to happen to the plant.</p>
</section>
<section>
<h2>Choosing a product</h2>
<p>Pick a recipe to see what loading it would write. <code>confirm</code> is on by default, so a load names the values first — a recipe that moves six setpoints at once deserves that.</p>
<div className="demo-row">
<RecipePanel id="line3" label="Line 3 recipes"></RecipePanel>
</div>
<div className="demo-actions">
<button className="demo-button" id="straight">Load without asking</button>
<button className="demo-button" id="refuse">Make the plant refuse one</button>
<button className="demo-button" id="clear">Clear the outcome</button>
</div>
<p className="hint" id="log">The panel raises <code>recipeApply</code>; this page does the writing and hands back a result.</p>
</section>
<section>
<h2>It never writes</h2>
<p>Loading raises <code>recipeApply</code> and the host performs the writes — through the station, which refuses a tag that is not writable and records who asked — then sets <code>result</code>. The element cannot know who is pressing the button or how the writes should be audited, so it does not pretend to. A load that half-succeeds says so: the entries the plant refused stay struck through in the values table.</p>
</section>
<section>
<h2>Read-only</h2>
<p><code>readOnly</code> removes the button rather than disabling it: a control that cannot be used should not invite a press. An operator with view rights still reads what each product is.</p>
<div className="demo-row">
<RecipePanel id="viewer" label="Products (view only)" readOnly></RecipePanel>
</div>
</section>
</div>
</div>
);
};
export default App;
react/App.css
/* The panel is a real control on a screen, so it is given the room one would have. */
.demo-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
smart-recipe-panel {
width: 520px;
max-width: 100%;
height: 260px;
}
/* ── Demo chrome, shared by the Industrial demos ─────────────────────────
Headings, prose, buttons and notes are the same on every demo page. This block
is appended by scripts and comes last, so it wins over the rules above. */
.smart-demo-container {
padding: 4px 0 32px;
}
.smart-demo-container section {
margin-bottom: 36px;
}
.smart-demo-container section h2 {
margin: 0 0 8px;
font-size: 17px;
font-weight: 600;
line-height: 1.35;
color: var(--smart-text-color);
}
.smart-demo-container #overview h2 {
margin-bottom: 10px;
font-size: 20px;
max-width: 78ch;
}
.smart-demo-container section > p {
margin: 0 0 14px;
max-width: 84ch;
font-size: 14px;
line-height: 1.55;
color: var(--smart-text-secondary-color);
}
.smart-demo-container code {
padding: 1px 5px;
border-radius: 2px;
background: var(--smart-ui-state-hover);
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
}
.smart-demo-container .demo-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-top: 12px;
}
.smart-demo-container .demo-actions label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--smart-text-color);
}
.smart-demo-container .demo-actions button,
.smart-demo-container .demo-button {
min-height: 30px;
padding: 0 12px;
border: 1px solid var(--smart-border);
border-radius: 3px;
background: var(--smart-industrial-panel-background, var(--smart-surface));
color: var(--smart-text-color);
font: inherit;
font-size: 13px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
cursor: pointer;
}
.smart-demo-container .demo-actions button:hover,
.smart-demo-container .demo-button:hover {
border-color: var(--smart-ui-state-border-hover);
background: var(--smart-ui-state-hover);
color: var(--smart-ui-state-color-hover);
}
.smart-demo-container .demo-actions button.demo-button-primary,
.smart-demo-container .demo-button-primary {
border-color: var(--smart-primary);
background: var(--smart-primary);
color: var(--smart-primary-color, #fff);
}
.smart-demo-container .demo-actions button[disabled],
.smart-demo-container .demo-button[disabled] {
opacity: 0.5;
cursor: default;
}
.smart-demo-container .demo-actions select,
.smart-demo-container .demo-actions input[type="text"],
.smart-demo-container .demo-actions input[type="number"] {
min-height: 30px;
padding: 2px 6px;
border: 1px solid var(--smart-border);
border-radius: 3px;
background: var(--smart-background);
color: var(--smart-text-color);
font: inherit;
font-size: 13px;
}
.smart-demo-container .hint,
.smart-demo-container .demo-log,
.smart-demo-container .demo-counts {
margin: 10px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--smart-text-secondary-color);
opacity: 1;
}
.smart-demo-container .demo-log,
.smart-demo-container .demo-counts {
font-family: Consolas, "Courier New", monospace;
}
.smart-demo-container .log {
list-style: none;
margin: 10px 0 0;
padding: 0;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--smart-text-secondary-color);
opacity: 1;
}
.smart-demo-container .log li {
padding: 2px 0;
}
The same demo in Vue, generated from the HTML demo. The frameworks guide has the project setup.
vue/App.vue
<template>
<div class="vue-root" theme="industrial" v-pre>
<div class="smart-demo-container">
<section id="overview">
<h2>Smart.RecipePanel is the control an operator uses to change what the line is making.</h2>
<p>A recipe is a named set of values — “Product A” is 180 °C and 42 bar
— and loading one writes them all in a single action, instead of typing six numbers into six
fields and getting the fifth wrong. The values are listed with the names an operator knows and the
units they are in, so the person pressing <b>Load</b> can read what is about to happen to the plant.</p>
</section>
<section>
<h2>Choosing a product</h2>
<p>Pick a recipe to see what loading it would write. <code>confirm</code> is on by default, so a load
names the values first — a recipe that moves six setpoints at once deserves that.</p>
<div class="demo-row">
<smart-recipe-panel id="line3" label="Line 3 recipes"></smart-recipe-panel>
</div>
<div class="demo-actions">
<button class="demo-button" id="straight">Load without asking</button>
<button class="demo-button" id="refuse">Make the plant refuse one</button>
<button class="demo-button" id="clear">Clear the outcome</button>
</div>
<p class="hint" id="log">The panel raises <code>recipeApply</code>; this page does the writing and hands back a result.</p>
</section>
<section>
<h2>It never writes</h2>
<p>Loading raises <code>recipeApply</code> and the host performs the writes — through the station,
which refuses a tag that is not writable and records who asked — then sets <code>result</code>.
The element cannot know who is pressing the button or how the writes should be audited, so it does
not pretend to. A load that half-succeeds says so: the entries the plant refused stay struck through
in the values table.</p>
</section>
<section>
<h2>Read-only</h2>
<p><code>readOnly</code> removes the button rather than disabling it: a control that cannot be used
should not invite a press. An operator with view rights still reads what each product is.</p>
<div class="demo-row">
<smart-recipe-panel id="viewer" label="Products (view only)" read-only></smart-recipe-panel>
</div>
</section>
</div>
</div>
</template>
<script>
import { onMounted } from "vue";
import "smart-industrial/source/styles/smart.default.css";
import "smart-industrial/source/styles/smart.industrial-elements.css";
import "smart-industrial/source/styles/smart.industrial.css";
import "smart-industrial/source/styles/smart.industrial-dark.css";
import "smart-industrial/source/modules/smart.recipepanel.js";
/* The recipe panel: the page holds the recipes and does the writing, as a real host would. */
export default {
name: "app",
setup() {
onMounted(() => {
/* The elements render after Vue has mounted them, and the demo's logic - written for
a page's load event - reads their parts, so it waits for every element in the tree
to have rendered first. */
const pending = Array.from(document.querySelector(".vue-root").querySelectorAll("*"))
.filter((element) => typeof element.whenRendered === "function" && !element.isRendered)
.map((element) => element.whenRendered());
Promise.all(pending).then(() => {
const RECIPES = [
{
id: 'product-a', name: 'Product A', note: 'the hot one',
entries: [
{ tag: 'plc.TIC-101.SP', label: 'Reactor temperature', value: 180, unit: '°C' },
{ tag: 'plc.PIC-201.SP', label: 'Header pressure', value: 42, unit: 'bar' },
{ tag: 'plc.FIC-104.SP', label: 'Feed flow', value: 430, unit: 'L/min' },
{ tag: 'plc.AG-212.SPD', label: 'Agitator speed', value: 1450, unit: 'rpm' },
{ tag: 'plc.SEQ.DWELL', label: 'Dwell', value: 90, unit: 's' }
]
},
{
id: 'product-b', name: 'Product B', note: 'the cold one',
entries: [
{ tag: 'plc.TIC-101.SP', label: 'Reactor temperature', value: 64, unit: '°C' },
{ tag: 'plc.PIC-201.SP', label: 'Header pressure', value: 18, unit: 'bar' },
{ tag: 'plc.FIC-104.SP', label: 'Feed flow', value: 210, unit: 'L/min' },
{ tag: 'plc.AG-212.SPD', label: 'Agitator speed', value: 900, unit: 'rpm' }
]
},
{
id: 'cip', name: 'Clean in place', note: 'between products',
entries: [
{ tag: 'plc.TIC-101.SP', label: 'Reactor temperature', value: 85, unit: '°C' },
{ tag: 'plc.FIC-104.SP', label: 'Feed flow', value: 600, unit: 'L/min' },
{ tag: 'plc.SEQ.DWELL', label: 'Dwell', value: 1200, unit: 's' }
]
},
{
id: 'standby', name: 'Standby', note: 'overnight',
entries: [
{ tag: 'plc.TIC-101.SP', label: 'Reactor temperature', value: 18, unit: '°C' },
{ tag: 'plc.AG-212.SPD', label: 'Agitator speed', value: 0, unit: 'rpm' }
]
}
];
const panel = document.getElementById('line3'),
viewer = document.getElementById('viewer'),
log = document.getElementById('log');
panel.recipes = RECIPES;
viewer.recipes = RECIPES;
viewer.selected = 'cip';
/* the plant, such as it is: one tag this page will refuse, to show a half-applied load */
let refuseTag = '';
/* The host writes. The panel asked; it did not act. */
panel.addEventListener('recipeApply', function (event) {
const asked = event.detail;
panel.busy = true;
log.textContent = 'Writing ' + asked.entries.length + ' values of "' + asked.name + '"…';
/* a real host would write through the station and wait for the confirmations */
setTimeout(function () {
const failed = asked.entries
.filter((entry) => entry.tag === refuseTag)
.map((entry) => ({ tag: entry.tag, error: entry.tag + ' is not writable' }));
panel.busy = false;
panel.result = {
name: asked.name,
applied: asked.entries.length - failed.length,
of: asked.entries.length,
failed: failed,
at: new Date().toISOString()
};
log.textContent = failed.length
? 'The plant refused ' + failed.length + ' of ' + asked.entries.length + ': ' + failed.map((f) => f.tag).join(', ')
: 'All ' + asked.entries.length + ' values written.';
}, 700);
});
panel.addEventListener('recipeSelect', function (event) {
log.textContent = 'Chose ' + event.detail.name + '.';
});
document.getElementById('straight').addEventListener('click', function () {
panel.confirm = !panel.confirm;
this.textContent = panel.confirm ? 'Load without asking' : 'Ask before loading';
log.textContent = panel.confirm ? 'Load asks first.' : 'Load writes straight away.';
});
document.getElementById('refuse').addEventListener('click', function () {
refuseTag = refuseTag ? '' : 'plc.AG-212.SPD';
this.textContent = refuseTag ? 'Let the plant take them all' : 'Make the plant refuse one';
log.textContent = refuseTag
? 'The agitator speed will be refused on the next load.'
: 'The plant will take every value again.';
});
document.getElementById('clear').addEventListener('click', function () {
panel.result = null;
log.textContent = 'Cleared.';
});
});
});
}
};
</script>
<style>
/* The panel is a real control on a screen, so it is given the room one would have. */
.demo-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
smart-recipe-panel {
width: 520px;
max-width: 100%;
height: 260px;
}
/* ── Demo chrome, shared by the Industrial demos ─────────────────────────
Headings, prose, buttons and notes are the same on every demo page. This block
is appended by scripts and comes last, so it wins over the rules above. */
.smart-demo-container {
padding: 4px 0 32px;
}
.smart-demo-container section {
margin-bottom: 36px;
}
.smart-demo-container section h2 {
margin: 0 0 8px;
font-size: 17px;
font-weight: 600;
line-height: 1.35;
color: var(--smart-text-color);
}
.smart-demo-container #overview h2 {
margin-bottom: 10px;
font-size: 20px;
max-width: 78ch;
}
.smart-demo-container section > p {
margin: 0 0 14px;
max-width: 84ch;
font-size: 14px;
line-height: 1.55;
color: var(--smart-text-secondary-color);
}
.smart-demo-container code {
padding: 1px 5px;
border-radius: 2px;
background: var(--smart-ui-state-hover);
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
}
.smart-demo-container .demo-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-top: 12px;
}
.smart-demo-container .demo-actions label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--smart-text-color);
}
.smart-demo-container .demo-actions button,
.smart-demo-container .demo-button {
min-height: 30px;
padding: 0 12px;
border: 1px solid var(--smart-border);
border-radius: 3px;
background: var(--smart-industrial-panel-background, var(--smart-surface));
color: var(--smart-text-color);
font: inherit;
font-size: 13px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
cursor: pointer;
}
.smart-demo-container .demo-actions button:hover,
.smart-demo-container .demo-button:hover {
border-color: var(--smart-ui-state-border-hover);
background: var(--smart-ui-state-hover);
color: var(--smart-ui-state-color-hover);
}
.smart-demo-container .demo-actions button.demo-button-primary,
.smart-demo-container .demo-button-primary {
border-color: var(--smart-primary);
background: var(--smart-primary);
color: var(--smart-primary-color, #fff);
}
.smart-demo-container .demo-actions button[disabled],
.smart-demo-container .demo-button[disabled] {
opacity: 0.5;
cursor: default;
}
.smart-demo-container .demo-actions select,
.smart-demo-container .demo-actions input[type="text"],
.smart-demo-container .demo-actions input[type="number"] {
min-height: 30px;
padding: 2px 6px;
border: 1px solid var(--smart-border);
border-radius: 3px;
background: var(--smart-background);
color: var(--smart-text-color);
font: inherit;
font-size: 13px;
}
.smart-demo-container .hint,
.smart-demo-container .demo-log,
.smart-demo-container .demo-counts {
margin: 10px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--smart-text-secondary-color);
opacity: 1;
}
.smart-demo-container .demo-log,
.smart-demo-container .demo-counts {
font-family: Consolas, "Courier New", monospace;
}
.smart-demo-container .log {
list-style: none;
margin: 10px 0 0;
padding: 0;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--smart-text-secondary-color);
opacity: 1;
}
.smart-demo-container .log li {
padding: 2px 0;
}
</style>
What the demo shows
- Choosing a product
- It never writes
- Read-only