diff --git a/apps/client/src/components/lookout/EditorModal.tsx b/apps/client/src/components/lookout/EditorModal.tsx
new file mode 100644
index 0000000..7b3436a
--- /dev/null
+++ b/apps/client/src/components/lookout/EditorModal.tsx
@@ -0,0 +1,54 @@
+import { useEffect } from "react";
+import { TimelapseEditor, setAccentColor } from "@lookout/react";
+
+import { Modal, ModalHeader, ModalContent } from "@/components/layout/Modal";
+
+// Lapse's brand red (mirrors --color-red in globals.css). Applied to the Lookout SDK's
+// document-root accent variables so the editor matches the rest of the app.
+const LAPSE_ACCENT = "#ec3750";
+
+/**
+ * The Lookout cut/edit step, wrapped in a Lapse-styled modal. Used right after a
+ * recording stops, and again by the publish page to resume an interrupted edit while
+ * the session's edit hold is still live.
+ *
+ * `onDone` fires when the user is finished with the editor — cuts applied or the
+ * editor dismissed. Either way the session publishes (the edit hold is a lease, and
+ * leaving the editor is the decision to publish), so callers should continue to the
+ * publish flow.
+ */
+export function EditorModal({ token, apiBaseUrl, onDone }: {
+ token: string;
+ apiBaseUrl: string;
+ onDone: () => void;
+}) {
+ // The editor is the only SDK-rendered surface in Lapse, so the accent lives here
+ // rather than on LookoutProvider. Set on the document root (the editor portals to
+ //
), restored on unmount so the accent never leaks past the editor.
+ useEffect(() => {
+ setAccentColor(LAPSE_ACCENT, null);
+ return () => setAccentColor(null, null);
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/components/lookout/LookoutRecorder.tsx b/apps/client/src/components/lookout/LookoutRecorder.tsx
index 0cb98f0..3f3e144 100644
--- a/apps/client/src/components/lookout/LookoutRecorder.tsx
+++ b/apps/client/src/components/lookout/LookoutRecorder.tsx
@@ -15,11 +15,18 @@ import {
removeStoredSession,
} from "@/components/lookout/sessions";
import RootLayout from "@/components/layout/RootLayout";
+import { EditorModal } from "@/components/lookout/EditorModal";
import { Modal, ModalHeader, ModalContent } from "@/components/layout/Modal";
import { LoadingModal } from "@/components/layout/LoadingModal";
import { ErrorModal } from "@/components/layout/ErrorModal";
+import { CopyField } from "@/components/ui/CopyField";
import { PillControlButton } from "@/components/ui/PillControlButton";
+/** The deep link that hands a recording session to the Lookout desktop app. */
+function desktopSessionLink(token: string): string {
+ return `lookout://session?token=${token}`;
+}
+
function formatTrackedTime(totalSeconds: number): string {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
@@ -624,7 +631,7 @@ export default function LookoutRecorder() {
function startWithConfig(cfg: LookoutSessionConfig) {
if (selectedMode === "desktop") {
- window.location.href = `lookout://session?token=${cfg.lookoutToken}`;
+ window.location.href = desktopSessionLink(cfg.lookoutToken);
setDesktopLaunched(true);
return;
}
@@ -676,9 +683,11 @@ export default function LookoutRecorder() {
}
if (desktopLaunched && config) {
+ const sessionLink = desktopSessionLink(config.lookoutToken);
+
return (
-
+
@@ -686,8 +695,12 @@ export default function LookoutRecorder() {
The Lookout app should have opened on your desktop. If nothing happened, you may need to install it first.
+
+ We'll bring you back here to publish once your timelapse is done.
+
+ {/* Installing is the way out of this screen for most people who end up reading it, so it leads. */}
Get Lookout
-
+
+
+
+ {/*
+ The deep link, in the flesh. Lookout has a "paste a lookout:// link" box precisely for when the
+ handoff doesn't fire on its own (a browser that swallows unknown schemes, a fresh install that
+ hasn't registered one yet), and this is the only place the link exists to be copied from.
+ */}
+
+
+ Still nothing? Copy this link and paste it into Lookout.
+
+
@@ -731,6 +767,8 @@ export default function LookoutRecorder() {
>
{ releasePendingStream(); setCaptureMode(null); setCameraDeviceId(null); }}
onBrowserError={(message) => {
// Browser capture failed — return to the selector (the "picker") and remember why,
@@ -868,8 +906,10 @@ function CameraPreviewVideo({ stream }: { stream: MediaStream }) {
// it as "go back to mode selection" rather than a scary error — see the error effect.
const SCREEN_CANCELLED_MESSAGE = "Screen sharing was cancelled.";
-function LapseRecorder({ draftId, onShareFailed, onBrowserError }: {
+function LapseRecorder({ draftId, lookoutToken, apiBaseUrl, onShareFailed, onBrowserError }: {
draftId: string;
+ lookoutToken: string;
+ apiBaseUrl: string;
onShareFailed: () => void;
onBrowserError: (message: string) => void;
}) {
@@ -877,6 +917,11 @@ function LapseRecorder({ draftId, onShareFailed, onBrowserError }: {
const { state, actions } = useLookout();
const [error, setError] = useState(null);
const [captureError, setCaptureError] = useState(null);
+ // Once recording stops we offer the cut/edit step before handing off to the publish
+ // page. `editorDone` flips when the user saves or dismisses the editor, which is what
+ // actually releases them to publish.
+ const [editorOpen, setEditorOpen] = useState(false);
+ const [editorDone, setEditorDone] = useState(false);
const screenStarted = useRef(false);
const isCamera = state.captureMode === "camera";
@@ -937,10 +982,18 @@ function LapseRecorder({ draftId, onShareFailed, onBrowserError }: {
}, 30 * 1000);
useEffect(() => {
- if (state.status === "stopped" || state.status === "compiling" || state.status === "complete") {
+ const recordingEnded = state.status === "stopped" || state.status === "compiling" || state.status === "complete";
+ if (!recordingEnded) return;
+
+ // The user has finished with the editor (saved cuts or dismissed it) — hand off to
+ // the existing publish page. Without a token to edit against, skip straight there so
+ // the flow never gets stuck.
+ if (editorDone || !lookoutToken) {
router.push(`/timelapse/publish/${draftId}`);
+ } else {
+ setEditorOpen(true);
}
- }, [state.status, draftId, router]);
+ }, [state.status, editorDone, lookoutToken, draftId, router]);
function handleStartSharing() {
actions.startSharing().catch((err) =>
@@ -958,12 +1011,32 @@ function LapseRecorder({ draftId, onShareFailed, onBrowserError }: {
async function stopRecording() {
try {
- await actions.stop();
+ // `edit: true` asks the server to hold the session open for editing after stop
+ // instead of compiling straight to `complete`. Without it the cut/edit step below
+ // gets a `not_ready` session ("isn't available for editing").
+ await actions.stop({ edit: true });
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to stop recording");
}
}
+ // After recording stops, the cut/edit step is offered in a Lapse-styled modal. This
+ // must precede the capture-state branches below (once stopped, `state.isSharing` is
+ // false, which would otherwise render the empty placeholder). Dismissing or saving
+ // sets `editorDone`, which the effect above turns into the publish-page handoff — the
+ // editor itself publishes the (possibly cut) session; we just continue Lapse's flow.
+ if (editorOpen && lookoutToken) {
+ return (
+
+ { setEditorOpen(false); setEditorDone(true); }}
+ />
+
+ );
+ }
+
// Camera failures show an inline modal; screen failures are handled by the effect above,
// which sends the user back to the selector with the error recorded.
if (captureError) {
diff --git a/apps/client/src/components/ui/CopyField.tsx b/apps/client/src/components/ui/CopyField.tsx
new file mode 100644
index 0000000..6b6eccf
--- /dev/null
+++ b/apps/client/src/components/ui/CopyField.tsx
@@ -0,0 +1,96 @@
+import { useEffect, useRef, useState } from "react";
+import clsx from "clsx";
+import Icon from "@hackclub/icons";
+
+/**
+ * A read-only field holding something the user has to take somewhere else - a link, a token - with a copy button
+ * beside it. The value stays visible and selects itself on focus, because copying needs a secure context and the
+ * user's permission, and a button that silently does nothing is worse than a field they can select by hand.
+ */
+export function CopyField({ value, label, className }: {
+ value: string;
+
+ /** Describes the value for screen readers, e.g. "Recording session link". */
+ label: string;
+
+ className?: string;
+}) {
+ const [copied, setCopied] = useState(false);
+ const resetTimer = useRef | null>(null);
+
+ // Copying right before this unmounts would otherwise leave the timer to set state on a gone component.
+ useEffect(() => () => {
+ if (resetTimer.current) clearTimeout(resetTimer.current);
+ }, []);
+
+ async function copy() {
+ try {
+ await navigator.clipboard.writeText(value);
+ } catch {
+ // No clipboard access - the field is selectable for exactly this case, so leave it be.
+ return;
+ }
+
+ setCopied(true);
+ if (resetTimer.current) clearTimeout(resetTimer.current);
+ resetTimer.current = setTimeout(() => setCopied(false), 2000);
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/client/src/pages/_app.tsx b/apps/client/src/pages/_app.tsx
index 739bc2a..9f694ac 100644
--- a/apps/client/src/pages/_app.tsx
+++ b/apps/client/src/pages/_app.tsx
@@ -31,7 +31,11 @@ const App: AppType = ({ Component, pageProps }) => {
}, [router]);
useEffect(() => {
- if (router.pathname.startsWith("/timelapse/publish") || router.pathname.startsWith("/timelapse/create"))
+ // `/timelapse/handoff` is exempt for the same reason as the other two: it's already taking the user to
+ // publish, and it has an unauthenticated state to show first that this redirect would trample.
+ if (router.pathname.startsWith("/timelapse/publish")
+ || router.pathname.startsWith("/timelapse/create")
+ || router.pathname.startsWith("/timelapse/handoff"))
return;
// Never probe a protected endpoint on the auth pages or without a session. The request would 401 and
diff --git a/apps/client/src/pages/timelapse/handoff/[id].tsx b/apps/client/src/pages/timelapse/handoff/[id].tsx
new file mode 100644
index 0000000..a31dc78
--- /dev/null
+++ b/apps/client/src/pages/timelapse/handoff/[id].tsx
@@ -0,0 +1,128 @@
+import { useEffect, useState } from "react";
+import { useRouter } from "next/router";
+import Icon from "@hackclub/icons";
+
+import { api } from "@/api";
+import { useAuthContext } from "@/context/AuthContext";
+
+import RootLayout from "@/components/layout/RootLayout";
+import { Button } from "@/components/ui/Button";
+import { CopyField } from "@/components/ui/CopyField";
+import { LoadingModal } from "@/components/layout/LoadingModal";
+import { NOT_FOUND_STATUS, StatusPage } from "@/components/layout/StatusPage";
+
+/**
+ * Where Lookout sends the user when a timelapse they recorded in the desktop app finishes compiling (its
+ * `redirectUrl` hook, set when we create the session). It opens in their *default* browser, which is not
+ * necessarily the one they started the recording in - so unlike the rest of the recording flow, this page
+ * can't assume a signed-in session, and says so instead of bouncing them into OAuth unannounced.
+ *
+ * It exists as its own route rather than pointing the hook straight at `/timelapse/publish/:id` because the
+ * hook is immutable: every session ever created carries the URL it was born with, forever. Keeping a page of
+ * our own in front of the publish flow means that URL stays valid even if the flow behind it moves.
+ */
+export default function Page() {
+ const router = useRouter();
+ const { currentUser, isLoading } = useAuthContext();
+
+ const rawId = router.query.id as string | undefined;
+ const draftId = rawId && rawId !== "undefined" ? rawId : undefined;
+
+ const [gone, setGone] = useState(false);
+ // This page's own URL, for the "wrong browser" case below. Read after mount - it doesn't exist during
+ // prerender, and it has to be the absolute form to be worth pasting anywhere.
+ const [handoffUrl, setHandoffUrl] = useState("");
+
+ useEffect(() => {
+ setHandoffUrl(window.location.href);
+ }, []);
+
+ useEffect(() => {
+ if (!router.isReady || isLoading || !draftId) return;
+
+ // Signing in returns here, not to the homepage, so the handoff survives the detour.
+ if (!currentUser) return;
+
+ (async () => {
+ // The draft is the whole point of the trip - if it's already been published or discarded (from another
+ // tab, another device, or an earlier visit to this same link), there's nothing to hand off to.
+ try {
+ const res = await api.timelapse.pollLookoutStatus({ draftId });
+ // Only a missing draft ends the trip here. Any other failure (Lookout being down, say) is the publish
+ // page's problem - it polls the same endpoint and reports errors properly.
+ if (!res.ok && res.error === "NOT_FOUND") {
+ setGone(true);
+ return;
+ }
+ } catch {
+ // A network blip shouldn't strand the user here either.
+ }
+
+ router.replace(`/timelapse/publish/${draftId}`);
+ })();
+ }, [router, router.isReady, isLoading, currentUser, draftId]);
+
+ if (router.isReady && !draftId) {
+ return ;
+ }
+
+ if (gone) {
+ return (
+
+
+
+
+
+
Nothing left to publish
+
+ This recording has already been published or discarded. Your timelapses are on your profile.
+
+
+
+
+
+
+ );
+ }
+
+ // Lookout opens the redirect in the user's *default* browser, which isn't necessarily the one they started
+ // recording in. Rather than pushing them through a second sign-in here, point them back at the browser that
+ // already has their session: opening Lapse there lands them on this timelapse's publish page by itself (see
+ // the draft check in `_app.tsx`). Signing in here still works for anyone who'd rather - or has to.
+ if (!isLoading && !currentUser) {
+ return (
+
+
+
+
+
+
Your timelapse is ready
+
+ This browser isn't signed in to Lapse. Head back to the browser you started recording
+ in - it'll pick up right where you left off.
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/client/src/pages/timelapse/publish/[id].tsx b/apps/client/src/pages/timelapse/publish/[id].tsx
index af8efb1..6b9133b 100644
--- a/apps/client/src/pages/timelapse/publish/[id].tsx
+++ b/apps/client/src/pages/timelapse/publish/[id].tsx
@@ -7,7 +7,8 @@ import type { TimelapseVisibility } from "@hackclub/lapse-api";
import { api } from "@/api";
import { useAuth } from "@/hooks/useAuth";
import { useInterval } from "@/hooks/useInterval";
-import { removeStoredSession } from "@/components/lookout/sessions";
+import { getStoredSessions, removeStoredSession, type StoredLookoutSession } from "@/components/lookout/sessions";
+import { EditorModal } from "@/components/lookout/EditorModal";
import RootLayout from "@/components/layout/RootLayout";
import { Button } from "@/components/ui/Button";
@@ -64,6 +65,23 @@ export default function Page() {
const [loadStatus, setLoadStatus] = useState(null);
const [step, setStep] = useState("details");
+
+ // Resuming an interrupted edit: if this page loads (e.g. after a refresh) while the
+ // session's edit hold is still live, the cut/edit modal reopens instead of leaving
+ // the user staring at the compile spinner until the hold lapses. The Lookout token
+ // for the draft is only known on this device, via the recorder's stored sessions.
+ const [storedSession, setStoredSession] = useState(null);
+ const [editorOpen, setEditorOpen] = useState(false);
+ const [editorDone, setEditorDone] = useState(false);
+ // Set from the status poll. Desktop recordings are edited in the desktop app, never here. `null` until a poll
+ // says either way - the editor stays shut while we don't know, rather than guessing and opening it.
+ const [recordedOnDesktop, setRecordedOnDesktop] = useState(null);
+
+ useEffect(() => {
+ // localStorage is client-only, so this can't run during render.
+ if (!draftId) return;
+ setStoredSession(getStoredSessions().find(s => s.draftId === draftId) ?? null);
+ }, [draftId]);
const [hackatimeProject, setHackatimeProject] = useState(null);
const [isLoadingHackatime, setIsLoadingHackatime] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
@@ -78,6 +96,10 @@ export default function Page() {
useInterval(async () => {
if (!draftId || compilationStatus !== "waiting") return;
+ // The status poll comes first: it's what tells us whether this session was recorded on the desktop, which
+ // the edit-hold probe below needs to know before it decides to open anything.
+ let onDesktop = recordedOnDesktop;
+
try {
const res = await api.timelapse.pollLookoutStatus({ draftId });
if (!res.ok) {
@@ -87,6 +109,9 @@ export default function Page() {
return;
}
+ onDesktop = res.data.recordedOnDesktop;
+ setRecordedOnDesktop(onDesktop);
+
if (res.data.lookoutStatus === "complete") {
setCompilationStatus("ready");
setVideoUrl(res.data.videoUrl);
@@ -98,6 +123,33 @@ export default function Page() {
} catch (err) {
console.warn("(publish.tsx) poll error:", err);
}
+
+ // While the session's edit hold is live, offer to resume editing instead of
+ // waiting out the hold. Asked directly of Lookout (the hold isn't visible through
+ // Lapse's status poll), and only until a definitive answer: a live hold opens the
+ // editor; a session past its hold can never become editable again, so stop asking.
+ //
+ // Desktop recordings are the exception. Their hold belongs to the desktop app's own editor window, which
+ // is very likely open on this exact session right now - a second editor here would have both surfaces
+ // renewing the same lease and racing to write cuts, last one winning. So we stay out of it and just wait
+ // for the compile, which is what the user is watching the app finish anyway.
+ if (storedSession && onDesktop === false && !editorOpen && !editorDone) {
+ try {
+ const res = await fetch(
+ `${storedSession.lookoutApiBaseUrl}/api/sessions/${storedSession.lookoutToken}/status`
+ );
+ if (res.ok) {
+ const status: { editable?: boolean; editHoldUntil?: string } = await res.json();
+ if (status.editable || status.editHoldUntil) {
+ setEditorOpen(true);
+ } else {
+ setEditorDone(true);
+ }
+ }
+ } catch (err) {
+ console.warn("(publish.tsx) edit-hold probe error:", err);
+ }
+ }
}, 3000);
function handleVisibilitySelect() {
@@ -323,6 +375,14 @@ export default function Page() {
)}
+ {editorOpen && storedSession && (
+ { setEditorOpen(false); setEditorDone(true); }}
+ />
+ )}
+
(path: string, options?: RequestInit): Promise
export async function createSession(
name?: string,
- metadata?: Record
+ metadata?: Record,
+ options?: { clips?: boolean; redirectUrl?: string }
): Promise {
const result = await lookoutFetch("/api/internal/sessions", {
method: "POST",
- body: JSON.stringify({ name, metadata }),
+ // `clips` (not `clip`/`clipsEnabled`) is the field that flips `clipsEnabled` on
+ // the session, unlocking the cut/edit flow.
+ //
+ // `redirectUrl` is Lookout's redirect hook: the recording client sends the user there once the
+ // timelapse finishes compiling (the desktop app opens it in their default browser). It's immutable
+ // after creation, and Lookout only accepts http(s).
+ body: JSON.stringify({ name, metadata, clips: options?.clips, redirectUrl: options?.redirectUrl }),
});
logInfo(`Created Lookout session ${result.sessionId}`);
@@ -71,6 +78,18 @@ export async function getSession(sessionId: string): Promise(`/api/internal/sessions/${sessionId}`);
}
+/**
+ * Whether a session was recorded through the Lookout desktop app, rather than in a browser through our own recorder.
+ *
+ * `clientInfo` is Lookout's User-Agent-like telemetry string, recorded on the session's *first* upload - so it's
+ * `null` for a session that never uploaded anything. The format is a convention rather than something Lookout
+ * enforces (see `formatClientInfo` in `@lookout/shared`), which is why this is a lenient prefix match: anything we
+ * can't confidently read as the desktop app counts as not-desktop, leaving Lapse's own behaviour as the default.
+ */
+export function isDesktopClient(clientInfo: string | null): boolean {
+ return /^Lookout Desktop\b/i.test(clientInfo ?? "");
+}
+
export async function getTimings(token: string): Promise {
const url = `${env.LOOKOUT_API_BASE_URL}/api/sessions/${token}/timings`;
const res = await fetch(url);
diff --git a/apps/server/src/routers/timelapse.ts b/apps/server/src/routers/timelapse.ts
index 27be7f5..599bbbd 100644
--- a/apps/server/src/routers/timelapse.ts
+++ b/apps/server/src/routers/timelapse.ts
@@ -504,14 +504,27 @@ export default os.router({
.handler(async (req) => {
const caller = req.context.user;
+ // Lookout's redirect hook has to be set at creation and can never be changed, so the draft's ID - which
+ // the hook's URL points at - has to exist before the session does. Mint it here instead of letting the
+ // database default do it; `lapseId()` is the same 12-character NanoID the schema would have generated.
+ const draftId = lapseId();
+
const session = await lookout.createSession(undefined, {
lapseUserId: caller.id,
lapseUserHandle: caller.handle,
source: "lapse",
+ }, {
+ clips: true,
+ // Recording in the desktop app leaves this tab with no way of knowing the timelapse is done, so we ask
+ // Lookout to send the user back to us the moment it compiles. Built here rather than accepted from the
+ // caller: any client holding `timelapse:write` could otherwise have the desktop app open an arbitrary
+ // URL in the user's default browser.
+ redirectUrl: `${env.WEB_BASE_URL}/timelapse/handoff/${draftId}`,
});
const draft = await database().draftLookoutTimelapse.create({
data: {
+ id: draftId,
lookoutSessionId: session.sessionId,
lookoutToken: session.token,
ownerId: caller.id,
@@ -592,6 +605,7 @@ export default os.router({
lookoutStatus: session.session.status,
videoUrl: session.session.videoUrl,
thumbnailUrl: session.session.thumbnailUrl,
+ recordedOnDesktop: lookout.isDesktopClient(session.clientInfo),
});
}),
diff --git a/package.json b/package.json
index f4de402..6fd5318 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,8 @@
"packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c",
"devDependencies": {
"tsup": "^8.5.1",
- "typescript": "^6.0.3"
+ "typescript": "^6.0.3",
+ "vitest": "^4.1.10"
},
"scripts": {
"build": "pnpm --filter @lookout/shared run build && pnpm -r run build",
diff --git a/packages/api/src/contracts/timelapse.ts b/packages/api/src/contracts/timelapse.ts
index db139c5..870bd3f 100644
--- a/packages/api/src/contracts/timelapse.ts
+++ b/packages/api/src/contracts/timelapse.ts
@@ -305,6 +305,9 @@ export const timelapseRouterContract = {
.describe("The video URL, if compilation is complete."),
thumbnailUrl: z.string().nullable()
.describe("The thumbnail URL, if compilation is complete."),
+
+ recordedOnDesktop: z.boolean()
+ .describe("Whether the session was recorded through the Lookout desktop app. Editing such a session belongs to the desktop app's own editor, so Lapse leaves it alone."),
})
),
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 59de37c..fc6eb96 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -18,6 +18,9 @@ importers:
typescript:
specifier: ^6.0.3
version: 6.0.3
+ vitest:
+ specifier: ^4.1.10
+ version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@27.4.0(@noble/hashes@2.2.0)(supports-color@8.1.1))(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2))
apps/client:
dependencies:
@@ -429,6 +432,12 @@ importers:
'@lookout/shared':
specifier: workspace:*
version: link:../../packages/shared
+ '@number-flow/react':
+ specifier: ^0.6.2
+ version: 0.6.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@phosphor-icons/react':
+ specifier: ^2.1.10
+ version: 2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@squircle-js/react':
specifier: ^1.3.0
version: 1.3.0(@types/react@19.2.18)(react@19.2.8)
@@ -438,6 +447,9 @@ importers:
motion:
specifier: ^12.38.0
version: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ mp4-muxer:
+ specifier: ^5.2.2
+ version: 5.2.2
devDependencies:
'@testing-library/dom':
specifier: ^10.4.1
@@ -512,6 +524,9 @@ packages:
'@aws-sdk/core@3.977.4':
resolution: {integrity: sha512-CEkcQlMOQJCvul60U7wdAOACjtdgFWDsfJI+6wUOGdhGNV2lGbuJpi/R50QLpFG3Tp+sQxa/RmzC3X7KHbhuTA==}
engines: {node: '>=20.0.0'}
+ deprecated: |-
+ Deprecated due to Document number parsing bug in JSON, see
+ https://github.com/aws/aws-sdk-js-v3/issues/8246. Newer version available.
'@aws-sdk/credential-provider-env@3.972.65':
resolution: {integrity: sha512-lJT2aRw9wCV8jPHyFJjdZLD4HTydL6/22AnCSOB8e/LqOc55nEJGLHkJQeSxhn8QiqyjFwPKQFtMw0ovjRUY/g==}
@@ -2594,6 +2609,12 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
+ '@number-flow/react@0.6.2':
+ resolution: {integrity: sha512-WjZuV4aA+vhRCgCF+adGLgFVVAJ8vdvq6EchRT2FiBIgElTXtDOA3YgkcMr9UHpvpS9v4H70AB5wZv0D9jc3QA==}
+ peerDependencies:
+ react: ^18 || ^19
+ react-dom: ^18 || ^19
+
'@open-draft/deferred-promise@2.2.0':
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
@@ -2718,6 +2739,13 @@ packages:
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
+ '@phosphor-icons/react@2.1.10':
+ resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ react: '>= 16.8'
+ react-dom: '>= 16.8'
+
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
@@ -3560,6 +3588,9 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+ '@types/dom-webcodecs@0.1.18':
+ resolution: {integrity: sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==}
+
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
@@ -3622,6 +3653,9 @@ packages:
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
+ '@types/wicg-file-system-access@2020.9.8':
+ resolution: {integrity: sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==}
+
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -4935,6 +4969,9 @@ packages:
jiti:
optional: true
+ esm-env@1.2.2:
+ resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
+
espree@10.4.0:
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -6088,6 +6125,10 @@ packages:
react-dom:
optional: true
+ mp4-muxer@5.2.2:
+ resolution: {integrity: sha512-dhozjTywI0h2qFzeShagt8YYw811fh1XlwiDCE2f6Aeqf6xG2CyuShoSa5E0AZDO8pPF0JOZ3wOmWBNWIGdSpQ==}
+ deprecated: This library is superseded by Mediabunny. Please migrate to it.
+
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@@ -6231,6 +6272,9 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
+ number-flow@0.6.2:
+ resolution: {integrity: sha512-MCnImG4Q5vPwhSXnov56nOuyyKn6LC+Qd7II1UiKc+ACRtug5iAtn0+CwXNxM38AC5lSowEY+oYEtZX2qMnUyw==}
+
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -9867,6 +9911,13 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
+ '@number-flow/react@0.6.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ esm-env: 1.2.2
+ number-flow: 0.6.2
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
'@open-draft/deferred-promise@2.2.0':
optional: true
@@ -10078,6 +10129,11 @@ snapshots:
dependencies:
'@noble/hashes': 1.8.0
+ '@phosphor-icons/react@2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
'@pinojs/redact@0.4.0': {}
'@polka/url@1.0.0-next.29': {}
@@ -10823,6 +10879,8 @@ snapshots:
'@types/deep-eql@4.0.2': {}
+ '@types/dom-webcodecs@0.1.18': {}
+
'@types/esrecurse@4.3.1': {}
'@types/estree@1.0.9': {}
@@ -10885,6 +10943,8 @@ snapshots:
'@types/whatwg-mimetype@3.0.2': {}
+ '@types/wicg-file-system-access@2020.9.8': {}
+
'@types/ws@8.18.1':
dependencies:
'@types/node': 26.1.2
@@ -11192,7 +11252,7 @@ snapshots:
obug: 2.1.4
std-env: 4.2.0
tinyrainbow: 3.1.1
- vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@27.4.0(@noble/hashes@2.2.0)(supports-color@8.1.1))(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2))
+ vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@27.4.0(@noble/hashes@2.2.0)(supports-color@8.1.1))(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2))
'@vitest/expect@4.1.10':
dependencies:
@@ -11203,6 +11263,15 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.1
+ '@vitest/mocker@4.1.10(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2))':
+ dependencies:
+ '@vitest/spy': 4.1.10
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ msw: 2.12.10(@types/node@26.1.2)(typescript@6.0.3)
+ vite: 8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2)
+
'@vitest/mocker@4.1.10(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.1.10
@@ -11239,7 +11308,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.17
tinyrainbow: 3.1.1
- vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@27.4.0(@noble/hashes@2.2.0)(supports-color@8.1.1))(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2))
+ vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@27.4.0(@noble/hashes@2.2.0)(supports-color@8.1.1))(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2))
'@vitest/utils@4.1.10':
dependencies:
@@ -12464,6 +12533,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ esm-env@1.2.2: {}
+
espree@10.4.0:
dependencies:
acorn: 8.18.0
@@ -13589,6 +13660,11 @@ snapshots:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
+ mp4-muxer@5.2.2:
+ dependencies:
+ '@types/dom-webcodecs': 0.1.18
+ '@types/wicg-file-system-access': 2020.9.8
+
mrmime@2.0.1: {}
ms@2.1.3: {}
@@ -13756,6 +13832,10 @@ snapshots:
dependencies:
boolbase: 1.0.0
+ number-flow@0.6.2:
+ dependencies:
+ esm-env: 1.2.2
+
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -15032,6 +15112,22 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
+ vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2):
+ dependencies:
+ lightningcss: 1.33.0
+ picomatch: 4.0.5
+ postcss: 8.5.25
+ rolldown: 1.2.1
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ '@types/node': 26.1.2
+ esbuild: 0.27.7
+ fsevents: 2.3.3
+ jiti: 2.7.0
+ terser: 5.49.0
+ tsx: 4.23.4
+ yaml: 2.8.2
+
vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2):
dependencies:
lightningcss: 1.33.0
@@ -15048,6 +15144,38 @@ snapshots:
tsx: 4.23.4
yaml: 2.8.2
+ vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@27.4.0(@noble/hashes@2.2.0)(supports-color@8.1.1))(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2)):
+ dependencies:
+ '@vitest/expect': 4.1.10
+ '@vitest/mocker': 4.1.10(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2))
+ '@vitest/pretty-format': 4.1.10
+ '@vitest/runner': 4.1.10
+ '@vitest/snapshot': 4.1.10
+ '@vitest/spy': 4.1.10
+ '@vitest/utils': 4.1.10
+ es-module-lexer: 2.3.1
+ expect-type: 1.4.0
+ magic-string: 0.30.21
+ obug: 2.1.4
+ pathe: 2.0.3
+ picomatch: 4.0.5
+ std-env: 4.2.0
+ tinybench: 2.9.0
+ tinyexec: 1.3.0
+ tinyglobby: 0.2.17
+ tinyrainbow: 3.1.1
+ vite: 8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@opentelemetry/api': 1.9.1
+ '@types/node': 26.1.2
+ '@vitest/coverage-v8': 4.1.10(vitest@4.1.10)
+ '@vitest/ui': 4.1.10(vitest@4.1.10)
+ happy-dom: 20.11.1
+ jsdom: 27.4.0(@noble/hashes@2.2.0)(supports-color@8.1.1)
+ transitivePeerDependencies:
+ - msw
+
vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@27.4.0(@noble/hashes@2.2.0)(supports-color@8.1.1))(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2)):
dependencies:
'@vitest/expect': 4.1.10
diff --git a/vendor/lookout b/vendor/lookout
index a76dcf4..80eb3c6 160000
--- a/vendor/lookout
+++ b/vendor/lookout
@@ -1 +1 @@
-Subproject commit a76dcf48c2fefb68ea3ba1caba12f68c14b25e2b
+Subproject commit 80eb3c6aa2f834380969b5988a341bcf78583d93