@snap-rail/framework-shadcn 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -2
- package/docs/framework-authoring.md +59 -0
- package/lib/fallback.js +307 -0
- package/lib/index.js +3036 -0
- package/lib/runtime.js +2827 -0
- package/lib/semantic.js +96 -0
- package/lib/session.js +48 -0
- package/lib/settings-pages.js +68 -0
- package/lib/slots.js +95 -0
- package/lib/types/Shell.d.ts +13 -0
- package/lib/types/Shell.js +17 -0
- package/lib/types/client.d.ts +41 -0
- package/lib/types/client.js +39 -0
- package/lib/types/fallback.d.ts +15 -0
- package/lib/types/fallback.js +52 -0
- package/lib/types/index.d.ts +17 -0
- package/lib/types/index.js +17 -0
- package/lib/types/runtime.d.ts +53 -0
- package/lib/types/runtime.js +75 -0
- package/lib/types/semantic.d.ts +55 -0
- package/lib/types/semantic.js +63 -0
- package/lib/types/session.d.ts +43 -0
- package/lib/types/session.js +76 -0
- package/lib/types/settings-dialog.d.ts +17 -0
- package/lib/types/settings-dialog.js +138 -0
- package/lib/types/settings-pages.d.ts +68 -0
- package/lib/types/settings-pages.js +85 -0
- package/lib/types/settings-plugins-page.d.ts +29 -0
- package/lib/types/settings-plugins-page.js +466 -0
- package/lib/types/settings-update-page.d.ts +17 -0
- package/lib/types/settings-update-page.js +124 -0
- package/lib/types/slots.d.ts +114 -0
- package/lib/types/slots.js +119 -0
- package/lib/types/ui/badge.d.ts +17 -0
- package/lib/types/ui/badge.js +29 -0
- package/lib/types/ui/button.d.ts +20 -0
- package/lib/types/ui/button.js +39 -0
- package/lib/types/ui/card.d.ts +14 -0
- package/lib/types/ui/card.js +18 -0
- package/lib/types/ui/checkbox.d.ts +10 -0
- package/lib/types/ui/checkbox.js +13 -0
- package/lib/types/ui/collapsible.d.ts +24 -0
- package/lib/types/ui/collapsible.js +31 -0
- package/lib/types/ui/dialog.d.ts +22 -0
- package/lib/types/ui/dialog.js +29 -0
- package/lib/types/ui/drag-scroll.d.ts +13 -0
- package/lib/types/ui/drag-scroll.js +85 -0
- package/lib/types/ui/hooks.d.ts +16 -0
- package/lib/types/ui/hooks.js +26 -0
- package/lib/types/ui/icons.d.ts +21 -0
- package/lib/types/ui/icons.js +18 -0
- package/lib/types/ui/index.d.ts +32 -0
- package/lib/types/ui/index.js +32 -0
- package/lib/types/ui/input.d.ts +8 -0
- package/lib/types/ui/input.js +6 -0
- package/lib/types/ui/label.d.ts +9 -0
- package/lib/types/ui/label.js +12 -0
- package/lib/types/ui/led.d.ts +13 -0
- package/lib/types/ui/led.js +11 -0
- package/lib/types/ui/number-input.d.ts +35 -0
- package/lib/types/ui/number-input.js +48 -0
- package/lib/types/ui/progress.d.ts +10 -0
- package/lib/types/ui/progress.js +14 -0
- package/lib/types/ui/radio-group.d.ts +12 -0
- package/lib/types/ui/radio-group.js +17 -0
- package/lib/types/ui/schema-form.d.ts +51 -0
- package/lib/types/ui/schema-form.js +124 -0
- package/lib/types/ui/scroll-area.d.ts +10 -0
- package/lib/types/ui/scroll-area.js +13 -0
- package/lib/types/ui/switch.d.ts +10 -0
- package/lib/types/ui/switch.js +13 -0
- package/lib/types/ui/table.d.ts +20 -0
- package/lib/types/ui/table.js +28 -0
- package/lib/types/ui/tabs.d.ts +16 -0
- package/lib/types/ui/tabs.js +23 -0
- package/lib/types/ui/textarea.d.ts +10 -0
- package/lib/types/ui/textarea.js +6 -0
- package/lib/types/ui/theme.d.ts +30 -0
- package/lib/types/ui/theme.js +33 -0
- package/lib/types/ui/tooltip.d.ts +23 -0
- package/lib/types/ui/tooltip.js +15 -0
- package/lib/types/ui/touch-select.d.ts +29 -0
- package/lib/types/ui/touch-select.js +37 -0
- package/lib/types/ui/utils.d.ts +13 -0
- package/lib/types/ui/utils.js +16 -0
- package/lib/types/variables.d.ts +168 -0
- package/lib/types/variables.js +282 -0
- package/lib/types/workflows.d.ts +98 -0
- package/lib/types/workflows.js +95 -0
- package/lib/ui/index.js +1101 -0
- package/lib/ui/theme.css +271 -0
- package/lib/variables.js +260 -0
- package/lib/workflows.js +85 -0
- package/package.json +138 -3
- package/src/ui/theme.css +271 -0
- package/templates/ui-extension/README.md +17 -0
- package/templates/ui-view/README.md +17 -0
package/lib/ui/theme.css
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The snap-rail theme: Tailwind v4 tokens carrying the industrial HMI
|
|
3
|
+
* palette. Two modes share one token vocabulary — the dark graphite-steel
|
|
4
|
+
* set is the @theme default, `html[data-mode='light']` overrides the same
|
|
5
|
+
* tokens with the fog-white shop-floor set (accent: cobalt #4a6cff/#3857e8,
|
|
6
|
+
* picked in docs/design/theme-samples.html). Data series colors live in
|
|
7
|
+
* --color-chart-1…6 (see docs/theme.md); red/amber/green stay reserved for
|
|
8
|
+
* state semantics. Class scanning is explicit (auto-detection roots at
|
|
9
|
+
* the Vite cwd, which is the app, not the workspace), @source globs
|
|
10
|
+
* resolve relative to this file, and gitignore is respected — build
|
|
11
|
+
* outputs under lib/lib-client/dist are skipped naturally. One broad
|
|
12
|
+
* glob covers every package's sources (spine client faces, suites,
|
|
13
|
+
* field stations alike), so the occupant roster is never re-listed
|
|
14
|
+
* here; the theme.spec gate fails loud if a package falls outside it.
|
|
15
|
+
*/
|
|
16
|
+
@import "tailwindcss";
|
|
17
|
+
|
|
18
|
+
@source "../../../../**/src/**/*.{ts,tsx}";
|
|
19
|
+
|
|
20
|
+
@theme {
|
|
21
|
+
--color-background: #0b0f14;
|
|
22
|
+
--color-foreground: #ced8e3;
|
|
23
|
+
|
|
24
|
+
--color-card: #11161d;
|
|
25
|
+
--color-card-foreground: #ced8e3;
|
|
26
|
+
|
|
27
|
+
--color-popover: #161b22;
|
|
28
|
+
--color-popover-foreground: #ced8e3;
|
|
29
|
+
|
|
30
|
+
--color-primary: #4a6cff;
|
|
31
|
+
--color-primary-foreground: #ffffff;
|
|
32
|
+
|
|
33
|
+
--color-secondary: #1c2430;
|
|
34
|
+
--color-secondary-foreground: #ced8e3;
|
|
35
|
+
|
|
36
|
+
--color-muted: #161b22;
|
|
37
|
+
--color-muted-foreground: #8b949e;
|
|
38
|
+
|
|
39
|
+
--color-accent: #1c2430;
|
|
40
|
+
--color-accent-foreground: #e8eef4;
|
|
41
|
+
|
|
42
|
+
--color-destructive: #f85149;
|
|
43
|
+
--color-destructive-foreground: #ffffff;
|
|
44
|
+
|
|
45
|
+
--color-success: #3fb950;
|
|
46
|
+
--color-success-foreground: #0b0f14;
|
|
47
|
+
|
|
48
|
+
--color-warning: #d29922;
|
|
49
|
+
--color-warning-foreground: #0b0f14;
|
|
50
|
+
|
|
51
|
+
--color-border: #1f2933;
|
|
52
|
+
--color-input: #1f2933;
|
|
53
|
+
--color-ring: #4a6cff;
|
|
54
|
+
|
|
55
|
+
/* Data series (docs/theme.md): series 1 = primary hue, 2/3 derived,
|
|
56
|
+
* 4-6 the steel-gray ramp. Not for state semantics. */
|
|
57
|
+
--color-chart-1: #4a6cff;
|
|
58
|
+
--color-chart-2: #3147a1;
|
|
59
|
+
--color-chart-3: #667fcd;
|
|
60
|
+
--color-chart-4: #7d8fa5;
|
|
61
|
+
--color-chart-5: #55677c;
|
|
62
|
+
--color-chart-6: #33414f;
|
|
63
|
+
|
|
64
|
+
--radius-sm: 6px;
|
|
65
|
+
--radius-md: 8px;
|
|
66
|
+
--radius-lg: 12px;
|
|
67
|
+
|
|
68
|
+
--font-sans: system-ui, "Segoe UI", "Microsoft YaHei", sans-serif;
|
|
69
|
+
--font-mono: "Cascadia Code", "Cascadia Mono", Consolas, ui-monospace, monospace;
|
|
70
|
+
--text-xs: 0.8125rem;
|
|
71
|
+
--text-sm: 0.9375rem;
|
|
72
|
+
--text-base: 1.0625rem;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@layer base {
|
|
76
|
+
:root {
|
|
77
|
+
color-scheme: dark;
|
|
78
|
+
/* Glow strengths (color-mix percentages) — brighter at night, restrained
|
|
79
|
+
* under shop lights. Consumed by the glow-number/glow-bar utilities. */
|
|
80
|
+
--glow-a: 55%;
|
|
81
|
+
--glow-b: 20%;
|
|
82
|
+
--bar-glow: 45%;
|
|
83
|
+
--app-gradient: radial-gradient(
|
|
84
|
+
130% 100% at 50% 0%,
|
|
85
|
+
color-mix(in srgb, var(--color-primary) 8%, var(--color-background)) 0%,
|
|
86
|
+
var(--color-background) 62%
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
html[data-mode='light'] {
|
|
91
|
+
color-scheme: light;
|
|
92
|
+
|
|
93
|
+
--color-background: #e4e9ef;
|
|
94
|
+
--color-foreground: #26323e;
|
|
95
|
+
|
|
96
|
+
--color-card: #f3f6f9;
|
|
97
|
+
--color-card-foreground: #26323e;
|
|
98
|
+
|
|
99
|
+
--color-popover: #ffffff;
|
|
100
|
+
--color-popover-foreground: #26323e;
|
|
101
|
+
|
|
102
|
+
--color-primary: #3857e8;
|
|
103
|
+
--color-primary-foreground: #ffffff;
|
|
104
|
+
|
|
105
|
+
--color-secondary: #dde4ec;
|
|
106
|
+
--color-secondary-foreground: #26323e;
|
|
107
|
+
|
|
108
|
+
--color-muted: #e6ebf1;
|
|
109
|
+
--color-muted-foreground: #5d6d7e;
|
|
110
|
+
|
|
111
|
+
--color-accent: #dde4ec;
|
|
112
|
+
--color-accent-foreground: #101b26;
|
|
113
|
+
|
|
114
|
+
--color-destructive: #dc2626;
|
|
115
|
+
--color-destructive-foreground: #ffffff;
|
|
116
|
+
|
|
117
|
+
--color-success: #15803d;
|
|
118
|
+
--color-success-foreground: #ffffff;
|
|
119
|
+
|
|
120
|
+
--color-warning: #b45309;
|
|
121
|
+
--color-warning-foreground: #ffffff;
|
|
122
|
+
|
|
123
|
+
--color-border: #c7d0da;
|
|
124
|
+
--color-input: #c7d0da;
|
|
125
|
+
--color-ring: #3857e8;
|
|
126
|
+
|
|
127
|
+
--color-chart-1: #3857e8;
|
|
128
|
+
--color-chart-2: #7088ee;
|
|
129
|
+
--color-chart-3: #667dd0;
|
|
130
|
+
--color-chart-4: #94a3b8;
|
|
131
|
+
--color-chart-5: #b3c0cd;
|
|
132
|
+
--color-chart-6: #ccd6e0;
|
|
133
|
+
|
|
134
|
+
--glow-a: 20%;
|
|
135
|
+
--glow-b: 7%;
|
|
136
|
+
--bar-glow: 30%;
|
|
137
|
+
--app-gradient: radial-gradient(
|
|
138
|
+
130% 100% at 50% 0%,
|
|
139
|
+
color-mix(in srgb, var(--color-primary) 6%, var(--color-background)) 0%,
|
|
140
|
+
var(--color-background) 62%
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
* {
|
|
145
|
+
border-color: var(--color-border);
|
|
146
|
+
}
|
|
147
|
+
html,
|
|
148
|
+
body,
|
|
149
|
+
#root {
|
|
150
|
+
height: 100%;
|
|
151
|
+
}
|
|
152
|
+
body {
|
|
153
|
+
background-color: var(--color-background);
|
|
154
|
+
background-image: var(--app-gradient);
|
|
155
|
+
color: var(--color-foreground);
|
|
156
|
+
font-family: var(--font-sans);
|
|
157
|
+
font-size: var(--text-base);
|
|
158
|
+
margin: 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* Chunky themed scrollbars: grabbable on a touch panel, tinted per mode. */
|
|
162
|
+
::-webkit-scrollbar {
|
|
163
|
+
width: 12px;
|
|
164
|
+
height: 12px;
|
|
165
|
+
}
|
|
166
|
+
::-webkit-scrollbar-track,
|
|
167
|
+
::-webkit-scrollbar-corner {
|
|
168
|
+
background: transparent;
|
|
169
|
+
}
|
|
170
|
+
::-webkit-scrollbar-thumb {
|
|
171
|
+
border: 3px solid transparent;
|
|
172
|
+
border-radius: 999px;
|
|
173
|
+
background: color-mix(in srgb, var(--color-muted-foreground) 32%, transparent);
|
|
174
|
+
background-clip: content-box;
|
|
175
|
+
}
|
|
176
|
+
::-webkit-scrollbar-thumb:hover {
|
|
177
|
+
background: color-mix(in srgb, var(--color-muted-foreground) 48%, transparent);
|
|
178
|
+
background-clip: content-box;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* A DragScroll container sets this while a mouse pan is active. */
|
|
182
|
+
[data-panning='true'] {
|
|
183
|
+
cursor: grabbing;
|
|
184
|
+
user-select: none;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* Modal entrance (Dialog surfaces); exit stays instant. */
|
|
188
|
+
.overlay-in {
|
|
189
|
+
animation: snap-overlay-in 0.15s ease-out;
|
|
190
|
+
}
|
|
191
|
+
.dialog-in {
|
|
192
|
+
animation: snap-dialog-in 0.18s ease-out;
|
|
193
|
+
}
|
|
194
|
+
@keyframes snap-overlay-in {
|
|
195
|
+
from {
|
|
196
|
+
opacity: 0;
|
|
197
|
+
}
|
|
198
|
+
to {
|
|
199
|
+
opacity: 1;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/* Centering lives on Tailwind v4's `translate` property (not `transform`),
|
|
203
|
+
* so keyframes must only animate scale/opacity — a translate here would
|
|
204
|
+
* stack with the utility and fling the panel off-center during entry. */
|
|
205
|
+
@keyframes snap-dialog-in {
|
|
206
|
+
from {
|
|
207
|
+
opacity: 0;
|
|
208
|
+
transform: scale(0.97);
|
|
209
|
+
}
|
|
210
|
+
to {
|
|
211
|
+
opacity: 1;
|
|
212
|
+
transform: scale(1);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
@media (prefers-reduced-motion: reduce) {
|
|
216
|
+
.overlay-in,
|
|
217
|
+
.dialog-in {
|
|
218
|
+
animation: none;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
@utility drag-region {
|
|
224
|
+
-webkit-app-region: drag;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
@utility no-drag {
|
|
228
|
+
-webkit-app-region: no-drag;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* Hero readouts: large tabular numerals pick up a primary-tinted halo. */
|
|
232
|
+
@utility glow-number {
|
|
233
|
+
text-shadow:
|
|
234
|
+
0 0 14px color-mix(in srgb, var(--color-primary) var(--glow-a), transparent),
|
|
235
|
+
0 0 44px color-mix(in srgb, var(--color-primary) var(--glow-b), transparent);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* Chart bodies: the series-1 bars get a soft light bloom. */
|
|
239
|
+
@utility glow-bar {
|
|
240
|
+
box-shadow: 0 0 12px color-mix(in srgb, var(--color-chart-1) var(--bar-glow), transparent);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* The selected-channel keyline: a 3px primary rail on the inline-start edge. */
|
|
244
|
+
@utility channel-keyline {
|
|
245
|
+
box-shadow: inset 3px 0 0 0 var(--color-primary);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/*
|
|
249
|
+
* Workflow sidebar attention effects. One keyframe pair serves every
|
|
250
|
+
* breathing light: the element sets --breathe-color (a CSS color) and
|
|
251
|
+
* --breathe-period (a duration); the glow ring pulses around a constant
|
|
252
|
+
* inner keyline so the item stays identifiable at the dim end.
|
|
253
|
+
*/
|
|
254
|
+
.breathe {
|
|
255
|
+
--breathe-color: var(--color-destructive);
|
|
256
|
+
--breathe-period: 2.4s;
|
|
257
|
+
animation: snap-breathe var(--breathe-period) ease-in-out infinite;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
@keyframes snap-breathe {
|
|
261
|
+
0%, 100% {
|
|
262
|
+
box-shadow:
|
|
263
|
+
inset 0 0 0 1px color-mix(in srgb, var(--breathe-color) 60%, transparent),
|
|
264
|
+
0 0 2px 0 color-mix(in srgb, var(--breathe-color) 25%, transparent);
|
|
265
|
+
}
|
|
266
|
+
50% {
|
|
267
|
+
box-shadow:
|
|
268
|
+
inset 0 0 0 2px var(--breathe-color),
|
|
269
|
+
0 0 16px 4px color-mix(in srgb, var(--breathe-color) 70%, transparent);
|
|
270
|
+
}
|
|
271
|
+
}
|
package/lib/variables.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import "@snap-rail/cordis";
|
|
2
|
+
import { subscribeFrame } from "@snap-rail/connection";
|
|
3
|
+
import { equipmentFrameSchemas, pointKey } from "@snap-rail/equipment/contract";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
//#region lib/types/variables.js
|
|
6
|
+
/**
|
|
7
|
+
* Mounts the `ctx.variables` registry. All state lives in construction-time
|
|
8
|
+
* closures: traceable context proxies rebind `this` on every method access,
|
|
9
|
+
* so `this`-reached state cannot back a service here (the uiSlots lesson).
|
|
10
|
+
*/
|
|
11
|
+
const variablesPlugin = {
|
|
12
|
+
name: "client-variables",
|
|
13
|
+
apply(ctx) {
|
|
14
|
+
const variables = /* @__PURE__ */ new Map();
|
|
15
|
+
ctx.provide("variables", {
|
|
16
|
+
list() {
|
|
17
|
+
return [...variables.values()];
|
|
18
|
+
},
|
|
19
|
+
register(caller, defs) {
|
|
20
|
+
const keys = defs.map((def) => pointKey(def));
|
|
21
|
+
if (new Set(keys).size !== keys.length) throw new Error(`variables: duplicate addresses in one registration: ${keys.join(", ")}`);
|
|
22
|
+
const installed = [];
|
|
23
|
+
for (const def of defs) {
|
|
24
|
+
const entry = {
|
|
25
|
+
...def,
|
|
26
|
+
source: "plugin"
|
|
27
|
+
};
|
|
28
|
+
const key = pointKey(def);
|
|
29
|
+
installed.push({
|
|
30
|
+
key,
|
|
31
|
+
entry
|
|
32
|
+
});
|
|
33
|
+
variables.set(key, entry);
|
|
34
|
+
}
|
|
35
|
+
ctx.emit("variables/changed");
|
|
36
|
+
const remove = () => {
|
|
37
|
+
let changed = false;
|
|
38
|
+
for (const { key, entry } of installed) if (variables.get(key) === entry) {
|
|
39
|
+
variables.delete(key);
|
|
40
|
+
changed = true;
|
|
41
|
+
}
|
|
42
|
+
if (changed) ctx.emit("variables/changed");
|
|
43
|
+
};
|
|
44
|
+
caller.effect(() => remove);
|
|
45
|
+
return remove;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Resolve a binding against the generic mapping document. The device must
|
|
52
|
+
* exist, the group entity must exist on it, and the point form must find
|
|
53
|
+
* the point in exactly that group — an unknown group name must read as
|
|
54
|
+
* unresolved, never as "all normal". A group resolves with at least one
|
|
55
|
+
* member, in the document's order (the driver's projection defines it; the
|
|
56
|
+
* first active member is a stable "primary").
|
|
57
|
+
*/
|
|
58
|
+
function resolveBinding(doc, binding) {
|
|
59
|
+
if (!doc.devices.some((device) => device.id === binding.device)) return void 0;
|
|
60
|
+
if (!doc.groups.some((group) => group.deviceId === binding.device && group.name === binding.group)) return void 0;
|
|
61
|
+
if ("name" in binding) {
|
|
62
|
+
if (!doc.points.some((point) => point.deviceId === binding.device && point.group === binding.group && point.name === binding.name)) return void 0;
|
|
63
|
+
return {
|
|
64
|
+
kind: "point",
|
|
65
|
+
ref: {
|
|
66
|
+
device: binding.device,
|
|
67
|
+
group: binding.group,
|
|
68
|
+
name: binding.name
|
|
69
|
+
},
|
|
70
|
+
name: binding.name
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const members = doc.points.filter((point) => point.deviceId === binding.device && point.group === binding.group).map((point) => ({
|
|
74
|
+
ref: {
|
|
75
|
+
device: point.deviceId,
|
|
76
|
+
group: point.group,
|
|
77
|
+
name: point.name
|
|
78
|
+
},
|
|
79
|
+
name: point.name
|
|
80
|
+
}));
|
|
81
|
+
if (members.length === 0) return void 0;
|
|
82
|
+
return {
|
|
83
|
+
kind: "group",
|
|
84
|
+
device: binding.device,
|
|
85
|
+
group: binding.group,
|
|
86
|
+
members
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Any-active truthiness: `true`, a nonzero number/bigint, a non-empty string. */
|
|
90
|
+
function isActiveValue(value) {
|
|
91
|
+
if (value === null) return false;
|
|
92
|
+
if (typeof value === "boolean") return value;
|
|
93
|
+
if (typeof value === "bigint") return value !== 0n;
|
|
94
|
+
if (typeof value === "number") return value !== 0;
|
|
95
|
+
return value.length > 0;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Watch one binding and emit a `BindingView` on every change. The watcher
|
|
99
|
+
* resolves the generic mapping document itself, seeds current values with
|
|
100
|
+
* one `field.points.read`, follows `field/point-updated` frames, and
|
|
101
|
+
* re-resolves on a `field/mappings-changed` frame — re-mapping takes effect
|
|
102
|
+
* without remounting the consumer.
|
|
103
|
+
*
|
|
104
|
+
* The returned stop function drops this watcher's subscription references;
|
|
105
|
+
* the host counts references per address, so overlapping watchers of one
|
|
106
|
+
* address never starve each other.
|
|
107
|
+
*/
|
|
108
|
+
function watchBinding(ctx, binding, listener) {
|
|
109
|
+
const { link } = ctx.client;
|
|
110
|
+
let live = true;
|
|
111
|
+
let resolved;
|
|
112
|
+
let refs = [];
|
|
113
|
+
const samples = /* @__PURE__ */ new Map();
|
|
114
|
+
const compute = () => {
|
|
115
|
+
if (resolved === void 0) {
|
|
116
|
+
listener({ kind: "unresolved" });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (resolved.kind === "point") {
|
|
120
|
+
listener({
|
|
121
|
+
kind: "point",
|
|
122
|
+
ref: resolved.ref,
|
|
123
|
+
name: resolved.name,
|
|
124
|
+
sample: samples.get(pointKey(resolved.ref))
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const active = [];
|
|
129
|
+
const abnormal = [];
|
|
130
|
+
const pending = [];
|
|
131
|
+
for (const member of resolved.members) {
|
|
132
|
+
const sample = samples.get(pointKey(member.ref));
|
|
133
|
+
if (sample === void 0 || sample.value === null && sample.time === 0) pending.push(member.name);
|
|
134
|
+
else if (sample.value === null) abnormal.push(member.name);
|
|
135
|
+
else if (isActiveValue(sample.value)) active.push({
|
|
136
|
+
name: member.name,
|
|
137
|
+
value: sample.value,
|
|
138
|
+
time: sample.time
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
listener({
|
|
142
|
+
kind: "group",
|
|
143
|
+
device: resolved.device,
|
|
144
|
+
group: resolved.group,
|
|
145
|
+
status: active.length > 0 ? "active" : "normal",
|
|
146
|
+
active,
|
|
147
|
+
abnormal,
|
|
148
|
+
pending
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
const resubscribe = () => {
|
|
152
|
+
link.call("equipment.mappings.list", {}).then((result) => {
|
|
153
|
+
if (!live || !result.ok) return;
|
|
154
|
+
const next = resolveBinding(result.value.mappings, binding);
|
|
155
|
+
const nextRefs = next === void 0 ? [] : next.kind === "point" ? [next.ref] : next.members.map((member) => member.ref);
|
|
156
|
+
const wanted = new Set(nextRefs.map(pointKey));
|
|
157
|
+
for (const ref of refs) if (!wanted.has(pointKey(ref))) link.call("equipment.points.unsubscribe", { points: [ref] }).catch(() => {});
|
|
158
|
+
for (const ref of nextRefs) if (!refs.some((current) => pointKey(current) === pointKey(ref))) link.call("equipment.points.subscribe", { points: [ref] }).catch(() => {});
|
|
159
|
+
refs = nextRefs;
|
|
160
|
+
resolved = next;
|
|
161
|
+
samples.clear();
|
|
162
|
+
if (nextRefs.length === 0) {
|
|
163
|
+
compute();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
link.call("equipment.points.read", { points: [...nextRefs] }).then((read) => {
|
|
167
|
+
if (!live) return;
|
|
168
|
+
if (read.ok) for (const sample of read.value.samples) samples.set(pointKey(sample), {
|
|
169
|
+
value: sample.value,
|
|
170
|
+
time: sample.time
|
|
171
|
+
});
|
|
172
|
+
compute();
|
|
173
|
+
}).catch(() => {
|
|
174
|
+
if (live) compute();
|
|
175
|
+
});
|
|
176
|
+
}).catch(() => {});
|
|
177
|
+
};
|
|
178
|
+
const detachUpdates = subscribeFrame(link, "equipment/point-updated", equipmentFrameSchemas["equipment/point-updated"], (frame) => {
|
|
179
|
+
const ref = {
|
|
180
|
+
device: frame.device,
|
|
181
|
+
group: frame.group,
|
|
182
|
+
name: frame.name
|
|
183
|
+
};
|
|
184
|
+
if (!refs.some((current) => pointKey(current) === pointKey(ref))) return;
|
|
185
|
+
samples.set(pointKey(ref), {
|
|
186
|
+
value: frame.value,
|
|
187
|
+
time: frame.time
|
|
188
|
+
});
|
|
189
|
+
compute();
|
|
190
|
+
});
|
|
191
|
+
const detachConfig = link.subscribe("equipment/mappings-changed", () => {
|
|
192
|
+
resubscribe();
|
|
193
|
+
});
|
|
194
|
+
resubscribe();
|
|
195
|
+
return () => {
|
|
196
|
+
live = false;
|
|
197
|
+
detachUpdates();
|
|
198
|
+
detachConfig();
|
|
199
|
+
if (refs.length > 0) link.call("equipment.points.unsubscribe", { points: [...refs] }).catch(() => {});
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/** Stable identity of a binding for hook dependency lists. */
|
|
203
|
+
function bindingKey(binding) {
|
|
204
|
+
return "name" in binding ? `${binding.device}/${binding.group}/${binding.name}` : `${binding.device}/${binding.group}`;
|
|
205
|
+
}
|
|
206
|
+
/** The React recipe over `watchBinding`; `unresolved` until first observation. */
|
|
207
|
+
function useBinding(ctx, binding) {
|
|
208
|
+
const [view, setView] = useState({ kind: "unresolved" });
|
|
209
|
+
const { link } = ctx.client;
|
|
210
|
+
const key = bindingKey(binding);
|
|
211
|
+
useEffect(() => watchBinding(ctx, binding, setView), [link, key]);
|
|
212
|
+
return view;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The consume recipe for demand plugins: seed the current value with a
|
|
216
|
+
* `field.points.read`, then follow `field/point-updated` increments. Returns
|
|
217
|
+
* `undefined` until the first observation — an unmapped declaration stays
|
|
218
|
+
* undefined.
|
|
219
|
+
*
|
|
220
|
+
* The hook holds one subscription reference for its lifetime; the matching
|
|
221
|
+
* `field.points.unsubscribe` fires on unmount, and the host's reference
|
|
222
|
+
* counting keeps other watchers of the same address live.
|
|
223
|
+
*/
|
|
224
|
+
function usePoint(ctx, binding) {
|
|
225
|
+
const [sample, setSample] = useState(void 0);
|
|
226
|
+
const { link } = ctx.client;
|
|
227
|
+
const key = pointKey(binding);
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
let live = true;
|
|
230
|
+
const detach = subscribeFrame(link, "equipment/point-updated", equipmentFrameSchemas["equipment/point-updated"], (frame) => {
|
|
231
|
+
const frameRef = {
|
|
232
|
+
device: frame.device,
|
|
233
|
+
group: frame.group,
|
|
234
|
+
name: frame.name
|
|
235
|
+
};
|
|
236
|
+
if (pointKey(frameRef) !== key) return;
|
|
237
|
+
if (live) setSample({
|
|
238
|
+
value: frame.value,
|
|
239
|
+
time: frame.time
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
link.call("equipment.points.subscribe", { points: [binding] }).catch(() => {});
|
|
243
|
+
link.call("equipment.points.read", { points: [binding] }).then((result) => {
|
|
244
|
+
if (!live || !result.ok) return;
|
|
245
|
+
const first = result.value.samples[0];
|
|
246
|
+
if (first !== void 0) setSample({
|
|
247
|
+
value: first.value,
|
|
248
|
+
time: first.time
|
|
249
|
+
});
|
|
250
|
+
}).catch(() => {});
|
|
251
|
+
return () => {
|
|
252
|
+
live = false;
|
|
253
|
+
detach();
|
|
254
|
+
link.call("equipment.points.unsubscribe", { points: [binding] }).catch(() => {});
|
|
255
|
+
};
|
|
256
|
+
}, [link, key]);
|
|
257
|
+
return sample;
|
|
258
|
+
}
|
|
259
|
+
//#endregion
|
|
260
|
+
export { variablesPlugin as default, resolveBinding, useBinding, usePoint, watchBinding };
|
package/lib/workflows.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import "@snap-rail/cordis";
|
|
2
|
+
//#region lib/types/workflows.js
|
|
3
|
+
/** Whether two epoch-ms times fall on the same local calendar day. */
|
|
4
|
+
function sameLocalDay(a, b) {
|
|
5
|
+
return new Date(a).toDateString() === new Date(b).toDateString();
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Mounts the `ctx.workflows` registry. All state lives in construction-time
|
|
9
|
+
* closures: traceable context proxies rebind `this` on every method access,
|
|
10
|
+
* so `this`-reached state cannot back a service here (the uiSlots lesson).
|
|
11
|
+
*/
|
|
12
|
+
const workflowsPlugin = {
|
|
13
|
+
name: "client-workflows",
|
|
14
|
+
apply(ctx) {
|
|
15
|
+
const workflows = /* @__PURE__ */ new Map();
|
|
16
|
+
let nextSeq = 0;
|
|
17
|
+
let activeId;
|
|
18
|
+
let operator = null;
|
|
19
|
+
let events = [];
|
|
20
|
+
const satisfies = (requirement) => events.some((event) => event.action === requirement.action && (requirement.scope === "day" ? sameLocalDay(event.time, Date.now()) : event.actor === operator && sameLocalDay(event.time, Date.now())));
|
|
21
|
+
const changed = () => {
|
|
22
|
+
ctx.emit("workflow/changed");
|
|
23
|
+
};
|
|
24
|
+
ctx.provide("workflows", {
|
|
25
|
+
list() {
|
|
26
|
+
return [...workflows.values()].sort((a, b) => a.def.order - b.def.order || a.seq - b.seq).map(({ def, alert }) => ({
|
|
27
|
+
...def,
|
|
28
|
+
unlocked: def.requires.every(satisfies),
|
|
29
|
+
active: def.id === activeId,
|
|
30
|
+
alert
|
|
31
|
+
}));
|
|
32
|
+
},
|
|
33
|
+
register(caller, def) {
|
|
34
|
+
workflows.set(def.id, {
|
|
35
|
+
def,
|
|
36
|
+
alert: null,
|
|
37
|
+
seq: nextSeq++
|
|
38
|
+
});
|
|
39
|
+
changed();
|
|
40
|
+
const remove = () => {
|
|
41
|
+
if (workflows.delete(def.id)) {
|
|
42
|
+
if (activeId === def.id) activeId = void 0;
|
|
43
|
+
changed();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
caller.effect(() => remove);
|
|
47
|
+
return remove;
|
|
48
|
+
},
|
|
49
|
+
setActive(id) {
|
|
50
|
+
if (activeId === id) return;
|
|
51
|
+
activeId = id;
|
|
52
|
+
changed();
|
|
53
|
+
},
|
|
54
|
+
setAlert(id, alert) {
|
|
55
|
+
const entry = workflows.get(id);
|
|
56
|
+
if (entry === void 0 || entry.alert === alert) return;
|
|
57
|
+
if (alert !== null && alert.kind === "halo") alert = {
|
|
58
|
+
kind: "halo",
|
|
59
|
+
progress: Math.min(1, Math.max(0, alert.progress))
|
|
60
|
+
};
|
|
61
|
+
entry.alert = alert;
|
|
62
|
+
changed();
|
|
63
|
+
},
|
|
64
|
+
setOperator(next) {
|
|
65
|
+
if (operator === next) return;
|
|
66
|
+
operator = next;
|
|
67
|
+
changed();
|
|
68
|
+
},
|
|
69
|
+
feedEvents(next) {
|
|
70
|
+
events = [...next].sort((a, b) => a.time - b.time);
|
|
71
|
+
changed();
|
|
72
|
+
},
|
|
73
|
+
announce(action, actor) {
|
|
74
|
+
events = [...events, {
|
|
75
|
+
action,
|
|
76
|
+
actor: actor ?? operator ?? "client",
|
|
77
|
+
time: Date.now()
|
|
78
|
+
}];
|
|
79
|
+
changed();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
//#endregion
|
|
85
|
+
export { workflowsPlugin as default };
|