@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.
Files changed (97) hide show
  1. package/README.md +20 -2
  2. package/docs/framework-authoring.md +59 -0
  3. package/lib/fallback.js +307 -0
  4. package/lib/index.js +3036 -0
  5. package/lib/runtime.js +2827 -0
  6. package/lib/semantic.js +96 -0
  7. package/lib/session.js +48 -0
  8. package/lib/settings-pages.js +68 -0
  9. package/lib/slots.js +95 -0
  10. package/lib/types/Shell.d.ts +13 -0
  11. package/lib/types/Shell.js +17 -0
  12. package/lib/types/client.d.ts +41 -0
  13. package/lib/types/client.js +39 -0
  14. package/lib/types/fallback.d.ts +15 -0
  15. package/lib/types/fallback.js +52 -0
  16. package/lib/types/index.d.ts +17 -0
  17. package/lib/types/index.js +17 -0
  18. package/lib/types/runtime.d.ts +53 -0
  19. package/lib/types/runtime.js +75 -0
  20. package/lib/types/semantic.d.ts +55 -0
  21. package/lib/types/semantic.js +63 -0
  22. package/lib/types/session.d.ts +43 -0
  23. package/lib/types/session.js +76 -0
  24. package/lib/types/settings-dialog.d.ts +17 -0
  25. package/lib/types/settings-dialog.js +138 -0
  26. package/lib/types/settings-pages.d.ts +68 -0
  27. package/lib/types/settings-pages.js +85 -0
  28. package/lib/types/settings-plugins-page.d.ts +29 -0
  29. package/lib/types/settings-plugins-page.js +466 -0
  30. package/lib/types/settings-update-page.d.ts +17 -0
  31. package/lib/types/settings-update-page.js +124 -0
  32. package/lib/types/slots.d.ts +114 -0
  33. package/lib/types/slots.js +119 -0
  34. package/lib/types/ui/badge.d.ts +17 -0
  35. package/lib/types/ui/badge.js +29 -0
  36. package/lib/types/ui/button.d.ts +20 -0
  37. package/lib/types/ui/button.js +39 -0
  38. package/lib/types/ui/card.d.ts +14 -0
  39. package/lib/types/ui/card.js +18 -0
  40. package/lib/types/ui/checkbox.d.ts +10 -0
  41. package/lib/types/ui/checkbox.js +13 -0
  42. package/lib/types/ui/collapsible.d.ts +24 -0
  43. package/lib/types/ui/collapsible.js +31 -0
  44. package/lib/types/ui/dialog.d.ts +22 -0
  45. package/lib/types/ui/dialog.js +29 -0
  46. package/lib/types/ui/drag-scroll.d.ts +13 -0
  47. package/lib/types/ui/drag-scroll.js +85 -0
  48. package/lib/types/ui/hooks.d.ts +16 -0
  49. package/lib/types/ui/hooks.js +26 -0
  50. package/lib/types/ui/icons.d.ts +21 -0
  51. package/lib/types/ui/icons.js +18 -0
  52. package/lib/types/ui/index.d.ts +32 -0
  53. package/lib/types/ui/index.js +32 -0
  54. package/lib/types/ui/input.d.ts +8 -0
  55. package/lib/types/ui/input.js +6 -0
  56. package/lib/types/ui/label.d.ts +9 -0
  57. package/lib/types/ui/label.js +12 -0
  58. package/lib/types/ui/led.d.ts +13 -0
  59. package/lib/types/ui/led.js +11 -0
  60. package/lib/types/ui/number-input.d.ts +35 -0
  61. package/lib/types/ui/number-input.js +48 -0
  62. package/lib/types/ui/progress.d.ts +10 -0
  63. package/lib/types/ui/progress.js +14 -0
  64. package/lib/types/ui/radio-group.d.ts +12 -0
  65. package/lib/types/ui/radio-group.js +17 -0
  66. package/lib/types/ui/schema-form.d.ts +51 -0
  67. package/lib/types/ui/schema-form.js +124 -0
  68. package/lib/types/ui/scroll-area.d.ts +10 -0
  69. package/lib/types/ui/scroll-area.js +13 -0
  70. package/lib/types/ui/switch.d.ts +10 -0
  71. package/lib/types/ui/switch.js +13 -0
  72. package/lib/types/ui/table.d.ts +20 -0
  73. package/lib/types/ui/table.js +28 -0
  74. package/lib/types/ui/tabs.d.ts +16 -0
  75. package/lib/types/ui/tabs.js +23 -0
  76. package/lib/types/ui/textarea.d.ts +10 -0
  77. package/lib/types/ui/textarea.js +6 -0
  78. package/lib/types/ui/theme.d.ts +30 -0
  79. package/lib/types/ui/theme.js +33 -0
  80. package/lib/types/ui/tooltip.d.ts +23 -0
  81. package/lib/types/ui/tooltip.js +15 -0
  82. package/lib/types/ui/touch-select.d.ts +29 -0
  83. package/lib/types/ui/touch-select.js +37 -0
  84. package/lib/types/ui/utils.d.ts +13 -0
  85. package/lib/types/ui/utils.js +16 -0
  86. package/lib/types/variables.d.ts +168 -0
  87. package/lib/types/variables.js +282 -0
  88. package/lib/types/workflows.d.ts +98 -0
  89. package/lib/types/workflows.js +95 -0
  90. package/lib/ui/index.js +1101 -0
  91. package/lib/ui/theme.css +271 -0
  92. package/lib/variables.js +260 -0
  93. package/lib/workflows.js +85 -0
  94. package/package.json +138 -3
  95. package/src/ui/theme.css +271 -0
  96. package/templates/ui-extension/README.md +17 -0
  97. package/templates/ui-view/README.md +17 -0
@@ -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
+ }
@@ -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 };
@@ -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 };