@sola-air-ui/core 1.0.3 → 1.1.1
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 +48 -0
- package/dist/sola-core.iife.js +159 -65
- package/dist/sola-core.iife.min.js +3 -2
- package/package.json +1 -1
- package/src/index.js +26 -71
- package/src/sentinel.js +179 -0
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @sola-air-ui/core
|
|
2
|
+
|
|
3
|
+
Zero-VDOM reactivity engine for [Sola AIR](https://sola-air.dev) — signals, derived values, effects, component lifecycle, and the `$intent` primitive. This is the runtime that compiled `.sola` components import; most apps won't call it directly (use `@sola-air-ui/compiler` or the `sola-air` meta-package instead), but it's published standalone for anyone embedding Sola-compiled output in a custom build pipeline.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @sola-air-ui/core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## What's in here
|
|
12
|
+
|
|
13
|
+
- **Signals** — `createSignal`, `createDerived`, `createEffect`, `flushSync`
|
|
14
|
+
- **Lifecycle** — `onMount`, `onDestroy`, `pushContext`/`popContext` (component-scoped mount/destroy queues)
|
|
15
|
+
- **Data & intent** — `createData`, `createIntent`, `configureIntent` (ambient AI state resolution, with SSE streaming support)
|
|
16
|
+
- **IIFE build** — `@sola-air-ui/core/iife` exports a pre-bundled `dist/sola-core.iife.js` for no-bundler environments (e.g. embedding in a CMS widget or a `<script>` tag), guarded so multiple copies on one page share a single instance via `window.SolaCore`.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
Compiled `.sola` output imports these directly:
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
import { createSignal, createDerived, onMount } from '@sola-air-ui/core';
|
|
24
|
+
|
|
25
|
+
const [count, setCount] = createSignal(0);
|
|
26
|
+
const doubled = createDerived(() => count() * 2);
|
|
27
|
+
|
|
28
|
+
onMount(() => {
|
|
29
|
+
console.log('mounted, doubled =', doubled());
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Component lifecycle contract
|
|
34
|
+
|
|
35
|
+
`pushContext()` / `popContext(ctx)` scope `onMount`/`onDestroy` callbacks to one component instance. If your own runtime code manually mounts nested components, always flush a component's mounts/destroys with the **specific context object** `pushContext()` returned for it — not by relying on whichever context happens to be globally "active":
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
const ctx = pushContext();
|
|
39
|
+
onMount(() => { /* ... */ });
|
|
40
|
+
// ...build DOM, possibly mounting nested child components...
|
|
41
|
+
__flush_mounts(ctx); // pass ctx explicitly
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
This matters because a mounted child component's own context stays on the stack (it isn't popped until that child unmounts), so relying on implicit "current" context after nested mounts silently drops the parent's own mount callbacks. `@sola-air-ui/compiler`-generated code already does this correctly.
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
MIT — see the [repo root](https://github.com/rbm3267/sola-air) for the full license and [changelog](https://github.com/rbm3267/sola-air/blob/main/CHANGELOG.md).
|
package/dist/sola-core.iife.js
CHANGED
|
@@ -40,6 +40,157 @@ var SolaCore = (() => {
|
|
|
40
40
|
pushContext: () => pushContext,
|
|
41
41
|
signalMesh: () => signalMesh
|
|
42
42
|
});
|
|
43
|
+
|
|
44
|
+
// src/sentinel.js
|
|
45
|
+
var FIELD_BUFFER_MAX_EVENTS = 50;
|
|
46
|
+
var FIELD_BUFFER_WINDOW_MS = 6e4;
|
|
47
|
+
var FIELD_TEXT_PREVIEW_MAX_CHARS = 200;
|
|
48
|
+
function now() {
|
|
49
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
50
|
+
}
|
|
51
|
+
var SolaSentinel = class {
|
|
52
|
+
constructor(name = "default", options = {}) {
|
|
53
|
+
this.name = name;
|
|
54
|
+
this.thresholdMs = options.thresholdMs || 600;
|
|
55
|
+
this.maxRageClicks = options.maxRageClicks || 3;
|
|
56
|
+
this.clickHistory = [];
|
|
57
|
+
this.subscribers = /* @__PURE__ */ new Set();
|
|
58
|
+
this.frictionEvents = [];
|
|
59
|
+
this.flowIndex = 99.8;
|
|
60
|
+
this.fieldHistory = [];
|
|
61
|
+
this.lastActivityAt = 0;
|
|
62
|
+
this.lastSuggestedAt = -Infinity;
|
|
63
|
+
this.idleThresholdMs = options.idleThresholdMs ?? 1500;
|
|
64
|
+
this.minSuggestIntervalMs = options.minSuggestIntervalMs ?? 8e3;
|
|
65
|
+
this.minEventsForSuggestion = options.minEventsForSuggestion ?? 2;
|
|
66
|
+
}
|
|
67
|
+
recordClick(actionId, target = "button") {
|
|
68
|
+
const ts = now();
|
|
69
|
+
this.clickHistory.push({ actionId, target, timestamp: ts });
|
|
70
|
+
this.clickHistory = this.clickHistory.filter((c) => ts - c.timestamp < 2e3);
|
|
71
|
+
const recent = this.clickHistory.filter((c) => c.actionId === actionId && ts - c.timestamp < this.thresholdMs);
|
|
72
|
+
if (recent.length >= this.maxRageClicks) {
|
|
73
|
+
this.triggerFrictionAlert({
|
|
74
|
+
type: "RAGE_CLICK",
|
|
75
|
+
actionId,
|
|
76
|
+
target,
|
|
77
|
+
count: recent.length,
|
|
78
|
+
timestamp: ts,
|
|
79
|
+
severity: "HIGH",
|
|
80
|
+
message: `Rage-click burst: ${recent.length} taps in ${Math.round(ts - recent[0].timestamp)}ms`
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
recordSignalDrop(topic, error) {
|
|
85
|
+
this.triggerFrictionAlert({
|
|
86
|
+
type: "SIGNAL_TIMEOUT",
|
|
87
|
+
topic,
|
|
88
|
+
error: error?.message || String(error),
|
|
89
|
+
timestamp: now(),
|
|
90
|
+
severity: "CRITICAL",
|
|
91
|
+
message: `Signal channel "${topic}" breached SLA timeout (504 Gateway Stall)`
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
triggerFrictionAlert(event) {
|
|
95
|
+
this.frictionEvents.unshift(event);
|
|
96
|
+
if (this.frictionEvents.length > 50) this.frictionEvents.pop();
|
|
97
|
+
this._recomputeFlowIndex(event.timestamp);
|
|
98
|
+
this.subscribers.forEach((cb) => {
|
|
99
|
+
try {
|
|
100
|
+
cb(event, this);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
console.error(e);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
onFriction(cb) {
|
|
107
|
+
this.subscribers.add(cb);
|
|
108
|
+
return () => this.subscribers.delete(cb);
|
|
109
|
+
}
|
|
110
|
+
// ─── Flow index ───
|
|
111
|
+
// A real computed score, not a fixed decrement: severity- and recency-weighted
|
|
112
|
+
// friction events, the share of field visits that were backtracks, plus how
|
|
113
|
+
// erratic the pacing between field events is (a proxy for hesitation).
|
|
114
|
+
_recomputeFlowIndex(ts = now()) {
|
|
115
|
+
let score = 99.8;
|
|
116
|
+
const recentFriction = this.frictionEvents.filter((e) => ts - e.timestamp < 12e4);
|
|
117
|
+
score -= recentFriction.reduce((sum, e) => {
|
|
118
|
+
const severityWeight = e.severity === "CRITICAL" ? 6 : e.severity === "HIGH" ? 3.8 : 2;
|
|
119
|
+
const recencyWeight = Math.max(0.3, 1 - (ts - e.timestamp) / 12e4);
|
|
120
|
+
return sum + severityWeight * recencyWeight;
|
|
121
|
+
}, 0);
|
|
122
|
+
const focusEvents = this.fieldHistory.filter((e) => e.type === "focus");
|
|
123
|
+
if (focusEvents.length > 0) {
|
|
124
|
+
const revisitRatio = focusEvents.filter((e) => e.revisit).length / focusEvents.length;
|
|
125
|
+
score -= revisitRatio * 15;
|
|
126
|
+
}
|
|
127
|
+
if (this.fieldHistory.length >= 3) {
|
|
128
|
+
const gaps = [];
|
|
129
|
+
for (let i = 1; i < this.fieldHistory.length; i++) {
|
|
130
|
+
gaps.push(this.fieldHistory[i].timestamp - this.fieldHistory[i - 1].timestamp);
|
|
131
|
+
}
|
|
132
|
+
const mean = gaps.reduce((a, b) => a + b, 0) / gaps.length;
|
|
133
|
+
const variance = gaps.reduce((sum, g) => sum + (g - mean) ** 2, 0) / gaps.length;
|
|
134
|
+
score -= Math.min(10, Math.sqrt(variance) / 500);
|
|
135
|
+
}
|
|
136
|
+
this.flowIndex = Math.max(0, Math.min(99.8, Number(score.toFixed(1))));
|
|
137
|
+
return this.flowIndex;
|
|
138
|
+
}
|
|
139
|
+
// ─── Ambient field observation ───
|
|
140
|
+
_pushFieldEvent(event) {
|
|
141
|
+
this.fieldHistory.push(event);
|
|
142
|
+
this.fieldHistory = this.fieldHistory.filter((e) => event.timestamp - e.timestamp < FIELD_BUFFER_WINDOW_MS).slice(-FIELD_BUFFER_MAX_EVENTS);
|
|
143
|
+
this.lastActivityAt = event.timestamp;
|
|
144
|
+
this._recomputeFlowIndex(event.timestamp);
|
|
145
|
+
}
|
|
146
|
+
recordFieldFocus(fieldId, ts = now()) {
|
|
147
|
+
const revisit = this.fieldHistory.some((e) => e.type === "blur" && e.fieldId === fieldId);
|
|
148
|
+
this._pushFieldEvent({ type: "focus", fieldId, revisit, timestamp: ts });
|
|
149
|
+
return revisit;
|
|
150
|
+
}
|
|
151
|
+
recordFieldBlur(fieldId, value, ts = now()) {
|
|
152
|
+
const text = String(value ?? "");
|
|
153
|
+
const preview = text.length > FIELD_TEXT_PREVIEW_MAX_CHARS ? text.slice(-FIELD_TEXT_PREVIEW_MAX_CHARS) : text;
|
|
154
|
+
this._pushFieldEvent({ type: "blur", fieldId, valuePreview: preview, valueLength: text.length, timestamp: ts });
|
|
155
|
+
}
|
|
156
|
+
// ─── Significance gate ───
|
|
157
|
+
// Fires at most once per `minSuggestIntervalMs`, only after `idleThresholdMs`
|
|
158
|
+
// of inactivity following new activity — never on every keystroke.
|
|
159
|
+
checkSignificance(ts = now()) {
|
|
160
|
+
if (this.fieldHistory.length < this.minEventsForSuggestion) return false;
|
|
161
|
+
if (this.lastActivityAt <= this.lastSuggestedAt) return false;
|
|
162
|
+
if (ts - this.lastActivityAt < this.idleThresholdMs) return false;
|
|
163
|
+
if (ts - this.lastSuggestedAt < this.minSuggestIntervalMs) return false;
|
|
164
|
+
this.lastSuggestedAt = ts;
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
// ─── Prompt builder ───
|
|
168
|
+
// Compact natural-language description of recent field activity, oldest first.
|
|
169
|
+
buildPrompt() {
|
|
170
|
+
if (this.fieldHistory.length === 0) return null;
|
|
171
|
+
const lines = this.fieldHistory.map((e) => {
|
|
172
|
+
if (e.type === "focus") {
|
|
173
|
+
return e.revisit ? `User returned to field "${e.fieldId}".` : `User focused field "${e.fieldId}".`;
|
|
174
|
+
}
|
|
175
|
+
if (e.valueLength === 0) return `User left field "${e.fieldId}" empty.`;
|
|
176
|
+
return `Field "${e.fieldId}" now contains: "${e.valuePreview}"`;
|
|
177
|
+
});
|
|
178
|
+
return [
|
|
179
|
+
"You are an ambient UX assistant embedded in a form.",
|
|
180
|
+
"Recent user activity, oldest first:",
|
|
181
|
+
...lines,
|
|
182
|
+
"",
|
|
183
|
+
"Based only on this activity, suggest exactly one concise, specific next-step action the user might want.",
|
|
184
|
+
'Respond as compact JSON only: {"label": string (<=60 chars), "action": string (<=140 chars), "confidence": number 0-1}.',
|
|
185
|
+
'If nothing useful can be suggested, respond {"label": null}.'
|
|
186
|
+
].join("\n");
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
function createSentinel(name, options) {
|
|
190
|
+
return new SolaSentinel(name, options);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/index.js
|
|
43
194
|
var effectStack = [];
|
|
44
195
|
var pendingEffects = /* @__PURE__ */ new Set();
|
|
45
196
|
var isFlushing = false;
|
|
@@ -181,19 +332,19 @@ var SolaCore = (() => {
|
|
|
181
332
|
activeContext.destroys.push(fn);
|
|
182
333
|
}
|
|
183
334
|
}
|
|
184
|
-
function __flush_mounts() {
|
|
185
|
-
if (
|
|
186
|
-
const cbs = [...
|
|
187
|
-
|
|
335
|
+
function __flush_mounts(ctx = activeContext) {
|
|
336
|
+
if (ctx && ctx.mounts.length > 0) {
|
|
337
|
+
const cbs = [...ctx.mounts];
|
|
338
|
+
ctx.mounts = [];
|
|
188
339
|
for (const cb of cbs) {
|
|
189
340
|
cb();
|
|
190
341
|
}
|
|
191
342
|
}
|
|
192
343
|
}
|
|
193
|
-
function __flush_destroys() {
|
|
194
|
-
if (
|
|
195
|
-
const cbs = [...
|
|
196
|
-
|
|
344
|
+
function __flush_destroys(ctx = activeContext) {
|
|
345
|
+
if (ctx && ctx.destroys.length > 0) {
|
|
346
|
+
const cbs = [...ctx.destroys];
|
|
347
|
+
ctx.destroys = [];
|
|
197
348
|
for (const cb of cbs) {
|
|
198
349
|
cb();
|
|
199
350
|
}
|
|
@@ -442,62 +593,5 @@ var SolaCore = (() => {
|
|
|
442
593
|
};
|
|
443
594
|
var signalMesh = new SignalMeshEngine();
|
|
444
595
|
var createTopicSignal = (topic, initialVal) => signalMesh.topic(topic, initialVal);
|
|
445
|
-
var SolaSentinel = class {
|
|
446
|
-
constructor(name = "default", options = {}) {
|
|
447
|
-
this.name = name;
|
|
448
|
-
this.thresholdMs = options.thresholdMs || 600;
|
|
449
|
-
this.maxRageClicks = options.maxRageClicks || 3;
|
|
450
|
-
this.clickHistory = [];
|
|
451
|
-
this.subscribers = /* @__PURE__ */ new Set();
|
|
452
|
-
this.frictionEvents = [];
|
|
453
|
-
this.flowIndex = 99.8;
|
|
454
|
-
}
|
|
455
|
-
recordClick(actionId, target = "button") {
|
|
456
|
-
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
457
|
-
this.clickHistory.push({ actionId, target, timestamp: now });
|
|
458
|
-
this.clickHistory = this.clickHistory.filter((c) => now - c.timestamp < 2e3);
|
|
459
|
-
const recent = this.clickHistory.filter((c) => c.actionId === actionId && now - c.timestamp < this.thresholdMs);
|
|
460
|
-
if (recent.length >= this.maxRageClicks) {
|
|
461
|
-
this.triggerFrictionAlert({
|
|
462
|
-
type: "RAGE_CLICK",
|
|
463
|
-
actionId,
|
|
464
|
-
target,
|
|
465
|
-
count: recent.length,
|
|
466
|
-
timestamp: now,
|
|
467
|
-
severity: "HIGH",
|
|
468
|
-
message: `Rage-click burst: ${recent.length} taps in ${Math.round(now - recent[0].timestamp)}ms`
|
|
469
|
-
});
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
recordSignalDrop(topic, error) {
|
|
473
|
-
this.triggerFrictionAlert({
|
|
474
|
-
type: "SIGNAL_TIMEOUT",
|
|
475
|
-
topic,
|
|
476
|
-
error: error?.message || String(error),
|
|
477
|
-
timestamp: typeof performance !== "undefined" ? performance.now() : Date.now(),
|
|
478
|
-
severity: "CRITICAL",
|
|
479
|
-
message: `Signal channel "${topic}" breached SLA timeout (504 Gateway Stall)`
|
|
480
|
-
});
|
|
481
|
-
}
|
|
482
|
-
triggerFrictionAlert(event) {
|
|
483
|
-
this.frictionEvents.unshift(event);
|
|
484
|
-
if (this.frictionEvents.length > 50) this.frictionEvents.pop();
|
|
485
|
-
this.flowIndex = Math.max(68.5, Number((this.flowIndex - 3.8).toFixed(1)));
|
|
486
|
-
this.subscribers.forEach((cb) => {
|
|
487
|
-
try {
|
|
488
|
-
cb(event, this);
|
|
489
|
-
} catch (e) {
|
|
490
|
-
console.error(e);
|
|
491
|
-
}
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
onFriction(cb) {
|
|
495
|
-
this.subscribers.add(cb);
|
|
496
|
-
return () => this.subscribers.delete(cb);
|
|
497
|
-
}
|
|
498
|
-
};
|
|
499
|
-
function createSentinel(name, options) {
|
|
500
|
-
return new SolaSentinel(name, options);
|
|
501
|
-
}
|
|
502
596
|
return __toCommonJS(src_exports);
|
|
503
597
|
})();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
/* @sola-air-ui/core v1.0.2 | MIT */
|
|
2
|
-
var SolaCore=(()=>{var
|
|
3
|
-
`);
|
|
2
|
+
var SolaCore=(()=>{var I=Object.defineProperty;var R=Object.getOwnPropertyDescriptor;var L=Object.getOwnPropertyNames;var $=Object.prototype.hasOwnProperty;var N=(s,t)=>{for(var e in t)I(s,e,{get:t[e],enumerable:!0})},O=(s,t,e,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let i of L(t))!$.call(s,i)&&i!==e&&I(s,i,{get:()=>t[i],enumerable:!(n=R(t,i))||n.enumerable});return s};var W=s=>O(I({},"__esModule",{value:!0}),s);var et={};N(et,{SolaSentinel:()=>E,__flush_destroys:()=>j,__flush_mounts:()=>V,configureData:()=>Y,configureIntent:()=>G,createData:()=>Z,createDerived:()=>U,createEffect:()=>D,createIntent:()=>z,createSentinel:()=>C,createSignal:()=>S,createTopicSignal:()=>tt,flushSync:()=>M,onDestroy:()=>k,onMount:()=>B,popContext:()=>P,pushContext:()=>X,signalMesh:()=>T});function y(){return typeof performance<"u"?performance.now():Date.now()}var E=class{constructor(t="default",e={}){this.name=t,this.thresholdMs=e.thresholdMs||600,this.maxRageClicks=e.maxRageClicks||3,this.clickHistory=[],this.subscribers=new Set,this.frictionEvents=[],this.flowIndex=99.8,this.fieldHistory=[],this.lastActivityAt=0,this.lastSuggestedAt=-1/0,this.idleThresholdMs=e.idleThresholdMs??1500,this.minSuggestIntervalMs=e.minSuggestIntervalMs??8e3,this.minEventsForSuggestion=e.minEventsForSuggestion??2}recordClick(t,e="button"){let n=y();this.clickHistory.push({actionId:t,target:e,timestamp:n}),this.clickHistory=this.clickHistory.filter(r=>n-r.timestamp<2e3);let i=this.clickHistory.filter(r=>r.actionId===t&&n-r.timestamp<this.thresholdMs);i.length>=this.maxRageClicks&&this.triggerFrictionAlert({type:"RAGE_CLICK",actionId:t,target:e,count:i.length,timestamp:n,severity:"HIGH",message:`Rage-click burst: ${i.length} taps in ${Math.round(n-i[0].timestamp)}ms`})}recordSignalDrop(t,e){this.triggerFrictionAlert({type:"SIGNAL_TIMEOUT",topic:t,error:e?.message||String(e),timestamp:y(),severity:"CRITICAL",message:`Signal channel "${t}" breached SLA timeout (504 Gateway Stall)`})}triggerFrictionAlert(t){this.frictionEvents.unshift(t),this.frictionEvents.length>50&&this.frictionEvents.pop(),this._recomputeFlowIndex(t.timestamp),this.subscribers.forEach(e=>{try{e(t,this)}catch(n){console.error(n)}})}onFriction(t){return this.subscribers.add(t),()=>this.subscribers.delete(t)}_recomputeFlowIndex(t=y()){let e=99.8,n=this.frictionEvents.filter(r=>t-r.timestamp<12e4);e-=n.reduce((r,o)=>{let l=o.severity==="CRITICAL"?6:o.severity==="HIGH"?3.8:2,c=Math.max(.3,1-(t-o.timestamp)/12e4);return r+l*c},0);let i=this.fieldHistory.filter(r=>r.type==="focus");if(i.length>0){let r=i.filter(o=>o.revisit).length/i.length;e-=r*15}if(this.fieldHistory.length>=3){let r=[];for(let c=1;c<this.fieldHistory.length;c++)r.push(this.fieldHistory[c].timestamp-this.fieldHistory[c-1].timestamp);let o=r.reduce((c,u)=>c+u,0)/r.length,l=r.reduce((c,u)=>c+(u-o)**2,0)/r.length;e-=Math.min(10,Math.sqrt(l)/500)}return this.flowIndex=Math.max(0,Math.min(99.8,Number(e.toFixed(1)))),this.flowIndex}_pushFieldEvent(t){this.fieldHistory.push(t),this.fieldHistory=this.fieldHistory.filter(e=>t.timestamp-e.timestamp<6e4).slice(-50),this.lastActivityAt=t.timestamp,this._recomputeFlowIndex(t.timestamp)}recordFieldFocus(t,e=y()){let n=this.fieldHistory.some(i=>i.type==="blur"&&i.fieldId===t);return this._pushFieldEvent({type:"focus",fieldId:t,revisit:n,timestamp:e}),n}recordFieldBlur(t,e,n=y()){let i=String(e??""),r=i.length>200?i.slice(-200):i;this._pushFieldEvent({type:"blur",fieldId:t,valuePreview:r,valueLength:i.length,timestamp:n})}checkSignificance(t=y()){return this.fieldHistory.length<this.minEventsForSuggestion||this.lastActivityAt<=this.lastSuggestedAt||t-this.lastActivityAt<this.idleThresholdMs||t-this.lastSuggestedAt<this.minSuggestIntervalMs?!1:(this.lastSuggestedAt=t,!0)}buildPrompt(){return this.fieldHistory.length===0?null:["You are an ambient UX assistant embedded in a form.","Recent user activity, oldest first:",...this.fieldHistory.map(e=>e.type==="focus"?e.revisit?`User returned to field "${e.fieldId}".`:`User focused field "${e.fieldId}".`:e.valueLength===0?`User left field "${e.fieldId}" empty.`:`Field "${e.fieldId}" now contains: "${e.valuePreview}"`),"","Based only on this activity, suggest exactly one concise, specific next-step action the user might want.",'Respond as compact JSON only: {"label": string (<=60 chars), "action": string (<=140 chars), "confidence": number 0-1}.','If nothing useful can be suggested, respond {"label": null}.'].join(`
|
|
3
|
+
`)}};function C(s,t){return new E(s,t)}var h=[],v=new Set,_=!1;function M(){for(;v.size>0;){let s=[...v];v.clear();for(let t of s)t.execute()}_=!1}function H(){_||(_=!0,queueMicrotask(M))}function S(s){let t=s,e=new Set;return[()=>{let r=h[h.length-1];return r&&(e.add(r),r.dependencies.add(e)),t},r=>{if(t!==r){t=r;for(let o of[...e])v.add(o);H()}}]}function D(s){let t={execute(){e(),h.push(t);try{s()}finally{h.pop()}},dependencies:new Set,cleanup:e};function e(){for(let n of t.dependencies)n.delete(t);t.dependencies.clear()}h.push(t);try{s()}finally{h.pop()}return e}function U(s){let t,e=!0,n=new Set,i=new Set,r={execute(){if(!e){e=!0;for(let l of[...n])v.add(l);H()}},dependencies:i,cleanup(){for(let l of i)l.delete(r);i.clear()}};return()=>{let l=h[h.length-1];if(l&&(n.add(l),l.dependencies.add(n)),e){r.cleanup(),i=new Set,r.dependencies=i,h.push(r);try{t=s()}finally{h.pop()}e=!1}return t}}var b=[],m=null;function X(){let s={mounts:[],destroys:[]};return b.push(s),m=s,s}function P(s){let t=b.lastIndexOf(s);t!==-1&&b.splice(t,1),m=b.length>0?b[b.length-1]:null}function B(s){m?m.mounts.push(s):s()}function k(s){m&&m.destroys.push(s)}function V(s=m){if(s&&s.mounts.length>0){let t=[...s.mounts];s.mounts=[];for(let e of t)e()}}function j(s=m){if(s&&s.destroys.length>0){let t=[...s.destroys];s.destroys=[];for(let e of t)e()}}var q={provider:"local",endpoint:"/api/intent",model:"gemini-2.5-flash",stream:!1},x={...q};function G(s){x={...x,...s}}async function J(s,t,e,n){let i=s.body.getReader(),r=new TextDecoder,o="";try{for(;;){let{done:l,value:c}=await i.read();if(l)break;o+=r.decode(c,{stream:!0});let u=o.split(`
|
|
4
|
+
`);o=u.pop();for(let a of u){if(!a.startsWith("data: "))continue;let f=a.slice(6).trim();if(f==="[DONE]"){e();return}try{let p=JSON.parse(f),d=p.token??p.delta??p.content??"";d&&t(d)}catch{f&&t(f)}}}e()}catch(l){l.name!=="AbortError"&&n(l)}}function z(s,t={}){let e={...x,...t},[n,i]=S(t.initial??null),[r,o]=S(!1),[l,c]=S(null),u=null;k(()=>{u&&u.abort()}),D(()=>{let f=typeof s=="function"?s():s;if(!f)return;u&&u.abort(),u=new AbortController,i(null),c(null),o(!0);let p=JSON.stringify({messages:[{role:"user",content:f}],model:e.model,provider:e.provider,stream:e.stream});fetch(e.endpoint,{method:"POST",headers:{"Content-Type":"application/json"},body:p,signal:u.signal}).then(d=>{if(!d.ok)throw new Error(`Intent failed: ${d.status}`);if(e.stream){let g="";return J(d,w=>{g+=w,i(g)},()=>o(!1),w=>{c(w.message),o(!1)})}return d.json().then(g=>{g?.components?.length>0?i(g.components[0]):g?.result!=null?i(g.result):i(g),o(!1)})}).catch(d=>{d.name!=="AbortError"&&(console.error("[Sola Intent Error]",d),c(d.message),o(!1))})});let a=n;return a.loading=r,a.error=l,a}var K={relayEndpoint:"http://localhost:4040/api/query",refresh:null},A={...K};function Y(s){A={...A,...s}}function Q(s){if(!s)return null;let t=s.match(/^(\d+)(s|m|h)$/);if(!t)return null;let e=parseInt(t[1]);switch(t[2]){case"s":return e*1e3;case"m":return e*60*1e3;case"h":return e*3600*1e3}return null}function Z(s,t={}){let e={...A,...t},[n,i]=S({loading:!0,data:null,error:null}),r=null,o=null;function l(){r&&r.abort(),r=new AbortController,i({loading:!0,data:n().data,error:null}),fetch(e.relayEndpoint,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({source:s,query:e.query||null,filters:e.filters||null,sort:e.sort||null,limit:e.limit||null,offset:e.offset||null}),signal:r.signal}).then(a=>{if(!a.ok)throw new Error(`Data fetch failed: ${a.status}`);return a.json()}).then(a=>{i({loading:!1,data:a.rows||a,error:null})}).catch(a=>{a.name!=="AbortError"&&(console.error("[Sola Data Error]",a),i({loading:!1,data:null,error:a.message}))})}l();let c=Q(e.refresh);c&&(o=setInterval(l,c));let u=()=>n();return u.refetch=l,u.stop=()=>{o&&clearInterval(o),r&&r.abort()},u}var F=class{constructor(){this.topics=new Map,this.telemetrySubscribers=new Set,this.cycleStack=new Set}topic(t,e){if(!this.topics.has(t)){let[o,l]=S(e);this.topics.set(t,{read:o,write:l,value:e,subscribers:new Set})}let n=this.topics.get(t);return[()=>n.read(),(o,l="signal")=>{let c=typeof o=="function"?o(n.value):o;if(n.value===c)return;if(this.cycleStack.has(t)){console.warn(`[Sola Signal Mesh] Cycle detected on topic "${t}". Aborting cyclic dispatch.`);return}let u=n.value;n.value=c,n.write(c);let a={topic:t,value:c,prevValue:u,timestamp:typeof performance<"u"?performance.now():Date.now(),originWidgetId:l};this.telemetrySubscribers.forEach(f=>{try{f(a)}catch(p){console.error(p)}}),this.cycleStack.add(t);try{n.subscribers.forEach(f=>{try{f(c,a)}catch(p){console.error(p)}})}finally{this.cycleStack.delete(t)}}]}subscribe(t,e){this.topics.has(t)||this.topic(t,void 0);let n=this.topics.get(t);return n.subscribers.add(e),()=>n.subscribers.delete(e)}onTelemetry(t){return this.telemetrySubscribers.add(t),()=>this.telemetrySubscribers.delete(t)}},T=new F,tt=(s,t)=>T.topic(s,t);return W(et);})();
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -178,22 +178,34 @@ export function onDestroy(fn) {
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
// Called by compiled mount() function to flush instance mounts
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
181
|
+
// Called by compiled mount() function to flush instance mounts.
|
|
182
|
+
//
|
|
183
|
+
// Takes the specific context to flush (the one `pushContext()` returned for
|
|
184
|
+
// THIS component instance) rather than trusting the module-global
|
|
185
|
+
// `activeContext`. If a nested child component mounts during this
|
|
186
|
+
// component's own DOM construction, the child's own pushContext() call
|
|
187
|
+
// reassigns `activeContext` to the child's context and never pops it back
|
|
188
|
+
// (a mounted child stays on the stack until it unmounts) — so by the time
|
|
189
|
+
// the parent reaches its own flush call, `activeContext` no longer points
|
|
190
|
+
// at the parent. Falls back to `activeContext` when called with no
|
|
191
|
+
// argument, for compiled bundles built before this fix.
|
|
192
|
+
export function __flush_mounts(ctx = activeContext) {
|
|
193
|
+
if (ctx && ctx.mounts.length > 0) {
|
|
194
|
+
const cbs = [...ctx.mounts];
|
|
195
|
+
ctx.mounts = [];
|
|
186
196
|
for (const cb of cbs) {
|
|
187
197
|
cb();
|
|
188
198
|
}
|
|
189
199
|
}
|
|
190
200
|
}
|
|
191
201
|
|
|
192
|
-
// Called when a component is torn down
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
202
|
+
// Called when a component is torn down. Same explicit-context fix as
|
|
203
|
+
// __flush_mounts above — see that comment for why activeContext alone
|
|
204
|
+
// isn't reliable once nested components are involved.
|
|
205
|
+
export function __flush_destroys(ctx = activeContext) {
|
|
206
|
+
if (ctx && ctx.destroys.length > 0) {
|
|
207
|
+
const cbs = [...ctx.destroys];
|
|
208
|
+
ctx.destroys = [];
|
|
197
209
|
for (const cb of cbs) {
|
|
198
210
|
cb();
|
|
199
211
|
}
|
|
@@ -476,65 +488,8 @@ class SignalMeshEngine {
|
|
|
476
488
|
export const signalMesh = new SignalMeshEngine();
|
|
477
489
|
export const createTopicSignal = (topic, initialVal) => signalMesh.topic(topic, initialVal);
|
|
478
490
|
|
|
479
|
-
// ─── Sola Sentinel & Intent Telemetry Observer ───
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
this.thresholdMs = options.thresholdMs || 600;
|
|
484
|
-
this.maxRageClicks = options.maxRageClicks || 3;
|
|
485
|
-
this.clickHistory = [];
|
|
486
|
-
this.subscribers = new Set();
|
|
487
|
-
this.frictionEvents = [];
|
|
488
|
-
this.flowIndex = 99.8;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
recordClick(actionId, target = 'button') {
|
|
492
|
-
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
493
|
-
this.clickHistory.push({ actionId, target, timestamp: now });
|
|
494
|
-
this.clickHistory = this.clickHistory.filter(c => now - c.timestamp < 2000);
|
|
495
|
-
|
|
496
|
-
const recent = this.clickHistory.filter(c => c.actionId === actionId && now - c.timestamp < this.thresholdMs);
|
|
497
|
-
if (recent.length >= this.maxRageClicks) {
|
|
498
|
-
this.triggerFrictionAlert({
|
|
499
|
-
type: 'RAGE_CLICK',
|
|
500
|
-
actionId,
|
|
501
|
-
target,
|
|
502
|
-
count: recent.length,
|
|
503
|
-
timestamp: now,
|
|
504
|
-
severity: 'HIGH',
|
|
505
|
-
message: `Rage-click burst: ${recent.length} taps in ${Math.round(now - recent[0].timestamp)}ms`
|
|
506
|
-
});
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
recordSignalDrop(topic, error) {
|
|
511
|
-
this.triggerFrictionAlert({
|
|
512
|
-
type: 'SIGNAL_TIMEOUT',
|
|
513
|
-
topic,
|
|
514
|
-
error: error?.message || String(error),
|
|
515
|
-
timestamp: typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
|
516
|
-
severity: 'CRITICAL',
|
|
517
|
-
message: `Signal channel "${topic}" breached SLA timeout (504 Gateway Stall)`
|
|
518
|
-
});
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
triggerFrictionAlert(event) {
|
|
522
|
-
this.frictionEvents.unshift(event);
|
|
523
|
-
if (this.frictionEvents.length > 50) this.frictionEvents.pop();
|
|
524
|
-
this.flowIndex = Math.max(68.5, Number((this.flowIndex - 3.8).toFixed(1)));
|
|
525
|
-
|
|
526
|
-
this.subscribers.forEach(cb => {
|
|
527
|
-
try { cb(event, this); } catch(e) { console.error(e); }
|
|
528
|
-
});
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
onFriction(cb) {
|
|
532
|
-
this.subscribers.add(cb);
|
|
533
|
-
return () => this.subscribers.delete(cb);
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
export function createSentinel(name, options) {
|
|
538
|
-
return new SolaSentinel(name, options);
|
|
539
|
-
}
|
|
491
|
+
// ─── Sola Sentinel & Ambient Intent Telemetry Observer ───
|
|
492
|
+
// Moved to sentinel.js — friction/rage-click detection, plus ambient
|
|
493
|
+
// field-behavior capture, significance gating, and prompt building.
|
|
494
|
+
export { SolaSentinel, createSentinel } from './sentinel.js';
|
|
540
495
|
|
package/src/sentinel.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// ─── Sola Sentinel & Ambient Intent Telemetry Observer ───
|
|
2
|
+
// Rage-click / signal-drop friction detection, plus ambient field-level
|
|
3
|
+
// behavior capture (focus, revisit, blur-with-value) feeding a debounced
|
|
4
|
+
// significance gate and a prompt builder for $intent-driven suggestions.
|
|
5
|
+
|
|
6
|
+
const FIELD_BUFFER_MAX_EVENTS = 50;
|
|
7
|
+
const FIELD_BUFFER_WINDOW_MS = 60_000;
|
|
8
|
+
const FIELD_TEXT_PREVIEW_MAX_CHARS = 200;
|
|
9
|
+
|
|
10
|
+
function now() {
|
|
11
|
+
return typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class SolaSentinel {
|
|
15
|
+
constructor(name = 'default', options = {}) {
|
|
16
|
+
this.name = name;
|
|
17
|
+
this.thresholdMs = options.thresholdMs || 600;
|
|
18
|
+
this.maxRageClicks = options.maxRageClicks || 3;
|
|
19
|
+
this.clickHistory = [];
|
|
20
|
+
this.subscribers = new Set();
|
|
21
|
+
this.frictionEvents = [];
|
|
22
|
+
this.flowIndex = 99.8;
|
|
23
|
+
|
|
24
|
+
// Ambient field-behavior observation
|
|
25
|
+
this.fieldHistory = [];
|
|
26
|
+
this.lastActivityAt = 0;
|
|
27
|
+
this.lastSuggestedAt = -Infinity;
|
|
28
|
+
this.idleThresholdMs = options.idleThresholdMs ?? 1500;
|
|
29
|
+
this.minSuggestIntervalMs = options.minSuggestIntervalMs ?? 8000;
|
|
30
|
+
this.minEventsForSuggestion = options.minEventsForSuggestion ?? 2;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
recordClick(actionId, target = 'button') {
|
|
34
|
+
const ts = now();
|
|
35
|
+
this.clickHistory.push({ actionId, target, timestamp: ts });
|
|
36
|
+
this.clickHistory = this.clickHistory.filter(c => ts - c.timestamp < 2000);
|
|
37
|
+
|
|
38
|
+
const recent = this.clickHistory.filter(c => c.actionId === actionId && ts - c.timestamp < this.thresholdMs);
|
|
39
|
+
if (recent.length >= this.maxRageClicks) {
|
|
40
|
+
this.triggerFrictionAlert({
|
|
41
|
+
type: 'RAGE_CLICK',
|
|
42
|
+
actionId,
|
|
43
|
+
target,
|
|
44
|
+
count: recent.length,
|
|
45
|
+
timestamp: ts,
|
|
46
|
+
severity: 'HIGH',
|
|
47
|
+
message: `Rage-click burst: ${recent.length} taps in ${Math.round(ts - recent[0].timestamp)}ms`
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
recordSignalDrop(topic, error) {
|
|
53
|
+
this.triggerFrictionAlert({
|
|
54
|
+
type: 'SIGNAL_TIMEOUT',
|
|
55
|
+
topic,
|
|
56
|
+
error: error?.message || String(error),
|
|
57
|
+
timestamp: now(),
|
|
58
|
+
severity: 'CRITICAL',
|
|
59
|
+
message: `Signal channel "${topic}" breached SLA timeout (504 Gateway Stall)`
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
triggerFrictionAlert(event) {
|
|
64
|
+
this.frictionEvents.unshift(event);
|
|
65
|
+
if (this.frictionEvents.length > 50) this.frictionEvents.pop();
|
|
66
|
+
this._recomputeFlowIndex(event.timestamp);
|
|
67
|
+
|
|
68
|
+
this.subscribers.forEach(cb => {
|
|
69
|
+
try { cb(event, this); } catch (e) { console.error(e); }
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
onFriction(cb) {
|
|
74
|
+
this.subscribers.add(cb);
|
|
75
|
+
return () => this.subscribers.delete(cb);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Flow index ───
|
|
79
|
+
// A real computed score, not a fixed decrement: severity- and recency-weighted
|
|
80
|
+
// friction events, the share of field visits that were backtracks, plus how
|
|
81
|
+
// erratic the pacing between field events is (a proxy for hesitation).
|
|
82
|
+
_recomputeFlowIndex(ts = now()) {
|
|
83
|
+
let score = 99.8;
|
|
84
|
+
|
|
85
|
+
const recentFriction = this.frictionEvents.filter(e => ts - e.timestamp < 120_000);
|
|
86
|
+
score -= recentFriction.reduce((sum, e) => {
|
|
87
|
+
const severityWeight = e.severity === 'CRITICAL' ? 6 : e.severity === 'HIGH' ? 3.8 : 2;
|
|
88
|
+
const recencyWeight = Math.max(0.3, 1 - (ts - e.timestamp) / 120_000);
|
|
89
|
+
return sum + severityWeight * recencyWeight;
|
|
90
|
+
}, 0);
|
|
91
|
+
|
|
92
|
+
const focusEvents = this.fieldHistory.filter(e => e.type === 'focus');
|
|
93
|
+
if (focusEvents.length > 0) {
|
|
94
|
+
const revisitRatio = focusEvents.filter(e => e.revisit).length / focusEvents.length;
|
|
95
|
+
score -= revisitRatio * 15;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (this.fieldHistory.length >= 3) {
|
|
99
|
+
const gaps = [];
|
|
100
|
+
for (let i = 1; i < this.fieldHistory.length; i++) {
|
|
101
|
+
gaps.push(this.fieldHistory[i].timestamp - this.fieldHistory[i - 1].timestamp);
|
|
102
|
+
}
|
|
103
|
+
const mean = gaps.reduce((a, b) => a + b, 0) / gaps.length;
|
|
104
|
+
const variance = gaps.reduce((sum, g) => sum + (g - mean) ** 2, 0) / gaps.length;
|
|
105
|
+
score -= Math.min(10, Math.sqrt(variance) / 500);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.flowIndex = Math.max(0, Math.min(99.8, Number(score.toFixed(1))));
|
|
109
|
+
return this.flowIndex;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Ambient field observation ───
|
|
113
|
+
|
|
114
|
+
_pushFieldEvent(event) {
|
|
115
|
+
this.fieldHistory.push(event);
|
|
116
|
+
this.fieldHistory = this.fieldHistory
|
|
117
|
+
.filter(e => event.timestamp - e.timestamp < FIELD_BUFFER_WINDOW_MS)
|
|
118
|
+
.slice(-FIELD_BUFFER_MAX_EVENTS);
|
|
119
|
+
this.lastActivityAt = event.timestamp;
|
|
120
|
+
this._recomputeFlowIndex(event.timestamp);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
recordFieldFocus(fieldId, ts = now()) {
|
|
124
|
+
const revisit = this.fieldHistory.some(e => e.type === 'blur' && e.fieldId === fieldId);
|
|
125
|
+
this._pushFieldEvent({ type: 'focus', fieldId, revisit, timestamp: ts });
|
|
126
|
+
return revisit;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
recordFieldBlur(fieldId, value, ts = now()) {
|
|
130
|
+
const text = String(value ?? '');
|
|
131
|
+
const preview = text.length > FIELD_TEXT_PREVIEW_MAX_CHARS
|
|
132
|
+
? text.slice(-FIELD_TEXT_PREVIEW_MAX_CHARS)
|
|
133
|
+
: text;
|
|
134
|
+
this._pushFieldEvent({ type: 'blur', fieldId, valuePreview: preview, valueLength: text.length, timestamp: ts });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Significance gate ───
|
|
138
|
+
// Fires at most once per `minSuggestIntervalMs`, only after `idleThresholdMs`
|
|
139
|
+
// of inactivity following new activity — never on every keystroke.
|
|
140
|
+
checkSignificance(ts = now()) {
|
|
141
|
+
if (this.fieldHistory.length < this.minEventsForSuggestion) return false;
|
|
142
|
+
if (this.lastActivityAt <= this.lastSuggestedAt) return false;
|
|
143
|
+
if (ts - this.lastActivityAt < this.idleThresholdMs) return false;
|
|
144
|
+
if (ts - this.lastSuggestedAt < this.minSuggestIntervalMs) return false;
|
|
145
|
+
|
|
146
|
+
this.lastSuggestedAt = ts;
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── Prompt builder ───
|
|
151
|
+
// Compact natural-language description of recent field activity, oldest first.
|
|
152
|
+
buildPrompt() {
|
|
153
|
+
if (this.fieldHistory.length === 0) return null;
|
|
154
|
+
|
|
155
|
+
const lines = this.fieldHistory.map(e => {
|
|
156
|
+
if (e.type === 'focus') {
|
|
157
|
+
return e.revisit
|
|
158
|
+
? `User returned to field "${e.fieldId}".`
|
|
159
|
+
: `User focused field "${e.fieldId}".`;
|
|
160
|
+
}
|
|
161
|
+
if (e.valueLength === 0) return `User left field "${e.fieldId}" empty.`;
|
|
162
|
+
return `Field "${e.fieldId}" now contains: "${e.valuePreview}"`;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return [
|
|
166
|
+
'You are an ambient UX assistant embedded in a form.',
|
|
167
|
+
'Recent user activity, oldest first:',
|
|
168
|
+
...lines,
|
|
169
|
+
'',
|
|
170
|
+
'Based only on this activity, suggest exactly one concise, specific next-step action the user might want.',
|
|
171
|
+
'Respond as compact JSON only: {"label": string (<=60 chars), "action": string (<=140 chars), "confidence": number 0-1}.',
|
|
172
|
+
'If nothing useful can be suggested, respond {"label": null}.'
|
|
173
|
+
].join('\n');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function createSentinel(name, options) {
|
|
178
|
+
return new SolaSentinel(name, options);
|
|
179
|
+
}
|