@hyperframes/studio 0.8.172 → 0.8.174
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-BDj9J28W.js → hyperframes-player-B4KfQdfl.js} +1 -1
- package/dist/assets/{index--HWjGqrU.js → index-Bsc8xBh9.js} +1 -1
- package/dist/assets/{index-DkQKsFxx.css → index-CESvLzz0.css} +1 -1
- package/dist/assets/{index-CVxB8lqm.js → index-DGl_Zvb3.js} +128 -128
- package/dist/index.html +2 -2
- package/dist/index.js +1520 -1150
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/DesktopGuideCard.tsx +292 -0
- package/src/components/FrameyGlyph.tsx +77 -27
- package/src/components/OpenInDesktopButton.dom.test.tsx +403 -21
- package/src/components/OpenInDesktopButton.tsx +246 -73
- package/src/styles/components.css +161 -1
|
@@ -1,41 +1,121 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { useStudioShellContext } from "../contexts/StudioContext";
|
|
3
|
+
import { useFileManagerContextOptional } from "../contexts/FileManagerContext";
|
|
4
|
+
import { resolveTabId } from "../telemetry/tabId";
|
|
3
5
|
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
4
6
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
7
|
+
import {
|
|
8
|
+
GuideCard,
|
|
9
|
+
Swap,
|
|
10
|
+
isWaiting,
|
|
11
|
+
labelOf,
|
|
12
|
+
moodOf,
|
|
13
|
+
type DesktopRoute,
|
|
14
|
+
type Progress,
|
|
15
|
+
} from "./DesktopGuideCard";
|
|
5
16
|
import { FrameyGlyph } from "./FrameyGlyph";
|
|
6
|
-
import { Button,
|
|
17
|
+
import { Button, Popover, cn } from "./ui";
|
|
7
18
|
|
|
8
|
-
// "
|
|
9
|
-
//
|
|
19
|
+
// "Install the desktop app": the preview server (`/api/open-in-desktop`) says whether to show it, whether the app is
|
|
20
|
+
// here and whether it takes the project. With the app ready a press opens the project in it; otherwise the press
|
|
21
|
+
// opens a card where Framey walks through download, install and open.
|
|
10
22
|
const ROUTE = "/api/open-in-desktop";
|
|
11
23
|
/** How far the pupils travel towards the pointer, in the glyph's own units. */
|
|
12
24
|
const GAZE = 2.2;
|
|
13
25
|
/** Long enough for the flight to finish before the label comes back. */
|
|
14
26
|
const OPENING_MS = 1600;
|
|
27
|
+
/** While the card is open and the app is not here yet, how often to ask the preview server again. */
|
|
28
|
+
const POLL_MS = 2000;
|
|
29
|
+
/** After this long without the app turning up, the card asks whether the download finished. */
|
|
30
|
+
const SLOW_MS = 60_000;
|
|
15
31
|
|
|
16
32
|
type OpenResult = { opened: true } | { opened: false; reason: string; downloadUrl: string };
|
|
17
33
|
|
|
18
|
-
type
|
|
34
|
+
type Eligibility = "open_in_app" | "download" | "no_app_for_platform";
|
|
19
35
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
36
|
+
/** Every Guide step change, for the funnel: never a download counted as an install. */
|
|
37
|
+
type GuideStep = "opened" | "closed" | "download_opened" | "install_detected";
|
|
38
|
+
|
|
39
|
+
/** The download, tagged with this page's telemetry `tab_id` so a later install can be matched to the press. */
|
|
40
|
+
const withCta = (url: string): string => {
|
|
41
|
+
const tagged = new URL(url);
|
|
42
|
+
tagged.searchParams.set("hf_cta", resolveTabId());
|
|
43
|
+
return tagged.toString();
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const eligibilityOf = (route: DesktopRoute): Eligibility =>
|
|
47
|
+
route.handoff ? "open_in_app" : route.downloadUrl ? "download" : "no_app_for_platform";
|
|
48
|
+
|
|
49
|
+
/** The preview server's answer, or null when there is none (the desktop's own Studio, or no server). */
|
|
50
|
+
async function readRoute(): Promise<DesktopRoute | null> {
|
|
51
|
+
const res = await studioApiFetch(ROUTE);
|
|
52
|
+
if (!res.ok) return null;
|
|
53
|
+
const body = (await res.json()) as {
|
|
54
|
+
available?: unknown;
|
|
55
|
+
handoff?: unknown;
|
|
56
|
+
downloadUrl?: unknown;
|
|
57
|
+
os?: unknown;
|
|
58
|
+
installed?: unknown;
|
|
59
|
+
} | null;
|
|
60
|
+
if (!body) return null;
|
|
61
|
+
const downloadUrl = typeof body.downloadUrl === "string" ? body.downloadUrl : null;
|
|
62
|
+
return {
|
|
63
|
+
handoff: body.available === true && body.handoff === true,
|
|
64
|
+
downloadUrl: body.available === true ? downloadUrl : null,
|
|
65
|
+
context: {
|
|
66
|
+
os: typeof body.os === "string" ? body.os : "unknown",
|
|
67
|
+
app_installed: body.installed === true,
|
|
68
|
+
download_available: downloadUrl !== null,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Null hides the button: no route, or nothing here to open or download. `setRoute` takes a fresher answer. */
|
|
74
|
+
function useDesktopRoute() {
|
|
22
75
|
const [route, setRoute] = useState<DesktopRoute | null>(null);
|
|
23
76
|
useEffect(() => {
|
|
24
77
|
let live = true;
|
|
25
|
-
|
|
26
|
-
.then((
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
78
|
+
readRoute()
|
|
79
|
+
.then((answered) => {
|
|
80
|
+
if (!live || !answered) return;
|
|
81
|
+
trackStudioEvent("desktop_cta_eligibility", {
|
|
82
|
+
eligibility: eligibilityOf(answered),
|
|
83
|
+
...answered.context,
|
|
84
|
+
});
|
|
85
|
+
if (answered.handoff || answered.downloadUrl) setRoute(answered);
|
|
32
86
|
})
|
|
33
87
|
.catch(() => {});
|
|
34
88
|
return () => {
|
|
35
89
|
live = false;
|
|
36
90
|
};
|
|
37
91
|
}, []);
|
|
38
|
-
return route;
|
|
92
|
+
return [route, setRoute] as const;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One impression per page, once the button is on screen. */
|
|
96
|
+
function useImpression(anchor: React.RefObject<HTMLElement | null>, route: DesktopRoute | null) {
|
|
97
|
+
const shown = route !== null;
|
|
98
|
+
const routeRef = useRef(route);
|
|
99
|
+
routeRef.current = route;
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const el = anchor.current;
|
|
102
|
+
if (!shown || !el) return;
|
|
103
|
+
const record = () => {
|
|
104
|
+
const now = routeRef.current!;
|
|
105
|
+
trackStudioEvent("desktop_cta_impression", {
|
|
106
|
+
eligibility: eligibilityOf(now),
|
|
107
|
+
...now.context,
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
if (typeof IntersectionObserver === "undefined") return record();
|
|
111
|
+
const seen = new IntersectionObserver((entries) => {
|
|
112
|
+
if (!entries.some((entry) => entry.isIntersecting)) return;
|
|
113
|
+
seen.disconnect();
|
|
114
|
+
record();
|
|
115
|
+
});
|
|
116
|
+
seen.observe(el);
|
|
117
|
+
return () => seen.disconnect();
|
|
118
|
+
}, [anchor, shown]);
|
|
39
119
|
}
|
|
40
120
|
|
|
41
121
|
/** Framey looks at the pointer: one listener, one write per frame, no re-render. */
|
|
@@ -64,97 +144,190 @@ function useGaze(
|
|
|
64
144
|
}, [anchor, eye]);
|
|
65
145
|
}
|
|
66
146
|
|
|
67
|
-
|
|
147
|
+
/** While the card is open and the app is not here, ask the preview server again; stops on close or once found. */
|
|
148
|
+
function useInstallWatch(watching: boolean, onAnswer: (route: DesktopRoute) => void): void {
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!watching) return;
|
|
151
|
+
let live = true;
|
|
152
|
+
const ask = () =>
|
|
153
|
+
readRoute()
|
|
154
|
+
.then((answered) => live && answered && onAnswer(answered))
|
|
155
|
+
.catch(() => {});
|
|
156
|
+
void ask();
|
|
157
|
+
const timer = setInterval(ask, POLL_MS);
|
|
158
|
+
return () => {
|
|
159
|
+
live = false;
|
|
160
|
+
clearInterval(timer);
|
|
161
|
+
};
|
|
162
|
+
}, [watching, onAnswer]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const LOOK =
|
|
166
|
+
"hf-framey-trigger gap-1.5 border border-accent/40 bg-accent/10 pl-1 font-semibold text-text-0 enabled:hover:bg-accent/20 hover:bg-accent/20";
|
|
167
|
+
const LOOK_FAILED = "border-danger/45 bg-danger/8 text-danger-ink enabled:hover:bg-danger/14";
|
|
168
|
+
|
|
169
|
+
/** The guide's state and the two actions that move it: the download and the hand-off. */
|
|
170
|
+
function useGuide(route: DesktopRoute | null, setRoute: (route: DesktopRoute) => void) {
|
|
68
171
|
const { showToast } = useStudioShellContext();
|
|
69
|
-
const
|
|
172
|
+
const [cardOpen, setCardOpen] = useState(false);
|
|
70
173
|
const [opening, setOpening] = useState(false);
|
|
71
|
-
|
|
72
|
-
const [
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
174
|
+
const [failed, setFailed] = useState(false);
|
|
175
|
+
const [downloaded, setDownloaded] = useState(false);
|
|
176
|
+
const [slow, setSlow] = useState(false);
|
|
177
|
+
const [arrived, setArrived] = useState(false);
|
|
178
|
+
const installed = route?.context.app_installed === true;
|
|
179
|
+
// The funnel counts one install per page, even if the app is removed and found again.
|
|
180
|
+
const installCounted = useRef(false);
|
|
181
|
+
|
|
182
|
+
const onAnswer = useCallback(
|
|
183
|
+
(answered: DesktopRoute) => {
|
|
184
|
+
if (!answered.context.app_installed) return;
|
|
185
|
+
setRoute(answered);
|
|
186
|
+
setArrived(true);
|
|
187
|
+
if (installCounted.current) return;
|
|
188
|
+
installCounted.current = true;
|
|
189
|
+
trackStudioEvent("desktop_cta_guide", { step: "install_detected", ...answered.context });
|
|
190
|
+
},
|
|
191
|
+
[setRoute],
|
|
192
|
+
);
|
|
193
|
+
useInstallWatch(cardOpen && route !== null && !installed, onAnswer);
|
|
194
|
+
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (!downloaded || installed) return;
|
|
197
|
+
setSlow(false);
|
|
198
|
+
const timer = setTimeout(() => setSlow(true), SLOW_MS);
|
|
199
|
+
return () => clearTimeout(timer);
|
|
200
|
+
}, [downloaded, installed]);
|
|
201
|
+
|
|
76
202
|
if (!route) return null;
|
|
203
|
+
const context = route.context;
|
|
204
|
+
const guideStep = (step: GuideStep) =>
|
|
205
|
+
trackStudioEvent("desktop_cta_guide", { step, ...context });
|
|
206
|
+
const outcome = (result: "opened" | "sent_to_download" | "failed", reason?: string) =>
|
|
207
|
+
trackStudioEvent("desktop_cta_outcome", {
|
|
208
|
+
outcome: result,
|
|
209
|
+
...(reason ? { reason } : {}),
|
|
210
|
+
...context,
|
|
211
|
+
});
|
|
77
212
|
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
213
|
+
const showCard = (next: boolean) => {
|
|
214
|
+
if (next === cardOpen) return;
|
|
215
|
+
setCardOpen(next);
|
|
216
|
+
guideStep(next ? "opened" : "closed");
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const download = () => {
|
|
220
|
+
if (!route.downloadUrl) return;
|
|
221
|
+
trackStudioEvent("toolbar_action", { action: "get_desktop_app", ...context });
|
|
222
|
+
const tab = window.open(withCta(route.downloadUrl), "_blank");
|
|
223
|
+
if (!tab) {
|
|
224
|
+
showToast(`Get the HyperFrames desktop app at ${route.downloadUrl}`, "info");
|
|
225
|
+
outcome("failed", "download_tab_blocked");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
tab.opener = null;
|
|
229
|
+
setDownloaded(true);
|
|
230
|
+
outcome("sent_to_download");
|
|
231
|
+
guideStep("download_opened");
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const landed = async (result: OpenResult) => {
|
|
235
|
+
if (result.opened) {
|
|
236
|
+
outcome("opened");
|
|
237
|
+
showCard(false);
|
|
238
|
+
showToast("Opening this project in the HyperFrames desktop app", "info");
|
|
239
|
+
await new Promise((done) => setTimeout(done, OPENING_MS));
|
|
82
240
|
return;
|
|
83
241
|
}
|
|
242
|
+
outcome("failed", result.reason);
|
|
243
|
+
// The app is gone: back to the download. Anything else: say so where the person is, with a way through.
|
|
244
|
+
if (result.reason === "not-installed")
|
|
245
|
+
setRoute({ ...route, handoff: false, context: { ...context, app_installed: false } });
|
|
246
|
+
else setFailed(true);
|
|
247
|
+
showCard(true);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const open = async () => {
|
|
84
251
|
if (opening) return;
|
|
85
252
|
setOpening(true);
|
|
86
|
-
|
|
253
|
+
setFailed(false);
|
|
254
|
+
trackStudioEvent("toolbar_action", { action: "open_in_desktop", ...context });
|
|
87
255
|
try {
|
|
88
|
-
const res = await studioApiFetch(ROUTE, {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
256
|
+
const res = await studioApiFetch(ROUTE, {
|
|
257
|
+
method: "POST",
|
|
258
|
+
headers: { "content-type": "application/json" },
|
|
259
|
+
body: JSON.stringify({ cta: resolveTabId() }),
|
|
260
|
+
});
|
|
261
|
+
await landed((await res.json()) as OpenResult);
|
|
94
262
|
} catch {
|
|
263
|
+
outcome("failed", "preview_server_unreachable");
|
|
95
264
|
showToast("Couldn't reach the preview server to open the app", "error");
|
|
96
265
|
} finally {
|
|
97
266
|
setOpening(false);
|
|
98
267
|
}
|
|
99
268
|
};
|
|
100
269
|
|
|
270
|
+
const progress: Progress = { route, opening, failed, downloaded, slow, arrived };
|
|
271
|
+
return { progress, cardOpen, showCard, download, open };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function OpenInDesktopButton() {
|
|
275
|
+
const projectDir = useFileManagerContextOptional()?.projectDir ?? null;
|
|
276
|
+
const [route, setRoute] = useDesktopRoute();
|
|
277
|
+
const trigger = useRef<HTMLButtonElement>(null);
|
|
278
|
+
const eye = useRef<SVGGElement>(null);
|
|
279
|
+
useGaze(trigger, eye);
|
|
280
|
+
useImpression(trigger, route);
|
|
281
|
+
const guide = useGuide(route, setRoute);
|
|
282
|
+
if (!guide) return null;
|
|
283
|
+
const { progress, cardOpen, showCard, download, open } = guide;
|
|
284
|
+
const label = labelOf(progress);
|
|
285
|
+
const waiting = isWaiting(progress);
|
|
286
|
+
|
|
101
287
|
return (
|
|
102
288
|
<Popover
|
|
103
|
-
open={
|
|
104
|
-
//
|
|
105
|
-
// something to say (the download). A second handler on the button raced this one.
|
|
289
|
+
open={cardOpen}
|
|
290
|
+
// With the app ready the press is the hand-off itself; everything else opens the card.
|
|
106
291
|
onOpenChange={(next) => {
|
|
107
|
-
if (next) void open();
|
|
108
|
-
|
|
292
|
+
if (next && progress.route.handoff && !progress.failed) return void open();
|
|
293
|
+
showCard(next);
|
|
109
294
|
}}
|
|
110
295
|
side="bottom"
|
|
111
296
|
align="end"
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
className="w-72"
|
|
297
|
+
aria-label="Get the desktop app"
|
|
298
|
+
className="w-[360px] overflow-hidden p-0"
|
|
115
299
|
trigger={
|
|
116
300
|
<Button
|
|
117
301
|
ref={trigger}
|
|
118
302
|
variant="ghost"
|
|
119
303
|
data-testid="header-open-in-desktop"
|
|
120
|
-
data-opening={opening || undefined}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
304
|
+
data-opening={progress.opening || undefined}
|
|
305
|
+
aria-busy={progress.opening || waiting || undefined}
|
|
306
|
+
title={
|
|
307
|
+
progress.route.handoff
|
|
308
|
+
? "Open this project in the HyperFrames desktop app"
|
|
309
|
+
: "Get the free HyperFrames desktop app"
|
|
310
|
+
}
|
|
311
|
+
className={cn(LOOK, progress.failed && LOOK_FAILED)}
|
|
312
|
+
icon={
|
|
313
|
+
<FrameyGlyph size={24} className="hf-framey" eyeRef={eye} mood={moodOf(progress)} />
|
|
314
|
+
}
|
|
124
315
|
>
|
|
125
|
-
{
|
|
126
|
-
{
|
|
127
|
-
<span className="rounded-full bg-
|
|
128
|
-
|
|
316
|
+
<Swap value={label}>{label}</Swap>
|
|
317
|
+
{waiting && (
|
|
318
|
+
<span className="rounded-full bg-on px-1.5 text-[10px] leading-4 font-bold text-accent-ink tabular-nums">
|
|
319
|
+
2 of 3
|
|
129
320
|
</span>
|
|
130
321
|
)}
|
|
131
322
|
</Button>
|
|
132
323
|
}
|
|
133
324
|
>
|
|
134
|
-
<
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
<li>Chat to change anything in the video</li>
|
|
141
|
-
<li>Draw on a frame to point at it</li>
|
|
142
|
-
<li>Watch me act out each edit</li>
|
|
143
|
-
</ul>
|
|
144
|
-
<div className="mt-3 flex items-center justify-end gap-2 whitespace-nowrap">
|
|
145
|
-
<Button variant="ghost" onClick={() => setDownloadUrl(null)}>
|
|
146
|
-
Not now
|
|
147
|
-
</Button>
|
|
148
|
-
<a
|
|
149
|
-
href={downloadUrl ?? undefined}
|
|
150
|
-
target="_blank"
|
|
151
|
-
rel="noopener noreferrer"
|
|
152
|
-
className={cn(buttonBase, buttonVariants.primary, buttonSizes.md)}
|
|
153
|
-
onClick={() => trackStudioEvent("toolbar_action", { action: "get_desktop_app" })}
|
|
154
|
-
>
|
|
155
|
-
Get the desktop app
|
|
156
|
-
</a>
|
|
157
|
-
</div>
|
|
325
|
+
<GuideCard
|
|
326
|
+
progress={progress}
|
|
327
|
+
projectDir={projectDir}
|
|
328
|
+
onDownload={download}
|
|
329
|
+
onOpen={() => void open()}
|
|
330
|
+
/>
|
|
158
331
|
</Popover>
|
|
159
332
|
);
|
|
160
333
|
}
|
|
@@ -956,7 +956,7 @@
|
|
|
956
956
|
}
|
|
957
957
|
}
|
|
958
958
|
|
|
959
|
-
/* Framey on the header's "
|
|
959
|
+
/* Framey on the header's "Install the desktop app" (OpenInDesktopButton.tsx): a pulse as the page opens, a wiggle
|
|
960
960
|
* on hover, a flight towards the app while it opens. The eye follows the pointer from script. */
|
|
961
961
|
@keyframes hf-framey-wiggle {
|
|
962
962
|
0%,
|
|
@@ -1020,3 +1020,163 @@
|
|
|
1020
1020
|
transition: none;
|
|
1021
1021
|
}
|
|
1022
1022
|
}
|
|
1023
|
+
|
|
1024
|
+
/* Framey's moods, timed as the desktop app times them: it breathes and blinks, its eyes circle while it waits,
|
|
1025
|
+
* it hops once when the app turns up, flies while a project hands over, and shakes when that fails. */
|
|
1026
|
+
.hf-framey-mood .hf-framey-body {
|
|
1027
|
+
transform-box: fill-box;
|
|
1028
|
+
transform-origin: 50% 60%;
|
|
1029
|
+
animation: hf-framey-breath 3.27s ease-in-out infinite;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
.hf-framey-mood .hf-framey-lid,
|
|
1033
|
+
.hf-framey-mood .hf-framey-whole {
|
|
1034
|
+
transform-box: fill-box;
|
|
1035
|
+
transform-origin: 50% 50%;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
.hf-framey-mood .hf-framey-whole {
|
|
1039
|
+
transform-origin: 50% 100%;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
.hf-framey-mood:is([data-mood="idle"], [data-mood="found"]) .hf-framey-lid {
|
|
1043
|
+
animation: hf-framey-blink 4.6s infinite;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
.hf-framey-mood[data-mood="found"] .hf-framey-whole {
|
|
1047
|
+
animation: hf-framey-hop 1.8s cubic-bezier(0.2, 0.8, 0.2, 1) 2;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
.hf-framey-mood[data-mood="opening"] .hf-framey-whole {
|
|
1051
|
+
animation: hf-framey-handoff 2.4s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
.hf-framey-mood[data-mood="error"] .hf-framey-whole {
|
|
1055
|
+
animation: hf-framey-shake 0.6s ease-in-out 1;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
.hf-framey-orbit {
|
|
1059
|
+
animation: hf-framey-orbit 1.4s linear infinite;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
@keyframes hf-framey-breath {
|
|
1063
|
+
50% {
|
|
1064
|
+
transform: scale(1.018);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
@keyframes hf-framey-blink {
|
|
1069
|
+
0%,
|
|
1070
|
+
93%,
|
|
1071
|
+
100% {
|
|
1072
|
+
transform: scaleY(1);
|
|
1073
|
+
}
|
|
1074
|
+
95.5% {
|
|
1075
|
+
transform: scaleY(0.12);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
@keyframes hf-framey-hop {
|
|
1080
|
+
0%,
|
|
1081
|
+
40%,
|
|
1082
|
+
100% {
|
|
1083
|
+
transform: translateY(0);
|
|
1084
|
+
}
|
|
1085
|
+
20% {
|
|
1086
|
+
transform: translateY(-7px);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
@keyframes hf-framey-orbit {
|
|
1091
|
+
0%,
|
|
1092
|
+
100% {
|
|
1093
|
+
transform: translate(3px, 0);
|
|
1094
|
+
}
|
|
1095
|
+
25% {
|
|
1096
|
+
transform: translate(0, 3px);
|
|
1097
|
+
}
|
|
1098
|
+
50% {
|
|
1099
|
+
transform: translate(-3px, 0);
|
|
1100
|
+
}
|
|
1101
|
+
75% {
|
|
1102
|
+
transform: translate(0, -3px);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
@keyframes hf-framey-handoff {
|
|
1107
|
+
0% {
|
|
1108
|
+
transform: none;
|
|
1109
|
+
}
|
|
1110
|
+
45%,
|
|
1111
|
+
60% {
|
|
1112
|
+
transform: translate(14px, -4px) rotate(10deg);
|
|
1113
|
+
opacity: 1;
|
|
1114
|
+
}
|
|
1115
|
+
80% {
|
|
1116
|
+
transform: translate(30px, -8px) rotate(12deg);
|
|
1117
|
+
opacity: 0;
|
|
1118
|
+
}
|
|
1119
|
+
81% {
|
|
1120
|
+
transform: translate(-10px, 0);
|
|
1121
|
+
opacity: 0;
|
|
1122
|
+
}
|
|
1123
|
+
100% {
|
|
1124
|
+
transform: none;
|
|
1125
|
+
opacity: 1;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
@keyframes hf-framey-shake {
|
|
1130
|
+
0%,
|
|
1131
|
+
100% {
|
|
1132
|
+
transform: translateX(0);
|
|
1133
|
+
}
|
|
1134
|
+
20% {
|
|
1135
|
+
transform: translateX(-3px);
|
|
1136
|
+
}
|
|
1137
|
+
40% {
|
|
1138
|
+
transform: translateX(3px);
|
|
1139
|
+
}
|
|
1140
|
+
60% {
|
|
1141
|
+
transform: translateX(-2px);
|
|
1142
|
+
}
|
|
1143
|
+
80% {
|
|
1144
|
+
transform: translateX(2px);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/* The desktop-app guide card: a label or step that changes settles in; a finished step's tick pops in. */
|
|
1149
|
+
.hf-guide-swap {
|
|
1150
|
+
animation: hf-guide-swap 180ms var(--ease-out-quint);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
.hf-guide-tick {
|
|
1154
|
+
animation: hf-guide-tick 300ms var(--ease-out-quint);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
@keyframes hf-guide-swap {
|
|
1158
|
+
from {
|
|
1159
|
+
opacity: 0;
|
|
1160
|
+
transform: translateY(3px);
|
|
1161
|
+
filter: blur(2px);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
@keyframes hf-guide-tick {
|
|
1166
|
+
from {
|
|
1167
|
+
opacity: 0;
|
|
1168
|
+
transform: scale(0.3);
|
|
1169
|
+
filter: blur(3px);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1174
|
+
.hf-framey-mood .hf-framey-body,
|
|
1175
|
+
.hf-framey-mood .hf-framey-lid,
|
|
1176
|
+
.hf-framey-mood .hf-framey-whole,
|
|
1177
|
+
.hf-framey-orbit,
|
|
1178
|
+
.hf-guide-swap,
|
|
1179
|
+
.hf-guide-tick {
|
|
1180
|
+
animation: none;
|
|
1181
|
+
}
|
|
1182
|
+
}
|