@sola-air-ui/core 1.0.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/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@sola-air-ui/core",
3
+ "version": "1.0.0",
4
+ "description": "Zero-VDOM reactivity engine — signals, effects, lifecycle, and intent primitives",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "keywords": ["sola", "reactivity", "signals", "zero-vdom", "ui", "frontend"],
11
+ "license": "MIT",
12
+ "homepage": "https://sola-air.dev",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/rbm3267/sola"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public",
19
+ "registry": "https://registry.npmjs.org/"
20
+ }
21
+ }
@@ -0,0 +1,80 @@
1
+ // ─── Sola Universal ActionContract Protocol ───
2
+ // Bridges Live Data State (Driver 1) with Human Behavioral Telemetry (Driver 2)
3
+
4
+ export type ActionSeverity = 'info' | 'low' | 'medium' | 'high' | 'critical';
5
+ export type VisualAffordance = 'hidden' | 'calm' | 'expanded_preview' | 'urgent_override';
6
+ export type TransactionCapability = 'idempotent' | 'transactional' | 'fire_and_forget';
7
+
8
+ export interface BehavioralVector {
9
+ activeDwellTarget: string | null;
10
+ dwellDurationMs: number;
11
+ rageClickCount: number;
12
+ typingVelocityCps: number;
13
+ persona: 'visual_explorer' | 'sre_commander' | 'finops_auditor';
14
+ densityMode: 'comfortable' | 'compact' | 'emergency';
15
+ }
16
+
17
+ export interface ActionExecutionContext<TData = any> {
18
+ sourceId: string;
19
+ recordId: string;
20
+ data: TData;
21
+ userContext?: {
22
+ userId: string;
23
+ roles: string[];
24
+ };
25
+ metadata?: Record<string, any>;
26
+ }
27
+
28
+ export interface ActionResult<TOutput = any> {
29
+ success: boolean;
30
+ actionId: string;
31
+ transactionId?: string;
32
+ timestamp: number;
33
+ output?: TOutput;
34
+ message?: string;
35
+ error?: {
36
+ code: string;
37
+ message: string;
38
+ };
39
+ }
40
+
41
+ export interface IntentRecord<TData = any, TPayload = any> {
42
+ id: string;
43
+ actionId: string;
44
+ recordId: string;
45
+ data: TData;
46
+ payload: TPayload;
47
+ status: 'pending' | 'committed' | 'rolled_back' | 'failed';
48
+ tier: 0 | 1 | 2;
49
+ timestamp: number;
50
+ expiresAt?: number;
51
+ errorMessage?: string;
52
+ }
53
+
54
+ export interface ActionContract<TData = any, TPayload = any, TResult = any> {
55
+ id: string;
56
+ title: string;
57
+ description: string;
58
+ category: 'mitigation' | 'mutation' | 'diagnostic' | 'export';
59
+ severity: ActionSeverity;
60
+ tier: 1 | 2; // Staging tier definition
61
+ capability: TransactionCapability;
62
+ blastRadiusMessage?: string; // Estimated impact message for Tier 2 swipe drawer
63
+
64
+ // Guard check: Does data & permission allow this action to exist?
65
+ isPermitted?: (ctx: ActionExecutionContext<TData>) => boolean;
66
+
67
+ // Dual-Driver evaluation: Should this action be surfaced given current data + human behavior?
68
+ isSurfaced: (data: TData, behavior: BehavioralVector) => boolean;
69
+
70
+ // Urgency score from 0.0 to 1.0
71
+ computeUrgency: (data: TData, behavior: BehavioralVector) => number;
72
+
73
+ // Resolves the visual representation (Calm pill vs Expanded Drawer vs Urgent 1-Click Button)
74
+ resolveAffordance: (data: TData, behavior: BehavioralVector) => VisualAffordance;
75
+
76
+ // Split staging lifecycle
77
+ stage: (payload: TPayload, ctx: ActionExecutionContext<TData>) => Promise<IntentRecord<TData, TPayload>>;
78
+ commit: (intentId: string) => Promise<ActionResult<TResult>>;
79
+ rollback?: (intentId: string) => Promise<ActionResult<TResult>>;
80
+ }
package/src/index.js ADDED
@@ -0,0 +1,493 @@
1
+ // @sola/core — Reactivity Engine V4
2
+ // Milestone 1: Full reactivity primitives, lifecycle hooks, batched updates, configurable intents
3
+
4
+ // ─── Effect Stack ───
5
+ let effectStack = [];
6
+ let pendingEffects = new Set();
7
+ let isFlushing = false;
8
+
9
+ export function flushSync() {
10
+ while (pendingEffects.size > 0) {
11
+ const effects = [...pendingEffects];
12
+ pendingEffects.clear();
13
+ for (const effect of effects) {
14
+ effect.execute();
15
+ }
16
+ }
17
+ isFlushing = false;
18
+ }
19
+
20
+ // ─── Batched Updates ───
21
+ function scheduleFlush() {
22
+ if (!isFlushing) {
23
+ isFlushing = true;
24
+ queueMicrotask(flushSync);
25
+ }
26
+ }
27
+
28
+ // ─── createSignal ───
29
+ export function createSignal(initialValue) {
30
+ let value = initialValue;
31
+ const subscribers = new Set();
32
+
33
+ const read = () => {
34
+ const currentEffect = effectStack[effectStack.length - 1];
35
+ if (currentEffect) {
36
+ subscribers.add(currentEffect);
37
+ currentEffect.dependencies.add(subscribers);
38
+ }
39
+ return value;
40
+ };
41
+
42
+ const write = (newValue) => {
43
+ if (value !== newValue) {
44
+ value = newValue;
45
+ for (const sub of [...subscribers]) {
46
+ pendingEffects.add(sub);
47
+ }
48
+ scheduleFlush();
49
+ }
50
+ };
51
+
52
+ return [read, write];
53
+ }
54
+
55
+ // ─── createEffect ───
56
+ export function createEffect(fn) {
57
+ const effect = {
58
+ execute() {
59
+ cleanup();
60
+ effectStack.push(effect);
61
+ try {
62
+ fn();
63
+ } finally {
64
+ effectStack.pop();
65
+ }
66
+ },
67
+ dependencies: new Set(),
68
+ cleanup
69
+ };
70
+
71
+ function cleanup() {
72
+ for (const dep of effect.dependencies) {
73
+ dep.delete(effect);
74
+ }
75
+ effect.dependencies.clear();
76
+ }
77
+
78
+ // Run immediately (synchronous initial execution)
79
+ effectStack.push(effect);
80
+ try {
81
+ fn();
82
+ } finally {
83
+ effectStack.pop();
84
+ }
85
+
86
+ return cleanup;
87
+ }
88
+
89
+ // ─── createDerived ───
90
+ // Computed signal that auto-tracks dependencies and lazily recomputes.
91
+ export function createDerived(fn) {
92
+ let cachedValue;
93
+ let dirty = true;
94
+ const subscribers = new Set();
95
+ let innerDependencies = new Set();
96
+
97
+ // Track when our dependencies change
98
+ const markDirty = {
99
+ execute() {
100
+ if (!dirty) {
101
+ dirty = true;
102
+ // Propagate to our own subscribers
103
+ for (const sub of [...subscribers]) {
104
+ pendingEffects.add(sub);
105
+ }
106
+ scheduleFlush();
107
+ }
108
+ },
109
+ dependencies: innerDependencies,
110
+ cleanup() {
111
+ for (const dep of innerDependencies) {
112
+ dep.delete(markDirty);
113
+ }
114
+ innerDependencies.clear();
115
+ }
116
+ };
117
+
118
+ const read = () => {
119
+ // Subscribe the current running effect to us
120
+ const currentEffect = effectStack[effectStack.length - 1];
121
+ if (currentEffect) {
122
+ subscribers.add(currentEffect);
123
+ currentEffect.dependencies.add(subscribers);
124
+ }
125
+
126
+ if (dirty) {
127
+ // Clean up old deps
128
+ markDirty.cleanup();
129
+ innerDependencies = new Set();
130
+ markDirty.dependencies = innerDependencies;
131
+
132
+ // Track which signals fn() reads
133
+ effectStack.push(markDirty);
134
+ try {
135
+ cachedValue = fn();
136
+ } finally {
137
+ effectStack.pop();
138
+ }
139
+ dirty = false;
140
+ }
141
+
142
+ return cachedValue;
143
+ };
144
+
145
+ return read;
146
+ }
147
+
148
+ // ─── Component Lifecycle Scope Context Stack ───
149
+ const contextStack = [];
150
+ let activeContext = null;
151
+
152
+ export function pushContext() {
153
+ const ctx = { mounts: [], destroys: [] };
154
+ contextStack.push(ctx);
155
+ activeContext = ctx;
156
+ return ctx;
157
+ }
158
+
159
+ export function popContext(ctx) {
160
+ const idx = contextStack.lastIndexOf(ctx);
161
+ if (idx !== -1) {
162
+ contextStack.splice(idx, 1);
163
+ }
164
+ activeContext = contextStack.length > 0 ? contextStack[contextStack.length - 1] : null;
165
+ }
166
+
167
+ export function onMount(fn) {
168
+ if (activeContext) {
169
+ activeContext.mounts.push(fn);
170
+ } else {
171
+ fn();
172
+ }
173
+ }
174
+
175
+ export function onDestroy(fn) {
176
+ if (activeContext) {
177
+ activeContext.destroys.push(fn);
178
+ }
179
+ }
180
+
181
+ // Called by compiled mount() function to flush instance mounts
182
+ export function __flush_mounts() {
183
+ if (activeContext && activeContext.mounts.length > 0) {
184
+ const cbs = [...activeContext.mounts];
185
+ activeContext.mounts = [];
186
+ for (const cb of cbs) {
187
+ cb();
188
+ }
189
+ }
190
+ }
191
+
192
+ // Called when a component is torn down
193
+ export function __flush_destroys() {
194
+ if (activeContext && activeContext.destroys.length > 0) {
195
+ const cbs = [...activeContext.destroys];
196
+ activeContext.destroys = [];
197
+ for (const cb of cbs) {
198
+ cb();
199
+ }
200
+ }
201
+ }
202
+
203
+ // ─── createIntent ───
204
+ // Configurable ambient intent resolver.
205
+ const defaultIntentConfig = {
206
+ provider: 'local',
207
+ endpoint: '/api/intent',
208
+ model: 'gemini-2.5-flash'
209
+ };
210
+
211
+ let globalIntentConfig = { ...defaultIntentConfig };
212
+
213
+ export function configureIntent(config) {
214
+ globalIntentConfig = { ...globalIntentConfig, ...config };
215
+ }
216
+
217
+ export function createIntent(promptFn, options = {}) {
218
+ const config = { ...globalIntentConfig, ...options };
219
+ const [read, write] = createSignal(options.initial || 'Resolving...');
220
+ let abortController = null;
221
+
222
+ onDestroy(() => {
223
+ if (abortController) abortController.abort();
224
+ });
225
+
226
+ createEffect(() => {
227
+ const currentPrompt = typeof promptFn === 'function' ? promptFn() : promptFn;
228
+ if (!currentPrompt) return;
229
+
230
+ if (abortController) {
231
+ abortController.abort();
232
+ }
233
+ abortController = new AbortController();
234
+
235
+ const url = config.endpoint;
236
+ write('Resolving...');
237
+
238
+ fetch(url, {
239
+ method: 'POST',
240
+ headers: { 'Content-Type': 'application/json' },
241
+ body: JSON.stringify({
242
+ messages: [{ role: 'user', content: currentPrompt }],
243
+ model: config.model,
244
+ provider: config.provider
245
+ }),
246
+ signal: abortController.signal
247
+ })
248
+ .then(res => {
249
+ if (!res.ok) throw new Error(`Intent failed: ${res.status}`);
250
+ return res.json();
251
+ })
252
+ .then(data => {
253
+ if (data && data.components && data.components.length > 0) {
254
+ write(data.components[0]);
255
+ } else if (data && data.result) {
256
+ write(data.result);
257
+ } else {
258
+ write(data);
259
+ }
260
+ })
261
+ .catch(err => {
262
+ if (err.name !== 'AbortError') {
263
+ console.error('[Sola Intent Error]', err);
264
+ write({ error: err.message });
265
+ }
266
+ });
267
+ });
268
+
269
+ return read;
270
+ }
271
+
272
+ // ─── createData ───
273
+ // Reactive data connection through the Sola Relay.
274
+ const defaultDataConfig = {
275
+ relayEndpoint: 'http://localhost:4040/api/query',
276
+ refresh: null // e.g. '30s', '1m', '5m'
277
+ };
278
+
279
+ let globalDataConfig = { ...defaultDataConfig };
280
+
281
+ export function configureData(config) {
282
+ globalDataConfig = { ...globalDataConfig, ...config };
283
+ }
284
+
285
+ function parseInterval(str) {
286
+ if (!str) return null;
287
+ const match = str.match(/^(\d+)(s|m|h)$/);
288
+ if (!match) return null;
289
+ const val = parseInt(match[1]);
290
+ switch (match[2]) {
291
+ case 's': return val * 1000;
292
+ case 'm': return val * 60 * 1000;
293
+ case 'h': return val * 3600 * 1000;
294
+ }
295
+ return null;
296
+ }
297
+
298
+ export function createData(source, options = {}) {
299
+ const config = { ...globalDataConfig, ...options };
300
+ const [read, write] = createSignal({ loading: true, data: null, error: null });
301
+ let abortController = null;
302
+ let refreshTimer = null;
303
+
304
+ function fetchData() {
305
+ if (abortController) abortController.abort();
306
+ abortController = new AbortController();
307
+
308
+ write({ loading: true, data: read().data, error: null });
309
+
310
+ fetch(config.relayEndpoint, {
311
+ method: 'POST',
312
+ headers: { 'Content-Type': 'application/json' },
313
+ body: JSON.stringify({
314
+ source,
315
+ query: config.query || null,
316
+ filters: config.filters || null,
317
+ sort: config.sort || null,
318
+ limit: config.limit || null,
319
+ offset: config.offset || null
320
+ }),
321
+ signal: abortController.signal
322
+ })
323
+ .then(res => {
324
+ if (!res.ok) throw new Error(`Data fetch failed: ${res.status}`);
325
+ return res.json();
326
+ })
327
+ .then(data => {
328
+ write({ loading: false, data: data.rows || data, error: null });
329
+ })
330
+ .catch(err => {
331
+ if (err.name !== 'AbortError') {
332
+ console.error('[Sola Data Error]', err);
333
+ write({ loading: false, data: null, error: err.message });
334
+ }
335
+ });
336
+ }
337
+
338
+ // Initial fetch
339
+ fetchData();
340
+
341
+ // Auto-refresh
342
+ const interval = parseInterval(config.refresh);
343
+ if (interval) {
344
+ refreshTimer = setInterval(fetchData, interval);
345
+ }
346
+
347
+ // Return a signal that provides .loading, .data, .error
348
+ const accessor = () => read();
349
+ accessor.refetch = fetchData;
350
+ accessor.stop = () => {
351
+ if (refreshTimer) clearInterval(refreshTimer);
352
+ if (abortController) abortController.abort();
353
+ };
354
+
355
+ return accessor;
356
+ }
357
+
358
+ // ─── Named Cross-Widget Signal Telemetry Mesh ───
359
+
360
+ class SignalMeshEngine {
361
+ constructor() {
362
+ this.topics = new Map();
363
+ this.telemetrySubscribers = new Set();
364
+ this.cycleStack = new Set();
365
+ }
366
+
367
+ topic(name, initialValue) {
368
+ if (!this.topics.has(name)) {
369
+ const [read, write] = createSignal(initialValue);
370
+ this.topics.set(name, { read, write, value: initialValue, subscribers: new Set() });
371
+ }
372
+
373
+ const entry = this.topics.get(name);
374
+
375
+ const read = () => entry.read();
376
+ const write = (next, originId = 'signal') => {
377
+ const nextVal = typeof next === 'function' ? next(entry.value) : next;
378
+ if (entry.value === nextVal) return;
379
+
380
+ if (this.cycleStack.has(name)) {
381
+ console.warn(`[Sola Signal Mesh] Cycle detected on topic "${name}". Aborting cyclic dispatch.`);
382
+ return;
383
+ }
384
+
385
+ const prev = entry.value;
386
+ entry.value = nextVal;
387
+ entry.write(nextVal);
388
+
389
+ const event = {
390
+ topic: name,
391
+ value: nextVal,
392
+ prevValue: prev,
393
+ timestamp: typeof performance !== 'undefined' ? performance.now() : Date.now(),
394
+ originWidgetId: originId
395
+ };
396
+
397
+ this.telemetrySubscribers.forEach(cb => {
398
+ try { cb(event); } catch (e) { console.error(e); }
399
+ });
400
+
401
+ this.cycleStack.add(name);
402
+ try {
403
+ entry.subscribers.forEach(sub => {
404
+ try { sub(nextVal, event); } catch (e) { console.error(e); }
405
+ });
406
+ } finally {
407
+ this.cycleStack.delete(name);
408
+ }
409
+ };
410
+
411
+ return [read, write];
412
+ }
413
+
414
+ subscribe(name, fn) {
415
+ if (!this.topics.has(name)) {
416
+ this.topic(name, undefined);
417
+ }
418
+ const entry = this.topics.get(name);
419
+ entry.subscribers.add(fn);
420
+ return () => entry.subscribers.delete(fn);
421
+ }
422
+
423
+ onTelemetry(fn) {
424
+ this.telemetrySubscribers.add(fn);
425
+ return () => this.telemetrySubscribers.delete(fn);
426
+ }
427
+ }
428
+
429
+ export const signalMesh = new SignalMeshEngine();
430
+ export const createTopicSignal = (topic, initialVal) => signalMesh.topic(topic, initialVal);
431
+
432
+ // ─── Sola Sentinel & Intent Telemetry Observer ───
433
+ export class SolaSentinel {
434
+ constructor(name = 'default', options = {}) {
435
+ this.name = name;
436
+ this.thresholdMs = options.thresholdMs || 600;
437
+ this.maxRageClicks = options.maxRageClicks || 3;
438
+ this.clickHistory = [];
439
+ this.subscribers = new Set();
440
+ this.frictionEvents = [];
441
+ this.flowIndex = 99.8;
442
+ }
443
+
444
+ recordClick(actionId, target = 'button') {
445
+ const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
446
+ this.clickHistory.push({ actionId, target, timestamp: now });
447
+ this.clickHistory = this.clickHistory.filter(c => now - c.timestamp < 2000);
448
+
449
+ const recent = this.clickHistory.filter(c => c.actionId === actionId && now - c.timestamp < this.thresholdMs);
450
+ if (recent.length >= this.maxRageClicks) {
451
+ this.triggerFrictionAlert({
452
+ type: 'RAGE_CLICK',
453
+ actionId,
454
+ target,
455
+ count: recent.length,
456
+ timestamp: now,
457
+ severity: 'HIGH',
458
+ message: `Rage-click burst: ${recent.length} taps in ${Math.round(now - recent[0].timestamp)}ms`
459
+ });
460
+ }
461
+ }
462
+
463
+ recordSignalDrop(topic, error) {
464
+ this.triggerFrictionAlert({
465
+ type: 'SIGNAL_TIMEOUT',
466
+ topic,
467
+ error: error?.message || String(error),
468
+ timestamp: typeof performance !== 'undefined' ? performance.now() : Date.now(),
469
+ severity: 'CRITICAL',
470
+ message: `Signal channel "${topic}" breached SLA timeout (504 Gateway Stall)`
471
+ });
472
+ }
473
+
474
+ triggerFrictionAlert(event) {
475
+ this.frictionEvents.unshift(event);
476
+ if (this.frictionEvents.length > 50) this.frictionEvents.pop();
477
+ this.flowIndex = Math.max(68.5, Number((this.flowIndex - 3.8).toFixed(1)));
478
+
479
+ this.subscribers.forEach(cb => {
480
+ try { cb(event, this); } catch(e) { console.error(e); }
481
+ });
482
+ }
483
+
484
+ onFriction(cb) {
485
+ this.subscribers.add(cb);
486
+ return () => this.subscribers.delete(cb);
487
+ }
488
+ }
489
+
490
+ export function createSentinel(name, options) {
491
+ return new SolaSentinel(name, options);
492
+ }
493
+