@hyperframes/studio 0.8.122 → 0.8.123
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-DMzBHhbH.js → hyperframes-player-yABdb6yk.js} +1 -1
- package/dist/assets/{index-lyjhSEzi.js → index-D77MIjSJ.js} +1 -1
- package/dist/assets/{index-CBRX2eZX.js → index-DWjF2Kxw.js} +1 -1
- package/dist/assets/{index-B-YDp8hN.js → index-Dt7lWtY7.js} +146 -146
- package/dist/assets/{index-t1LH6H5H.css → index-LrNlT5Tg.css} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +1195 -1009
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/FrameyGlyph.tsx +52 -0
- package/src/components/OpenInDesktopButton.dom.test.tsx +113 -0
- package/src/components/OpenInDesktopButton.tsx +153 -0
- package/src/components/StudioHeader.dom.test.tsx +2 -0
- package/src/components/StudioHeader.tsx +2 -0
- package/src/styles/components.css +51 -0
- package/src/styles/theme.css +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.123",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.123",
|
|
64
|
+
"@hyperframes/parsers": "0.8.123",
|
|
65
|
+
"@hyperframes/player": "0.8.123",
|
|
66
|
+
"@hyperframes/sdk": "0.8.123",
|
|
67
|
+
"@hyperframes/shader-transitions": "0.8.123",
|
|
68
|
+
"@hyperframes/studio-server": "0.8.123"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.123"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Ref } from "react";
|
|
2
|
+
|
|
3
|
+
// Framey, the desktop app's cursor mascot, as hyperframes-internal packages/nib draws it: a rounded triangle with a
|
|
4
|
+
// white rim and one eye. The viewBox frames the body with its rim; the tip points up and left like a cursor.
|
|
5
|
+
const BODY = "M77.55 48.44 L122.77 65.85 L99.3 89.41 Z";
|
|
6
|
+
const EYE = { cx: 111.6, cy: 68.2, r: 11.5 };
|
|
7
|
+
|
|
8
|
+
/** `eyeRef` moves the pupils (a translate of a few px) without re-rendering. */
|
|
9
|
+
export function FrameyGlyph({
|
|
10
|
+
size,
|
|
11
|
+
className,
|
|
12
|
+
eyeRef,
|
|
13
|
+
}: {
|
|
14
|
+
size: number;
|
|
15
|
+
className?: string;
|
|
16
|
+
eyeRef?: Ref<SVGGElement>;
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<svg
|
|
20
|
+
width={size}
|
|
21
|
+
height={size}
|
|
22
|
+
viewBox="58 30 82 76"
|
|
23
|
+
aria-hidden="true"
|
|
24
|
+
className={className}
|
|
25
|
+
overflow="visible"
|
|
26
|
+
>
|
|
27
|
+
<path
|
|
28
|
+
d={BODY}
|
|
29
|
+
fill="var(--color-white)"
|
|
30
|
+
stroke="var(--color-white)"
|
|
31
|
+
strokeWidth={24}
|
|
32
|
+
strokeLinejoin="round"
|
|
33
|
+
/>
|
|
34
|
+
<path
|
|
35
|
+
d={BODY}
|
|
36
|
+
fill="var(--color-framey)"
|
|
37
|
+
stroke="var(--color-framey)"
|
|
38
|
+
strokeWidth={19}
|
|
39
|
+
strokeLinejoin="round"
|
|
40
|
+
/>
|
|
41
|
+
<circle cx={EYE.cx} cy={EYE.cy} r={EYE.r} fill="var(--color-white)" />
|
|
42
|
+
<g ref={eyeRef} className="hf-framey-eye">
|
|
43
|
+
<path
|
|
44
|
+
d={`M${EYE.cx - 2.6} ${EYE.cy - 2} V${EYE.cy + 2} M${EYE.cx + 2.6} ${EYE.cy - 2} V${EYE.cy + 2}`}
|
|
45
|
+
stroke="var(--color-framey-ink)"
|
|
46
|
+
strokeWidth={2.4}
|
|
47
|
+
strokeLinecap="round"
|
|
48
|
+
/>
|
|
49
|
+
</g>
|
|
50
|
+
</svg>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* "Edit with Framey": shown only where the preview server answers on macOS; a press opens the project in the app,
|
|
5
|
+
* or introduces Framey with the download while the app cannot take it or is missing.
|
|
6
|
+
*/
|
|
7
|
+
import React, { act } from "react";
|
|
8
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
9
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
12
|
+
|
|
13
|
+
const showToast = vi.fn();
|
|
14
|
+
vi.mock("../contexts/StudioContext", () => ({ useStudioShellContext: () => ({ showToast }) }));
|
|
15
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
16
|
+
|
|
17
|
+
const { OpenInDesktopButton } = await import("./OpenInDesktopButton");
|
|
18
|
+
|
|
19
|
+
const DOWNLOAD = "https://hyperframes.dev/studio/download";
|
|
20
|
+
let mounted: { root: Root; host: HTMLElement } | null = null;
|
|
21
|
+
let posts: number;
|
|
22
|
+
|
|
23
|
+
function serve(get: Response | null, post?: unknown) {
|
|
24
|
+
posts = 0;
|
|
25
|
+
vi.stubGlobal(
|
|
26
|
+
"fetch",
|
|
27
|
+
vi.fn(async (_url: string, init?: RequestInit) => {
|
|
28
|
+
if (init?.method === "POST") {
|
|
29
|
+
posts += 1;
|
|
30
|
+
return Response.json(post);
|
|
31
|
+
}
|
|
32
|
+
return get ?? new Response("Not found", { status: 404 });
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function mount(): Promise<HTMLElement> {
|
|
38
|
+
const host = document.createElement("div");
|
|
39
|
+
document.body.append(host);
|
|
40
|
+
const root = createRoot(host);
|
|
41
|
+
mounted = { root, host };
|
|
42
|
+
await act(async () => root.render(<OpenInDesktopButton />));
|
|
43
|
+
return host;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const downloadHref = () =>
|
|
47
|
+
[...document.querySelectorAll("a")]
|
|
48
|
+
.find((a) => a.textContent?.includes("Get HyperFrames Studio"))
|
|
49
|
+
?.getAttribute("href");
|
|
50
|
+
|
|
51
|
+
const button = () =>
|
|
52
|
+
document.querySelector<HTMLButtonElement>('[data-testid="header-open-in-desktop"]');
|
|
53
|
+
|
|
54
|
+
beforeEach(() => showToast.mockReset());
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vi.unstubAllGlobals();
|
|
58
|
+
if (!mounted) return;
|
|
59
|
+
const { root, host } = mounted;
|
|
60
|
+
mounted = null;
|
|
61
|
+
act(() => root.unmount());
|
|
62
|
+
host.remove();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("stays hidden where the server has no hand-off (the desktop's own Studio)", async () => {
|
|
66
|
+
serve(null);
|
|
67
|
+
await mount();
|
|
68
|
+
expect(button()).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("stays hidden when the server says it cannot open the app (not macOS)", async () => {
|
|
72
|
+
serve(Response.json({ available: false }));
|
|
73
|
+
await mount();
|
|
74
|
+
expect(button()).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("while the app cannot take a project yet, introduces Framey with the download and opens nothing", async () => {
|
|
78
|
+
serve(Response.json({ available: true, handoff: false }), { opened: true });
|
|
79
|
+
await mount();
|
|
80
|
+
await act(async () => button()!.click());
|
|
81
|
+
expect(posts).toBe(0);
|
|
82
|
+
expect(document.body.textContent).toContain("Meet Framey");
|
|
83
|
+
expect(downloadHref()).toBe(DOWNLOAD);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("opens the project in the app and says so", async () => {
|
|
87
|
+
serve(Response.json({ available: true, handoff: true }), {
|
|
88
|
+
opened: true,
|
|
89
|
+
bundleId: "dev.hyperframes.desktop",
|
|
90
|
+
});
|
|
91
|
+
await mount();
|
|
92
|
+
await act(async () => button()!.click());
|
|
93
|
+
expect(posts).toBe(1);
|
|
94
|
+
expect(showToast).toHaveBeenCalledWith(
|
|
95
|
+
"Framey is opening this project in HyperFrames Studio",
|
|
96
|
+
"info",
|
|
97
|
+
);
|
|
98
|
+
expect(button()!.dataset.opening).toBe("true");
|
|
99
|
+
expect(document.body.textContent).not.toContain("Meet Framey");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("introduces Framey and offers the download when the app is missing", async () => {
|
|
103
|
+
serve(Response.json({ available: true, handoff: true }), {
|
|
104
|
+
opened: false,
|
|
105
|
+
reason: "not-installed",
|
|
106
|
+
downloadUrl: DOWNLOAD,
|
|
107
|
+
});
|
|
108
|
+
await mount();
|
|
109
|
+
await act(async () => button()!.click());
|
|
110
|
+
expect(showToast).not.toHaveBeenCalled();
|
|
111
|
+
expect(document.body.textContent).toContain("Meet Framey");
|
|
112
|
+
expect(downloadHref()).toBe(DOWNLOAD);
|
|
113
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useStudioShellContext } from "../contexts/StudioContext";
|
|
3
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
4
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
5
|
+
import { FrameyGlyph } from "./FrameyGlyph";
|
|
6
|
+
import { Button, buttonBase, buttonSizes, buttonVariants, cn, Popover } from "./ui";
|
|
7
|
+
|
|
8
|
+
// "Edit with Framey": the preview server (`/api/open-in-desktop`) says whether to show it and whether the app can
|
|
9
|
+
// take the project yet (its gate is the CLI's HANDOFF_READY); until then a press introduces Framey with the download.
|
|
10
|
+
const ROUTE = "/api/open-in-desktop";
|
|
11
|
+
const DOWNLOAD_URL = "https://hyperframes.dev/studio/download";
|
|
12
|
+
/** How far the pupils travel towards the pointer, in the glyph's own units. */
|
|
13
|
+
const GAZE = 2.2;
|
|
14
|
+
/** Long enough for the flight to finish before the label comes back. */
|
|
15
|
+
const OPENING_MS = 1600;
|
|
16
|
+
|
|
17
|
+
type OpenResult = { opened: true } | { opened: false; reason: string; downloadUrl: string };
|
|
18
|
+
|
|
19
|
+
/** Null hides the button: no such route (the app's own Studio), or not macOS. */
|
|
20
|
+
function useDesktopRoute(): { handoff: boolean } | null {
|
|
21
|
+
const [route, setRoute] = useState<{ handoff: boolean } | null>(null);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
let live = true;
|
|
24
|
+
studioApiFetch(ROUTE)
|
|
25
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
26
|
+
.then((body: { available?: unknown; handoff?: unknown } | null) => {
|
|
27
|
+
if (live && body?.available === true) setRoute({ handoff: body.handoff === true });
|
|
28
|
+
})
|
|
29
|
+
.catch(() => {});
|
|
30
|
+
return () => {
|
|
31
|
+
live = false;
|
|
32
|
+
};
|
|
33
|
+
}, []);
|
|
34
|
+
return route;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Framey looks at the pointer: one listener, one write per frame, no re-render. */
|
|
38
|
+
function useGaze(
|
|
39
|
+
anchor: React.RefObject<HTMLElement | null>,
|
|
40
|
+
eye: React.RefObject<SVGGElement | null>,
|
|
41
|
+
) {
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
let frame = 0;
|
|
44
|
+
const look = (event: PointerEvent) => {
|
|
45
|
+
cancelAnimationFrame(frame);
|
|
46
|
+
frame = requestAnimationFrame(() => {
|
|
47
|
+
const box = anchor.current?.getBoundingClientRect();
|
|
48
|
+
if (!box || !eye.current) return;
|
|
49
|
+
const dx = event.clientX - (box.left + box.height / 2);
|
|
50
|
+
const dy = event.clientY - (box.top + box.height / 2);
|
|
51
|
+
const distance = Math.hypot(dx, dy) || 1;
|
|
52
|
+
eye.current.style.transform = `translate(${(dx / distance) * GAZE}px, ${(dy / distance) * GAZE}px)`;
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
window.addEventListener("pointermove", look);
|
|
56
|
+
return () => {
|
|
57
|
+
cancelAnimationFrame(frame);
|
|
58
|
+
window.removeEventListener("pointermove", look);
|
|
59
|
+
};
|
|
60
|
+
}, [anchor, eye]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function OpenInDesktopButton() {
|
|
64
|
+
const { showToast } = useStudioShellContext();
|
|
65
|
+
const route = useDesktopRoute();
|
|
66
|
+
const [opening, setOpening] = useState(false);
|
|
67
|
+
// The card under the button, introducing Framey with the app's download.
|
|
68
|
+
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
|
69
|
+
const trigger = useRef<HTMLButtonElement>(null);
|
|
70
|
+
const eye = useRef<SVGGElement>(null);
|
|
71
|
+
useGaze(trigger, eye);
|
|
72
|
+
if (!route) return null;
|
|
73
|
+
|
|
74
|
+
const open = async () => {
|
|
75
|
+
if (!route.handoff) {
|
|
76
|
+
trackStudioEvent("toolbar_action", { action: "open_in_desktop" });
|
|
77
|
+
setDownloadUrl(DOWNLOAD_URL);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (opening) return;
|
|
81
|
+
setOpening(true);
|
|
82
|
+
trackStudioEvent("toolbar_action", { action: "open_in_desktop" });
|
|
83
|
+
try {
|
|
84
|
+
const res = await studioApiFetch(ROUTE, { method: "POST" });
|
|
85
|
+
const result = (await res.json()) as OpenResult;
|
|
86
|
+
if (result.opened) {
|
|
87
|
+
showToast("Framey is opening this project in HyperFrames Studio", "info");
|
|
88
|
+
await new Promise((done) => setTimeout(done, OPENING_MS));
|
|
89
|
+
} else setDownloadUrl(result.downloadUrl);
|
|
90
|
+
} catch {
|
|
91
|
+
showToast("Couldn't reach the preview server to open the app", "error");
|
|
92
|
+
} finally {
|
|
93
|
+
setOpening(false);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<Popover
|
|
99
|
+
open={downloadUrl !== null}
|
|
100
|
+
// The popover owns the trigger's press: a press runs the hand-off, and the card shows only when there is
|
|
101
|
+
// something to say (the download). A second handler on the button raced this one.
|
|
102
|
+
onOpenChange={(next) => {
|
|
103
|
+
if (next) void open();
|
|
104
|
+
else setDownloadUrl(null);
|
|
105
|
+
}}
|
|
106
|
+
side="bottom"
|
|
107
|
+
align="end"
|
|
108
|
+
arrow
|
|
109
|
+
aria-label="Meet Framey"
|
|
110
|
+
className="w-72"
|
|
111
|
+
trigger={
|
|
112
|
+
<Button
|
|
113
|
+
ref={trigger}
|
|
114
|
+
variant="ghost"
|
|
115
|
+
data-testid="header-open-in-desktop"
|
|
116
|
+
data-opening={opening || undefined}
|
|
117
|
+
title="Open this project in HyperFrames Studio, the desktop app, and edit it with Framey"
|
|
118
|
+
className="hf-framey-trigger gap-1.5 pl-1.5"
|
|
119
|
+
icon={<FrameyGlyph size={20} className="hf-framey" eyeRef={eye} />}
|
|
120
|
+
>
|
|
121
|
+
{opening ? "Opening in the app…" : "Edit with Framey"}
|
|
122
|
+
</Button>
|
|
123
|
+
}
|
|
124
|
+
>
|
|
125
|
+
<div className="flex items-center gap-2.5">
|
|
126
|
+
<FrameyGlyph size={36} />
|
|
127
|
+
<p className="text-step-12 font-medium text-text-0">Meet Framey</p>
|
|
128
|
+
</div>
|
|
129
|
+
<p className="mt-2 text-text-3">
|
|
130
|
+
I live in HyperFrames Studio, the free desktop app for macOS:
|
|
131
|
+
</p>
|
|
132
|
+
<ul className="mt-1.5 grid gap-1 text-text-1">
|
|
133
|
+
<li>Chat to change anything in the video</li>
|
|
134
|
+
<li>Draw on a frame to point at it</li>
|
|
135
|
+
<li>Watch me act out each edit</li>
|
|
136
|
+
</ul>
|
|
137
|
+
<div className="mt-3 flex items-center justify-end gap-2 whitespace-nowrap">
|
|
138
|
+
<Button variant="ghost" onClick={() => setDownloadUrl(null)}>
|
|
139
|
+
Not now
|
|
140
|
+
</Button>
|
|
141
|
+
<a
|
|
142
|
+
href={downloadUrl ?? undefined}
|
|
143
|
+
target="_blank"
|
|
144
|
+
rel="noopener noreferrer"
|
|
145
|
+
className={cn(buttonBase, buttonVariants.primary, buttonSizes.md)}
|
|
146
|
+
onClick={() => trackStudioEvent("toolbar_action", { action: "get_desktop_app" })}
|
|
147
|
+
>
|
|
148
|
+
Get HyperFrames Studio
|
|
149
|
+
</a>
|
|
150
|
+
</div>
|
|
151
|
+
</Popover>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -40,6 +40,8 @@ vi.mock("../contexts/PanelLayoutContext", () => ({
|
|
|
40
40
|
}));
|
|
41
41
|
|
|
42
42
|
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
43
|
+
// Probes the preview server on mount; covered in OpenInDesktopButton.dom.test.tsx.
|
|
44
|
+
vi.mock("./OpenInDesktopButton", () => ({ OpenInDesktopButton: () => null }));
|
|
43
45
|
|
|
44
46
|
const { StudioHeader } = await import("./StudioHeader");
|
|
45
47
|
const { ShowThemeToggle } = await import("./ThemeToggle");
|
|
@@ -6,6 +6,7 @@ import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
|
6
6
|
import { Button, buttonBase, buttonSizes, buttonVariants, cn, Tooltip } from "./ui";
|
|
7
7
|
import { Dock } from "./dock/Dock";
|
|
8
8
|
import { InspectorIcon } from "./icons/InspectorIcon";
|
|
9
|
+
import { OpenInDesktopButton } from "./OpenInDesktopButton";
|
|
9
10
|
import { HyperframesLogo } from "./ui/HyperframesLogo";
|
|
10
11
|
import { ShowThemeToggle, ThemeToggle } from "./ThemeToggle";
|
|
11
12
|
|
|
@@ -151,6 +152,7 @@ export const StudioHeader = memo(function StudioHeader({
|
|
|
151
152
|
</div>
|
|
152
153
|
{showThemeToggle && <ThemeToggle />}
|
|
153
154
|
<Dock.WindowMenu />
|
|
155
|
+
<OpenInDesktopButton />
|
|
154
156
|
<Tooltip
|
|
155
157
|
label={
|
|
156
158
|
ffmpegMissing
|
|
@@ -935,3 +935,54 @@
|
|
|
935
935
|
transform: scaleY(1);
|
|
936
936
|
}
|
|
937
937
|
}
|
|
938
|
+
|
|
939
|
+
/* Framey on the header's "Edit with Framey" (OpenInDesktopButton.tsx): a wiggle on hover, a flight towards
|
|
940
|
+
* the app while it opens. The eye follows the pointer from script. */
|
|
941
|
+
@keyframes hf-framey-wiggle {
|
|
942
|
+
0%,
|
|
943
|
+
100% {
|
|
944
|
+
transform: rotate(0deg);
|
|
945
|
+
}
|
|
946
|
+
25% {
|
|
947
|
+
transform: rotate(-14deg);
|
|
948
|
+
}
|
|
949
|
+
60% {
|
|
950
|
+
transform: rotate(10deg);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
@keyframes hf-framey-fly {
|
|
955
|
+
70% {
|
|
956
|
+
opacity: 1;
|
|
957
|
+
}
|
|
958
|
+
100% {
|
|
959
|
+
transform: translate(48px, -28px) rotate(25deg) scale(0.6);
|
|
960
|
+
opacity: 0;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
.hf-framey {
|
|
965
|
+
transform-origin: 30% 30%;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
.hf-framey-eye {
|
|
969
|
+
transition: transform 80ms linear;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
.hf-framey-trigger:hover .hf-framey {
|
|
973
|
+
animation: hf-framey-wiggle 0.5s ease-in-out;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
.hf-framey-trigger[data-opening] .hf-framey {
|
|
977
|
+
animation: hf-framey-fly 0.9s ease-in forwards;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
@media (prefers-reduced-motion: reduce) {
|
|
981
|
+
.hf-framey-trigger:hover .hf-framey,
|
|
982
|
+
.hf-framey-trigger[data-opening] .hf-framey {
|
|
983
|
+
animation: none;
|
|
984
|
+
}
|
|
985
|
+
.hf-framey-eye {
|
|
986
|
+
transition: none;
|
|
987
|
+
}
|
|
988
|
+
}
|
package/src/styles/theme.css
CHANGED
|
@@ -436,6 +436,9 @@
|
|
|
436
436
|
--color-violet-400: oklch(70.2% 0.183 293.541);
|
|
437
437
|
--color-violet-500: oklch(60.6% 0.25 292.717);
|
|
438
438
|
--color-white: #fff;
|
|
439
|
+
/* Framey, the desktop app's cursor mascot (hyperframes-internal packages/nib): the same in both themes. */
|
|
440
|
+
--color-framey: #3ce6ac;
|
|
441
|
+
--color-framey-ink: #16121a;
|
|
439
442
|
}
|
|
440
443
|
|
|
441
444
|
/* The colour-scheme picks each token's light or dark half: dark unless the document element
|