@vitrinka/web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +93 -0
  3. package/README.md +177 -0
  4. package/build/index.d.ts +11 -0
  5. package/build/index.js +1 -0
  6. package/build/next.d.ts +23 -0
  7. package/build/next.js +27 -0
  8. package/build/protocol/index.d.ts +74 -0
  9. package/build/protocol/index.js +22 -0
  10. package/build/recorder/RecorderProvider.d.ts +17 -0
  11. package/build/recorder/RecorderProvider.js +126 -0
  12. package/build/recorder/api-status.d.ts +6 -0
  13. package/build/recorder/api-status.js +6 -0
  14. package/build/recorder/api.d.ts +40 -0
  15. package/build/recorder/api.js +84 -0
  16. package/build/recorder/capture/click.d.ts +17 -0
  17. package/build/recorder/capture/click.js +77 -0
  18. package/build/recorder/capture/console.d.ts +3 -0
  19. package/build/recorder/capture/console.js +89 -0
  20. package/build/recorder/capture/nav.d.ts +8 -0
  21. package/build/recorder/capture/nav.js +54 -0
  22. package/build/recorder/capture/net.d.ts +22 -0
  23. package/build/recorder/capture/net.js +506 -0
  24. package/build/recorder/capture/redact.d.ts +38 -0
  25. package/build/recorder/capture/redact.js +54 -0
  26. package/build/recorder/capture/rrweb.d.ts +10 -0
  27. package/build/recorder/capture/rrweb.js +76 -0
  28. package/build/recorder/config.d.ts +50 -0
  29. package/build/recorder/config.js +100 -0
  30. package/build/recorder/control.d.ts +29 -0
  31. package/build/recorder/control.js +63 -0
  32. package/build/recorder/hud/AnnotateOverlay.d.ts +25 -0
  33. package/build/recorder/hud/AnnotateOverlay.js +122 -0
  34. package/build/recorder/hud/Hud.d.ts +12 -0
  35. package/build/recorder/hud/Hud.js +190 -0
  36. package/build/recorder/hud/LinkSheet.d.ts +26 -0
  37. package/build/recorder/hud/LinkSheet.js +15 -0
  38. package/build/recorder/hud/RecorderPill.d.ts +36 -0
  39. package/build/recorder/hud/RecorderPill.js +73 -0
  40. package/build/recorder/hud/Sheet.d.ts +20 -0
  41. package/build/recorder/hud/Sheet.js +36 -0
  42. package/build/recorder/hud/host.d.ts +27 -0
  43. package/build/recorder/hud/host.js +170 -0
  44. package/build/recorder/hud/icons.d.ts +15 -0
  45. package/build/recorder/hud/icons.js +40 -0
  46. package/build/recorder/hud/styles.d.ts +13 -0
  47. package/build/recorder/hud/styles.js +111 -0
  48. package/build/recorder/index.d.ts +46 -0
  49. package/build/recorder/index.js +61 -0
  50. package/build/recorder/link.d.ts +18 -0
  51. package/build/recorder/link.js +37 -0
  52. package/build/recorder/queue.d.ts +163 -0
  53. package/build/recorder/queue.js +642 -0
  54. package/build/recorder/session.d.ts +73 -0
  55. package/build/recorder/session.js +246 -0
  56. package/build/recorder/state.d.ts +26 -0
  57. package/build/recorder/state.js +42 -0
  58. package/build/recorder/storage/index.d.ts +35 -0
  59. package/build/recorder/storage/index.js +69 -0
  60. package/build/recorder/storage/memory.d.ts +2 -0
  61. package/build/recorder/storage/memory.js +2 -0
  62. package/package.json +77 -0
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Runtime configuration — the web recorder has no build-time env baking (the
3
+ * host app hands `url` to `VitrinkaRecorderRoot`, typically from
4
+ * `NEXT_PUBLIC_VITRINKA_URL`), so the API client reads the target from here.
5
+ *
6
+ * AUTH: the bearer is either an explicit `recorderKey` prop (an admin-minted
7
+ * `vkr_` recorder key — CI, e2e, unattended builds) or, when none is passed,
8
+ * the token minted by the DEVICE LINK (`@vitrinka/link`) and stored under
9
+ * `vitrinka.recorder.link`. Both are ingest-only `vkr_` tokens; the package
10
+ * treats them as opaque strings and never inspects the prefix.
11
+ */
12
+ import type { Linked } from '@vitrinka/link';
13
+ export interface RecorderConfig {
14
+ /** vitrinka base URL, trailing slash stripped. */
15
+ url: string;
16
+ /** An explicit recorder key; empty = use the stored device link. */
17
+ key?: string;
18
+ /** Reported in session meta (`appVersion`). */
19
+ appVersion?: string;
20
+ /** Explicit server lane; omitted = the key's project rule decides. */
21
+ environment?: string;
22
+ /** Device-link label; defaults to `<browser> on <os> · <host>`. */
23
+ label?: string;
24
+ }
25
+ export declare function configureRecorder(next: RecorderConfig): void;
26
+ export declare function recorderConfig(): Readonly<RecorderConfig>;
27
+ /** The recorder is enabled by the URL alone; auth comes from a key or a link. */
28
+ export declare function vitrinkaConfigured(): boolean;
29
+ /** Recorder's own traffic — the network capture layer must skip it. */
30
+ export declare function isVitrinkaUrl(url: string): boolean;
31
+ /** Storage key of the linked token (`vitrinka.recorder.link` in localStorage). */
32
+ export declare const LINK_KEY = "link";
33
+ export declare function readLink(): Linked | null;
34
+ export declare function storeLink(link: Linked): void;
35
+ export declare function clearLink(): void;
36
+ /** Has the recorder something to authenticate with? */
37
+ export declare function vitrinkaLinked(): boolean;
38
+ /** The bearer for the session doors: the explicit key wins over the link. */
39
+ export declare function bearerToken(): string;
40
+ /** Device-link label from the UA — deliberately simple, no parser dependency. */
41
+ export declare function defaultLinkLabel(): string;
42
+ /**
43
+ * Read the conventional env pair when the host app did not pass props. Written
44
+ * as literal `process.env.NEXT_PUBLIC_*` reads so a Next/webpack/Vite define
45
+ * step can inline them; guarded so a runtime without `process` reads nothing.
46
+ */
47
+ export declare function envConfig(): {
48
+ url: string;
49
+ key: string;
50
+ };
@@ -0,0 +1,100 @@
1
+ import { notify } from './state';
2
+ import { getRecorderStorage } from './storage';
3
+ const config = { url: '', key: '' };
4
+ export function configureRecorder(next) {
5
+ config.url = next.url.replace(/\/+$/, '');
6
+ config.key = next.key ?? '';
7
+ config.appVersion = next.appVersion;
8
+ config.environment = next.environment;
9
+ config.label = next.label;
10
+ }
11
+ export function recorderConfig() {
12
+ return config;
13
+ }
14
+ /** The recorder is enabled by the URL alone; auth comes from a key or a link. */
15
+ export function vitrinkaConfigured() {
16
+ return config.url !== '';
17
+ }
18
+ /** Recorder's own traffic — the network capture layer must skip it. */
19
+ export function isVitrinkaUrl(url) {
20
+ return config.url !== '' && url.startsWith(config.url);
21
+ }
22
+ // -- the stored device link --------------------------------------------------
23
+ /** Storage key of the linked token (`vitrinka.recorder.link` in localStorage). */
24
+ export const LINK_KEY = 'link';
25
+ export function readLink() {
26
+ const raw = getRecorderStorage().getString(LINK_KEY);
27
+ if (!raw)
28
+ return null;
29
+ try {
30
+ const l = JSON.parse(raw);
31
+ return typeof l.token === 'string' && l.token ? l : null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ export function storeLink(link) {
38
+ getRecorderStorage().set(LINK_KEY, JSON.stringify(link));
39
+ notify();
40
+ }
41
+ export function clearLink() {
42
+ getRecorderStorage().remove(LINK_KEY);
43
+ notify();
44
+ }
45
+ /** Has the recorder something to authenticate with? */
46
+ export function vitrinkaLinked() {
47
+ return Boolean(config.key) || readLink() !== null;
48
+ }
49
+ /** The bearer for the session doors: the explicit key wins over the link. */
50
+ export function bearerToken() {
51
+ return config.key || readLink()?.token || '';
52
+ }
53
+ /** Device-link label from the UA — deliberately simple, no parser dependency. */
54
+ export function defaultLinkLabel() {
55
+ if (config.label)
56
+ return config.label;
57
+ const ua = globalThis.navigator?.userAgent ?? '';
58
+ const browser = /Edg\//.test(ua)
59
+ ? 'Edge'
60
+ : /OPR\//.test(ua)
61
+ ? 'Opera'
62
+ : /Chrome\//.test(ua)
63
+ ? 'Chrome'
64
+ : /Firefox\//.test(ua)
65
+ ? 'Firefox'
66
+ : /Safari\//.test(ua)
67
+ ? 'Safari'
68
+ : 'Browser';
69
+ const os = /iPhone|iPad/.test(ua)
70
+ ? 'iOS'
71
+ : /Android/.test(ua)
72
+ ? 'Android'
73
+ : /Mac OS X/.test(ua)
74
+ ? 'macOS'
75
+ : /Windows/.test(ua)
76
+ ? 'Windows'
77
+ : /Linux/.test(ua)
78
+ ? 'Linux'
79
+ : 'unknown OS';
80
+ const host = globalThis.location?.host ?? '';
81
+ return `${browser} on ${os}${host ? ` · ${host}` : ''}`;
82
+ }
83
+ /**
84
+ * Read the conventional env pair when the host app did not pass props. Written
85
+ * as literal `process.env.NEXT_PUBLIC_*` reads so a Next/webpack/Vite define
86
+ * step can inline them; guarded so a runtime without `process` reads nothing.
87
+ */
88
+ export function envConfig() {
89
+ try {
90
+ if (typeof process === 'undefined' || !process.env)
91
+ return { url: '', key: '' };
92
+ return {
93
+ url: process.env.NEXT_PUBLIC_VITRINKA_URL ?? '',
94
+ key: process.env.NEXT_PUBLIC_VITRINKA_KEY ?? '',
95
+ };
96
+ }
97
+ catch {
98
+ return { url: '', key: '' };
99
+ }
100
+ }
@@ -0,0 +1,29 @@
1
+ import { type StartOptions } from './session';
2
+ export interface RecorderStatus {
3
+ recording: boolean;
4
+ sessionId?: string;
5
+ project?: string;
6
+ environment?: string;
7
+ title?: string;
8
+ boardUrl?: string;
9
+ paused?: boolean;
10
+ dead?: boolean;
11
+ deadReason?: string;
12
+ elapsedMs?: number;
13
+ events?: number;
14
+ queued?: number;
15
+ synced?: boolean;
16
+ healthState?: string;
17
+ }
18
+ export interface RecorderControl {
19
+ start(opts?: StartOptions): Promise<RecorderStatus>;
20
+ pause(): Promise<boolean>;
21
+ stop(): Promise<{
22
+ boardUrl?: string;
23
+ } & RecorderStatus>;
24
+ note(text: string): void;
25
+ status(): RecorderStatus;
26
+ }
27
+ export declare function snapshot(): RecorderStatus;
28
+ export declare const CONTROL_KEY = "__vitrinkaRecorder";
29
+ export declare function installControl(): () => void;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Programmatic control handle — `window.__vitrinkaRecorder`. The web sibling
3
+ * of the Expo recorder's devtools channel: an agent driving the page with
4
+ * Playwright can start a journey, drop notes and stop it without touching
5
+ * the HUD, and read a status snapshot back.
6
+ */
7
+ import { getState, health } from './queue';
8
+ import { addNote, elapsedOf, startSession, stopSession, togglePause } from './session';
9
+ export function snapshot() {
10
+ const rec = getState();
11
+ if (!rec)
12
+ return { recording: false };
13
+ const h = health();
14
+ return {
15
+ recording: true,
16
+ sessionId: rec.sessionId,
17
+ project: rec.project,
18
+ environment: rec.environment,
19
+ title: rec.title,
20
+ boardUrl: rec.boardUrl,
21
+ paused: rec.paused,
22
+ dead: Boolean(rec.dead),
23
+ deadReason: rec.deadReason ?? '',
24
+ elapsedMs: elapsedOf(rec),
25
+ events: rec.seq,
26
+ queued: h.queued,
27
+ synced: h.synced,
28
+ healthState: h.state,
29
+ };
30
+ }
31
+ export const CONTROL_KEY = '__vitrinkaRecorder';
32
+ export function installControl() {
33
+ const control = {
34
+ async start(opts = {}) {
35
+ // Idempotent: a second start must not orphan the first recording.
36
+ if (getState())
37
+ throw new Error('vitrinka: a session is already recording');
38
+ await startSession(opts);
39
+ return snapshot();
40
+ },
41
+ pause: () => togglePause(),
42
+ async stop() {
43
+ const captured = snapshot();
44
+ const done = await stopSession();
45
+ return { ...captured, boardUrl: done?.board?.url ?? captured.boardUrl };
46
+ },
47
+ note(text) {
48
+ const t = text.trim();
49
+ if (!getState())
50
+ throw new Error('vitrinka: no session is recording');
51
+ if (!t)
52
+ throw new Error('vitrinka: note text is empty');
53
+ addNote(t);
54
+ },
55
+ status: snapshot,
56
+ };
57
+ const g = globalThis;
58
+ g[CONTROL_KEY] = control;
59
+ return () => {
60
+ if (g[CONTROL_KEY] === control)
61
+ delete g[CONTROL_KEY];
62
+ };
63
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Annotate mode (⌖): crosshair, the hovered element outlined, a drag past
3
+ * 6px switches to a marquee; the page dims and a hint bar says what to do.
4
+ * Click → element pick (selector + rect), drag → region (rect). The chrome
5
+ * lives in the shadow root so the page cannot restyle it; the pointer
6
+ * handlers are document-level CAPTURE listeners so the page never sees the
7
+ * pick click.
8
+ */
9
+ import { type ReactElement } from 'react';
10
+ export interface Pick {
11
+ /** Viewport rect in CSS pixels. */
12
+ rect: {
13
+ x: number;
14
+ y: number;
15
+ w: number;
16
+ h: number;
17
+ };
18
+ selector: string;
19
+ text: string;
20
+ }
21
+ export interface AnnotateOverlayProps {
22
+ onPick: (pick: Pick) => void;
23
+ onCancel: () => void;
24
+ }
25
+ export declare function AnnotateOverlay({ onPick, onCancel }: AnnotateOverlayProps): ReactElement;
@@ -0,0 +1,122 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * Annotate mode (⌖): crosshair, the hovered element outlined, a drag past
4
+ * 6px switches to a marquee; the page dims and a hint bar says what to do.
5
+ * Click → element pick (selector + rect), drag → region (rect). The chrome
6
+ * lives in the shadow root so the page cannot restyle it; the pointer
7
+ * handlers are document-level CAPTURE listeners so the page never sees the
8
+ * pick click.
9
+ */
10
+ import { useEffect, useState } from 'react';
11
+ import { elementText, shortSelector } from '../capture/click';
12
+ import { AnnotateIcon } from './icons';
13
+ import { insideHud } from './host';
14
+ export function AnnotateOverlay({ onPick, onCancel }) {
15
+ const [box, setBox] = useState(null);
16
+ useEffect(() => {
17
+ let downAt = null;
18
+ let dragging = false;
19
+ document.documentElement.style.cursor = 'crosshair';
20
+ let raf = 0;
21
+ let last = null;
22
+ const same = (a, b) => a === b || (a !== null && b !== null && a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h);
23
+ const place = (next) => setBox((prev) => (same(prev, next) ? prev : next));
24
+ const frame = () => {
25
+ raf = 0;
26
+ if (!last)
27
+ return;
28
+ const { x, y } = last;
29
+ if (downAt && (dragging || Math.hypot(x - downAt.x, y - downAt.y) > 6)) {
30
+ dragging = true;
31
+ place({
32
+ x: Math.min(downAt.x, x),
33
+ y: Math.min(downAt.y, y),
34
+ w: Math.abs(x - downAt.x),
35
+ h: Math.abs(y - downAt.y),
36
+ });
37
+ return;
38
+ }
39
+ const el = document.elementFromPoint(x, y);
40
+ if (!el || insideHud(el)) {
41
+ place(null);
42
+ return;
43
+ }
44
+ const r = el.getBoundingClientRect();
45
+ place({ x: r.x - 3, y: r.y - 3, w: r.width + 2, h: r.height + 2 });
46
+ };
47
+ const move = (e) => {
48
+ last = { x: e.clientX, y: e.clientY };
49
+ if (raf === 0)
50
+ raf = requestAnimationFrame(frame);
51
+ };
52
+ const down = (e) => {
53
+ if (insideHud(e.target))
54
+ return;
55
+ e.preventDefault();
56
+ e.stopPropagation();
57
+ downAt = { x: e.clientX, y: e.clientY };
58
+ };
59
+ const up = (e) => {
60
+ if (!downAt)
61
+ return;
62
+ e.preventDefault();
63
+ e.stopPropagation();
64
+ const start = downAt;
65
+ const wasDrag = dragging;
66
+ downAt = null;
67
+ dragging = false;
68
+ if (wasDrag) {
69
+ const x = Math.min(start.x, e.clientX);
70
+ const y = Math.min(start.y, e.clientY);
71
+ const w = Math.abs(e.clientX - start.x);
72
+ const h = Math.abs(e.clientY - start.y);
73
+ if (w < 4 || h < 4) {
74
+ onCancel();
75
+ return;
76
+ }
77
+ onPick({ rect: { x, y, w, h }, selector: '', text: '' });
78
+ return;
79
+ }
80
+ const el = document.elementFromPoint(e.clientX, e.clientY);
81
+ if (!el || insideHud(el)) {
82
+ onCancel();
83
+ return;
84
+ }
85
+ const r = el.getBoundingClientRect();
86
+ onPick({
87
+ rect: { x: r.x, y: r.y, w: r.width, h: r.height },
88
+ selector: shortSelector(el),
89
+ text: elementText(el),
90
+ });
91
+ };
92
+ const swallowClick = (e) => {
93
+ if (insideHud(e.target))
94
+ return;
95
+ e.preventDefault();
96
+ e.stopPropagation();
97
+ };
98
+ const key = (e) => {
99
+ if (e.key === 'Escape') {
100
+ e.preventDefault();
101
+ e.stopPropagation();
102
+ onCancel();
103
+ }
104
+ };
105
+ document.addEventListener('pointermove', move, true);
106
+ document.addEventListener('pointerdown', down, true);
107
+ document.addEventListener('pointerup', up, true);
108
+ document.addEventListener('click', swallowClick, true);
109
+ document.addEventListener('keydown', key, true);
110
+ return () => {
111
+ document.documentElement.style.cursor = '';
112
+ if (raf !== 0)
113
+ cancelAnimationFrame(raf);
114
+ document.removeEventListener('pointermove', move, true);
115
+ document.removeEventListener('pointerdown', down, true);
116
+ document.removeEventListener('pointerup', up, true);
117
+ document.removeEventListener('click', swallowClick, true);
118
+ document.removeEventListener('keydown', key, true);
119
+ };
120
+ }, [onPick, onCancel]);
121
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "dim", "data-e2e": "annotate-dim" }), box ? (_jsx("div", { className: "outline", style: { left: box.x, top: box.y, width: box.w, height: box.h } })) : null, _jsxs("div", { className: "hint", children: [_jsx(AnnotateIcon, {}), " annotate \u2014 click an element or drag an area \u00B7 enter sends \u00B7 esc cancels"] })] }));
122
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The HUD tree, rendered into the shadow host by its own React root. Owns
3
+ * the sheet (open/title/ctx/pick + the surviving draft), annotate mode, the
4
+ * keyboard shortcuts and the Esc-anywhere / click-outside close.
5
+ */
6
+ import { type ReactElement } from 'react';
7
+ export interface HudProps {
8
+ /** The HUD host's own mount (the sheet portals here when no dialog is open). */
9
+ hostMount: HTMLElement;
10
+ defaultTitle: () => string;
11
+ }
12
+ export declare function Hud({ hostMount, defaultTitle }: HudProps): ReactElement;
@@ -0,0 +1,190 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The HUD tree, rendered into the shadow host by its own React root. Owns
4
+ * the sheet (open/title/ctx/pick + the surviving draft), annotate mode, the
5
+ * keyboard shortcuts and the Esc-anywhere / click-outside close.
6
+ */
7
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
8
+ import { createPortal } from 'react-dom';
9
+ import { readLink, recorderConfig, vitrinkaLinked } from '../config';
10
+ import { forgetLink, linkDevice, LinkExpired } from '../link';
11
+ import { getState } from '../queue';
12
+ import { addAnnotation, addNote, startSession, stopSession, togglePause } from '../session';
13
+ import { annotateState, setAnnotating, subscribe } from '../state';
14
+ import { AnnotateOverlay } from './AnnotateOverlay';
15
+ import { createSheetHost, insideHud, sheetTarget } from './host';
16
+ import { LinkSheet } from './LinkSheet';
17
+ import { RecorderPill } from './RecorderPill';
18
+ import { Sheet } from './Sheet';
19
+ import { HUD_CSS } from './styles';
20
+ let version = 0;
21
+ const bump = () => ++version;
22
+ function useRecorderState() {
23
+ return useSyncExternalStore((cb) => subscribe(() => { bump(); cb(); }), () => version, () => version);
24
+ }
25
+ export function Hud({ hostMount, defaultTitle }) {
26
+ useRecorderState();
27
+ const rec = getState();
28
+ const [sheet, setSheet] = useState(null);
29
+ const [draft, setDraft] = useState('');
30
+ const [starting, setStarting] = useState(false);
31
+ const [stopping, setStopping] = useState(false);
32
+ const annotating = annotateState.active;
33
+ const linked = vitrinkaLinked();
34
+ const canUnlink = !recorderConfig().key && readLink() !== null;
35
+ // Device link (Netflix-style code): the sheet paints the phase, this owns it.
36
+ const [link, setLink] = useState(null);
37
+ const linkRef = useRef(null);
38
+ const closeLink = useCallback(() => {
39
+ linkRef.current?.cancel();
40
+ linkRef.current = null;
41
+ setLink(null);
42
+ }, []);
43
+ const beginLink = useCallback(() => {
44
+ linkRef.current?.cancel();
45
+ setSheet(null);
46
+ setLink({ phase: 'starting', flow: null });
47
+ linkDevice()
48
+ .then((flow) => {
49
+ linkRef.current = flow;
50
+ setLink({ phase: 'waiting', flow });
51
+ return flow.linked.then(() => {
52
+ if (linkRef.current !== flow)
53
+ return;
54
+ linkRef.current = null;
55
+ setLink(null);
56
+ // Linked: start recording exactly as if a key had been passed.
57
+ onStartRef.current();
58
+ }, (e) => {
59
+ if (linkRef.current !== flow)
60
+ return;
61
+ linkRef.current = null;
62
+ if (e instanceof LinkExpired)
63
+ setLink({ phase: 'expired', flow });
64
+ else if (!(e instanceof Error && e.name === 'AbortError'))
65
+ setLink({ phase: 'error', flow, error: e instanceof Error ? e.message : String(e) });
66
+ });
67
+ })
68
+ .catch((e) => setLink({ phase: 'error', flow: null, error: e instanceof Error ? e.message : String(e) }));
69
+ }, []);
70
+ const onUnlink = useCallback(() => {
71
+ closeLink();
72
+ setSheet(null);
73
+ setAnnotating(false);
74
+ forgetLink();
75
+ }, [closeLink]);
76
+ const closeSheet = useCallback(() => setSheet(null), []);
77
+ const openNote = useCallback(() => {
78
+ if (!getState())
79
+ return;
80
+ setAnnotating(false);
81
+ setSheet({ title: 'Note', ctx: `step · ${location.pathname}`, pick: null });
82
+ }, []);
83
+ const toggleAnnotate = useCallback(() => {
84
+ if (!getState())
85
+ return;
86
+ setSheet(null);
87
+ setAnnotating(!annotateState.active);
88
+ }, []);
89
+ const onPick = useCallback((pick) => {
90
+ setAnnotating(false);
91
+ const ctx = pick.selector
92
+ ? `${pick.selector} · ${location.pathname}`
93
+ : `${Math.round(pick.rect.w)}×${Math.round(pick.rect.h)} · ${location.pathname}`;
94
+ setSheet({ title: pick.selector ? 'Annotate element' : 'Annotate region', ctx, pick });
95
+ }, []);
96
+ const cancelAnnotate = useCallback(() => setAnnotating(false), []);
97
+ const onSend = useCallback((text, task) => {
98
+ const s = sheet;
99
+ setSheet(null);
100
+ if (!s)
101
+ return;
102
+ if (s.pick) {
103
+ addAnnotation(text, s.pick.rect, s.pick.selector, { task });
104
+ setDraft('');
105
+ }
106
+ else if (text) {
107
+ addNote(text);
108
+ setDraft('');
109
+ }
110
+ }, [sheet]);
111
+ const onStart = useCallback(() => {
112
+ if (!vitrinkaLinked()) {
113
+ beginLink();
114
+ return;
115
+ }
116
+ setStarting(true);
117
+ startSession({ title: defaultTitle() })
118
+ .catch((e) => console.warn('vitrinka: start failed', e))
119
+ .finally(() => setStarting(false));
120
+ }, [defaultTitle, beginLink]);
121
+ const onStartRef = useRef(onStart);
122
+ onStartRef.current = onStart;
123
+ const onStop = useCallback(() => {
124
+ setSheet(null);
125
+ setAnnotating(false);
126
+ setStopping(true);
127
+ stopSession()
128
+ .catch((e) => console.warn('vitrinka: stop —', e instanceof Error ? e.message : e))
129
+ .finally(() => setStopping(false));
130
+ }, []);
131
+ const onPause = useCallback(() => {
132
+ void togglePause();
133
+ }, []);
134
+ // Shortcuts (window keydown): ⌥⇧A annotate · ⌥⇧N note · ⌥⇧P pause.
135
+ useEffect(() => {
136
+ const key = (e) => {
137
+ if (!e.altKey || !e.shiftKey || e.metaKey || e.ctrlKey)
138
+ return;
139
+ if (e.code === 'KeyA')
140
+ toggleAnnotate();
141
+ else if (e.code === 'KeyN')
142
+ openNote();
143
+ else if (e.code === 'KeyP')
144
+ onPause();
145
+ else
146
+ return;
147
+ e.preventDefault();
148
+ };
149
+ window.addEventListener('keydown', key);
150
+ return () => window.removeEventListener('keydown', key);
151
+ }, [toggleAnnotate, openNote, onPause]);
152
+ // Esc anywhere and click-outside close the sheet (capture-phase, so the
153
+ // page's own dialog never sees the Esc that closed ours).
154
+ useEffect(() => {
155
+ if (!sheet)
156
+ return;
157
+ const key = (e) => {
158
+ if (e.key !== 'Escape' || insideHud(e.target))
159
+ return;
160
+ e.preventDefault();
161
+ e.stopPropagation();
162
+ closeSheet();
163
+ };
164
+ const down = (e) => {
165
+ if (insideHud(e.target))
166
+ return;
167
+ closeSheet();
168
+ };
169
+ document.addEventListener('keydown', key, true);
170
+ document.addEventListener('pointerdown', down, true);
171
+ return () => {
172
+ document.removeEventListener('keydown', key, true);
173
+ document.removeEventListener('pointerdown', down, true);
174
+ };
175
+ }, [sheet, link, closeSheet, closeLink]);
176
+ // The sheet's portal target (D4): a host inside the topmost open dialog
177
+ // when one exists, else the HUD host. Resolved per open.
178
+ const portal = useMemo(() => {
179
+ if (!sheet)
180
+ return null;
181
+ const target = sheetTarget();
182
+ if (!target)
183
+ return { mount: hostMount, destroy: () => undefined, own: false };
184
+ const h = createSheetHost(target);
185
+ return { ...h, own: true };
186
+ }, [sheet, hostMount]);
187
+ useEffect(() => () => portal?.destroy(), [portal]);
188
+ const sheetEl = sheet && portal ? (_jsxs(_Fragment, { children: [portal.own ? _jsx("style", { children: HUD_CSS }) : null, _jsx("div", { className: "sheetwrap", style: portal.own ? undefined : { position: 'absolute', right: 0, bottom: 52 }, children: _jsx(Sheet, { title: sheet.title, ctx: sheet.ctx, pick: sheet.pick !== null, draft: draft, onDraft: setDraft, onSend: onSend, onClose: closeSheet }) })] })) : null;
189
+ return (_jsxs(_Fragment, { children: [_jsx("style", { children: HUD_CSS }), annotating && rec ? _jsx(AnnotateOverlay, { onPick: onPick, onCancel: cancelAnnotate }) : null, _jsxs("div", { style: { position: 'relative' }, children: [_jsx(RecorderPill, { rec: rec, composing: sheet !== null, annotating: annotating, stopping: stopping, starting: starting, linked: linked, canUnlink: canUnlink, onLink: beginLink, onUnlink: onUnlink, onStart: onStart, onPause: onPause, onNote: openNote, onAnnotate: toggleAnnotate, onStop: onStop }), sheetEl && portal ? createPortal(sheetEl, portal.mount) : null, link ? (_jsx("div", { className: "sheetwrap", style: { position: 'absolute', right: 0, bottom: 52 }, children: _jsx(LinkSheet, { phase: link.phase, start: link.flow?.start ?? null, error: link.error, onRetry: beginLink, onClose: closeLink }) })) : null] })] }));
190
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The link sheet: the 9-char code, "Open vitrinka" for the same-device path,
3
+ * the server-rendered QR for the desktop→phone path, and the waiting line.
4
+ * Polling runs in the parent; this only paints the state.
5
+ */
6
+ import type { ReactElement } from 'react';
7
+ import type { LinkStart } from '@vitrinka/link';
8
+ export type LinkPhase = 'starting' | 'waiting' | 'expired' | 'error';
9
+ export interface LinkSheetProps {
10
+ phase: LinkPhase;
11
+ start: LinkStart | null;
12
+ error?: string;
13
+ onRetry: () => void;
14
+ onClose: () => void;
15
+ }
16
+ export declare const LINK_STRINGS: {
17
+ readonly title: "Link recorder";
18
+ readonly open: "Open vitrinka";
19
+ readonly waiting: "waiting for approval…";
20
+ readonly starting: "asking vitrinka for a code…";
21
+ readonly expired: "code expired — try again";
22
+ readonly retry: "Try again";
23
+ readonly qrAlt: "Scan to link";
24
+ readonly hint: "approve on this device, or scan from your phone";
25
+ };
26
+ export declare function LinkSheet({ phase, start, error, onRetry, onClose }: LinkSheetProps): ReactElement;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { CloseIcon, NewTabIcon } from './icons';
3
+ export const LINK_STRINGS = {
4
+ title: 'Link recorder',
5
+ open: 'Open vitrinka',
6
+ waiting: 'waiting for approval…',
7
+ starting: 'asking vitrinka for a code…',
8
+ expired: 'code expired — try again',
9
+ retry: 'Try again',
10
+ qrAlt: 'Scan to link',
11
+ hint: 'approve on this device, or scan from your phone',
12
+ };
13
+ export function LinkSheet({ phase, start, error, onRetry, onClose }) {
14
+ return (_jsxs("div", { className: "pop link", role: "dialog", "aria-labelledby": "vt-link-title", "data-e2e": "link-sheet", children: [_jsxs("div", { className: "pop-head", children: [_jsxs("label", { children: [_jsx("i", {}), _jsx("span", { id: "vt-link-title", children: LINK_STRINGS.title })] }), _jsx("button", { type: "button", className: "closeb", "aria-label": "Close", onClick: onClose, children: _jsx(CloseIcon, {}) })] }), phase === 'starting' || !start ? (_jsx("div", { className: "linkline", children: phase === 'error' ? error || 'could not start the link' : LINK_STRINGS.starting })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "code", "data-e2e": "link-code", children: start.user_code }), _jsxs("div", { className: "linkrow", children: [_jsxs("a", { className: "sendb", href: start.verifyUrl, target: "_blank", rel: "noopener noreferrer", children: [_jsx(NewTabIcon, {}), _jsx("span", { children: LINK_STRINGS.open })] }), _jsx("img", { className: "qr", src: start.qrUrl, alt: LINK_STRINGS.qrAlt, width: 96, height: 96 })] }), _jsxs("div", { className: phase === 'expired' || phase === 'error' ? 'linkline bad' : 'linkline', children: [phase === 'waiting' ? LINK_STRINGS.waiting : phase === 'expired' ? LINK_STRINGS.expired : error, phase === 'expired' || phase === 'error' ? (_jsx("button", { type: "button", className: "retry", onClick: onRetry, children: LINK_STRINGS.retry })) : null] })] })), _jsx("div", { className: "hints", children: LINK_STRINGS.hint })] }));
15
+ }