@reticlehq/browser 2.4.0 → 2.6.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/dist/actions/actions-dom.d.ts +8 -1
- package/dist/actions/actions-dom.js +26 -12
- package/dist/actions/actions.d.ts +46 -0
- package/dist/actions/actions.js +114 -26
- package/dist/actions/appeared-text.d.ts +27 -0
- package/dist/actions/appeared-text.js +92 -0
- package/dist/actions/scroll.js +1 -1
- package/dist/commands/commands.js +45 -22
- package/dist/connect-options.d.ts +6 -0
- package/dist/dom/a11y.js +20 -20
- package/dist/dom/auto-anchor.js +1 -1
- package/dist/dom/blind-spots.js +1 -1
- package/dist/dom/chart.js +5 -5
- package/dist/dom/desktop-capture.d.ts +2 -1
- package/dist/dom/desktop-capture.js +4 -4
- package/dist/dom/occlusion.js +2 -2
- package/dist/dom/query.js +68 -25
- package/dist/dom/realm.js +13 -13
- package/dist/dom/refs.d.ts +14 -0
- package/dist/dom/refs.js +20 -1
- package/dist/dom/shadow-registry.js +2 -2
- package/dist/dom/snapshot.js +32 -11
- package/dist/dom/source.js +3 -3
- package/dist/dom/theme.js +6 -6
- package/dist/dom/virtualized.js +1 -1
- package/dist/observers/blind-spots.js +2 -2
- package/dist/observers/console.js +3 -3
- package/dist/observers/dom.js +5 -5
- package/dist/observers/download.d.ts +2 -1
- package/dist/observers/download.js +2 -2
- package/dist/observers/focus.js +1 -1
- package/dist/observers/frames.js +1 -1
- package/dist/observers/health.js +70 -2
- package/dist/observers/install-all.d.ts +2 -1
- package/dist/observers/ipc.d.ts +2 -7
- package/dist/observers/ipc.js +7 -7
- package/dist/observers/network-body.js +2 -2
- package/dist/observers/network-redact.js +5 -5
- package/dist/observers/network-stream.js +2 -2
- package/dist/observers/network.d.ts +3 -3
- package/dist/observers/network.js +9 -9
- package/dist/observers/perf.js +1 -1
- package/dist/observers/scroll.js +1 -1
- package/dist/observers/state.js +1 -1
- package/dist/observers/storage.js +7 -7
- package/dist/presenter/presenter-brand.d.ts +19 -0
- package/dist/presenter/presenter-brand.js +21 -0
- package/dist/presenter/presenter-config.d.ts +17 -0
- package/dist/presenter/presenter-config.js +21 -0
- package/dist/presenter/presenter-controls.d.ts +1 -11
- package/dist/presenter/presenter-controls.js +6 -7
- package/dist/presenter/presenter-log.js +2 -2
- package/dist/presenter/presenter-run-state.js +1 -1
- package/dist/presenter/presenter-styles.d.ts +1 -1
- package/dist/presenter/presenter-styles.js +20 -9
- package/dist/presenter/presenter-tally.js +3 -3
- package/dist/presenter/presenter-test-helpers.d.ts +2 -1
- package/dist/presenter/presenter-test-helpers.js +2 -2
- package/dist/presenter/presenter-verbs.js +16 -11
- package/dist/presenter/presenter.js +13 -10
- package/dist/recorder/recorder.js +10 -9
- package/dist/registry/store-adapters.js +1 -1
- package/dist/registry/stores.js +3 -3
- package/dist/reticle-presenter-helpers.js +3 -3
- package/dist/reticle.js +31 -13
- package/dist/review/annotator.js +7 -7
- package/dist/security/serialization.js +9 -9
- package/dist/session-continuity.d.ts +17 -0
- package/dist/session-continuity.js +42 -0
- package/dist/timers/clock.js +28 -15
- package/dist/timers/native-console.js +1 -1
- package/dist/timers/native-timers.js +4 -4
- package/dist/transport/transport.d.ts +23 -0
- package/dist/transport/transport.js +85 -25
- package/package.json +6 -4
|
@@ -7,8 +7,8 @@ import { CONTROLS_CSS } from './presenter-controls.js';
|
|
|
7
7
|
*/
|
|
8
8
|
// No web-font @import: a dev-only SDK must not make the host page issue a cross-origin request to
|
|
9
9
|
// fonts.googleapis.com (and trip a `style-src 'self'` CSP with a console violation) just for HUD
|
|
10
|
-
// polish. The font
|
|
11
|
-
//
|
|
10
|
+
// polish. The font var below already prefers Inter if present and falls back to the system stack
|
|
11
|
+
// otherwise — so the overlay stays self-contained and never touches the network.
|
|
12
12
|
export const PRESENTER_CSS = `
|
|
13
13
|
[data-reticle-glow]{position:fixed;inset:0;pointer-events:none;z-index:2147483600;opacity:0;
|
|
14
14
|
transition:opacity .25s ease;box-shadow:inset 0 0 0 3px rgba(99,102,241,.9),inset 0 0 28px 6px rgba(99,102,241,.45);}
|
|
@@ -36,7 +36,6 @@ export const PRESENTER_CSS = `
|
|
|
36
36
|
--reticle-line:rgba(255,255,255,.09);--reticle-line2:rgba(255,255,255,.05);
|
|
37
37
|
--reticle-read:#54d2e6;--reticle-ok:#3dd7a6;--reticle-bad:#ff7a7a;
|
|
38
38
|
--reticle-font:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
|
39
|
-
--reticle-serif:"IBM Plex Serif",Georgia,"Times New Roman",serif;
|
|
40
39
|
position:fixed;left:50%;right:auto;bottom:20px;box-sizing:border-box;
|
|
41
40
|
width:384px;height:468px;max-width:calc(100vw - 32px);max-height:calc(100vh - 32px);
|
|
42
41
|
display:flex;flex-direction:column;overflow:hidden;text-align:left;z-index:2147483647;pointer-events:none;
|
|
@@ -68,11 +67,15 @@ export const PRESENTER_CSS = `
|
|
|
68
67
|
[data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud][data-on="1"]{pointer-events:auto;}
|
|
69
68
|
[data-reticle-hud] .reticle-hud-head{display:flex;align-items:center;gap:8px;flex:none;
|
|
70
69
|
padding:12px 12px 12px 15px;border-bottom:1px solid var(--reticle-line2);}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
[data-reticle-hud] .reticle-
|
|
70
|
+
/* Brand block: both marks always in the DOM, CSS picks one off data-reticle-min (see below) so the
|
|
71
|
+
collapsed/expanded swap needs no JS state. Artwork paints with currentColor, so it inherits
|
|
72
|
+
--reticle-fg and reads correctly on any HUD surface instead of baking in the export's #FAFAFA. */
|
|
73
|
+
[data-reticle-hud] .reticle-brand{display:inline-flex;align-items:center;flex:none;color:var(--reticle-fg);}
|
|
74
|
+
[data-reticle-hud] .reticle-mark{display:none;height:18px;width:auto;flex:none;}
|
|
75
|
+
[data-reticle-hud] .reticle-wordmark{display:block;height:16px;width:auto;flex:none;}
|
|
76
|
+
/* Visually hidden, still announced — the marks are aria-hidden artwork, this names the HUD. */
|
|
77
|
+
[data-reticle-hud] .reticle-brand-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;
|
|
78
|
+
overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0;}
|
|
76
79
|
[data-reticle-hud] .reticle-head-sp{flex:1;}
|
|
77
80
|
[data-reticle-hud] .reticle-live{display:none;flex:1;min-width:0;color:var(--reticle-muted);font-size:12.5px;
|
|
78
81
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
|
@@ -103,7 +106,6 @@ export const PRESENTER_CSS = `
|
|
|
103
106
|
[data-reticle-hud] .reticle-chip[data-mode="idle"]{display:none;}
|
|
104
107
|
[data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud]{height:50px;border-radius:25px;cursor:pointer;}
|
|
105
108
|
[data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] .reticle-hud-head{border-bottom:none;height:50px;padding:0 12px 0 16px;}
|
|
106
|
-
[data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] .reticle-brand,
|
|
107
109
|
[data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] .reticle-chip,
|
|
108
110
|
[data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] .reticle-head-sp,
|
|
109
111
|
[data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] [data-reticle-min-btn],
|
|
@@ -113,6 +115,9 @@ export const PRESENTER_CSS = `
|
|
|
113
115
|
[data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] [data-reticle-log],
|
|
114
116
|
[data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] [data-reticle-foot],
|
|
115
117
|
[data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] .reticle-banner{display:none;}
|
|
118
|
+
/* Collapsed to a pill: mark only, beside the dot. Expanded: the full wordmark. */
|
|
119
|
+
[data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] .reticle-wordmark{display:none;}
|
|
120
|
+
[data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] .reticle-mark{display:block;}
|
|
116
121
|
[data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] .reticle-live{display:block;}
|
|
117
122
|
[data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] .reticle-maxhint{display:inline-flex;}
|
|
118
123
|
[data-reticle-mode="reading"] [data-reticle-glow][data-on="1"]{
|
|
@@ -122,5 +127,11 @@ export const PRESENTER_CSS = `
|
|
|
122
127
|
[data-reticle-overlay][data-reticle-throttled="1"] [data-reticle-glow][data-on="1"]{
|
|
123
128
|
box-shadow:inset 0 0 0 3px rgba(251,191,36,.9),inset 0 0 28px 6px rgba(251,191,36,.45);}
|
|
124
129
|
[data-reticle-overlay][data-reticle-throttled="1"] [data-reticle-hud]{--reticle-accent:#fbbf24;--reticle-accent-soft:rgba(251,191,36,.16);}
|
|
130
|
+
/* The two INFINITE animations — the header dot and the border glow — are pure decoration and were
|
|
131
|
+
blinking regardless of the user's motion preference. Everything else the presenter animates is a
|
|
132
|
+
one-shot (ripple, tally pop) or a transition, which reduce-motion tolerates. */
|
|
133
|
+
@media (prefers-reduced-motion:reduce){
|
|
134
|
+
[data-reticle-glow][data-on="1"],
|
|
135
|
+
[data-reticle-glow][data-on="1"][data-busy="1"]{animation:none;}}
|
|
125
136
|
${LOG_CSS}
|
|
126
137
|
${CONTROLS_CSS}`;
|
|
@@ -21,14 +21,14 @@ export function renderTally(el, runLog, prev) {
|
|
|
21
21
|
const next = countVerdicts(runLog);
|
|
22
22
|
if (el === undefined)
|
|
23
23
|
return next;
|
|
24
|
-
if (next.passes
|
|
24
|
+
if (0 === next.passes && 0 === next.fails) {
|
|
25
25
|
el.setAttribute('hidden', '');
|
|
26
26
|
return next;
|
|
27
27
|
}
|
|
28
28
|
const bumpPass = next.passes > prev.passes ? ' data-bump="1"' : '';
|
|
29
29
|
const bumpFail = next.fails > prev.fails ? ' data-bump="1"' : '';
|
|
30
|
-
const dimP = next.passes
|
|
31
|
-
const dimF = next.fails
|
|
30
|
+
const dimP = 0 === next.passes ? ' data-z="1"' : '';
|
|
31
|
+
const dimF = 0 === next.fails ? ' data-z="1"' : '';
|
|
32
32
|
el.removeAttribute('hidden');
|
|
33
33
|
el.innerHTML =
|
|
34
34
|
`<span class="reticle-t-pass"${dimP}${bumpPass}>✓ ${String(next.passes)}</span>` +
|
|
@@ -7,9 +7,10 @@ export declare const wait: (ms: number) => Promise<void>;
|
|
|
7
7
|
export declare const until: (pred: () => boolean, ms?: number) => Promise<boolean>;
|
|
8
8
|
export declare const dataOn: () => string | null;
|
|
9
9
|
export declare const dataBusy: () => string | null;
|
|
10
|
-
|
|
10
|
+
interface GlowFlips {
|
|
11
11
|
enters: number;
|
|
12
12
|
exits: number;
|
|
13
13
|
stop: () => void;
|
|
14
14
|
}
|
|
15
15
|
export declare function trackGlowFlips(glow: HTMLElement): GlowFlips;
|
|
16
|
+
export {};
|
|
@@ -22,9 +22,9 @@ export function trackGlowFlips(glow) {
|
|
|
22
22
|
if (r.attributeName !== 'data-on')
|
|
23
23
|
continue;
|
|
24
24
|
const v = glow.getAttribute('data-on');
|
|
25
|
-
if (
|
|
25
|
+
if ('1' === v)
|
|
26
26
|
counts.enters++;
|
|
27
|
-
if (
|
|
27
|
+
if ('0' === v)
|
|
28
28
|
counts.exits++;
|
|
29
29
|
}
|
|
30
30
|
});
|
|
@@ -1,26 +1,31 @@
|
|
|
1
1
|
// Present-tense verb for an action label in the presenter HUD (cursor status + act-log row).
|
|
2
2
|
// Presenter-only UI copy — never a wire string. Shared by presenter.ts (cursor) and reticle.ts
|
|
3
3
|
// (act-log row) so the two stay in lockstep.
|
|
4
|
+
//
|
|
5
|
+
// The CASES are wire values though, and they are core's ActionType. Spelled as free strings here,
|
|
6
|
+
// a rename in core left this switch silently falling through to `default` — which returns the raw
|
|
7
|
+
// action name, so the HUD degrades to "webmcp Save" instead of a verb and nothing fails.
|
|
8
|
+
import { ActionType } from '@reticlehq/core';
|
|
4
9
|
export function actionVerb(action) {
|
|
5
10
|
switch (action) {
|
|
6
|
-
case
|
|
7
|
-
case
|
|
11
|
+
case ActionType.CLICK:
|
|
12
|
+
case ActionType.DBLCLICK:
|
|
8
13
|
return 'Clicking';
|
|
9
|
-
case
|
|
10
|
-
case
|
|
14
|
+
case ActionType.FILL:
|
|
15
|
+
case ActionType.TYPE:
|
|
11
16
|
return 'Typing into';
|
|
12
|
-
case
|
|
17
|
+
case ActionType.HOVER:
|
|
13
18
|
return 'Hovering';
|
|
14
|
-
case
|
|
19
|
+
case ActionType.SELECT:
|
|
15
20
|
return 'Selecting';
|
|
16
|
-
case
|
|
21
|
+
case ActionType.SUBMIT:
|
|
17
22
|
return 'Submitting';
|
|
18
|
-
case
|
|
19
|
-
case
|
|
23
|
+
case ActionType.CHECK:
|
|
24
|
+
case ActionType.UNCHECK:
|
|
20
25
|
return 'Toggling';
|
|
21
|
-
case
|
|
26
|
+
case ActionType.UPLOAD:
|
|
22
27
|
return 'Uploading to';
|
|
23
|
-
case
|
|
28
|
+
case ActionType.DRAG:
|
|
24
29
|
return 'Dragging';
|
|
25
30
|
default:
|
|
26
31
|
return action;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { ReticleCommand, PresenterMode, SessionState, isPresenterTone, isSessionState, } from '@reticlehq/core';
|
|
1
|
+
import { ActionType, ReticleCommand, PresenterMode, SessionState, isPresenterTone, isSessionState, } from '@reticlehq/core';
|
|
2
2
|
import { refs } from '../dom/refs.js';
|
|
3
3
|
import { actionVerb } from './presenter-verbs.js';
|
|
4
4
|
import { nativeSetTimeout, nativeClearTimeout, nativeNow } from '../timers/native-timers.js';
|
|
5
5
|
import { LOG_KIND, CHIP_LABEL, DATA_RETICLE_LOG, HUMAN_ROW_PREFIX, clampLogMax, formatElapsed, humanDuration, appendLogRow, } from './presenter-log.js';
|
|
6
6
|
import { PRESENTER_CSS } from './presenter-styles.js';
|
|
7
|
-
import {
|
|
7
|
+
import { BRAND_HTML } from './presenter-brand.js';
|
|
8
|
+
import { BorderMode, DEFAULT_BORDER_MODE, DATA_BUSY, BUSY_OFF, effectivePaceMs, GlowPhase, IDLE_AFTER_MS, HEARTBEAT_MS, IDLE_NOTICE_MS, IDLE_END_MS, IDLE_END_MIN_MS, GLOW_FADE_MS, GLOW_ON, GLOW_OFF, DATA_ON, MIN_ATTR, THROTTLED_ATTR, } from './presenter-config.js';
|
|
8
9
|
import { buildRunState } from './presenter-run-state.js';
|
|
9
10
|
import { moveCursor, ringAround, spawnRipple, pace } from './presenter-effects.js';
|
|
10
11
|
import { GlowController } from './presenter-glow.js';
|
|
@@ -57,7 +58,7 @@ export class Presenter {
|
|
|
57
58
|
#onControl;
|
|
58
59
|
#panel;
|
|
59
60
|
constructor(options = {}) {
|
|
60
|
-
this.#paceMs = options.paceMs
|
|
61
|
+
this.#paceMs = effectivePaceMs(options.paceMs);
|
|
61
62
|
this.#now = options.now ?? nativeNow;
|
|
62
63
|
this.#heartbeatMs = options.heartbeatMs ?? HEARTBEAT_MS;
|
|
63
64
|
this.#idleNoticeMs = options.idleNoticeMs ?? IDLE_NOTICE_MS;
|
|
@@ -111,7 +112,7 @@ export class Presenter {
|
|
|
111
112
|
return void this.#panel.setFlows(a['flows']);
|
|
112
113
|
const state = a['state'];
|
|
113
114
|
const tone = a['tone'];
|
|
114
|
-
const text = typeof a['text']
|
|
115
|
+
const text = 'string' === typeof a['text'] && a['text'].length > 0 ? a['text'] : undefined;
|
|
115
116
|
if (isSessionState(state))
|
|
116
117
|
this.setState(state, text, isPresenterTone(tone) ? tone : undefined);
|
|
117
118
|
}
|
|
@@ -124,7 +125,7 @@ export class Presenter {
|
|
|
124
125
|
this.#pruneLog();
|
|
125
126
|
}
|
|
126
127
|
mount() {
|
|
127
|
-
if (this.#root !== undefined || typeof document
|
|
128
|
+
if (this.#root !== undefined || 'undefined' === typeof document)
|
|
128
129
|
return;
|
|
129
130
|
const style = document.createElement('style');
|
|
130
131
|
style.setAttribute('data-reticle-overlay', '');
|
|
@@ -137,7 +138,7 @@ export class Presenter {
|
|
|
137
138
|
<div data-reticle-cursor></div>
|
|
138
139
|
<div data-reticle-ring></div>
|
|
139
140
|
<div data-reticle-hud>
|
|
140
|
-
<div class="reticle-hud-head"
|
|
141
|
+
<div class="reticle-hud-head">${BRAND_HTML}<span class="reticle-chip" data-reticle-chip></span><span class="reticle-tally" data-reticle-tally hidden></span><span class="reticle-live"></span><span class="reticle-head-sp"></span><button type="button" data-reticle-min-btn title="Minimise" aria-label="Minimise the panel">⌄</button>${CONTROLS_HEAD_HTML}<span class="reticle-maxhint" aria-hidden="true">⌃</span></div>
|
|
141
142
|
<div class="reticle-act-strip"><span class="reticle-act">idle</span></div>
|
|
142
143
|
${CONTROLS_BANNER_HTML}
|
|
143
144
|
<div ${DATA_RETICLE_LOG}></div>
|
|
@@ -162,7 +163,7 @@ export class Presenter {
|
|
|
162
163
|
setMin(true);
|
|
163
164
|
});
|
|
164
165
|
root.querySelector('.reticle-hud-head')?.addEventListener('click', () => {
|
|
165
|
-
if (root.getAttribute(MIN_ATTR)
|
|
166
|
+
if ('1' === root.getAttribute(MIN_ATTR))
|
|
166
167
|
setMin(false); // clicking the minimised bar restores
|
|
167
168
|
});
|
|
168
169
|
this.#glowCtl.setElements(this.#glow, this.#cursor);
|
|
@@ -348,7 +349,7 @@ export class Presenter {
|
|
|
348
349
|
if (this.#log === undefined)
|
|
349
350
|
return undefined;
|
|
350
351
|
const trimmed = text.trim();
|
|
351
|
-
if (trimmed.length
|
|
352
|
+
if (0 === trimmed.length)
|
|
352
353
|
return undefined;
|
|
353
354
|
this.#logBaseMs ??= ms;
|
|
354
355
|
// Structured run-log entry (mirrors the DOM row) for the exported run state, capped like the DOM.
|
|
@@ -381,7 +382,7 @@ export class Presenter {
|
|
|
381
382
|
}
|
|
382
383
|
/** Back-compat: narration appends to the live log (append-only, never overwrites). */
|
|
383
384
|
narrate(text, level = 'info') {
|
|
384
|
-
const line =
|
|
385
|
+
const line = 'info' === level ? text : `[${level}] ${text}`;
|
|
385
386
|
return this.log(LOG_KIND.NARRATION, line);
|
|
386
387
|
}
|
|
387
388
|
#pruneLog() {
|
|
@@ -417,7 +418,9 @@ export class Presenter {
|
|
|
417
418
|
moveCursor(this.#cursor, cx, cy);
|
|
418
419
|
ringAround(this.#ring, rect);
|
|
419
420
|
await pace(this.#paceMs);
|
|
420
|
-
if (
|
|
421
|
+
if (ActionType.CLICK === action ||
|
|
422
|
+
ActionType.DBLCLICK === action ||
|
|
423
|
+
ActionType.SUBMIT === action)
|
|
421
424
|
spawnRipple(this.#root, cx, cy);
|
|
422
425
|
}
|
|
423
426
|
}
|
|
@@ -33,6 +33,7 @@ const ANNOTATION_LABEL = {
|
|
|
33
33
|
[AnnotationKind.ASSERT_SIGNAL]: 'assert signal',
|
|
34
34
|
[AnnotationKind.ASSERT_VISIBLE]: 'assert visible',
|
|
35
35
|
[AnnotationKind.ASSERT_STATE]: 'assert state',
|
|
36
|
+
[AnnotationKind.ASSERT_NET]: 'assert request',
|
|
36
37
|
[AnnotationKind.MARK_DYNAMIC]: 'mark dynamic',
|
|
37
38
|
[AnnotationKind.SUCCESS_STATE]: 'success state',
|
|
38
39
|
[AnnotationKind.INTENT]: 'intent',
|
|
@@ -64,13 +65,13 @@ export function anchorFor(el) {
|
|
|
64
65
|
function isTextbox(el) {
|
|
65
66
|
if (el instanceof HTMLTextAreaElement)
|
|
66
67
|
return true;
|
|
67
|
-
return el instanceof HTMLInputElement && inputRole(el)
|
|
68
|
+
return el instanceof HTMLInputElement && 'textbox' === inputRole(el);
|
|
68
69
|
}
|
|
69
70
|
function inputRole(el) {
|
|
70
71
|
const type = el.type.toLowerCase();
|
|
71
|
-
if (
|
|
72
|
+
if ('checkbox' === type)
|
|
72
73
|
return 'checkbox';
|
|
73
|
-
if (
|
|
74
|
+
if ('radio' === type)
|
|
74
75
|
return 'radio';
|
|
75
76
|
if (['text', 'email', 'tel', 'url', 'search', 'password', ''].includes(type))
|
|
76
77
|
return 'textbox';
|
|
@@ -163,7 +164,7 @@ class Recorder {
|
|
|
163
164
|
mount() {
|
|
164
165
|
if (this.#root !== undefined)
|
|
165
166
|
return;
|
|
166
|
-
if (typeof document
|
|
167
|
+
if ('undefined' === typeof document)
|
|
167
168
|
return;
|
|
168
169
|
this.#buildToolbar();
|
|
169
170
|
this.#installCapture();
|
|
@@ -222,7 +223,7 @@ class Recorder {
|
|
|
222
223
|
// semantic step (CHECK/UNCHECK/SELECT). Recording a CLICK here too would replay any onChange
|
|
223
224
|
// side effect twice (analytics, dependent-field resets), so skip the duplicate.
|
|
224
225
|
if ((target instanceof HTMLInputElement &&
|
|
225
|
-
(
|
|
226
|
+
('checkbox' === inputRole(target) || 'radio' === inputRole(target))) ||
|
|
226
227
|
target instanceof HTMLSelectElement) {
|
|
227
228
|
return;
|
|
228
229
|
}
|
|
@@ -244,12 +245,12 @@ class Recorder {
|
|
|
244
245
|
const target = this.#ignore(ev);
|
|
245
246
|
if (target === undefined)
|
|
246
247
|
return;
|
|
247
|
-
if (target instanceof HTMLInputElement && inputRole(target)
|
|
248
|
+
if (target instanceof HTMLInputElement && 'checkbox' === inputRole(target)) {
|
|
248
249
|
this.#flushPendingFill();
|
|
249
250
|
this.#steps.push(buildStep(target, target.checked ? ActionType.CHECK : ActionType.UNCHECK));
|
|
250
251
|
return;
|
|
251
252
|
}
|
|
252
|
-
if (target instanceof HTMLInputElement && inputRole(target)
|
|
253
|
+
if (target instanceof HTMLInputElement && 'radio' === inputRole(target)) {
|
|
253
254
|
this.#flushPendingFill();
|
|
254
255
|
this.#steps.push(buildStep(target, ActionType.CHECK));
|
|
255
256
|
return;
|
|
@@ -328,7 +329,7 @@ class Recorder {
|
|
|
328
329
|
#start() {
|
|
329
330
|
// A fresh span — no leakage from a previous Record→Stop cycle.
|
|
330
331
|
this.#steps = [];
|
|
331
|
-
this.#startPath = typeof location
|
|
332
|
+
this.#startPath = 'undefined' === typeof location ? undefined : location.pathname;
|
|
332
333
|
this.#annotations = [];
|
|
333
334
|
this.#pendingFill = undefined;
|
|
334
335
|
this.#draft = undefined;
|
|
@@ -340,7 +341,7 @@ class Recorder {
|
|
|
340
341
|
const name = this.#nameField() ?? this.#deps.defaultName ?? DEFAULT_NAME;
|
|
341
342
|
const flow = compileRecording(name, this.#steps, this.#annotations, this.#deps.now(), this.#startPath);
|
|
342
343
|
this.#deps.emit(EventType.FLOW_RECORDED, { name, flow });
|
|
343
|
-
this.#setStatus(this.#steps.length
|
|
344
|
+
this.#setStatus(0 === this.#steps.length ? RECORDER_EMPTY_MSG : STATUS_IDLE);
|
|
344
345
|
this.#setPhase(RecorderPhase.IDLE);
|
|
345
346
|
this.#closeMenu();
|
|
346
347
|
}
|
package/dist/registry/stores.js
CHANGED
|
@@ -20,12 +20,12 @@ function isStoreLike(source) {
|
|
|
20
20
|
// Zustand's `create` returns a CALLABLE hook that also carries getState/subscribe, so a store can be
|
|
21
21
|
// typeof 'function' as well as 'object'. What distinguishes it from a plain getter is those two members
|
|
22
22
|
// — never the typeof. (Checking only for 'object' left every Zustand app on pull-only reads.)
|
|
23
|
-
if (
|
|
23
|
+
if (null === source)
|
|
24
24
|
return false;
|
|
25
25
|
if (typeof source !== 'object' && typeof source !== 'function')
|
|
26
26
|
return false;
|
|
27
27
|
const candidate = source;
|
|
28
|
-
return typeof candidate.getState
|
|
28
|
+
return 'function' === typeof candidate.getState && 'function' === typeof candidate.subscribe;
|
|
29
29
|
}
|
|
30
30
|
/**
|
|
31
31
|
* App calls this once per store. PREFER passing the store itself — `registerStore('app', useApp)` —
|
|
@@ -43,7 +43,7 @@ export function registerStore(name, source, subscribe) {
|
|
|
43
43
|
return;
|
|
44
44
|
}
|
|
45
45
|
if (typeof source !== 'function') {
|
|
46
|
-
const hasSubscribe = typeof source
|
|
46
|
+
const hasSubscribe = 'object' === typeof source && source !== null && 'subscribe' in source;
|
|
47
47
|
nativeWarn(`[reticle] store "${name}" is neither a getter function nor a {getState, subscribe} store` +
|
|
48
48
|
(hasSubscribe
|
|
49
49
|
? ` — it has subscribe but no getState. Wrap it with an adapter or add getState.`
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { ReticleCommand, PresenterMode } from '@reticlehq/core';
|
|
1
|
+
import { ReticleCommand, PresenterMode, QueryBy } from '@reticlehq/core';
|
|
2
2
|
import { refs } from './dom/refs.js';
|
|
3
3
|
import { describe } from './dom/a11y.js';
|
|
4
4
|
/** Coerce an unknown arg to a string, falling back to `fallback` (default empty) when it isn't one. */
|
|
5
5
|
export function str(value, fallback = '') {
|
|
6
|
-
return typeof value
|
|
6
|
+
return 'string' === typeof value ? value : fallback;
|
|
7
7
|
}
|
|
8
8
|
/** A short human label for a ref ("button \"Save\"") for the presenter HUD. */
|
|
9
9
|
export function refLabel(refId) {
|
|
@@ -69,7 +69,7 @@ export function presentStatus(commandName, args = {}) {
|
|
|
69
69
|
}
|
|
70
70
|
/** Compact "what we're looking for" from a query's args (testid/value/name/role/text/label). */
|
|
71
71
|
function queryTarget(q) {
|
|
72
|
-
const testid = str(q['testid']) ?? (str(q['by'])
|
|
72
|
+
const testid = str(q['testid']) ?? (QueryBy.TESTID === str(q['by']) ? str(q['value']) : undefined);
|
|
73
73
|
if (testid !== undefined)
|
|
74
74
|
return `[testid=${testid}]`;
|
|
75
75
|
const name = str(q['name']);
|
package/dist/reticle.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { EventType, RETICLE_DEFAULT_PORT, RETICLE_PROTOCOL_VERSION, RETICLE_URL_PARAM, bridgeWsUrl, ReticleCommand, MessageKind, SESSION_AUTO, SessionState, TRANSPORT_LIMITS, buildRedactionPolicy, isLoopbackHostname, isLocalPage, setActiveRedactionPolicy, wireRedactionKeys, RETICLE_ROOT_GLOBAL, } from '@reticlehq/core';
|
|
1
|
+
import { EventType, RETICLE_DEFAULT_PORT, RETICLE_PROTOCOL_VERSION, RETICLE_URL_PARAM, bridgeWsUrl, ReticleCommand, MessageKind, SESSION_AUTO, SessionState, TRANSPORT_LIMITS, buildRedactionPolicy, isLoopbackHostname, isLocalPage, setActiveRedactionPolicy, wireRedactionKeys, RETICLE_ROOT_GLOBAL, RETICLE_SDK_VERSION_GLOBAL, CONTRACT_FINGERPRINT, } from '@reticlehq/core';
|
|
2
|
+
import { rememberSessionLabel } from './session-continuity.js';
|
|
2
3
|
import { createCommandRegistry, RELOAD_CACHE_BUST_PARAM, } from './commands/commands.js';
|
|
3
4
|
import { Transport } from './transport/transport.js';
|
|
4
5
|
import { adapterNames } from './registry/adapters.js';
|
|
@@ -19,7 +20,7 @@ import { Annotator } from './review/annotator.js';
|
|
|
19
20
|
* behind `import.meta.env.DEV` so the SDK is dead-code-eliminated from prod bundles entirely.
|
|
20
21
|
*/
|
|
21
22
|
export function shouldBlockProduction(nodeEnv, allowInProduction) {
|
|
22
|
-
return
|
|
23
|
+
return 'production' === nodeEnv && !allowInProduction;
|
|
23
24
|
}
|
|
24
25
|
export function connectionPolicy(pageHostname, bridgeUrl, allowNonLocalhost, token,
|
|
25
26
|
/** The page's protocol. Desktop webviews (file:, app:, tauri:) are local despite a non-loopback host. */
|
|
@@ -53,7 +54,7 @@ pageProtocol = 'http:') {
|
|
|
53
54
|
reason: 'Reticle is disabled outside localhost unless allowNonLocalhost is explicitly enabled',
|
|
54
55
|
};
|
|
55
56
|
}
|
|
56
|
-
if (token === undefined || token.length
|
|
57
|
+
if (token === undefined || 0 === token.length) {
|
|
57
58
|
return { allowed: false, reason: 'a pairing token is required outside localhost' };
|
|
58
59
|
}
|
|
59
60
|
return { allowed: true };
|
|
@@ -145,6 +146,7 @@ export class Reticle {
|
|
|
145
146
|
#annotator;
|
|
146
147
|
#eventCount = 0;
|
|
147
148
|
#token;
|
|
149
|
+
#sdkVersion;
|
|
148
150
|
#projectId;
|
|
149
151
|
/** App-declared extra redaction keys, announced in hello so the driven path honours them too. */
|
|
150
152
|
#redactKeys = [];
|
|
@@ -153,13 +155,13 @@ export class Reticle {
|
|
|
153
155
|
connect(options = {}) {
|
|
154
156
|
if (this.#connected)
|
|
155
157
|
return;
|
|
156
|
-
if (typeof window
|
|
158
|
+
if ('undefined' === typeof window || 'undefined' === typeof document)
|
|
157
159
|
return;
|
|
158
160
|
// Dev-only backstop: refuse to activate in a production build (SSR healthcheck, prod bundle opened
|
|
159
161
|
// on localhost). `process` may not exist in a raw browser, so read NODE_ENV off globalThis.
|
|
160
162
|
const proc = globalThis.process;
|
|
161
163
|
const nodeEnv = proc?.env?.NODE_ENV;
|
|
162
|
-
if (shouldBlockProduction(nodeEnv, options.allowInProduction
|
|
164
|
+
if (shouldBlockProduction(nodeEnv, true === options.allowInProduction)) {
|
|
163
165
|
globalThis.console.warn('[Reticle] disabled in production (NODE_ENV=production). Gate the import behind ' +
|
|
164
166
|
'import.meta.env.DEV, or pass allowInProduction:true to override.');
|
|
165
167
|
return;
|
|
@@ -169,7 +171,7 @@ export class Reticle {
|
|
|
169
171
|
// reload permanently pollutes the URL the app and the agent see.
|
|
170
172
|
stripReloadCacheBustParam();
|
|
171
173
|
const url = options.url ?? bridgeWsUrl(RETICLE_DEFAULT_PORT);
|
|
172
|
-
const policy = connectionPolicy(window.location.hostname, url, options.allowNonLocalhost
|
|
174
|
+
const policy = connectionPolicy(window.location.hostname, url, true === options.allowNonLocalhost, options.token, window.location.protocol);
|
|
173
175
|
if (!policy.allowed) {
|
|
174
176
|
globalThis.console.warn(`[Reticle] ${policy.reason ?? 'connection blocked'}`);
|
|
175
177
|
return;
|
|
@@ -179,10 +181,21 @@ export class Reticle {
|
|
|
179
181
|
if (options.root !== undefined && options.root.length > 0) {
|
|
180
182
|
globalThis[RETICLE_ROOT_GLOBAL] = options.root;
|
|
181
183
|
}
|
|
184
|
+
// The build plugin defines this; a hand-wired connect can pass it explicitly. Either way an
|
|
185
|
+
// absent value means UNKNOWN, so the bridge never reports an unknown pair as in sync.
|
|
186
|
+
const declaredVersion = options.sdkVersion ?? globalThis[RETICLE_SDK_VERSION_GLOBAL];
|
|
187
|
+
this.#sdkVersion =
|
|
188
|
+
'string' === typeof declaredVersion && declaredVersion.length > 0
|
|
189
|
+
? declaredVersion
|
|
190
|
+
: undefined;
|
|
182
191
|
// A pooled/headless launcher can stamp identity via namespaced URL params; explicit (non-auto)
|
|
183
192
|
// options win, but the `auto` sentinel defers to the URL param so leases correlate.
|
|
184
193
|
const identity = resolveConnectIdentity(options, window.location.search);
|
|
185
|
-
|
|
194
|
+
// Remembered per TAB, so a reload rejoins the same session instead of appearing as a new one and
|
|
195
|
+
// stranding the agent's handle — see session-continuity. An explicit id (connect option, or the
|
|
196
|
+
// lease's URL param) still wins.
|
|
197
|
+
const explicitSession = resolveSessionLabel(identity.session, () => '');
|
|
198
|
+
this.#session = rememberSessionLabel(explicitSession.length > 0 ? explicitSession : undefined, 'undefined' === typeof globalThis.sessionStorage ? undefined : globalThis.sessionStorage, () => 'function' === typeof globalThis.crypto?.randomUUID
|
|
186
199
|
? `s${globalThis.crypto.randomUUID()}`
|
|
187
200
|
: `s${Date.now().toString(36)}`);
|
|
188
201
|
this.#token =
|
|
@@ -211,7 +224,7 @@ export class Reticle {
|
|
|
211
224
|
// lodash debounce/throttle in the app (now - lastCall stays 0), so search boxes, autosave
|
|
212
225
|
// and resize handlers stop firing until a reload — with nothing on screen explaining why.
|
|
213
226
|
resetClock();
|
|
214
|
-
if (this.#presenter?.sessionActive
|
|
227
|
+
if (true === this.#presenter?.sessionActive) {
|
|
215
228
|
this.#presenter.setState(SessionState.ENDED, BRIDGE_LOST_SUMMARY);
|
|
216
229
|
}
|
|
217
230
|
},
|
|
@@ -229,9 +242,9 @@ export class Reticle {
|
|
|
229
242
|
setCapabilitiesListener(() => this.#transport?.reannounce());
|
|
230
243
|
const emit = this.#emit;
|
|
231
244
|
this.#teardowns = installAllObservers(emit, {
|
|
232
|
-
captureBodies: options.captureNetworkBodies
|
|
245
|
+
captureBodies: true === options.captureNetworkBodies,
|
|
233
246
|
});
|
|
234
|
-
if (options.overlay
|
|
247
|
+
if (true === options.overlay) {
|
|
235
248
|
this.#overlay = installOverlay();
|
|
236
249
|
this.#overlay.update({ connected: true, events: 0 });
|
|
237
250
|
}
|
|
@@ -261,7 +274,7 @@ export class Reticle {
|
|
|
261
274
|
// so the presenter is visible as soon as the agent is reachable — not just on first command.
|
|
262
275
|
this.#presenter.mount();
|
|
263
276
|
}
|
|
264
|
-
if (options.recorder
|
|
277
|
+
if (true === options.recorder) {
|
|
265
278
|
this.#recorder = installRecorder({ emit, now: () => Date.now() });
|
|
266
279
|
this.#recorder.mount();
|
|
267
280
|
}
|
|
@@ -375,7 +388,12 @@ export class Reticle {
|
|
|
375
388
|
adapters: adapterNames(),
|
|
376
389
|
...(this.#token === undefined ? {} : { token: this.#token }),
|
|
377
390
|
hasCapabilities: hasCapabilities(),
|
|
378
|
-
|
|
391
|
+
// Absent when no build plugin supplied one — "unknown", never "matching".
|
|
392
|
+
...(this.#sdkVersion === undefined ? {} : { sdkVersion: this.#sdkVersion }),
|
|
393
|
+
// Always present: derived from THIS build's core, so it needs no build plugin to supply it.
|
|
394
|
+
// It is the half of the skew check that works on a hand-wired connect.
|
|
395
|
+
contract: CONTRACT_FINGERPRINT,
|
|
396
|
+
...(0 === this.#redactKeys.length ? {} : { redactKeys: this.#redactKeys }),
|
|
379
397
|
};
|
|
380
398
|
}
|
|
381
399
|
async #handleCommand(command) {
|
|
@@ -388,7 +406,7 @@ export class Reticle {
|
|
|
388
406
|
// SESSION_CONFIG: the agent tunes the session for the app (currently the idle-end window).
|
|
389
407
|
if (command.name === ReticleCommand.SESSION_CONFIG) {
|
|
390
408
|
const idleEndMs = command.args['idleEndMs'];
|
|
391
|
-
if (typeof idleEndMs
|
|
409
|
+
if ('number' === typeof idleEndMs)
|
|
392
410
|
this.#presenter?.setIdleEndMs(idleEndMs);
|
|
393
411
|
return { ok: true, result: { applied: this.#presenter !== undefined, idleEndMs } };
|
|
394
412
|
}
|
package/dist/review/annotator.js
CHANGED
|
@@ -80,7 +80,7 @@ export class Annotator {
|
|
|
80
80
|
return this.#markCount;
|
|
81
81
|
}
|
|
82
82
|
mount() {
|
|
83
|
-
if (this.#root !== undefined || typeof document
|
|
83
|
+
if (this.#root !== undefined || 'undefined' === typeof document)
|
|
84
84
|
return;
|
|
85
85
|
const style = document.createElement('style');
|
|
86
86
|
style.setAttribute(MARK_ATTR, 'style');
|
|
@@ -193,7 +193,7 @@ export class Annotator {
|
|
|
193
193
|
return;
|
|
194
194
|
}
|
|
195
195
|
const rect = target.getBoundingClientRect();
|
|
196
|
-
if (rect.width
|
|
196
|
+
if (0 === rect.width && 0 === rect.height) {
|
|
197
197
|
this.#hi.setAttribute('data-on', '0');
|
|
198
198
|
return;
|
|
199
199
|
}
|
|
@@ -239,22 +239,22 @@ export class Annotator {
|
|
|
239
239
|
const send = pop.querySelector('button[data-send]');
|
|
240
240
|
const submit = () => {
|
|
241
241
|
const note = textarea?.value.trim() ?? '';
|
|
242
|
-
if (note.length
|
|
242
|
+
if (0 === note.length)
|
|
243
243
|
return; // never send an empty mark
|
|
244
244
|
this.#sendMark(note, resolved, x, y);
|
|
245
245
|
this.#closePopover();
|
|
246
246
|
};
|
|
247
247
|
textarea?.addEventListener('input', () => {
|
|
248
248
|
if (send !== null)
|
|
249
|
-
send.disabled = textarea.value.trim().length
|
|
249
|
+
send.disabled = 0 === textarea.value.trim().length;
|
|
250
250
|
});
|
|
251
251
|
// Keyboard ergonomics devs expect: ⌘/Ctrl+Enter sends, Esc cancels — without leaving the textarea.
|
|
252
252
|
textarea?.addEventListener('keydown', (e) => {
|
|
253
|
-
if (e.key
|
|
253
|
+
if ('Escape' === e.key) {
|
|
254
254
|
e.preventDefault();
|
|
255
255
|
this.#closePopover();
|
|
256
256
|
}
|
|
257
|
-
else if (e.key
|
|
257
|
+
else if ('Enter' === e.key && (e.metaKey || e.ctrlKey)) {
|
|
258
258
|
e.preventDefault();
|
|
259
259
|
submit();
|
|
260
260
|
}
|
|
@@ -269,7 +269,7 @@ export class Annotator {
|
|
|
269
269
|
anchor: resolved.anchor,
|
|
270
270
|
strategy: resolved.strategy,
|
|
271
271
|
label: resolved.label,
|
|
272
|
-
route: typeof location
|
|
272
|
+
route: 'undefined' === typeof location ? '' : location.pathname + location.search,
|
|
273
273
|
};
|
|
274
274
|
if (resolved.source !== undefined)
|
|
275
275
|
data['source'] = resolved.source;
|
|
@@ -31,18 +31,18 @@ function sanitize(value, state, depth, key) {
|
|
|
31
31
|
return TRUNCATED_VALUE;
|
|
32
32
|
}
|
|
33
33
|
state.nodes += 1;
|
|
34
|
-
if (
|
|
34
|
+
if (null === value || 'boolean' === typeof value)
|
|
35
35
|
return value;
|
|
36
|
-
if (typeof value
|
|
37
|
-
return boundedString(value, state, key?.toLowerCase()
|
|
36
|
+
if ('string' === typeof value) {
|
|
37
|
+
return boundedString(value, state, 'error' === key?.toLowerCase()
|
|
38
38
|
? TRANSPORT_LIMITS.MAX_ERROR_LENGTH
|
|
39
39
|
: TRANSPORT_LIMITS.MAX_STRING_LENGTH);
|
|
40
40
|
}
|
|
41
|
-
if (typeof value
|
|
41
|
+
if ('number' === typeof value)
|
|
42
42
|
return Number.isFinite(value) ? value : null;
|
|
43
|
-
if (typeof value
|
|
43
|
+
if ('bigint' === typeof value)
|
|
44
44
|
return value.toString();
|
|
45
|
-
if (
|
|
45
|
+
if ('undefined' === typeof value || 'function' === typeof value || 'symbol' === typeof value) {
|
|
46
46
|
return OMIT_VALUE;
|
|
47
47
|
}
|
|
48
48
|
// An invalid Date (`new Date(NaN)` / `new Date(undefined)`, easy to land in app state) throws
|
|
@@ -65,7 +65,7 @@ function sanitize(value, state, depth, key) {
|
|
|
65
65
|
state.droppedItems += value.size - n;
|
|
66
66
|
break;
|
|
67
67
|
}
|
|
68
|
-
const sk = boundedString(typeof k
|
|
68
|
+
const sk = boundedString('string' === typeof k ? k : String(k), state, MAX_KEY_LENGTH);
|
|
69
69
|
const sv = sanitize(v, state, depth + 1, sk);
|
|
70
70
|
if (sv !== OMIT_VALUE)
|
|
71
71
|
out[sk] = sv;
|
|
@@ -176,7 +176,7 @@ function sanitize(value, state, depth, key) {
|
|
|
176
176
|
const read = keys.map((key) => {
|
|
177
177
|
try {
|
|
178
178
|
const v = value[key];
|
|
179
|
-
return { key, value: v, scalar:
|
|
179
|
+
return { key, value: v, scalar: null === v || typeof v !== 'object', ok: true };
|
|
180
180
|
}
|
|
181
181
|
catch {
|
|
182
182
|
return { key, value: undefined, scalar: true, ok: false };
|
|
@@ -224,7 +224,7 @@ export function sanitizeWithReport(value) {
|
|
|
224
224
|
};
|
|
225
225
|
const sanitized = sanitize(value, state, 0);
|
|
226
226
|
const out = sanitized === OMIT_VALUE ? null : sanitized;
|
|
227
|
-
if (state.droppedItems
|
|
227
|
+
if (0 === state.droppedItems && 0 === state.truncatedValues)
|
|
228
228
|
return { value: out };
|
|
229
229
|
const parts = [];
|
|
230
230
|
if (state.droppedItems > 0)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep a tab's session id across a reload.
|
|
3
|
+
*
|
|
4
|
+
* The id was minted fresh on every `connect()`. A reload is a new document, so the page came back as
|
|
5
|
+
* a NEW session while the agent still held the old id — and every call after
|
|
6
|
+
* `reticle_navigate { reload: true }` was refused with "no browser session connected". Reported on 6
|
|
7
|
+
* of 6 apps; a reload is the most ordinary thing an agent does.
|
|
8
|
+
*
|
|
9
|
+
* `sessionStorage` is exactly the right scope: it survives reloads and same-tab navigations, and is
|
|
10
|
+
* NOT shared with another tab of the same app — so two tabs stay two sessions, which is what the
|
|
11
|
+
* multi-session tooling assumes.
|
|
12
|
+
*
|
|
13
|
+
* An explicit id still wins. `connect({ session })` and the `__reticle_session` URL param a lease
|
|
14
|
+
* stamps are callers ASSERTING an identity, not asking for one; they are also remembered, so a
|
|
15
|
+
* leased tab that reloads rejoins its own lease.
|
|
16
|
+
*/
|
|
17
|
+
export declare function rememberSessionLabel(explicit: string | undefined, store: Storage | undefined, generate: () => string): string;
|