@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 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. **Unlink** in the ⋯ menu
60
- forgets it so does a 401 from the server. `recorderKey` (an admin-minted
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
- Unlinked: **Link recorder** bottom-right. Linked and idle: a quiet dot click to **start** (the session title is
113
- `document.title`, or the `title` prop). Recording: rec dot · timer · name ·
114
- sync glyph · controls (keycaps show on hover):
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 360px sheet — Enter sends, ⇧Enter newline, Esc / ✕ / click-outside cancel (the draft survives a cancel) |
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
- inlineImages: true,
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, {}), " annotate \u2014 click an element or drag an area \u00B7 enter sends \u00B7 esc cancels"] })] }));
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, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
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 surviving draft), annotate mode, the
5
- * keyboard shortcuts and the Esc-anywhere / click-outside close.
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 { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
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): ⌥⇧A annotate · ⌥⇧N note · ⌥⇧P pause.
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 (!sheet)
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
- }, [sheet, hostMount]);
208
+ }, [sheetP.mounted, hostMount]);
187
209
  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] })] }));
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, and the waiting line.
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: "approve on this device, or scan from your phone";
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: 'approve on this device, or scan from your phone',
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
- 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
+ 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 pill — the extension's HUD: rec dot · timer · name · sync glyph ·
3
- * with keycaps on hover (⌥⇧P / ⌥⇧N / ⌥⇧A on Apple platforms, Alt⇧
4
- * elsewhere), a menu (Open board · Stop), and a one-line health detail
5
- * that unfolds only when something is wrong. Idle (no session): a quiet grip
6
- * that starts a recording.
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 pill — the extension's HUD: rec dot · timer · name · sync glyph ·
4
- * with keycaps on hover (⌥⇧P / ⌥⇧N / ⌥⇧A on Apple platforms, Alt⇧
5
- * elsewhere), a menu (Open board · Stop), and a one-line health detail
6
- * that unfolds only when something is wrong. Idle (no session): a quiet grip
7
- * that starts a recording.
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 { useEffect, useState } from 'react';
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
- if (!p.rec && !p.linked) {
53
- return (_jsx("div", { className: "stack", children: _jsxs("button", { type: "button", className: "grip link", "aria-label": "Link recorder", onClick: p.onLink, children: [_jsx("span", { className: "dot" }), _jsx("span", { children: "Link recorder" })] }) }));
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
- return (_jsx("div", { className: "stack", children: _jsx("button", { type: "button", className: p.starting ? 'grip busy' : 'grip', "aria-label": "Start recording", title: "Start a vitrinka recording", onClick: p.onStart, disabled: p.starting, children: _jsx("span", { className: "dot" }) }) }));
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 h = health();
60
- const hl = healthLine(h, p.stopping);
61
- const paused = rec.paused || rec.dead || p.stopping;
62
- const cls = ['pill', paused ? 'paused' : '', p.composing ? 'composing' : ''].filter(Boolean).join(' ');
63
- return (_jsxs("div", { className: "stack", children: [_jsxs("div", { className: cls, "data-e2e": "recorder-pill", children: [_jsx("span", { className: "dot" }), _jsx("span", { className: "time", children: fmtClock(elapsedOf(rec)) }), _jsx("span", { className: "name", children: rec.title || `${rec.project} · ${rec.environment}` }), _jsx("span", { className: `sync${hl.cls ? ' ' + hl.cls : ''}`, title: "everything captured has reached vitrinka", children: hl.glyph }), _jsxs("button", { type: "button", className: "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: "b-note", "aria-label": "Note", onClick: p.onNote, children: [_jsx(PencilIcon, {}), _jsxs("kbd", { children: [MOD, "N note"] })] }), _jsxs("button", { type: "button", className: p.annotating ? 'snap b-snap on' : 'snap b-snap', "aria-label": "Annotate", "aria-pressed": p.annotating, onClick: p.onAnnotate, children: [_jsx(AnnotateIcon, {}), _jsxs("kbd", { children: [MOD, "A annotate"] })] }), _jsxs("span", { className: "menuwrap", children: [_jsx("button", { type: "button", className: "b-more", "aria-label": "More", "aria-expanded": menu, onClick: () => setMenu((m) => !m), children: _jsx(MoreIcon, {}) }), menu ? (_jsxs("div", { className: "menu", role: "menu", children: [rec.boardUrl ? (_jsxs("button", { type: "button", role: "menuitem", onClick: () => {
64
- setMenu(false);
65
- window.open(rec.boardUrl, '_blank', 'noopener');
66
- }, children: [_jsx(NewTabIcon, {}), " Open board"] })) : null, _jsxs("button", { type: "button", role: "menuitem", className: "danger", onClick: () => {
67
- setMenu(false);
68
- p.onStop();
69
- }, children: [_jsx(StopIcon, {}), " Stop recording"] }), p.canUnlink ? (_jsxs("button", { type: "button", role: "menuitem", onClick: () => {
70
- setMenu(false);
71
- p.onUnlink();
72
- }, children: [_jsx(CloseIcon, {}), " Unlink"] })) : null] })) : null] })] }), _jsx("div", { className: ['detail', hl.line ? 'show' : '', hl.bad ? 'bad' : ''].filter(Boolean).join(' '), children: hl.line })] }));
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: 360px, a
3
- * 16px textarea, in the kicker, a three-column footer (board|task ·
4
- * context · Send) and the hint line. Enter sends, ⇧Enter newlines, Esc
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;