@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,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;