@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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The variable seam: demand plugins declare the process variables they
|
|
3
|
+
* listen to — a device, a group, a name, and a semantic type, nothing
|
|
4
|
+
* protocol-specific — and mapping surfaces (a driver's settings page) list
|
|
5
|
+
* the registry to wire those declarations onto real register addresses.
|
|
6
|
+
* Point names are unique only within their group, so a declaration is
|
|
7
|
+
* itself the full address — the same (device, group, name) triple the field
|
|
8
|
+
* seam speaks natively; consumers ride `field.points.subscribe` /
|
|
9
|
+
* `field/point-updated` with no id munging.
|
|
10
|
+
*
|
|
11
|
+
* Bindings (below the registry) generalize consumption: a page addresses
|
|
12
|
+
* field data as a device-qualified point (`{device, group, name}`) or a
|
|
13
|
+
* device's whole business group (`{device, group}`) and gets a live
|
|
14
|
+
* aggregated view (any member active ⇒ the group is active with the member
|
|
15
|
+
* names). Group membership is mapping configuration served by the field
|
|
16
|
+
* seam's generic mapping document (`field.mappings.list`, dialect-free), so
|
|
17
|
+
* a `field/mappings-changed` frame re-resolves the binding in place.
|
|
18
|
+
*
|
|
19
|
+
* @module @snap-rail/framework-shadcn/variables
|
|
20
|
+
*/
|
|
21
|
+
import { Context, type Plugin } from '@snap-rail/cordis';
|
|
22
|
+
import { type MappingDocument, type PointRef, type PointType, type PointValue } from '@snap-rail/equipment/contract';
|
|
23
|
+
/** What one demand plugin contributes: the variable it wants to hear about,
|
|
24
|
+
* addressed by its full triple. */
|
|
25
|
+
export interface VariableDef {
|
|
26
|
+
/** The device the variable lives on. */
|
|
27
|
+
device: string;
|
|
28
|
+
/** The business group the variable lives in. */
|
|
29
|
+
group: string;
|
|
30
|
+
/** Point name within the group (unique there, not globally). */
|
|
31
|
+
name: string;
|
|
32
|
+
/** Semantic value type (the field seam's type vocabulary). */
|
|
33
|
+
type: PointType;
|
|
34
|
+
/** Human-facing label for mapping surfaces. */
|
|
35
|
+
title?: string;
|
|
36
|
+
}
|
|
37
|
+
/** A registered variable as mapping surfaces see it. */
|
|
38
|
+
export interface VariableEntry extends VariableDef {
|
|
39
|
+
/** Where the declaration came from (phase 1: plugin code only). */
|
|
40
|
+
source: 'plugin';
|
|
41
|
+
}
|
|
42
|
+
/** Variable registry exposed as `ctx.variables`. */
|
|
43
|
+
export interface VariablesService {
|
|
44
|
+
/** Registered variables in registration order. */
|
|
45
|
+
list(): readonly VariableEntry[];
|
|
46
|
+
/** Register variables; disposal (caller unload) removes them all.
|
|
47
|
+
* The (device, group, name) triple is the uniqueness contract: a
|
|
48
|
+
* same-address re-registration replaces the earlier one.
|
|
49
|
+
* @param caller - owning context; unload drops every def in the call.
|
|
50
|
+
* @param defs - the declarations; duplicate addresses inside one call throw.
|
|
51
|
+
*/
|
|
52
|
+
register(caller: Context, defs: readonly VariableDef[]): () => void;
|
|
53
|
+
}
|
|
54
|
+
declare module '@snap-rail/cordis' {
|
|
55
|
+
interface Context {
|
|
56
|
+
variables: VariablesService;
|
|
57
|
+
}
|
|
58
|
+
interface Events {
|
|
59
|
+
/** The variable registry changed (add, replace, or removal). */
|
|
60
|
+
'variables/changed'(): void;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Mounts the `ctx.variables` registry. All state lives in construction-time
|
|
65
|
+
* closures: traceable context proxies rebind `this` on every method access,
|
|
66
|
+
* so `this`-reached state cannot back a service here (the uiSlots lesson).
|
|
67
|
+
*/
|
|
68
|
+
declare const variablesPlugin: Plugin.Object<void>;
|
|
69
|
+
export default variablesPlugin;
|
|
70
|
+
/** How a demand page addresses field data: one device-qualified point or a
|
|
71
|
+
* device's whole business group. The triple is mandatory — point names are
|
|
72
|
+
* group-scoped, so a bare name cannot address anything. */
|
|
73
|
+
export type FieldBinding = {
|
|
74
|
+
device: string;
|
|
75
|
+
group: string;
|
|
76
|
+
name: string;
|
|
77
|
+
} | {
|
|
78
|
+
device: string;
|
|
79
|
+
group: string;
|
|
80
|
+
};
|
|
81
|
+
/** One resolved group member: its field address plus display name. */
|
|
82
|
+
export interface ResolvedMember {
|
|
83
|
+
ref: PointRef;
|
|
84
|
+
name: string;
|
|
85
|
+
}
|
|
86
|
+
/** A binding after resolution against the mapping document. */
|
|
87
|
+
export type ResolvedBinding = {
|
|
88
|
+
kind: 'point';
|
|
89
|
+
ref: PointRef;
|
|
90
|
+
name: string;
|
|
91
|
+
} | {
|
|
92
|
+
kind: 'group';
|
|
93
|
+
device: string;
|
|
94
|
+
group: string;
|
|
95
|
+
members: readonly ResolvedMember[];
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Resolve a binding against the generic mapping document. The device must
|
|
99
|
+
* exist, the group entity must exist on it, and the point form must find
|
|
100
|
+
* the point in exactly that group — an unknown group name must read as
|
|
101
|
+
* unresolved, never as "all normal". A group resolves with at least one
|
|
102
|
+
* member, in the document's order (the driver's projection defines it; the
|
|
103
|
+
* first active member is a stable "primary").
|
|
104
|
+
*/
|
|
105
|
+
export declare function resolveBinding(doc: MappingDocument, binding: FieldBinding): ResolvedBinding | undefined;
|
|
106
|
+
/** One active group member with the sample that activated it. */
|
|
107
|
+
export interface BoundMember {
|
|
108
|
+
/** The point name — the business name a group view surfaces. */
|
|
109
|
+
name: string;
|
|
110
|
+
value: PointValue;
|
|
111
|
+
time: number;
|
|
112
|
+
}
|
|
113
|
+
/** The live view of a binding a listener receives. */
|
|
114
|
+
export type BindingView = {
|
|
115
|
+
kind: 'unresolved';
|
|
116
|
+
} | {
|
|
117
|
+
kind: 'point';
|
|
118
|
+
ref: PointRef;
|
|
119
|
+
name: string;
|
|
120
|
+
sample: {
|
|
121
|
+
value: PointValue;
|
|
122
|
+
time: number;
|
|
123
|
+
} | undefined;
|
|
124
|
+
} | {
|
|
125
|
+
kind: 'group';
|
|
126
|
+
device: string;
|
|
127
|
+
group: string;
|
|
128
|
+
/** `active` when any member is truthy; `normal` otherwise. */
|
|
129
|
+
status: 'normal' | 'active';
|
|
130
|
+
/** Active members in group display order (see `resolveBinding`). */
|
|
131
|
+
active: readonly BoundMember[];
|
|
132
|
+
/** Members whose latest sample is `null` (link abnormal after observing). */
|
|
133
|
+
abnormal: readonly string[];
|
|
134
|
+
/** Mapped members with no observation yet. */
|
|
135
|
+
pending: readonly string[];
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* Watch one binding and emit a `BindingView` on every change. The watcher
|
|
139
|
+
* resolves the generic mapping document itself, seeds current values with
|
|
140
|
+
* one `field.points.read`, follows `field/point-updated` frames, and
|
|
141
|
+
* re-resolves on a `field/mappings-changed` frame — re-mapping takes effect
|
|
142
|
+
* without remounting the consumer.
|
|
143
|
+
*
|
|
144
|
+
* The returned stop function drops this watcher's subscription references;
|
|
145
|
+
* the host counts references per address, so overlapping watchers of one
|
|
146
|
+
* address never starve each other.
|
|
147
|
+
*/
|
|
148
|
+
export declare function watchBinding(ctx: Context, binding: FieldBinding, listener: (view: BindingView) => void): () => void;
|
|
149
|
+
/** The React recipe over `watchBinding`; `unresolved` until first observation. */
|
|
150
|
+
export declare function useBinding(ctx: Context, binding: FieldBinding): BindingView;
|
|
151
|
+
/**
|
|
152
|
+
* The consume recipe for demand plugins: seed the current value with a
|
|
153
|
+
* `field.points.read`, then follow `field/point-updated` increments. Returns
|
|
154
|
+
* `undefined` until the first observation — an unmapped declaration stays
|
|
155
|
+
* undefined.
|
|
156
|
+
*
|
|
157
|
+
* The hook holds one subscription reference for its lifetime; the matching
|
|
158
|
+
* `field.points.unsubscribe` fires on unmount, and the host's reference
|
|
159
|
+
* counting keeps other watchers of the same address live.
|
|
160
|
+
*/
|
|
161
|
+
export declare function usePoint(ctx: Context, binding: {
|
|
162
|
+
device: string;
|
|
163
|
+
group: string;
|
|
164
|
+
name: string;
|
|
165
|
+
}): {
|
|
166
|
+
value: PointValue;
|
|
167
|
+
time: number;
|
|
168
|
+
} | undefined;
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The variable seam: demand plugins declare the process variables they
|
|
3
|
+
* listen to — a device, a group, a name, and a semantic type, nothing
|
|
4
|
+
* protocol-specific — and mapping surfaces (a driver's settings page) list
|
|
5
|
+
* the registry to wire those declarations onto real register addresses.
|
|
6
|
+
* Point names are unique only within their group, so a declaration is
|
|
7
|
+
* itself the full address — the same (device, group, name) triple the field
|
|
8
|
+
* seam speaks natively; consumers ride `field.points.subscribe` /
|
|
9
|
+
* `field/point-updated` with no id munging.
|
|
10
|
+
*
|
|
11
|
+
* Bindings (below the registry) generalize consumption: a page addresses
|
|
12
|
+
* field data as a device-qualified point (`{device, group, name}`) or a
|
|
13
|
+
* device's whole business group (`{device, group}`) and gets a live
|
|
14
|
+
* aggregated view (any member active ⇒ the group is active with the member
|
|
15
|
+
* names). Group membership is mapping configuration served by the field
|
|
16
|
+
* seam's generic mapping document (`field.mappings.list`, dialect-free), so
|
|
17
|
+
* a `field/mappings-changed` frame re-resolves the binding in place.
|
|
18
|
+
*
|
|
19
|
+
* @module @snap-rail/framework-shadcn/variables
|
|
20
|
+
*/
|
|
21
|
+
import { Context } from '@snap-rail/cordis';
|
|
22
|
+
import { subscribeFrame } from '@snap-rail/connection';
|
|
23
|
+
import { equipmentFrameSchemas, pointKey, } from '@snap-rail/equipment/contract';
|
|
24
|
+
import { useEffect, useState } from 'react';
|
|
25
|
+
/**
|
|
26
|
+
* Mounts the `ctx.variables` registry. All state lives in construction-time
|
|
27
|
+
* closures: traceable context proxies rebind `this` on every method access,
|
|
28
|
+
* so `this`-reached state cannot back a service here (the uiSlots lesson).
|
|
29
|
+
*/
|
|
30
|
+
const variablesPlugin = {
|
|
31
|
+
name: 'client-variables',
|
|
32
|
+
apply(ctx) {
|
|
33
|
+
const variables = new Map();
|
|
34
|
+
ctx.provide('variables', {
|
|
35
|
+
list() {
|
|
36
|
+
return [...variables.values()];
|
|
37
|
+
},
|
|
38
|
+
register(caller, defs) {
|
|
39
|
+
const keys = defs.map(def => pointKey(def));
|
|
40
|
+
if (new Set(keys).size !== keys.length) {
|
|
41
|
+
throw new Error(`variables: duplicate addresses in one registration: ${keys.join(', ')}`);
|
|
42
|
+
}
|
|
43
|
+
const installed = [];
|
|
44
|
+
for (const def of defs) {
|
|
45
|
+
const entry = { ...def, source: 'plugin' };
|
|
46
|
+
const key = pointKey(def);
|
|
47
|
+
installed.push({ key, entry });
|
|
48
|
+
variables.set(key, entry);
|
|
49
|
+
}
|
|
50
|
+
ctx.emit('variables/changed');
|
|
51
|
+
const remove = () => {
|
|
52
|
+
let changed = false;
|
|
53
|
+
for (const { key, entry } of installed) {
|
|
54
|
+
// Identity check: only drop addresses this call still owns (a
|
|
55
|
+
// later re-registration by someone else stays).
|
|
56
|
+
if (variables.get(key) === entry) {
|
|
57
|
+
variables.delete(key);
|
|
58
|
+
changed = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (changed)
|
|
62
|
+
ctx.emit('variables/changed');
|
|
63
|
+
};
|
|
64
|
+
// Effects take a body producing the disposer; passing `remove`
|
|
65
|
+
// itself would run it as setup.
|
|
66
|
+
caller.effect(() => remove);
|
|
67
|
+
return remove;
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
export default variablesPlugin;
|
|
73
|
+
/**
|
|
74
|
+
* Resolve a binding against the generic mapping document. The device must
|
|
75
|
+
* exist, the group entity must exist on it, and the point form must find
|
|
76
|
+
* the point in exactly that group — an unknown group name must read as
|
|
77
|
+
* unresolved, never as "all normal". A group resolves with at least one
|
|
78
|
+
* member, in the document's order (the driver's projection defines it; the
|
|
79
|
+
* first active member is a stable "primary").
|
|
80
|
+
*/
|
|
81
|
+
export function resolveBinding(doc, binding) {
|
|
82
|
+
if (!doc.devices.some(device => device.id === binding.device))
|
|
83
|
+
return undefined;
|
|
84
|
+
if (!doc.groups.some(group => group.deviceId === binding.device && group.name === binding.group))
|
|
85
|
+
return undefined;
|
|
86
|
+
if ('name' in binding) {
|
|
87
|
+
const mapped = doc.points.some(point => point.deviceId === binding.device && point.group === binding.group && point.name === binding.name);
|
|
88
|
+
if (!mapped)
|
|
89
|
+
return undefined;
|
|
90
|
+
return {
|
|
91
|
+
kind: 'point',
|
|
92
|
+
ref: { device: binding.device, group: binding.group, name: binding.name },
|
|
93
|
+
name: binding.name,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const members = doc.points
|
|
97
|
+
.filter(point => point.deviceId === binding.device && point.group === binding.group)
|
|
98
|
+
.map(point => ({ ref: { device: point.deviceId, group: point.group, name: point.name }, name: point.name }));
|
|
99
|
+
if (members.length === 0)
|
|
100
|
+
return undefined;
|
|
101
|
+
return { kind: 'group', device: binding.device, group: binding.group, members };
|
|
102
|
+
}
|
|
103
|
+
/** Any-active truthiness: `true`, a nonzero number/bigint, a non-empty string. */
|
|
104
|
+
function isActiveValue(value) {
|
|
105
|
+
if (value === null)
|
|
106
|
+
return false;
|
|
107
|
+
if (typeof value === 'boolean')
|
|
108
|
+
return value;
|
|
109
|
+
if (typeof value === 'bigint')
|
|
110
|
+
return value !== 0n;
|
|
111
|
+
if (typeof value === 'number')
|
|
112
|
+
return value !== 0;
|
|
113
|
+
return value.length > 0;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Watch one binding and emit a `BindingView` on every change. The watcher
|
|
117
|
+
* resolves the generic mapping document itself, seeds current values with
|
|
118
|
+
* one `field.points.read`, follows `field/point-updated` frames, and
|
|
119
|
+
* re-resolves on a `field/mappings-changed` frame — re-mapping takes effect
|
|
120
|
+
* without remounting the consumer.
|
|
121
|
+
*
|
|
122
|
+
* The returned stop function drops this watcher's subscription references;
|
|
123
|
+
* the host counts references per address, so overlapping watchers of one
|
|
124
|
+
* address never starve each other.
|
|
125
|
+
*/
|
|
126
|
+
export function watchBinding(ctx, binding, listener) {
|
|
127
|
+
const { link } = ctx.client;
|
|
128
|
+
let live = true;
|
|
129
|
+
let resolved;
|
|
130
|
+
let refs = [];
|
|
131
|
+
const samples = new Map();
|
|
132
|
+
const compute = () => {
|
|
133
|
+
if (resolved === undefined) {
|
|
134
|
+
listener({ kind: 'unresolved' });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (resolved.kind === 'point') {
|
|
138
|
+
listener({ kind: 'point', ref: resolved.ref, name: resolved.name, sample: samples.get(pointKey(resolved.ref)) });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const active = [];
|
|
142
|
+
const abnormal = [];
|
|
143
|
+
const pending = [];
|
|
144
|
+
for (const member of resolved.members) {
|
|
145
|
+
const sample = samples.get(pointKey(member.ref));
|
|
146
|
+
// `null` with `time` 0 is the field seam's "registered but never
|
|
147
|
+
// observed" read; a `null` with a real timestamp is link abnormal.
|
|
148
|
+
if (sample === undefined || (sample.value === null && sample.time === 0))
|
|
149
|
+
pending.push(member.name);
|
|
150
|
+
else if (sample.value === null)
|
|
151
|
+
abnormal.push(member.name);
|
|
152
|
+
else if (isActiveValue(sample.value))
|
|
153
|
+
active.push({ name: member.name, value: sample.value, time: sample.time });
|
|
154
|
+
}
|
|
155
|
+
listener({
|
|
156
|
+
kind: 'group',
|
|
157
|
+
device: resolved.device,
|
|
158
|
+
group: resolved.group,
|
|
159
|
+
status: active.length > 0 ? 'active' : 'normal',
|
|
160
|
+
active,
|
|
161
|
+
abnormal,
|
|
162
|
+
pending,
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
const resubscribe = () => {
|
|
166
|
+
void link.call('equipment.mappings.list', {})
|
|
167
|
+
.then(result => {
|
|
168
|
+
if (!live || !result.ok)
|
|
169
|
+
return;
|
|
170
|
+
const next = resolveBinding(result.value.mappings, binding);
|
|
171
|
+
const nextRefs = next === undefined ? [] : next.kind === 'point' ? [next.ref] : next.members.map(member => member.ref);
|
|
172
|
+
const wanted = new Set(nextRefs.map(pointKey));
|
|
173
|
+
for (const ref of refs) {
|
|
174
|
+
if (!wanted.has(pointKey(ref)))
|
|
175
|
+
void link.call('equipment.points.unsubscribe', { points: [ref] }).catch(() => { });
|
|
176
|
+
}
|
|
177
|
+
for (const ref of nextRefs) {
|
|
178
|
+
if (!refs.some(current => pointKey(current) === pointKey(ref))) {
|
|
179
|
+
void link.call('equipment.points.subscribe', { points: [ref] }).catch(() => { });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
refs = nextRefs;
|
|
183
|
+
resolved = next;
|
|
184
|
+
samples.clear();
|
|
185
|
+
if (nextRefs.length === 0) {
|
|
186
|
+
compute();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
void link.call('equipment.points.read', { points: [...nextRefs] })
|
|
190
|
+
.then(read => {
|
|
191
|
+
if (!live)
|
|
192
|
+
return;
|
|
193
|
+
if (read.ok) {
|
|
194
|
+
for (const sample of read.value.samples) {
|
|
195
|
+
samples.set(pointKey(sample), { value: sample.value, time: sample.time });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// A read mixing registered and unregistered addresses fails
|
|
199
|
+
// wholesale (not-found); compute anyway — the missing members
|
|
200
|
+
// simply read as pending until the rig catches up.
|
|
201
|
+
compute();
|
|
202
|
+
})
|
|
203
|
+
.catch(() => {
|
|
204
|
+
// The call itself failed (carrier); the view observes nothing yet.
|
|
205
|
+
if (live)
|
|
206
|
+
compute();
|
|
207
|
+
});
|
|
208
|
+
})
|
|
209
|
+
.catch(() => { });
|
|
210
|
+
};
|
|
211
|
+
const detachUpdates = subscribeFrame(link, 'equipment/point-updated', equipmentFrameSchemas['equipment/point-updated'], frame => {
|
|
212
|
+
const ref = { device: frame.device, group: frame.group, name: frame.name };
|
|
213
|
+
if (!refs.some(current => pointKey(current) === pointKey(ref)))
|
|
214
|
+
return;
|
|
215
|
+
samples.set(pointKey(ref), { value: frame.value, time: frame.time });
|
|
216
|
+
compute();
|
|
217
|
+
});
|
|
218
|
+
const detachConfig = link.subscribe('equipment/mappings-changed', () => { resubscribe(); });
|
|
219
|
+
resubscribe();
|
|
220
|
+
return () => {
|
|
221
|
+
live = false;
|
|
222
|
+
detachUpdates();
|
|
223
|
+
detachConfig();
|
|
224
|
+
if (refs.length > 0)
|
|
225
|
+
void link.call('equipment.points.unsubscribe', { points: [...refs] }).catch(() => { });
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** Stable identity of a binding for hook dependency lists. */
|
|
229
|
+
function bindingKey(binding) {
|
|
230
|
+
return 'name' in binding
|
|
231
|
+
? `${binding.device}/${binding.group}/${binding.name}`
|
|
232
|
+
: `${binding.device}/${binding.group}`;
|
|
233
|
+
}
|
|
234
|
+
/** The React recipe over `watchBinding`; `unresolved` until first observation. */
|
|
235
|
+
export function useBinding(ctx, binding) {
|
|
236
|
+
const [view, setView] = useState({ kind: 'unresolved' });
|
|
237
|
+
const { link } = ctx.client;
|
|
238
|
+
const key = bindingKey(binding);
|
|
239
|
+
useEffect(() => watchBinding(ctx, binding, setView), [link, key]);
|
|
240
|
+
return view;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* The consume recipe for demand plugins: seed the current value with a
|
|
244
|
+
* `field.points.read`, then follow `field/point-updated` increments. Returns
|
|
245
|
+
* `undefined` until the first observation — an unmapped declaration stays
|
|
246
|
+
* undefined.
|
|
247
|
+
*
|
|
248
|
+
* The hook holds one subscription reference for its lifetime; the matching
|
|
249
|
+
* `field.points.unsubscribe` fires on unmount, and the host's reference
|
|
250
|
+
* counting keeps other watchers of the same address live.
|
|
251
|
+
*/
|
|
252
|
+
export function usePoint(ctx, binding) {
|
|
253
|
+
const [sample, setSample] = useState(undefined);
|
|
254
|
+
const { link } = ctx.client;
|
|
255
|
+
const key = pointKey(binding);
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
let live = true;
|
|
258
|
+
const detach = subscribeFrame(link, 'equipment/point-updated', equipmentFrameSchemas['equipment/point-updated'], frame => {
|
|
259
|
+
const frameRef = { device: frame.device, group: frame.group, name: frame.name };
|
|
260
|
+
if (pointKey(frameRef) !== key)
|
|
261
|
+
return;
|
|
262
|
+
if (live)
|
|
263
|
+
setSample({ value: frame.value, time: frame.time });
|
|
264
|
+
});
|
|
265
|
+
void link.call('equipment.points.subscribe', { points: [binding] }).catch(() => { });
|
|
266
|
+
void link.call('equipment.points.read', { points: [binding] })
|
|
267
|
+
.then(result => {
|
|
268
|
+
if (!live || !result.ok)
|
|
269
|
+
return;
|
|
270
|
+
const first = result.value.samples[0];
|
|
271
|
+
if (first !== undefined)
|
|
272
|
+
setSample({ value: first.value, time: first.time });
|
|
273
|
+
})
|
|
274
|
+
.catch(() => { });
|
|
275
|
+
return () => {
|
|
276
|
+
live = false;
|
|
277
|
+
detach();
|
|
278
|
+
void link.call('equipment.points.unsubscribe', { points: [binding] }).catch(() => { });
|
|
279
|
+
};
|
|
280
|
+
}, [link, key]);
|
|
281
|
+
return sample;
|
|
282
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workflow seam: `ctx.workflows` is a generic registry-plus-evaluator —
|
|
3
|
+
* it knows the mechanism (registration, gating, alerts) and nothing about any
|
|
4
|
+
* concrete workflow. The gating chain emerges from each occupant's
|
|
5
|
+
* declarative `requires`; the truth source for satisfaction is the audit
|
|
6
|
+
* event stream fed back by the layout (`audit.list` on boot/login) and
|
|
7
|
+
* extended in-session by `announce` after a plugin records an event.
|
|
8
|
+
*
|
|
9
|
+
* @module @snap-rail/framework-shadcn/workflows
|
|
10
|
+
*/
|
|
11
|
+
import { Context, type Plugin } from '@snap-rail/cordis';
|
|
12
|
+
import type { ReactNode } from 'react';
|
|
13
|
+
/** How a requirement's event counts as satisfying (who, and when). */
|
|
14
|
+
export type RequirementScope = 'operator-day' | 'day';
|
|
15
|
+
/** One gating declaration: satisfied when a matching event exists. */
|
|
16
|
+
export interface Requirement {
|
|
17
|
+
/** The audit action that satisfies the requirement. */
|
|
18
|
+
action: string;
|
|
19
|
+
/** `operator-day`: the current operator, today. `day`: anyone, today. */
|
|
20
|
+
scope: RequirementScope;
|
|
21
|
+
}
|
|
22
|
+
/** A sidebar attention effect driven by the workflow owner. */
|
|
23
|
+
export type WorkflowAlert = {
|
|
24
|
+
kind: 'steady';
|
|
25
|
+
color: 'red' | 'yellow';
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'halo';
|
|
28
|
+
progress: number;
|
|
29
|
+
};
|
|
30
|
+
/** What one workflow contributes at registration. */
|
|
31
|
+
export interface WorkflowDef {
|
|
32
|
+
/** Stable workflow id (the sidebar and content key). */
|
|
33
|
+
id: string;
|
|
34
|
+
/** Sidebar label; locked items render gray with no extra text. */
|
|
35
|
+
title: string;
|
|
36
|
+
/** Sidebar ordering; ties break by registration time. */
|
|
37
|
+
order: number;
|
|
38
|
+
/** Gating declarations; empty means always unlocked. */
|
|
39
|
+
requires: readonly Requirement[];
|
|
40
|
+
render(): ReactNode;
|
|
41
|
+
}
|
|
42
|
+
/** A workflow as the sidebar/content sees it, with derived state. */
|
|
43
|
+
export interface WorkflowEntry extends WorkflowDef {
|
|
44
|
+
/** Whether the gating declarations are satisfied right now. */
|
|
45
|
+
unlocked: boolean;
|
|
46
|
+
/** Whether this workflow is the active content page. */
|
|
47
|
+
active: boolean;
|
|
48
|
+
/** The current sidebar attention effect, or `null`. */
|
|
49
|
+
alert: WorkflowAlert | null;
|
|
50
|
+
}
|
|
51
|
+
/** One business event used for gating evaluation. */
|
|
52
|
+
export interface WorkflowEvent {
|
|
53
|
+
action: string;
|
|
54
|
+
actor: string;
|
|
55
|
+
time: number;
|
|
56
|
+
}
|
|
57
|
+
/** Workflow registry exposed as `ctx.workflows`. */
|
|
58
|
+
export interface WorkflowsService {
|
|
59
|
+
/** Registered workflows in sidebar order with derived state. */
|
|
60
|
+
list(): readonly WorkflowEntry[];
|
|
61
|
+
/** Register one workflow; disposal (caller unload) removes it.
|
|
62
|
+
* @param caller - owning context; unload drops the workflow.
|
|
63
|
+
* @param def - the contribution; same-id re-registration replaces.
|
|
64
|
+
*/
|
|
65
|
+
register(caller: Context, def: WorkflowDef): () => void;
|
|
66
|
+
/** Make one workflow the active content page. */
|
|
67
|
+
setActive(id: string): void;
|
|
68
|
+
/** Set or clear a sidebar attention effect.
|
|
69
|
+
* @param id - the workflow id.
|
|
70
|
+
* @param alert - the effect, or `null` to clear.
|
|
71
|
+
*/
|
|
72
|
+
setAlert(id: string, alert: WorkflowAlert | null): void;
|
|
73
|
+
/** Set the gating anchor operator (`operator-day` scope re-evaluates). */
|
|
74
|
+
setOperator(operator: string | null): void;
|
|
75
|
+
/** Replace the event set (from `audit.list`) and re-evaluate gating. */
|
|
76
|
+
feedEvents(events: readonly WorkflowEvent[]): void;
|
|
77
|
+
/** Append one in-session event (after the plugin recorded it host-side).
|
|
78
|
+
* @param action - the audit action.
|
|
79
|
+
* @param actor - defaults to the current anchor operator.
|
|
80
|
+
*/
|
|
81
|
+
announce(action: string, actor?: string): void;
|
|
82
|
+
}
|
|
83
|
+
declare module '@snap-rail/cordis' {
|
|
84
|
+
interface Context {
|
|
85
|
+
workflows: WorkflowsService;
|
|
86
|
+
}
|
|
87
|
+
interface Events {
|
|
88
|
+
/** Registry, active page, alert, or gating state changed. */
|
|
89
|
+
'workflow/changed'(): void;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Mounts the `ctx.workflows` registry. All state lives in construction-time
|
|
94
|
+
* closures: traceable context proxies rebind `this` on every method access,
|
|
95
|
+
* so `this`-reached state cannot back a service here (the uiSlots lesson).
|
|
96
|
+
*/
|
|
97
|
+
declare const workflowsPlugin: Plugin.Object<void>;
|
|
98
|
+
export default workflowsPlugin;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workflow seam: `ctx.workflows` is a generic registry-plus-evaluator —
|
|
3
|
+
* it knows the mechanism (registration, gating, alerts) and nothing about any
|
|
4
|
+
* concrete workflow. The gating chain emerges from each occupant's
|
|
5
|
+
* declarative `requires`; the truth source for satisfaction is the audit
|
|
6
|
+
* event stream fed back by the layout (`audit.list` on boot/login) and
|
|
7
|
+
* extended in-session by `announce` after a plugin records an event.
|
|
8
|
+
*
|
|
9
|
+
* @module @snap-rail/framework-shadcn/workflows
|
|
10
|
+
*/
|
|
11
|
+
import { Context } from '@snap-rail/cordis';
|
|
12
|
+
/** Whether two epoch-ms times fall on the same local calendar day. */
|
|
13
|
+
function sameLocalDay(a, b) {
|
|
14
|
+
return new Date(a).toDateString() === new Date(b).toDateString();
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Mounts the `ctx.workflows` registry. All state lives in construction-time
|
|
18
|
+
* closures: traceable context proxies rebind `this` on every method access,
|
|
19
|
+
* so `this`-reached state cannot back a service here (the uiSlots lesson).
|
|
20
|
+
*/
|
|
21
|
+
const workflowsPlugin = {
|
|
22
|
+
name: 'client-workflows',
|
|
23
|
+
apply(ctx) {
|
|
24
|
+
const workflows = new Map();
|
|
25
|
+
let nextSeq = 0;
|
|
26
|
+
let activeId;
|
|
27
|
+
let operator = null;
|
|
28
|
+
let events = [];
|
|
29
|
+
const satisfies = (requirement) => events.some(event => event.action === requirement.action
|
|
30
|
+
&& (requirement.scope === 'day'
|
|
31
|
+
? sameLocalDay(event.time, Date.now())
|
|
32
|
+
: event.actor === operator && sameLocalDay(event.time, Date.now())));
|
|
33
|
+
const changed = () => {
|
|
34
|
+
ctx.emit('workflow/changed');
|
|
35
|
+
};
|
|
36
|
+
ctx.provide('workflows', {
|
|
37
|
+
list() {
|
|
38
|
+
return [...workflows.values()]
|
|
39
|
+
.sort((a, b) => a.def.order - b.def.order || a.seq - b.seq)
|
|
40
|
+
.map(({ def, alert }) => ({
|
|
41
|
+
...def,
|
|
42
|
+
unlocked: def.requires.every(satisfies),
|
|
43
|
+
active: def.id === activeId,
|
|
44
|
+
alert,
|
|
45
|
+
}));
|
|
46
|
+
},
|
|
47
|
+
register(caller, def) {
|
|
48
|
+
workflows.set(def.id, { def, alert: null, seq: nextSeq++ });
|
|
49
|
+
changed();
|
|
50
|
+
const remove = () => {
|
|
51
|
+
if (workflows.delete(def.id)) {
|
|
52
|
+
if (activeId === def.id)
|
|
53
|
+
activeId = undefined;
|
|
54
|
+
changed();
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
// Effects take a body producing the disposer; passing `remove`
|
|
58
|
+
// itself would run it as setup.
|
|
59
|
+
caller.effect(() => remove);
|
|
60
|
+
return remove;
|
|
61
|
+
},
|
|
62
|
+
setActive(id) {
|
|
63
|
+
if (activeId === id)
|
|
64
|
+
return;
|
|
65
|
+
activeId = id;
|
|
66
|
+
changed();
|
|
67
|
+
},
|
|
68
|
+
setAlert(id, alert) {
|
|
69
|
+
const entry = workflows.get(id);
|
|
70
|
+
if (entry === undefined || entry.alert === alert)
|
|
71
|
+
return;
|
|
72
|
+
if (alert !== null && alert.kind === 'halo') {
|
|
73
|
+
alert = { kind: 'halo', progress: Math.min(1, Math.max(0, alert.progress)) };
|
|
74
|
+
}
|
|
75
|
+
entry.alert = alert;
|
|
76
|
+
changed();
|
|
77
|
+
},
|
|
78
|
+
setOperator(next) {
|
|
79
|
+
if (operator === next)
|
|
80
|
+
return;
|
|
81
|
+
operator = next;
|
|
82
|
+
changed();
|
|
83
|
+
},
|
|
84
|
+
feedEvents(next) {
|
|
85
|
+
events = [...next].sort((a, b) => a.time - b.time);
|
|
86
|
+
changed();
|
|
87
|
+
},
|
|
88
|
+
announce(action, actor) {
|
|
89
|
+
events = [...events, { action, actor: actor ?? operator ?? 'client', time: Date.now() }];
|
|
90
|
+
changed();
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
export default workflowsPlugin;
|