@vitrinka/web 0.1.1 → 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 +22 -0
- package/README.md +16 -5
- 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/session.d.ts +2 -2
- package/build/recorder/session.js +1 -1
- package/package.json +2 -2
|
@@ -1,111 +1,266 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The HUD's stylesheet
|
|
3
|
-
* it
|
|
4
|
-
*
|
|
2
|
+
* The HUD's stylesheet. Lives inside the shadow root (a `<style>` element),
|
|
3
|
+
* so it never touches host CSS and host CSS never touches it.
|
|
4
|
+
*
|
|
5
|
+
* Shapes (recorder-hud-subtle): the 28px glass PUCK at idle, the dot+timer
|
|
6
|
+
* CAPSULE while recording that unfolds into the TRAY on intent, the 6px edge
|
|
7
|
+
* TAB when tucked. The `.dock` rests on one of six spots (`data-row` t|b ×
|
|
8
|
+
* `data-col` l|c|r) by viewport insets, so an unfolding tray always grows
|
|
9
|
+
* toward the centre; a drag moves it by `transform` only. Motion follows
|
|
10
|
+
* the transitions.dev token scale.
|
|
5
11
|
*/
|
|
6
12
|
export const HUD_CSS_BASE = `
|
|
7
13
|
* { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
|
8
14
|
svg { width:1em; height:1em; vertical-align:-.125em; }
|
|
9
|
-
.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
.
|
|
17
|
-
|
|
18
|
-
.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
15
|
+
.hud {
|
|
16
|
+
--ink: rgba(26,22,23,.72); --ink-pop: rgba(26,22,23,.88); --ink-solid: #1d1a1b;
|
|
17
|
+
--ink-2: rgba(255,255,255,.08); --edge: rgba(255,255,255,.11); --rim: rgba(0,0,0,.30);
|
|
18
|
+
--fg: #f4efea; --fg-2: rgba(244,239,234,.66); --fg-3: rgba(244,239,234,.44);
|
|
19
|
+
--rec: #ff3b57; --warn: #f0a63a;
|
|
20
|
+
--m: 16px; --h: 28px; --b: 24px;
|
|
21
|
+
--duration-quick: 150ms; --duration-fast: 250ms; --duration-resize: 300ms; --duration-medium: 350ms; --duration-snap: 500ms;
|
|
22
|
+
--ease-smooth-out: cubic-bezier(0.22, 1, 0.36, 1); --ease-in-out: ease-in-out; --ease-out: ease-out;
|
|
23
|
+
--ease-spring: var(--ease-smooth-out);
|
|
24
|
+
--scale-open: .97; --scale-closing: .99; --blur-small: 2px; --rise: 16px;
|
|
25
|
+
color: var(--fg); font-size: 12px;
|
|
26
|
+
}
|
|
27
|
+
@supports (transition-timing-function: linear(0, 1)) {
|
|
28
|
+
/* a damped spring, ~4% overshoot over 500ms — the settle after a throw */
|
|
29
|
+
.hud { --ease-spring: linear(0, 0.042, 0.143, 0.274, 0.414, 0.549, 0.67, 0.773, 0.856, 0.921, 0.968, 1.001, 1.021, 1.033, 1.038, 1.038, 1.035, 1.031, 1.025, 1.02, 1.015, 1.011, 1.007, 1.004, 1); }
|
|
30
|
+
}
|
|
31
|
+
@media (pointer: coarse) { .hud { --m: 12px; --h: 32px; --b: 30px; } }
|
|
32
|
+
/* scoped under .hud so a control's own \`all:unset\` reset cannot wipe the surface */
|
|
33
|
+
.hud .glass, .hud .menu, .hud .pop, .hud .detail, .hud .hint {
|
|
34
|
+
background: var(--ink);
|
|
35
|
+
-webkit-backdrop-filter: blur(16px) saturate(1.5); backdrop-filter: blur(16px) saturate(1.5);
|
|
36
|
+
box-shadow: inset 0 0 0 1px var(--edge), 0 0 0 .5px var(--rim), 0 10px 28px -10px rgba(0,0,0,.5), 0 2px 6px -2px rgba(0,0,0,.28);
|
|
37
|
+
color: var(--fg);
|
|
38
|
+
}
|
|
39
|
+
.hud .menu, .hud .pop { background: var(--ink-pop); }
|
|
40
|
+
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
|
41
|
+
.hud .glass, .hud .menu, .hud .pop, .hud .detail, .hud .hint { background: var(--ink-solid); }
|
|
42
|
+
}
|
|
43
|
+
.sr { position:absolute; width:1px; height:1px; margin:-1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; }
|
|
44
|
+
|
|
45
|
+
/* the dock: one of six spots, or tucked into a side edge */
|
|
46
|
+
.dock { position:fixed; z-index:4; display:flex; pointer-events:auto; }
|
|
47
|
+
.dock[data-row="b"] { bottom: calc(var(--m) + env(safe-area-inset-bottom, 0px)); }
|
|
48
|
+
.dock[data-row="t"] { top: calc(var(--m) + env(safe-area-inset-top, 0px)); }
|
|
49
|
+
.dock[data-col="l"] { left: calc(var(--m) + env(safe-area-inset-left, 0px)); }
|
|
50
|
+
.dock[data-col="r"] { right: calc(var(--m) + env(safe-area-inset-right, 0px)); }
|
|
51
|
+
.dock[data-col="c"] { left: calc(50% - var(--hw, 36px) / 2); }
|
|
52
|
+
.dock[data-tuck] { top: clamp(8px, calc(var(--ty, .5) * 100% - 28px), calc(100% - 64px)); }
|
|
53
|
+
.dock[data-tuck="left"] { left:0; }
|
|
54
|
+
.dock[data-tuck="right"] { right:0; }
|
|
55
|
+
.dock.settling { transition: transform var(--duration-snap) var(--ease-spring); }
|
|
56
|
+
.dock.dragging .glass { box-shadow: inset 0 0 0 1px var(--edge), 0 0 0 .5px var(--rim), 0 18px 40px -12px rgba(0,0,0,.55), 0 4px 10px -4px rgba(0,0,0,.3); }
|
|
57
|
+
|
|
58
|
+
/* the recording dot: a soft ripple while recording, still otherwise */
|
|
59
|
+
.dot { position:relative; width:8px; height:8px; flex:none; border-radius:50%; background:var(--rec); }
|
|
60
|
+
.dot::after { content:""; position:absolute; inset:0; border-radius:50%; box-shadow:0 0 0 0 rgba(255,59,87,.5); animation:ripple 2s var(--ease-out) infinite; }
|
|
61
|
+
@keyframes ripple { 0% { box-shadow:0 0 0 0 rgba(255,59,87,.45); } 70%, 100% { box-shadow:0 0 0 7px rgba(255,59,87,0); } }
|
|
62
|
+
.bar[data-state="paused"] .dot { background:var(--fg-3); }
|
|
63
|
+
.bar[data-state="bad"] .dot { background:var(--warn); }
|
|
64
|
+
.bar[data-state="paused"] .dot::after, .bar[data-state="bad"] .dot::after { animation:none; }
|
|
65
|
+
|
|
66
|
+
/* idle: the puck — ring = not linked, dot = ready; the label slides out on intent */
|
|
67
|
+
.puck { all:unset; box-sizing:border-box; display:inline-flex; align-items:center; height:var(--h); min-width:var(--h);
|
|
68
|
+
padding:0 calc((var(--h) - 10px) / 2); border-radius:999px; cursor:pointer; touch-action:none; user-select:none; -webkit-user-select:none;
|
|
69
|
+
font-weight:600; font-size:12px; color:var(--fg); }
|
|
70
|
+
.dock[data-col="r"] .puck { flex-direction:row-reverse; }
|
|
71
|
+
.ring { width:10px; height:10px; flex:none; border-radius:50%; box-shadow:inset 0 0 0 1.5px var(--fg-2);
|
|
72
|
+
transition: background-color var(--duration-quick) var(--ease-out), box-shadow var(--duration-quick) var(--ease-out); }
|
|
73
|
+
.puck[data-state="ready"] .ring { background:var(--fg-2); box-shadow:none; }
|
|
74
|
+
.puck[data-state="busy"] .ring { background:var(--fg-3); box-shadow:none; animation:breathe 1.2s var(--ease-in-out) infinite; }
|
|
75
|
+
@keyframes breathe { 50% { opacity:.35; } }
|
|
76
|
+
.lab { display:grid; grid-template-columns:0fr; opacity:0;
|
|
77
|
+
transition: grid-template-columns var(--duration-resize) var(--ease-smooth-out), opacity var(--duration-quick) var(--ease-out); }
|
|
78
|
+
/* the gap to the ring is a clipped pseudo-element: padding would keep a folded label 10px wide */
|
|
79
|
+
.lab > span { min-width:0; overflow:hidden; white-space:nowrap; }
|
|
80
|
+
.lab > span::before, .lab > span::after { content:""; display:inline-block; }
|
|
81
|
+
.lab > span::before { width:8px; }
|
|
82
|
+
.lab > span::after { width:2px; }
|
|
83
|
+
.dock[data-col="r"] .lab > span::before { width:2px; }
|
|
84
|
+
.dock[data-col="r"] .lab > span::after { width:8px; }
|
|
85
|
+
.puck:focus-visible .lab { grid-template-columns:1fr; opacity:1; }
|
|
86
|
+
.puck:focus-visible { outline:2px solid var(--rec); outline-offset:2px; }
|
|
87
|
+
@media (hover: hover) {
|
|
88
|
+
.puck:hover .lab { grid-template-columns:1fr; opacity:1; }
|
|
89
|
+
.puck[data-state="ready"]:hover .ring { background:var(--rec); }
|
|
90
|
+
.puck[data-state="unlinked"]:hover .ring { box-shadow:inset 0 0 0 1.5px var(--fg); }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* recording: the capsule (handle) + the tray it unfolds */
|
|
94
|
+
.bar { position:relative; display:inline-flex; align-items:center; height:var(--h); border-radius:999px; }
|
|
95
|
+
.dock[data-col="r"] .bar, .dock[data-col="r"] .tray-in { flex-direction:row-reverse; }
|
|
96
|
+
.handle { all:unset; box-sizing:border-box; display:inline-flex; align-items:center; gap:7px; height:var(--h);
|
|
97
|
+
padding:0 11px 0 10px; border-radius:999px; cursor:grab; touch-action:none; user-select:none; -webkit-user-select:none; }
|
|
98
|
+
.dock.dragging .handle { cursor:grabbing; }
|
|
99
|
+
.handle:focus-visible { outline:2px solid var(--rec); outline-offset:2px; }
|
|
100
|
+
.time { font-weight:600; font-size:12px; line-height:1; font-variant-numeric:tabular-nums; letter-spacing:.01em; min-width:34px; }
|
|
101
|
+
.tray { display:grid; grid-template-columns:0fr; transition: grid-template-columns var(--duration-resize) var(--ease-smooth-out); }
|
|
102
|
+
.tray-in { min-width:0; overflow:hidden; display:flex; align-items:center; gap:1px; padding:34px 3px; margin:-34px 0;
|
|
103
|
+
pointer-events:none; opacity:0; filter:blur(var(--blur-small));
|
|
104
|
+
transition: opacity var(--duration-quick) var(--ease-in-out), filter var(--duration-quick) var(--ease-in-out); }
|
|
105
|
+
.tray-in > * { pointer-events:auto; }
|
|
106
|
+
.bar[data-open] .tray { grid-template-columns:1fr; }
|
|
107
|
+
.bar[data-open] .tray-in { opacity:1; filter:none; transition-duration: var(--duration-fast); transition-delay: 60ms; }
|
|
108
|
+
.sep { width:1px; height:14px; flex:none; margin:0 5px; background:var(--edge); }
|
|
109
|
+
.sync { display:inline-grid; place-items:center; width:18px; flex:none; font-size:11px; color:var(--fg-3); }
|
|
110
|
+
.sync.warn, .sync.bad { color:var(--warn); }
|
|
111
|
+
button.tb { all:unset; pointer-events:auto; box-sizing:border-box; position:relative; flex:none; display:inline-grid; place-items:center;
|
|
112
|
+
width:var(--b); height:var(--b); border-radius:999px; color:var(--fg-2); cursor:pointer; font-size:13px;
|
|
113
|
+
transition: background-color var(--duration-quick) var(--ease-out), color var(--duration-quick) var(--ease-out); }
|
|
114
|
+
button.tb.b-annotate { color:var(--rec); }
|
|
115
|
+
button.tb.on { background:var(--rec); color:#fff; }
|
|
116
|
+
button.tb:focus-visible { outline:2px solid var(--rec); outline-offset:-2px; }
|
|
117
|
+
@media (hover: hover) { button.tb:hover { background:var(--ink-2); color:var(--fg); } button.tb.on:hover { background:var(--rec); color:#fff; } }
|
|
118
|
+
kbd { position:absolute; left:50%; bottom:calc(100% + 8px); transform:translateX(-50%); padding:4px 6px; border-radius:6px;
|
|
119
|
+
background:var(--ink-pop); box-shadow:inset 0 0 0 1px var(--edge); color:var(--fg-2); font:600 10px/1 -apple-system, BlinkMacSystemFont, sans-serif;
|
|
120
|
+
letter-spacing:.02em; white-space:nowrap; opacity:0; pointer-events:none; transition:opacity var(--duration-quick) var(--ease-out); }
|
|
121
|
+
.dock[data-row="t"] kbd { bottom:auto; top:calc(100% + 8px); }
|
|
122
|
+
@media (hover: hover) { button.tb:hover kbd { opacity:1; transition-delay:400ms; } }
|
|
123
|
+
button.tb:focus-visible kbd { opacity:1; }
|
|
124
|
+
.bar.composing kbd { opacity:0 !important; }
|
|
125
|
+
|
|
126
|
+
/* the health line: only when something needs saying */
|
|
127
|
+
.detail { position:absolute; width:max-content; max-width:260px; padding:6px 11px; border-radius:999px; color:var(--fg-2); font-size:11px; line-height:1.35;
|
|
128
|
+
opacity:0; transform:translateY(4px); pointer-events:none;
|
|
129
|
+
transition: opacity var(--duration-fast) var(--ease-smooth-out), transform var(--duration-fast) var(--ease-smooth-out); }
|
|
130
|
+
.dock[data-row="b"] .detail { bottom:calc(100% + 6px); }
|
|
131
|
+
.dock[data-row="t"] .detail { top:calc(100% + 6px); transform:translateY(-4px); }
|
|
132
|
+
.dock[data-col="r"] .detail { right:0; }
|
|
133
|
+
.dock[data-col="l"] .detail, .dock[data-col="c"] .detail { left:0; }
|
|
25
134
|
.detail.show { opacity:1; transform:none; }
|
|
26
|
-
.detail.bad {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
/*
|
|
45
|
-
.
|
|
46
|
-
|
|
47
|
-
.
|
|
48
|
-
.
|
|
49
|
-
.
|
|
50
|
-
|
|
51
|
-
.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
.menu button {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
.
|
|
58
|
-
.
|
|
135
|
+
.detail.bad { color:var(--fg); box-shadow: inset 0 0 0 1px rgba(240,166,58,.55), 0 10px 28px -10px rgba(0,0,0,.5); }
|
|
136
|
+
|
|
137
|
+
/* the edge tab a tucked HUD becomes */
|
|
138
|
+
.tab { all:unset; box-sizing:border-box; position:relative; display:block; width:22px; height:56px; cursor:pointer; touch-action:none; }
|
|
139
|
+
.tab::before { content:""; position:absolute; top:0; bottom:0; width:6px; background:var(--ink);
|
|
140
|
+
-webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px);
|
|
141
|
+
box-shadow: inset 0 0 0 1px var(--edge), 0 0 0 .5px var(--rim), 0 6px 18px -6px rgba(0,0,0,.5);
|
|
142
|
+
transition: width var(--duration-quick) var(--ease-out); }
|
|
143
|
+
.tab::after { content:""; position:absolute; top:16px; bottom:16px; width:2px; border-radius:1px; background:var(--fg-3); }
|
|
144
|
+
.dock[data-tuck="left"] .tab::before { left:0; border-radius:0 7px 7px 0; }
|
|
145
|
+
.dock[data-tuck="right"] .tab::before { right:0; border-radius:7px 0 0 7px; }
|
|
146
|
+
.dock[data-tuck="left"] .tab::after { left:2px; }
|
|
147
|
+
.dock[data-tuck="right"] .tab::after { right:2px; }
|
|
148
|
+
.tab[data-state="rec"]::after { background:var(--rec); }
|
|
149
|
+
.tab[data-state="bad"]::after { background:var(--warn); }
|
|
150
|
+
.tab:focus-visible::before { width:10px; outline:2px solid var(--rec); outline-offset:1px; }
|
|
151
|
+
@media (hover: hover) { .tab:hover::before { width:9px; } }
|
|
152
|
+
|
|
153
|
+
/* the ⋯ menu — grows from the bar toward the viewport centre */
|
|
154
|
+
.menu { position:absolute; min-width:188px; padding:5px; border-radius:12px; display:flex; flex-direction:column; gap:1px; z-index:5; }
|
|
155
|
+
.dock[data-row="b"] .menu { bottom:calc(100% + 8px); }
|
|
156
|
+
.dock[data-row="t"] .menu { top:calc(100% + 8px); }
|
|
157
|
+
.dock[data-col="r"] .menu { right:0; }
|
|
158
|
+
.dock[data-col="l"] .menu, .dock[data-col="c"] .menu { left:0; }
|
|
159
|
+
.menu > button { all:unset; box-sizing:border-box; cursor:pointer; padding:8px 10px; border-radius:7px; color:var(--fg);
|
|
160
|
+
font-size:12.5px; line-height:1; display:flex; gap:9px; align-items:center; }
|
|
161
|
+
.menu > button svg { color:var(--fg-2); }
|
|
162
|
+
.menu > button:focus-visible { outline:2px solid var(--rec); outline-offset:-2px; }
|
|
163
|
+
@media (hover: hover) { .menu > button:hover { background:var(--ink-2); } .menu > button.danger:hover, .menu > button.danger:hover svg { color:var(--rec); } }
|
|
164
|
+
.moveto { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:6px 6px 6px 10px; margin-top:3px;
|
|
165
|
+
border-top:1px solid var(--edge); color:var(--fg-2); font-size:12px; }
|
|
166
|
+
.spots { display:grid; grid-template-columns:repeat(3, 24px); grid-template-rows:repeat(2, 24px); gap:0 2px; }
|
|
167
|
+
.spots button { all:unset; box-sizing:border-box; position:relative; cursor:pointer; border-radius:5px; }
|
|
168
|
+
.spots button::before { content:""; position:absolute; inset:7px 5px; border-radius:3px; background:var(--ink-2); box-shadow:inset 0 0 0 1px var(--edge); }
|
|
169
|
+
.spots button[aria-checked="true"]::before { background:var(--rec); box-shadow:none; }
|
|
170
|
+
.spots button:focus-visible { outline:2px solid var(--rec); outline-offset:-2px; }
|
|
171
|
+
@media (hover: hover) { .spots button:hover::before { background:var(--fg-3); } .spots button[aria-checked="true"]:hover::before { background:var(--rec); } }
|
|
172
|
+
|
|
173
|
+
/* presence: dropdown-style grow from the anchor on open, a softer shrink on close */
|
|
174
|
+
.grow { transform:scale(var(--scale-open)); opacity:0;
|
|
175
|
+
transition: transform var(--duration-fast) var(--ease-smooth-out), opacity var(--duration-fast) var(--ease-smooth-out); }
|
|
176
|
+
.grow[data-origin="bottom-right"] { transform-origin:bottom right; }
|
|
177
|
+
.grow[data-origin="bottom-left"] { transform-origin:bottom left; }
|
|
178
|
+
.grow[data-origin="bottom-center"] { transform-origin:bottom center; }
|
|
179
|
+
.grow[data-origin="top-right"] { transform-origin:top right; }
|
|
180
|
+
.grow[data-origin="top-left"] { transform-origin:top left; }
|
|
181
|
+
.grow[data-origin="top-center"] { transform-origin:top center; }
|
|
182
|
+
.grow.is-open { transform:scale(1); opacity:1; }
|
|
183
|
+
.grow.is-closing { transform:scale(var(--scale-closing)); opacity:0; pointer-events:none;
|
|
184
|
+
transition-duration: var(--duration-quick); }
|
|
185
|
+
/* phone: the sheet rises from the bottom edge (toast motion) */
|
|
186
|
+
.rise { transform:translateY(var(--rise)) scale(var(--scale-open)); opacity:0; filter:blur(var(--blur-small));
|
|
187
|
+
transition: transform var(--duration-fast) var(--ease-smooth-out), opacity var(--duration-fast) var(--ease-smooth-out), filter var(--duration-fast) var(--ease-smooth-out); }
|
|
188
|
+
.rise.is-open { transform:none; opacity:1; filter:none; transition-duration: var(--duration-medium); }
|
|
189
|
+
.rise.is-closing { pointer-events:none; }
|
|
190
|
+
|
|
59
191
|
/* pick chrome (annotate mode): dim, outline, hint — shadow-root children */
|
|
60
|
-
.dim { position:fixed; inset:0; z-index:1; pointer-events:none; background:rgba(0,0,0,.
|
|
61
|
-
.outline { position:fixed; z-index:2; pointer-events:none; border:2px solid
|
|
192
|
+
.dim { position:fixed; inset:0; z-index:1; pointer-events:none; background:rgba(0,0,0,.18); }
|
|
193
|
+
.outline { position:fixed; z-index:2; pointer-events:none; border:2px solid var(--rec); border-radius:6px;
|
|
62
194
|
box-shadow:0 0 0 4px rgba(255,59,87,.15); }
|
|
63
|
-
.hint { position:fixed; top:
|
|
64
|
-
border-radius:999px;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
195
|
+
.hint { position:fixed; top:14px; left:50%; transform:translateX(-50%); z-index:3; padding:8px 14px;
|
|
196
|
+
border-radius:999px; font-size:12px; font-weight:600; line-height:1; white-space:nowrap; display:flex; gap:8px; align-items:center; }
|
|
197
|
+
.hint svg { color:var(--rec); }
|
|
198
|
+
.hint .keys { color:var(--fg-3); font-weight:500; margin-left:-4px; }
|
|
199
|
+
@media (pointer: coarse) { .hint .keys { display:none; } }
|
|
200
|
+
.hud[data-spot="tc"] .hint { top:auto; bottom:14px; }
|
|
201
|
+
|
|
202
|
+
/* sheets: a popover anchored to the dock, or a phone bottom sheet */
|
|
203
|
+
.anchor { position:fixed; z-index:6; pointer-events:none; }
|
|
204
|
+
.anchor > * { pointer-events:auto; }
|
|
205
|
+
.phone { position:fixed; z-index:6; display:flex; flex-direction:column; justify-content:flex-end;
|
|
206
|
+
padding:0 8px calc(8px + env(safe-area-inset-bottom, 0px)); pointer-events:none; }
|
|
207
|
+
.phone > * { pointer-events:auto; }
|
|
208
|
+
.pop { width:min(288px, calc(100vw - 24px)); padding:12px; border-radius:14px; }
|
|
209
|
+
.phone .pop { width:100%; border-radius:18px; padding:14px; }
|
|
69
210
|
.pop-head { display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
.closeb:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
211
|
+
.title { display:flex; align-items:center; gap:7px; font-size:12.5px; font-weight:600; color:var(--fg); }
|
|
212
|
+
.title i { width:3px; height:11px; border-radius:1px; background:var(--rec); }
|
|
213
|
+
.closeb { all:unset; box-sizing:border-box; cursor:pointer; width:24px; height:24px; border-radius:7px; color:var(--fg-3);
|
|
214
|
+
display:inline-grid; place-items:center; font-size:12px; }
|
|
215
|
+
.closeb:focus-visible, .sendb:focus-visible, .retry:focus-visible, .dest button:focus-visible { outline:2px solid var(--rec); outline-offset:2px; }
|
|
216
|
+
@media (hover: hover) { .closeb:hover { color:var(--fg); background:var(--ink-2); } }
|
|
217
|
+
textarea { width:100%; margin-top:10px; padding:9px 11px; min-height:64px; resize:vertical; background:rgba(0,0,0,.28);
|
|
218
|
+
border:0; box-shadow:inset 0 0 0 1px var(--edge); border-radius:9px; color:var(--fg);
|
|
219
|
+
font:400 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
|
220
|
+
@media (pointer: coarse) { textarea { font-size:16px; } .hints { display:none; } }
|
|
221
|
+
textarea::placeholder { color:var(--fg-3); }
|
|
222
|
+
textarea:focus { outline:none; box-shadow:inset 0 0 0 1px var(--fg-3); }
|
|
223
|
+
.row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:10px; margin-top:10px; }
|
|
80
224
|
.row.nodest { grid-template-columns:minmax(0,1fr) auto; }
|
|
81
|
-
.ctx { font:
|
|
82
|
-
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
225
|
+
.ctx { font-size:11px; line-height:1.35; color:var(--fg-3); text-align:center; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
83
226
|
.row.nodest .ctx { text-align:left; }
|
|
84
|
-
.dest { display:flex;
|
|
85
|
-
.dest button { all:unset; cursor:pointer; padding:
|
|
86
|
-
|
|
87
|
-
.dest button[aria-pressed="true"] {
|
|
88
|
-
.sendb { all:unset; cursor:pointer; display:inline-flex; align-items:center; gap:6px;
|
|
89
|
-
background
|
|
90
|
-
.sendb:hover { background:#ff5670; }
|
|
91
|
-
.hints { margin-top:
|
|
227
|
+
.dest { display:inline-flex; padding:2px; border-radius:8px; background:rgba(0,0,0,.28); box-shadow:inset 0 0 0 1px var(--edge); }
|
|
228
|
+
.dest button { all:unset; box-sizing:border-box; cursor:pointer; padding:5px 8px; min-height:24px; border-radius:6px; color:var(--fg-3);
|
|
229
|
+
font-size:11.5px; font-weight:600; line-height:14px; transition: background-color var(--duration-quick) var(--ease-out), color var(--duration-quick) var(--ease-out); }
|
|
230
|
+
.dest button[aria-pressed="true"] { background:var(--ink-2); color:var(--fg); }
|
|
231
|
+
.sendb { all:unset; box-sizing:border-box; cursor:pointer; display:inline-flex; align-items:center; gap:6px; height:28px; padding:0 12px;
|
|
232
|
+
border-radius:8px; background:var(--rec); color:#fff; font-size:12.5px; font-weight:600; line-height:1; text-decoration:none; }
|
|
233
|
+
@media (hover: hover) { .sendb:hover { background:#ff5670; } }
|
|
234
|
+
.hints { margin-top:9px; font-size:11px; line-height:1; color:var(--fg-3); }
|
|
92
235
|
`;
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
236
|
+
/**
|
|
237
|
+
* Inline style of the HUD host: a 0×0 fixed box at the viewport origin (the
|
|
238
|
+
* dock and sheets are fixed children of its shadow root). Zero-size keeps
|
|
239
|
+
* rrweb's placeholder for this blocked node empty in every replay, and
|
|
240
|
+
* `pointer-events:none` lets the page under the HUD's gaps take every
|
|
241
|
+
* click. Overrides the [popover] UA sheet.
|
|
242
|
+
*/
|
|
243
|
+
export const HOST_STYLE = 'all:initial;position:fixed;z-index:2147483647;inset:0 auto auto 0;margin:0;padding:0;border:0;' +
|
|
244
|
+
'background:transparent;overflow:visible;width:0;height:0;pointer-events:none;color-scheme:normal;';
|
|
245
|
+
/** Inline style of a sheet host portaled into a dialog — the same 0×0 origin box. */
|
|
246
|
+
export const SHEET_HOST_STYLE = HOST_STYLE;
|
|
99
247
|
/** Link-sheet additions (appended to HUD_CSS). */
|
|
100
248
|
export const LINK_CSS = `
|
|
101
|
-
.
|
|
102
|
-
.
|
|
103
|
-
.
|
|
104
|
-
.
|
|
105
|
-
.
|
|
106
|
-
.linkline
|
|
107
|
-
.
|
|
108
|
-
.retry
|
|
109
|
-
|
|
249
|
+
.linkgrid { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:4px 12px; align-items:center; margin-top:10px; }
|
|
250
|
+
.linkgrid.noqr { grid-template-columns:minmax(0,1fr); }
|
|
251
|
+
.code { font-size:22px; font-weight:700; line-height:1.2; letter-spacing:.08em; font-variant-numeric:tabular-nums; color:var(--fg); }
|
|
252
|
+
.linkgrid .sendb { justify-self:start; margin-top:6px; }
|
|
253
|
+
.qr { grid-column:2; grid-row:1 / span 3; width:76px; height:76px; border-radius:8px; background:#fff; padding:4px; }
|
|
254
|
+
.linkline { margin-top:8px; font-size:11px; line-height:1.4; color:var(--fg-2); display:flex; gap:10px; align-items:center; }
|
|
255
|
+
.linkline.bad { color:var(--warn); }
|
|
256
|
+
.retry { all:unset; box-sizing:border-box; cursor:pointer; min-height:24px; padding:4px 9px; border-radius:7px; box-shadow:inset 0 0 0 1px var(--edge);
|
|
257
|
+
color:var(--fg); font-size:11.5px; font-weight:600; line-height:1; }
|
|
258
|
+
@media (hover: hover) { .retry:hover { background:var(--ink-2); } }
|
|
259
|
+
`;
|
|
260
|
+
/** Reduced motion: no ripple, no springs, no slides — every state change is instant. */
|
|
261
|
+
export const MOTION_CSS = `
|
|
262
|
+
@media (prefers-reduced-motion: reduce) {
|
|
263
|
+
.hud *, .hud *::before, .hud *::after { transition-duration:0ms !important; transition-delay:0ms !important; animation:none !important; }
|
|
264
|
+
}
|
|
110
265
|
`;
|
|
111
|
-
export const HUD_CSS = HUD_CSS_BASE + LINK_CSS;
|
|
266
|
+
export const HUD_CSS = HUD_CSS_BASE + LINK_CSS + MOTION_CSS;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the HUD rests and how it moves (recorder-hud-subtle D2). The dock
|
|
3
|
+
* sits on one of six spots by CSS insets (see `.dock` in styles.ts), so a
|
|
4
|
+
* resize or rotation needs no JS; a drag moves it by `transform` only and a
|
|
5
|
+
* release settles through `@vitrinka/link/dock` — a flick lands where it is
|
|
6
|
+
* aimed, a push past a side edge tucks it into a tab. The move to the new
|
|
7
|
+
* spot is a FLIP: the dock re-anchors, then springs from where it was let
|
|
8
|
+
* go. Arrow keys on a focused handle are the non-drag alternative.
|
|
9
|
+
* The place is remembered per origin in the recorder's storage.
|
|
10
|
+
*/
|
|
11
|
+
import { type Place, type Spot } from '@vitrinka/link/dock';
|
|
12
|
+
import { type KeyboardEvent, type MouseEvent, type PointerEvent, type RefObject } from 'react';
|
|
13
|
+
export interface DockHandle {
|
|
14
|
+
onPointerDown: (e: PointerEvent<HTMLElement>) => void;
|
|
15
|
+
onPointerMove: (e: PointerEvent<HTMLElement>) => void;
|
|
16
|
+
onPointerUp: (e: PointerEvent<HTMLElement>) => void;
|
|
17
|
+
onPointerCancel: (e: PointerEvent<HTMLElement>) => void;
|
|
18
|
+
onKeyDown: (e: KeyboardEvent<HTMLElement>) => void;
|
|
19
|
+
}
|
|
20
|
+
export interface Dock {
|
|
21
|
+
place: Place;
|
|
22
|
+
/** The spot the dock anchors by (a tucked dock: its side and half). */
|
|
23
|
+
spot: Spot;
|
|
24
|
+
ref: RefObject<HTMLDivElement | null>;
|
|
25
|
+
dragging: boolean;
|
|
26
|
+
/** Spread on every drag handle: the capsule handle, the puck, the tab. */
|
|
27
|
+
handle: DockHandle;
|
|
28
|
+
/** On the dock element: swallows the click that ends a drag. */
|
|
29
|
+
onClickCapture: (e: MouseEvent) => void;
|
|
30
|
+
moveTo: (next: Place) => void;
|
|
31
|
+
}
|
|
32
|
+
export declare function useDock(onMoveStart: () => void): Dock;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the HUD rests and how it moves (recorder-hud-subtle D2). The dock
|
|
3
|
+
* sits on one of six spots by CSS insets (see `.dock` in styles.ts), so a
|
|
4
|
+
* resize or rotation needs no JS; a drag moves it by `transform` only and a
|
|
5
|
+
* release settles through `@vitrinka/link/dock` — a flick lands where it is
|
|
6
|
+
* aimed, a push past a side edge tucks it into a tab. The move to the new
|
|
7
|
+
* spot is a FLIP: the dock re-anchors, then springs from where it was let
|
|
8
|
+
* go. Arrow keys on a focused handle are the non-drag alternative.
|
|
9
|
+
* The place is remembered per origin in the recorder's storage.
|
|
10
|
+
*/
|
|
11
|
+
import { colOf, neighbour, parsePlace, rowOf, settle, untuck } from '@vitrinka/link/dock';
|
|
12
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
13
|
+
import { getRecorderStorage } from '../storage';
|
|
14
|
+
const DOCK_KEY = 'dock';
|
|
15
|
+
/** Travel before a press becomes a drag — below it a press stays a tap. */
|
|
16
|
+
const DRAG_SLOP = 4;
|
|
17
|
+
/** Velocity is read over the last this-many ms of the drag. */
|
|
18
|
+
const VELOCITY_WINDOW = 100;
|
|
19
|
+
const ARROWS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']);
|
|
20
|
+
function reducedMotion() {
|
|
21
|
+
return globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
|
22
|
+
}
|
|
23
|
+
function margin() {
|
|
24
|
+
return globalThis.matchMedia?.('(pointer: coarse)').matches ? 12 : 16;
|
|
25
|
+
}
|
|
26
|
+
/** The point of `r` a place is anchored by: its corner, its middle on the c column, its mid-height when tucked. */
|
|
27
|
+
function anchorPoint(r, place) {
|
|
28
|
+
const spot = untuck(place);
|
|
29
|
+
const x = colOf(spot) === 'l' ? r.left : colOf(spot) === 'r' ? r.right : r.left + r.width / 2;
|
|
30
|
+
const y = 'tuck' in place ? r.top + r.height / 2 : rowOf(spot) === 't' ? r.top : r.bottom;
|
|
31
|
+
return { x, y };
|
|
32
|
+
}
|
|
33
|
+
export function useDock(onMoveStart) {
|
|
34
|
+
const [place, setPlace] = useState(() => parsePlace(getRecorderStorage().getString(DOCK_KEY)));
|
|
35
|
+
const [dragging, setDragging] = useState(false);
|
|
36
|
+
const ref = useRef(null);
|
|
37
|
+
const from = useRef(null);
|
|
38
|
+
const press = useRef(null);
|
|
39
|
+
const swallowClick = useRef(false);
|
|
40
|
+
const onMoveStartRef = useRef(onMoveStart);
|
|
41
|
+
onMoveStartRef.current = onMoveStart;
|
|
42
|
+
const moveTo = useCallback((next) => {
|
|
43
|
+
const el = ref.current;
|
|
44
|
+
if (el)
|
|
45
|
+
from.current = el.getBoundingClientRect();
|
|
46
|
+
setPlace(next);
|
|
47
|
+
try {
|
|
48
|
+
getRecorderStorage().set(DOCK_KEY, JSON.stringify(next));
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
console.warn('vitrinka: could not remember the HUD position', e);
|
|
52
|
+
}
|
|
53
|
+
}, []);
|
|
54
|
+
// FLIP: the dock has re-anchored; spring it from where it was let go.
|
|
55
|
+
useLayoutEffect(() => {
|
|
56
|
+
const el = ref.current;
|
|
57
|
+
const prev = from.current;
|
|
58
|
+
from.current = null;
|
|
59
|
+
if (!el || !prev)
|
|
60
|
+
return;
|
|
61
|
+
el.classList.remove('settling');
|
|
62
|
+
el.style.transform = '';
|
|
63
|
+
if (reducedMotion())
|
|
64
|
+
return;
|
|
65
|
+
const next = el.getBoundingClientRect();
|
|
66
|
+
const a = anchorPoint(prev, place);
|
|
67
|
+
const b = anchorPoint(next, place);
|
|
68
|
+
const dx = a.x - b.x;
|
|
69
|
+
const dy = a.y - b.y;
|
|
70
|
+
if (Math.abs(dx) < 1 && Math.abs(dy) < 1)
|
|
71
|
+
return;
|
|
72
|
+
el.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
|
|
73
|
+
void el.offsetWidth;
|
|
74
|
+
el.classList.add('settling');
|
|
75
|
+
el.style.transform = '';
|
|
76
|
+
const done = () => el.classList.remove('settling');
|
|
77
|
+
el.addEventListener('transitionend', done, { once: true });
|
|
78
|
+
}, [place]);
|
|
79
|
+
// The centre column anchors the handle's middle: keep --hw = its width.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const el = ref.current;
|
|
82
|
+
if (!el || typeof ResizeObserver !== 'function')
|
|
83
|
+
return;
|
|
84
|
+
const ro = new ResizeObserver(() => {
|
|
85
|
+
const h = el.querySelector('.handle, .puck, .tab');
|
|
86
|
+
if (h)
|
|
87
|
+
el.style.setProperty('--hw', `${h.offsetWidth}px`);
|
|
88
|
+
});
|
|
89
|
+
ro.observe(el);
|
|
90
|
+
return () => ro.disconnect();
|
|
91
|
+
}, []);
|
|
92
|
+
const release = useCallback((e, cancelled) => {
|
|
93
|
+
const p = press.current;
|
|
94
|
+
press.current = null;
|
|
95
|
+
if (!p || p.id !== e.pointerId)
|
|
96
|
+
return;
|
|
97
|
+
if (e.currentTarget.hasPointerCapture?.(e.pointerId))
|
|
98
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
99
|
+
if (!p.moved)
|
|
100
|
+
return;
|
|
101
|
+
setDragging(false);
|
|
102
|
+
swallowClick.current = true;
|
|
103
|
+
setTimeout(() => (swallowClick.current = false), 0);
|
|
104
|
+
const el = ref.current;
|
|
105
|
+
if (!el)
|
|
106
|
+
return;
|
|
107
|
+
const r = el.getBoundingClientRect();
|
|
108
|
+
if (cancelled) {
|
|
109
|
+
moveTo(place);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const s = p.samples;
|
|
113
|
+
const first = s[0];
|
|
114
|
+
const last = s[s.length - 1];
|
|
115
|
+
const dt = first && last ? (last.t - first.t) / 1000 : 0;
|
|
116
|
+
const v = first && last && dt > 0 ? { x: (last.x - first.x) / dt, y: (last.y - first.y) / dt } : { x: 0, y: 0 };
|
|
117
|
+
const m = margin();
|
|
118
|
+
moveTo(settle({ x: r.left, y: r.top, w: r.width, h: r.height }, v, { w: innerWidth, h: innerHeight }, { top: m, right: m, bottom: m, left: m }));
|
|
119
|
+
}, [moveTo, place]);
|
|
120
|
+
const handle = useMemo(() => ({
|
|
121
|
+
onPointerDown: (e) => {
|
|
122
|
+
if (e.button !== 0 || !e.isPrimary)
|
|
123
|
+
return;
|
|
124
|
+
press.current = { id: e.pointerId, x: e.clientX, y: e.clientY, moved: false, samples: [] };
|
|
125
|
+
// Capture at once: a fast first move would otherwise leave the handle
|
|
126
|
+
// before the drag begins and the page would get the rest.
|
|
127
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
128
|
+
},
|
|
129
|
+
onPointerMove: (e) => {
|
|
130
|
+
const p = press.current;
|
|
131
|
+
const el = ref.current;
|
|
132
|
+
if (!p || p.id !== e.pointerId || !el)
|
|
133
|
+
return;
|
|
134
|
+
const dx = e.clientX - p.x;
|
|
135
|
+
const dy = e.clientY - p.y;
|
|
136
|
+
if (!p.moved) {
|
|
137
|
+
if (Math.hypot(dx, dy) < DRAG_SLOP)
|
|
138
|
+
return;
|
|
139
|
+
p.moved = true;
|
|
140
|
+
el.classList.remove('settling');
|
|
141
|
+
setDragging(true);
|
|
142
|
+
onMoveStartRef.current();
|
|
143
|
+
}
|
|
144
|
+
el.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
|
|
145
|
+
const t = e.timeStamp;
|
|
146
|
+
p.samples.push({ x: e.clientX, y: e.clientY, t });
|
|
147
|
+
while (p.samples.length > 2 && t - p.samples[0].t > VELOCITY_WINDOW)
|
|
148
|
+
p.samples.shift();
|
|
149
|
+
},
|
|
150
|
+
onPointerUp: (e) => release(e, false),
|
|
151
|
+
onPointerCancel: (e) => release(e, true),
|
|
152
|
+
onKeyDown: (e) => {
|
|
153
|
+
if (!ARROWS.has(e.key) || e.altKey || e.metaKey || e.ctrlKey)
|
|
154
|
+
return;
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
onMoveStartRef.current();
|
|
157
|
+
moveTo(neighbour(place, e.key));
|
|
158
|
+
},
|
|
159
|
+
}), [moveTo, place, release]);
|
|
160
|
+
const onClickCapture = useCallback((e) => {
|
|
161
|
+
if (!swallowClick.current)
|
|
162
|
+
return;
|
|
163
|
+
swallowClick.current = false;
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
e.stopPropagation();
|
|
166
|
+
}, []);
|
|
167
|
+
return { place, spot: untuck(place), ref, dragging, handle, onClickCapture, moveTo };
|
|
168
|
+
}
|
|
@@ -17,8 +17,8 @@ import { type SessionState } from './queue';
|
|
|
17
17
|
export { currentRoute, notify, subscribe } from './state';
|
|
18
18
|
export type { SessionDone } from '../protocol';
|
|
19
19
|
/** Sent as `meta.recorder`; bumped with the package version. */
|
|
20
|
-
export declare const RECORDER_VERSION = "0.1.
|
|
21
|
-
export declare const RECORDER_ID = "web/0.1.
|
|
20
|
+
export declare const RECORDER_VERSION = "0.1.2";
|
|
21
|
+
export declare const RECORDER_ID = "web/0.1.2";
|
|
22
22
|
/** What `POST /api/v1/sessions` answers (the fields this recorder keeps). */
|
|
23
23
|
export interface SessionOut {
|
|
24
24
|
id: string;
|
|
@@ -5,7 +5,7 @@ import { armReconcile, capturesSettled, disarmReconcile, drainBuffer, getState,
|
|
|
5
5
|
import { currentRoute, notify } from './state';
|
|
6
6
|
export { currentRoute, notify, subscribe } from './state';
|
|
7
7
|
/** Sent as `meta.recorder`; bumped with the package version. */
|
|
8
|
-
export const RECORDER_VERSION = '0.1.
|
|
8
|
+
export const RECORDER_VERSION = '0.1.2';
|
|
9
9
|
export const RECORDER_ID = `web/${RECORDER_VERSION}`;
|
|
10
10
|
/**
|
|
11
11
|
* Re-apply a RECOVERED session's redaction policy after a reload — and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vitrinka/web",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "vitrinka toolkit for React DOM apps — the journey recorder: capture manual-testing sessions (rrweb DOM stream, clicks, navigation, network, console, notes) straight into vitrinka boards.",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"prepublishOnly": "tsc -p tsconfig.build.json"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@vitrinka/link": "^0.1.
|
|
48
|
+
"@vitrinka/link": "^0.1.2",
|
|
49
49
|
"@vitrinka/redact": "^0.1.0"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|