@vitrinka/web 0.1.0 → 0.1.2
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/CHANGELOG.md +34 -0
- package/README.md +21 -8
- package/build/recorder/capture/rrweb.js +5 -1
- package/build/recorder/hud/AnnotateOverlay.js +1 -1
- package/build/recorder/hud/Hud.d.ts +0 -5
- package/build/recorder/hud/Hud.js +66 -13
- package/build/recorder/hud/LinkSheet.d.ts +9 -3
- package/build/recorder/hud/LinkSheet.js +7 -3
- package/build/recorder/hud/RecorderPill.d.ts +14 -5
- package/build/recorder/hud/RecorderPill.js +164 -25
- package/build/recorder/hud/Sheet.d.ts +9 -4
- package/build/recorder/hud/Sheet.js +6 -5
- package/build/recorder/hud/layer.d.ts +52 -0
- package/build/recorder/hud/layer.js +121 -0
- package/build/recorder/hud/styles.d.ts +21 -7
- package/build/recorder/hud/styles.js +251 -96
- package/build/recorder/hud/useDock.d.ts +32 -0
- package/build/recorder/hud/useDock.js +168 -0
- package/build/recorder/link.d.ts +6 -2
- package/build/recorder/link.js +9 -4
- package/build/recorder/session.d.ts +2 -2
- package/build/recorder/session.js +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# @vitrinka/web
|
|
2
2
|
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
- **A HUD that is barely there.** Idle, it is a 28px glass puck (a hollow
|
|
6
|
+
ring until the device is linked) whose label slides out on hover or focus.
|
|
7
|
+
While recording it rests as a dot-and-timer capsule and unfolds into the
|
|
8
|
+
tools (sync · pause · note · annotate · more) on hover, focus or tap,
|
|
9
|
+
folding back 2.5s after you leave; the health line still opens by itself
|
|
10
|
+
when something is wrong. Smoked-glass surface, readable on light and dark
|
|
11
|
+
pages. Accessible names are unchanged; the capsule is `Recorder controls`
|
|
12
|
+
(`aria-expanded`), and a polite status line tells screen readers whether
|
|
13
|
+
it is recording, paused or offline.
|
|
14
|
+
- **Move it anywhere.** Drag the capsule or puck and it lands on one of six
|
|
15
|
+
spots (corners, top and bottom centre); a flick lands where it is thrown.
|
|
16
|
+
Push it past a side edge and it tucks into a 6px tab (`Show recorder`).
|
|
17
|
+
Arrow keys on the focused handle and the menu's **Move to** picker move it
|
|
18
|
+
without dragging. The spot is remembered per site.
|
|
19
|
+
- **Sheets open toward the page centre** from wherever the HUD sits, at most
|
|
20
|
+
288px wide; on a phone they are a bottom sheet above the keyboard, and the
|
|
21
|
+
link sheet drops the QR on touch devices (a phone cannot scan itself).
|
|
22
|
+
- `prefers-reduced-motion` stills the ripple, the springs and the slides.
|
|
23
|
+
- Needs `@vitrinka/link` 0.1.2 (`@vitrinka/link/dock`).
|
|
24
|
+
|
|
25
|
+
## 0.1.1
|
|
26
|
+
|
|
27
|
+
- **The recorder never changes the page under test**: rrweb records images by
|
|
28
|
+
URL (`inlineImages: false`). Inlining made rrweb set
|
|
29
|
+
`crossOrigin="anonymous"` on the page's own no-CORS cross-origin `<img>`,
|
|
30
|
+
which refetched it in CORS mode and broke it on hosts without ACAO.
|
|
31
|
+
- **Link into the right workspace**: a `url` addressing `/w/<slug>`
|
|
32
|
+
preselects that workspace on the approve page, and a token approved into
|
|
33
|
+
any other one is discarded; the link sheet says `linked into <x> — this app
|
|
34
|
+
records into <slug>; link again and pick <slug>`. Needs `@vitrinka/link`
|
|
35
|
+
≥ 0.1.1.
|
|
36
|
+
|
|
3
37
|
## 0.1.0
|
|
4
38
|
|
|
5
39
|
- Initial release: the React DOM journey recorder (`@vitrinka/web/recorder`)
|
package/README.md
CHANGED
|
@@ -56,8 +56,10 @@ testers **link their device from the pill**: the pill shows **Link
|
|
|
56
56
|
recorder**, the sheet shows a short code, **Open vitrinka** (approve on this
|
|
57
57
|
device) and a QR (scan from your phone); once approved the server mints an
|
|
58
58
|
ingest-only `vkr_` token, stored in `localStorage` under
|
|
59
|
-
`vitrinka.recorder.link`, and recording starts.
|
|
60
|
-
|
|
59
|
+
`vitrinka.recorder.link`, and recording starts. A `url` addressing
|
|
60
|
+
`/w/<slug>` preselects that workspace on the approve page; a token approved
|
|
61
|
+
into another workspace is discarded and the sheet says which one to pick.
|
|
62
|
+
**Unlink** in the ⋯ menu forgets it — so does a 401 from the server. `recorderKey` (an admin-minted
|
|
61
63
|
`vkr_` key) is for CI, e2e and unattended builds only; when passed it wins
|
|
62
64
|
over the link. The prop is `recorderKey`, not `key`: React reserves `key`
|
|
63
65
|
and never delivers it to a component.
|
|
@@ -109,16 +111,27 @@ returned unchanged otherwise.
|
|
|
109
111
|
|
|
110
112
|
## Using the pill
|
|
111
113
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
114
|
+
Idle, the HUD is a 28px glass puck: a hollow ring until the device is linked
|
|
115
|
+
(**Link recorder**), a muted dot once it is (**Start recording** — the session
|
|
116
|
+
title is `document.title`, or the `title` prop); the label slides out on hover
|
|
117
|
+
or focus. Recording, it rests as a dot + timer capsule (**Recorder controls**)
|
|
118
|
+
that unfolds into the tools on hover, focus or tap and folds back 2.5s after
|
|
119
|
+
you leave (keycaps show on hover):
|
|
115
120
|
|
|
116
121
|
| Control | Shortcut | What |
|
|
117
122
|
|---|---|---|
|
|
118
123
|
| ⏸ Pause / ▶ Resume | ⌥⇧P (Alt⇧P) | freezes the clock and capture |
|
|
119
|
-
| ✎ Note | ⌥⇧N | the
|
|
124
|
+
| ✎ Note | ⌥⇧N | the note sheet — Enter sends, ⇧Enter newline, Esc / ✕ / click-outside cancel (the draft survives a cancel) |
|
|
120
125
|
| ⌖ Annotate | ⌥⇧A | click an element or drag a region, then describe it; `board` (an annotation on the board) or `task` (also filed as an intake draft) |
|
|
121
|
-
| ⋯ | | **Open board** (the server-minted link) · **Stop recording** · **Unlink** |
|
|
126
|
+
| ⋯ | | **Open board** (the server-minted link) · **Stop recording** · **Unlink** · **Move to** |
|
|
127
|
+
|
|
128
|
+
Move it anywhere: drag the puck or capsule and it lands on one of six spots
|
|
129
|
+
(corners, top and bottom centre) — a flick lands where it is thrown; push it
|
|
130
|
+
past a side edge and it tucks into a 6px tab (**Show recorder** brings it
|
|
131
|
+
back). Arrow keys on the focused handle and **Move to** do the same without a
|
|
132
|
+
drag; the spot is remembered per site. Sheets (≤ 288px) open toward the
|
|
133
|
+
middle of the page from wherever it sits; on a phone they are a bottom sheet
|
|
134
|
+
above the keyboard and the link sheet drops the QR.
|
|
122
135
|
|
|
123
136
|
The sync glyph is honest: ✓ means the server confirmed it holds everything
|
|
124
137
|
captured; a second line unfolds only for a backlog, an outage (`offline ·
|
|
@@ -146,7 +159,7 @@ __vitrinkaRecorder.status(); // { recording, sessionId, elapsedMs, queued, synce
|
|
|
146
159
|
|
|
147
160
|
| Lane | Event `kind` | Payload |
|
|
148
161
|
|---|---|---|
|
|
149
|
-
| rrweb | `rrweb` | `{count}` + `blobKey` — the batch itself is uploaded as a chunk; inputs always masked, all text under a `maskAllText` policy |
|
|
162
|
+
| rrweb | `rrweb` | `{count}` + `blobKey` — the batch itself is uploaded as a chunk; inputs always masked, all text under a `maskAllText` policy; images recorded by URL, never inlined (inlining would alter the page's own `<img>`) |
|
|
150
163
|
| clicks | `click` | `{selector, text, rect, route}` |
|
|
151
164
|
| navigation | `nav` | `{url, route, spa}` |
|
|
152
165
|
| network | `net` | `{method, url, status, ms, reqHeaders, resHeaders, reqBody, resBody, via}` — redacted, capped at 64 KiB per body; the recorder's own uploads are never recorded |
|
|
@@ -30,7 +30,11 @@ export function startRRWeb() {
|
|
|
30
30
|
buf.push(ev);
|
|
31
31
|
},
|
|
32
32
|
checkoutEveryNms: CHECKOUT_MS,
|
|
33
|
-
|
|
33
|
+
// Never inline: to read a no-CORS cross-origin image rrweb sets
|
|
34
|
+
// crossOrigin='anonymous' on the page's own <img>, the browser refetches
|
|
35
|
+
// it in CORS mode and a host without ACAO shows a broken image — the
|
|
36
|
+
// recorder must never change the page under test. Replay loads by URL.
|
|
37
|
+
inlineImages: false,
|
|
34
38
|
collectFonts: true,
|
|
35
39
|
maskAllInputs: mask.maskAllInputs,
|
|
36
40
|
...(mask.maskTextSelector ? { maskTextSelector: mask.maskTextSelector } : {}),
|
|
@@ -118,5 +118,5 @@ export function AnnotateOverlay({ onPick, onCancel }) {
|
|
|
118
118
|
document.removeEventListener('keydown', key, true);
|
|
119
119
|
};
|
|
120
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, {}), "
|
|
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, {}), " Click an element or drag an area", _jsx("span", { className: "keys", children: " \u00B7 esc cancels" })] })] }));
|
|
122
122
|
}
|
|
@@ -1,8 +1,3 @@
|
|
|
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
1
|
import { type ReactElement } from 'react';
|
|
7
2
|
export interface HudProps {
|
|
8
3
|
/** The HUD host's own mount (the sheet portals here when no dialog is open). */
|
|
@@ -1,22 +1,27 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* The HUD tree, rendered into the shadow host by its own React root. Owns
|
|
4
|
-
* the sheet (open/title/ctx/pick + the
|
|
5
|
-
*
|
|
4
|
+
* the dock (where the HUD rests), the sheet (open/title/ctx/pick + the
|
|
5
|
+
* surviving draft) and where it opens, annotate mode, the keyboard
|
|
6
|
+
* shortcuts, the Esc-anywhere / click-outside close and the status line a
|
|
7
|
+
* screen reader hears while the capsule is folded.
|
|
6
8
|
*/
|
|
7
|
-
import {
|
|
9
|
+
import { colOf, rowOf } from '@vitrinka/link/dock';
|
|
10
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
|
|
8
11
|
import { createPortal } from 'react-dom';
|
|
9
12
|
import { readLink, recorderConfig, vitrinkaLinked } from '../config';
|
|
10
13
|
import { forgetLink, linkDevice, LinkExpired } from '../link';
|
|
11
|
-
import { getState } from '../queue';
|
|
14
|
+
import { getState, health } from '../queue';
|
|
12
15
|
import { addAnnotation, addNote, startSession, stopSession, togglePause } from '../session';
|
|
13
16
|
import { annotateState, setAnnotating, subscribe } from '../state';
|
|
14
17
|
import { AnnotateOverlay } from './AnnotateOverlay';
|
|
15
18
|
import { createSheetHost, insideHud, sheetTarget } from './host';
|
|
19
|
+
import { anchoredLayer, hostOrigin, PHONE_QUERY, phoneLayer, restingBox, useMedia, usePresence, useViewportTick } from './layer';
|
|
16
20
|
import { LinkSheet } from './LinkSheet';
|
|
17
21
|
import { RecorderPill } from './RecorderPill';
|
|
18
22
|
import { Sheet } from './Sheet';
|
|
19
23
|
import { HUD_CSS } from './styles';
|
|
24
|
+
import { useDock } from './useDock';
|
|
20
25
|
let version = 0;
|
|
21
26
|
const bump = () => ++version;
|
|
22
27
|
function useRecorderState() {
|
|
@@ -74,6 +79,9 @@ export function Hud({ hostMount, defaultTitle }) {
|
|
|
74
79
|
forgetLink();
|
|
75
80
|
}, [closeLink]);
|
|
76
81
|
const closeSheet = useCallback(() => setSheet(null), []);
|
|
82
|
+
// A drag closes the composer (its draft survives); the link sheet follows the dock.
|
|
83
|
+
const dock = useDock(closeSheet);
|
|
84
|
+
const phone = useMedia(PHONE_QUERY);
|
|
77
85
|
const openNote = useCallback(() => {
|
|
78
86
|
if (!getState())
|
|
79
87
|
return;
|
|
@@ -131,7 +139,9 @@ export function Hud({ hostMount, defaultTitle }) {
|
|
|
131
139
|
const onPause = useCallback(() => {
|
|
132
140
|
void togglePause();
|
|
133
141
|
}, []);
|
|
134
|
-
// Shortcuts (window keydown
|
|
142
|
+
// Shortcuts (window keydown, CAPTURE — the host's shield stops a keydown
|
|
143
|
+
// from a focused HUD control before it could bubble to window, and a drag
|
|
144
|
+
// leaves the handle focused): ⌥⇧A annotate · ⌥⇧N note · ⌥⇧P pause.
|
|
135
145
|
useEffect(() => {
|
|
136
146
|
const key = (e) => {
|
|
137
147
|
if (!e.altKey || !e.shiftKey || e.metaKey || e.ctrlKey)
|
|
@@ -146,8 +156,8 @@ export function Hud({ hostMount, defaultTitle }) {
|
|
|
146
156
|
return;
|
|
147
157
|
e.preventDefault();
|
|
148
158
|
};
|
|
149
|
-
window.addEventListener('keydown', key);
|
|
150
|
-
return () => window.removeEventListener('keydown', key);
|
|
159
|
+
window.addEventListener('keydown', key, true);
|
|
160
|
+
return () => window.removeEventListener('keydown', key, true);
|
|
151
161
|
}, [toggleAnnotate, openNote, onPause]);
|
|
152
162
|
// Esc anywhere and click-outside close the sheet (capture-phase, so the
|
|
153
163
|
// page's own dialog never sees the Esc that closed ours).
|
|
@@ -173,18 +183,61 @@ export function Hud({ hostMount, defaultTitle }) {
|
|
|
173
183
|
document.removeEventListener('pointerdown', down, true);
|
|
174
184
|
};
|
|
175
185
|
}, [sheet, link, closeSheet, closeLink]);
|
|
186
|
+
// Sheets enter and leave (presence) with their last content kept for the
|
|
187
|
+
// closing frames.
|
|
188
|
+
const sheetP = usePresence(sheet !== null, 150);
|
|
189
|
+
const linkP = usePresence(link !== null, 150);
|
|
190
|
+
const lastSheet = useRef(null);
|
|
191
|
+
const lastLink = useRef(null);
|
|
192
|
+
if (sheet)
|
|
193
|
+
lastSheet.current = sheet;
|
|
194
|
+
if (link)
|
|
195
|
+
lastLink.current = link;
|
|
196
|
+
const shownSheet = sheet ?? lastSheet.current;
|
|
197
|
+
const shownLink = link ?? lastLink.current;
|
|
176
198
|
// The sheet's portal target (D4): a host inside the topmost open dialog
|
|
177
|
-
// when one exists, else the HUD host. Resolved per open.
|
|
199
|
+
// when one exists, else the HUD host. Resolved per open, kept while it closes.
|
|
178
200
|
const portal = useMemo(() => {
|
|
179
|
-
if (!
|
|
201
|
+
if (!sheetP.mounted)
|
|
180
202
|
return null;
|
|
181
203
|
const target = sheetTarget();
|
|
182
204
|
if (!target)
|
|
183
205
|
return { mount: hostMount, destroy: () => undefined, own: false };
|
|
184
206
|
const h = createSheetHost(target);
|
|
185
207
|
return { ...h, own: true };
|
|
186
|
-
}, [
|
|
208
|
+
}, [sheetP.mounted, hostMount]);
|
|
187
209
|
useEffect(() => () => portal?.destroy(), [portal]);
|
|
188
|
-
|
|
189
|
-
|
|
210
|
+
// Where the open layer sits (D5): anchored to the dock's resting box and
|
|
211
|
+
// opening toward the centre, or a phone bottom sheet over the visual viewport.
|
|
212
|
+
const layerOpen = sheet !== null || link !== null;
|
|
213
|
+
const vvTick = useViewportTick(layerOpen && phone);
|
|
214
|
+
const [layer, setLayer] = useState({ style: {}, origin: 'bottom-right' });
|
|
215
|
+
useLayoutEffect(() => {
|
|
216
|
+
const el = dock.ref.current;
|
|
217
|
+
if (!layerOpen || !el)
|
|
218
|
+
return;
|
|
219
|
+
const o = hostOrigin(sheet && portal ? portal.mount : hostMount);
|
|
220
|
+
setLayer(phone ? { style: phoneLayer(o), origin: 'bottom-center' } : anchoredLayer(restingBox(el), dock.place, o));
|
|
221
|
+
}, [layerOpen, sheet, portal, hostMount, dock.place, dock.ref, phone, vvTick]);
|
|
222
|
+
const layerCls = phone ? 'phone' : 'anchor';
|
|
223
|
+
const motionCls = phone ? 'rise' : 'grow';
|
|
224
|
+
const sheetEl = shownSheet && portal ? (_jsxs("div", { className: "hud", children: [portal.own ? _jsx("style", { children: HUD_CSS }) : null, _jsx("div", { className: layerCls, style: layer.style, children: _jsx(Sheet, { className: `${motionCls} ${sheetP.cls}`, origin: layer.origin, title: shownSheet.title, ctx: shownSheet.ctx, pick: shownSheet.pick !== null, draft: draft, onDraft: setDraft, onSend: onSend, onClose: closeSheet }) })] })) : null;
|
|
225
|
+
// Stable phrases only: a live region re-announces on every text change.
|
|
226
|
+
const hs = rec ? health().state : null;
|
|
227
|
+
const status = !rec
|
|
228
|
+
? linked
|
|
229
|
+
? 'Recorder ready'
|
|
230
|
+
: 'Recorder not linked'
|
|
231
|
+
: hs === 'dead'
|
|
232
|
+
? 'Recording ended on the server'
|
|
233
|
+
: hs === 'offline'
|
|
234
|
+
? 'Recorder offline, retrying'
|
|
235
|
+
: rec.paused
|
|
236
|
+
? 'Recording paused'
|
|
237
|
+
: 'Recording';
|
|
238
|
+
const place = dock.place;
|
|
239
|
+
const dockAttrs = 'tuck' in place
|
|
240
|
+
? { 'data-tuck': place.tuck, style: { '--ty': String(place.y) } }
|
|
241
|
+
: { 'data-row': rowOf(place.spot), 'data-col': colOf(place.spot) };
|
|
242
|
+
return (_jsxs("div", { className: "hud", "data-spot": 'spot' in place ? place.spot : undefined, children: [_jsx("style", { children: HUD_CSS }), annotating && rec ? _jsx(AnnotateOverlay, { onPick: onPick, onCancel: cancelAnnotate }) : null, _jsx("div", { ref: dock.ref, className: dock.dragging ? 'dock dragging' : 'dock', ...dockAttrs, onClickCapture: dock.onClickCapture, children: _jsx(RecorderPill, { rec: rec, composing: sheet !== null, annotating: annotating, stopping: stopping, starting: starting, linked: linked, canUnlink: canUnlink, place: place, dragging: dock.dragging, handle: dock.handle, onMove: dock.moveTo, onLink: beginLink, onUnlink: onUnlink, onStart: onStart, onPause: onPause, onNote: openNote, onAnnotate: toggleAnnotate, onStop: onStop }) }), sheetEl && portal ? createPortal(sheetEl, portal.mount) : null, linkP.mounted && shownLink ? (_jsx("div", { className: layerCls, style: layer.style, children: _jsx(LinkSheet, { className: `${motionCls} ${linkP.cls}`, origin: layer.origin, phase: shownLink.phase, start: shownLink.flow?.start ?? null, error: shownLink.error, onRetry: beginLink, onClose: closeLink }) })) : null, _jsx("div", { className: "sr", role: "status", "aria-live": "polite", children: status })] }));
|
|
190
243
|
}
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
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
|
|
3
|
+
* the server-rendered QR for the desktop→phone path (fine pointers only),
|
|
4
|
+
* and the waiting line.
|
|
4
5
|
* Polling runs in the parent; this only paints the state.
|
|
5
6
|
*/
|
|
6
7
|
import type { ReactElement } from 'react';
|
|
7
8
|
import type { LinkStart } from '@vitrinka/link';
|
|
8
9
|
export type LinkPhase = 'starting' | 'waiting' | 'expired' | 'error';
|
|
9
10
|
export interface LinkSheetProps {
|
|
11
|
+
/** Presence classes (`grow`/`rise` + `is-open`/`is-closing`). */
|
|
12
|
+
className: string;
|
|
13
|
+
/** The corner it grows from (`bottom-right`, …). */
|
|
14
|
+
origin: string;
|
|
10
15
|
phase: LinkPhase;
|
|
11
16
|
start: LinkStart | null;
|
|
12
17
|
error?: string;
|
|
@@ -21,6 +26,7 @@ export declare const LINK_STRINGS: {
|
|
|
21
26
|
readonly expired: "code expired — try again";
|
|
22
27
|
readonly retry: "Try again";
|
|
23
28
|
readonly qrAlt: "Scan to link";
|
|
24
|
-
readonly hint: "
|
|
29
|
+
readonly hint: "Approve here, or scan it with your phone.";
|
|
30
|
+
readonly hintTouch: "Approve it in vitrinka, then come back here.";
|
|
25
31
|
};
|
|
26
|
-
export declare function LinkSheet({ phase, start, error, onRetry, onClose }: LinkSheetProps): ReactElement;
|
|
32
|
+
export declare function LinkSheet({ className, origin, phase, start, error, onRetry, onClose }: LinkSheetProps): ReactElement;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { CloseIcon, NewTabIcon } from './icons';
|
|
3
|
+
import { FINE_QUERY, useMedia } from './layer';
|
|
3
4
|
export const LINK_STRINGS = {
|
|
4
5
|
title: 'Link recorder',
|
|
5
6
|
open: 'Open vitrinka',
|
|
@@ -8,8 +9,11 @@ export const LINK_STRINGS = {
|
|
|
8
9
|
expired: 'code expired — try again',
|
|
9
10
|
retry: 'Try again',
|
|
10
11
|
qrAlt: 'Scan to link',
|
|
11
|
-
hint: '
|
|
12
|
+
hint: 'Approve here, or scan it with your phone.',
|
|
13
|
+
hintTouch: 'Approve it in vitrinka, then come back here.',
|
|
12
14
|
};
|
|
13
|
-
export function LinkSheet({ phase, start, error, onRetry, onClose }) {
|
|
14
|
-
|
|
15
|
+
export function LinkSheet({ className, origin, phase, start, error, onRetry, onClose }) {
|
|
16
|
+
// The QR serves the desktop→phone path; a touch device cannot scan itself (D5).
|
|
17
|
+
const qr = useMedia(FINE_QUERY);
|
|
18
|
+
return (_jsxs("div", { className: `pop link ${className}`, "data-origin": origin, role: "dialog", "aria-labelledby": "vt-link-title", "data-e2e": "link-sheet", children: [_jsxs("div", { className: "pop-head", children: [_jsxs("span", { className: "title", 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: [_jsxs("div", { className: qr ? 'linkgrid' : 'linkgrid noqr', children: [_jsx("div", { className: "code", "data-e2e": "link-code", children: start.user_code }), _jsxs("a", { className: "sendb", href: start.verifyUrl, target: "_blank", rel: "noopener noreferrer", children: [_jsx(NewTabIcon, {}), _jsx("span", { children: LINK_STRINGS.open })] }), qr ? _jsx("img", { className: "qr", src: start.qrUrl, alt: LINK_STRINGS.qrAlt, width: 76, height: 76 }) : null] }), _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: qr ? LINK_STRINGS.hint : LINK_STRINGS.hintTouch })] }));
|
|
15
19
|
}
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* that unfolds
|
|
6
|
-
*
|
|
2
|
+
* The HUD's face (recorder-hud-subtle D1/D4). Idle: a 28px glass PUCK — a
|
|
3
|
+
* hollow ring before the device is linked, a muted dot once it is — whose
|
|
4
|
+
* label slides out on hover/focus. Recording: a CAPSULE of rec dot + timer
|
|
5
|
+
* that unfolds into the TRAY (sync · ⏸ ✎ ⌖ ⋯, keycaps on hover) on hover,
|
|
6
|
+
* focus or tap and folds back ~2.5s after the pointer and focus leave; a
|
|
7
|
+
* one-line health detail unfolds only when something is wrong. Tucked: a 6px
|
|
8
|
+
* edge TAB. The capsule handle, the puck and the tab are the drag handles.
|
|
7
9
|
*/
|
|
10
|
+
import { type Place, type Spot } from '@vitrinka/link/dock';
|
|
8
11
|
import { type ReactElement } from 'react';
|
|
9
12
|
import { type RecorderHealth, type SessionState } from '../queue';
|
|
13
|
+
import type { DockHandle } from './useDock';
|
|
10
14
|
export declare const MOD: string;
|
|
15
|
+
export declare const SPOT_NAMES: Record<Spot, string>;
|
|
11
16
|
/** The extension's renderHealth: glyph + class + optional detail line. */
|
|
12
17
|
export declare function healthLine(h: RecorderHealth, stopping: boolean): {
|
|
13
18
|
glyph: ReactElement | string;
|
|
@@ -25,6 +30,10 @@ export interface RecorderPillProps {
|
|
|
25
30
|
linked: boolean;
|
|
26
31
|
/** A stored link can be forgotten; an explicit key cannot. */
|
|
27
32
|
canUnlink: boolean;
|
|
33
|
+
place: Place;
|
|
34
|
+
dragging: boolean;
|
|
35
|
+
handle: DockHandle;
|
|
36
|
+
onMove: (place: Place) => void;
|
|
28
37
|
onLink: () => void;
|
|
29
38
|
onUnlink: () => void;
|
|
30
39
|
onStart: () => void;
|
|
@@ -1,16 +1,32 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
|
-
* The
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* that unfolds
|
|
7
|
-
*
|
|
3
|
+
* The HUD's face (recorder-hud-subtle D1/D4). Idle: a 28px glass PUCK — a
|
|
4
|
+
* hollow ring before the device is linked, a muted dot once it is — whose
|
|
5
|
+
* label slides out on hover/focus. Recording: a CAPSULE of rec dot + timer
|
|
6
|
+
* that unfolds into the TRAY (sync · ⏸ ✎ ⌖ ⋯, keycaps on hover) on hover,
|
|
7
|
+
* focus or tap and folds back ~2.5s after the pointer and focus leave; a
|
|
8
|
+
* one-line health detail unfolds only when something is wrong. Tucked: a 6px
|
|
9
|
+
* edge TAB. The capsule handle, the puck and the tab are the drag handles.
|
|
8
10
|
*/
|
|
9
|
-
import {
|
|
11
|
+
import { SPOTS, untuck } from '@vitrinka/link/dock';
|
|
12
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
10
13
|
import { health } from '../queue';
|
|
11
14
|
import { elapsedOf } from '../session';
|
|
12
15
|
import { AnnotateIcon, CheckIcon, CloseIcon, MoreIcon, NewTabIcon, PauseIcon, PencilIcon, PlayIcon, StopIcon } from './icons';
|
|
16
|
+
import { usePresence } from './layer';
|
|
13
17
|
export const MOD = /Mac|iPhone|iPad/.test(globalThis.navigator?.platform ?? '') ? '⌥⇧' : 'Alt⇧';
|
|
18
|
+
/** How long the tray stays unfolded after the pointer and focus leave it. */
|
|
19
|
+
const FOLD_MS = 2500;
|
|
20
|
+
/** …and after a touch interaction inside it (no hover to keep it open). */
|
|
21
|
+
const FOLD_TOUCH_MS = 4000;
|
|
22
|
+
export const SPOT_NAMES = {
|
|
23
|
+
tl: 'top left',
|
|
24
|
+
tc: 'top centre',
|
|
25
|
+
tr: 'top right',
|
|
26
|
+
bl: 'bottom left',
|
|
27
|
+
bc: 'bottom centre',
|
|
28
|
+
br: 'bottom right',
|
|
29
|
+
};
|
|
14
30
|
function fmtAge(ms) {
|
|
15
31
|
return ms < 1000 ? 'just now' : ms < 60000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60000)}m`;
|
|
16
32
|
}
|
|
@@ -41,33 +57,156 @@ export function healthLine(h, stopping) {
|
|
|
41
57
|
export function RecorderPill(p) {
|
|
42
58
|
const [, tick] = useState(0);
|
|
43
59
|
const [menu, setMenu] = useState(false);
|
|
60
|
+
const [open, setOpen] = useState(false);
|
|
61
|
+
const fold = useRef(undefined);
|
|
62
|
+
const hovered = useRef(false);
|
|
63
|
+
const barRef = useRef(null);
|
|
64
|
+
const moreRef = useRef(null);
|
|
65
|
+
const menuP = usePresence(menu, 150);
|
|
66
|
+
const hold = menu || p.composing || p.annotating;
|
|
44
67
|
useEffect(() => {
|
|
45
68
|
const t = setInterval(() => tick((n) => n + 1), 1000);
|
|
46
69
|
return () => clearInterval(t);
|
|
47
70
|
}, []);
|
|
48
71
|
useEffect(() => {
|
|
49
|
-
if (!p.rec)
|
|
72
|
+
if (!p.rec) {
|
|
50
73
|
setMenu(false);
|
|
74
|
+
setOpen(false);
|
|
75
|
+
}
|
|
51
76
|
}, [p.rec]);
|
|
52
|
-
|
|
53
|
-
|
|
77
|
+
const cancelFold = useCallback(() => clearTimeout(fold.current), []);
|
|
78
|
+
// A hovering pointer or keyboard focus inside keeps the tray; a tap's
|
|
79
|
+
// focus does not (touch has no "leave" to fold on).
|
|
80
|
+
const foldIn = useCallback((ms) => {
|
|
81
|
+
cancelFold();
|
|
82
|
+
fold.current = setTimeout(() => {
|
|
83
|
+
if (!hovered.current && !barRef.current?.querySelector(':focus-visible'))
|
|
84
|
+
setOpen(false);
|
|
85
|
+
}, ms);
|
|
86
|
+
}, [cancelFold]);
|
|
87
|
+
useEffect(() => cancelFold, [cancelFold]);
|
|
88
|
+
// A drag folds everything; a held tray (menu, sheet, annotate) never folds,
|
|
89
|
+
// and folds on the usual delay once the hold ends and nothing keeps it.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (p.dragging) {
|
|
92
|
+
cancelFold();
|
|
93
|
+
setMenu(false);
|
|
94
|
+
setOpen(false);
|
|
95
|
+
}
|
|
96
|
+
}, [p.dragging, cancelFold]);
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (hold)
|
|
99
|
+
cancelFold();
|
|
100
|
+
else if (open)
|
|
101
|
+
foldIn(FOLD_MS);
|
|
102
|
+
}, [hold, open, cancelFold, foldIn]);
|
|
103
|
+
// Folded tools are out of the tab order and the accessibility tree
|
|
104
|
+
// (imperative: React 18 and 19 disagree on the `inert` prop).
|
|
105
|
+
const trayRef = useRef(null);
|
|
106
|
+
const unfolded = open || hold;
|
|
107
|
+
useLayoutEffect(() => {
|
|
108
|
+
if (trayRef.current)
|
|
109
|
+
trayRef.current.inert = !unfolded;
|
|
110
|
+
});
|
|
111
|
+
// Hover is read with NATIVE pointerenter/leave: React synthesises enter
|
|
112
|
+
// from over/out pairs and drops an `over` whose related target belongs to
|
|
113
|
+
// any React root — on a React page the pointer always arrives from the
|
|
114
|
+
// app's own tree, so a synthetic onPointerEnter never fires on the HUD.
|
|
115
|
+
const holdRef = useRef(hold);
|
|
116
|
+
holdRef.current = hold;
|
|
117
|
+
const hasBar = p.rec !== null && !('tuck' in p.place);
|
|
118
|
+
useLayoutEffect(() => {
|
|
119
|
+
const bar = barRef.current;
|
|
120
|
+
if (!bar)
|
|
121
|
+
return;
|
|
122
|
+
// The capsule often appears UNDER a resting pointer (it replaces the
|
|
123
|
+
// puck that was just clicked): that pointer never "enters", so seed it.
|
|
124
|
+
if (bar.matches(':hover')) {
|
|
125
|
+
hovered.current = true;
|
|
126
|
+
setOpen(true);
|
|
127
|
+
}
|
|
128
|
+
const enter = (e) => {
|
|
129
|
+
if (e.pointerType !== 'mouse')
|
|
130
|
+
return;
|
|
131
|
+
hovered.current = true;
|
|
132
|
+
cancelFold();
|
|
133
|
+
setOpen(true);
|
|
134
|
+
};
|
|
135
|
+
const leave = (e) => {
|
|
136
|
+
if (e.pointerType !== 'mouse')
|
|
137
|
+
return;
|
|
138
|
+
hovered.current = false;
|
|
139
|
+
if (!holdRef.current)
|
|
140
|
+
foldIn(FOLD_MS);
|
|
141
|
+
};
|
|
142
|
+
bar.addEventListener('pointerenter', enter);
|
|
143
|
+
bar.addEventListener('pointerleave', leave);
|
|
144
|
+
return () => {
|
|
145
|
+
bar.removeEventListener('pointerenter', enter);
|
|
146
|
+
bar.removeEventListener('pointerleave', leave);
|
|
147
|
+
};
|
|
148
|
+
}, [hasBar, cancelFold, foldIn]);
|
|
149
|
+
// The menu closes on Esc (focus back on ⋯) and on a press anywhere else.
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (!menu)
|
|
152
|
+
return;
|
|
153
|
+
const key = (e) => {
|
|
154
|
+
if (e.key !== 'Escape')
|
|
155
|
+
return;
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
e.stopPropagation();
|
|
158
|
+
setMenu(false);
|
|
159
|
+
moreRef.current?.focus();
|
|
160
|
+
};
|
|
161
|
+
const down = (e) => {
|
|
162
|
+
if (!e.composedPath().includes(barRef.current))
|
|
163
|
+
setMenu(false);
|
|
164
|
+
};
|
|
165
|
+
document.addEventListener('keydown', key, true);
|
|
166
|
+
document.addEventListener('pointerdown', down, true);
|
|
167
|
+
return () => {
|
|
168
|
+
document.removeEventListener('keydown', key, true);
|
|
169
|
+
document.removeEventListener('pointerdown', down, true);
|
|
170
|
+
};
|
|
171
|
+
}, [menu]);
|
|
172
|
+
if ('tuck' in p.place) {
|
|
173
|
+
const h = p.rec ? healthLine(health(), p.stopping) : null;
|
|
174
|
+
return (_jsx("button", { type: "button", className: "tab", "aria-label": "Show recorder", "data-state": h?.bad ? 'bad' : p.rec && !p.rec.paused ? 'rec' : 'idle', ...p.handle, onClick: () => p.onMove({ spot: untuck(p.place) }) }));
|
|
54
175
|
}
|
|
55
176
|
if (!p.rec) {
|
|
56
|
-
|
|
177
|
+
const unlinked = !p.linked;
|
|
178
|
+
const label = unlinked ? 'Link recorder' : 'Start recording';
|
|
179
|
+
return (_jsxs("button", { type: "button", className: "puck glass", "data-state": unlinked ? 'unlinked' : p.starting ? 'busy' : 'ready', "aria-label": label, onClick: unlinked ? p.onLink : p.onStart, disabled: p.starting, ...p.handle, children: [_jsx("span", { className: "ring" }), _jsx("span", { className: "lab", "aria-hidden": "true", children: _jsx("span", { children: label }) })] }));
|
|
57
180
|
}
|
|
58
181
|
const rec = p.rec;
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
182
|
+
const hl = healthLine(health(), p.stopping);
|
|
183
|
+
const state = hl.bad ? 'bad' : rec.paused || rec.dead || p.stopping ? 'paused' : 'rec';
|
|
184
|
+
const current = 'spot' in p.place ? p.place.spot : null;
|
|
185
|
+
const origin = `${current?.[0] === 't' ? 'top' : 'bottom'}-${current?.[1] === 'r' ? 'right' : 'left'}`;
|
|
186
|
+
const onTouch = (e) => {
|
|
187
|
+
if (e.pointerType !== 'mouse' && open)
|
|
188
|
+
foldIn(FOLD_TOUCH_MS);
|
|
189
|
+
};
|
|
190
|
+
// Keyboard focus unfolds; a tap's focus is left to the tap's own toggle.
|
|
191
|
+
const onFocus = (e) => {
|
|
192
|
+
if (!e.target.matches(':focus-visible'))
|
|
193
|
+
return;
|
|
194
|
+
cancelFold();
|
|
195
|
+
setOpen(true);
|
|
196
|
+
};
|
|
197
|
+
const onBlur = (e) => {
|
|
198
|
+
if (barRef.current?.contains(e.relatedTarget))
|
|
199
|
+
return;
|
|
200
|
+
if (!hold)
|
|
201
|
+
foldIn(FOLD_MS);
|
|
202
|
+
};
|
|
203
|
+
const toggle = () => {
|
|
204
|
+
setOpen((o) => !o);
|
|
205
|
+
foldIn(FOLD_TOUCH_MS);
|
|
206
|
+
};
|
|
207
|
+
const act = (fn) => () => {
|
|
208
|
+
setMenu(false);
|
|
209
|
+
fn();
|
|
210
|
+
};
|
|
211
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: barRef, className: p.composing ? 'bar glass composing' : 'bar glass', "data-e2e": "recorder-pill", "data-state": state, "data-open": unfolded ? '' : undefined, onPointerDown: onTouch, onFocus: onFocus, onBlur: onBlur, children: [_jsxs("button", { type: "button", className: "handle", "aria-label": "Recorder controls", "aria-expanded": unfolded, onClick: toggle, ...p.handle, children: [_jsx("span", { className: "dot" }), _jsx("span", { className: "time", children: fmtClock(elapsedOf(rec)) })] }), _jsx("div", { ref: trayRef, className: "tray", children: _jsxs("div", { className: "tray-in", children: [_jsx("span", { className: "sep" }), _jsx("span", { className: `sync${hl.cls ? ' ' + hl.cls : ''}`, title: "everything captured has reached vitrinka", children: hl.glyph }), _jsxs("button", { type: "button", className: "tb b-pause", "aria-label": rec.paused ? 'Resume' : 'Pause', onClick: p.onPause, children: [rec.paused ? _jsx(PlayIcon, {}) : _jsx(PauseIcon, {}), _jsxs("kbd", { children: [MOD, "P ", rec.paused ? 'resume' : 'pause'] })] }), _jsxs("button", { type: "button", className: "tb b-note", "aria-label": "Note", onClick: p.onNote, children: [_jsx(PencilIcon, {}), _jsxs("kbd", { children: [MOD, "N note"] })] }), _jsxs("button", { type: "button", className: p.annotating ? 'tb b-annotate on' : 'tb b-annotate', "aria-label": "Annotate", "aria-pressed": p.annotating, onClick: p.onAnnotate, children: [_jsx(AnnotateIcon, {}), _jsxs("kbd", { children: [MOD, "A annotate"] })] }), _jsx("button", { ref: moreRef, type: "button", className: "tb b-more", "aria-label": "More", "aria-haspopup": "menu", "aria-expanded": menu, onClick: () => setMenu((m) => !m), children: _jsx(MoreIcon, {}) })] }) }), menuP.mounted ? (_jsxs("div", { className: `menu grow ${menuP.cls}`, "data-origin": origin, role: "menu", "aria-label": "Recorder", children: [rec.boardUrl ? (_jsxs("button", { type: "button", role: "menuitem", onClick: act(() => window.open(rec.boardUrl, '_blank', 'noopener')), children: [_jsx(NewTabIcon, {}), " Open board"] })) : null, _jsxs("button", { type: "button", role: "menuitem", className: "danger", onClick: act(p.onStop), children: [_jsx(StopIcon, {}), " Stop recording"] }), p.canUnlink ? (_jsxs("button", { type: "button", role: "menuitem", onClick: act(p.onUnlink), children: [_jsx(CloseIcon, {}), " Unlink"] })) : null, _jsxs("div", { className: "moveto", role: "group", "aria-label": "Move to", children: [_jsx("span", { "aria-hidden": "true", children: "Move to" }), _jsx("span", { className: "spots", children: SPOTS.map((s) => (_jsx("button", { type: "button", role: "menuitemradio", "aria-checked": current === s, "aria-label": `Move to ${SPOT_NAMES[s]}`, onClick: act(() => p.onMove({ spot: s })) }, s))) })] })] })) : null] }), _jsx("div", { className: ['detail', hl.line ? 'show' : '', hl.bad ? 'bad' : ''].filter(Boolean).join(' '), "aria-hidden": !hl.line, children: hl.line })] }));
|
|
73
212
|
}
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The composer sheet — the extension's hud.html, as a component:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The composer sheet — the extension's hud.html, as a component: ≤ 288px
|
|
3
|
+
* anchored to the dock (a bottom sheet on phones), a 14px textarea (16px on
|
|
4
|
+
* touch, so iOS never zooms), ✕ in the title row, a three-column footer
|
|
5
|
+
* (board|task · context · ↑ Send) and the hint line (hidden on touch). Enter sends, ⇧Enter newlines, Esc
|
|
5
6
|
* cancels. The DRAFT is owned by the caller so a cancel keeps it until the
|
|
6
7
|
* next send (recorder-hud-polish D3); the board|task choice resets on every
|
|
7
8
|
* open — a destination is per observation, not a mode.
|
|
8
9
|
*/
|
|
9
10
|
import { type ReactElement } from 'react';
|
|
10
11
|
export interface SheetProps {
|
|
12
|
+
/** Presence classes (`grow`/`rise` + `is-open`/`is-closing`). */
|
|
13
|
+
className: string;
|
|
14
|
+
/** The corner it grows from (`bottom-right`, …). */
|
|
15
|
+
origin: string;
|
|
11
16
|
title: string;
|
|
12
17
|
ctx: string;
|
|
13
18
|
/** True when the sheet composes an annotation (shows board|task). */
|
|
@@ -17,4 +22,4 @@ export interface SheetProps {
|
|
|
17
22
|
onSend: (text: string, task: boolean) => void;
|
|
18
23
|
onClose: () => void;
|
|
19
24
|
}
|
|
20
|
-
export declare function Sheet({ title, ctx, pick, draft, onDraft, onSend, onClose }: SheetProps): ReactElement;
|
|
25
|
+
export declare function Sheet({ className, origin, title, ctx, pick, draft, onDraft, onSend, onClose }: SheetProps): ReactElement;
|