Refine banner login and dashboard actions, restore zoom holds

This commit is contained in:
webadderall
2026-09-23 14:22:21 +10:00
parent bebe00778d
commit 252f455dfb
18 changed files with 366 additions and 208 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 794 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 100 KiB

+8
View File
@@ -0,0 +1,8 @@
# Official provider logos
Unmodified standalone color logos displayed inside HeroUI buttons; labels and controls belong to Recordly.
- `google-logo.png`: https://developers.google.com/static/identity/images/g-logo.png — linked by https://developers.google.com/identity/branding-guidelines
- `microsoft-logo.svg`: https://learn.microsoft.com/en-us/entra/identity-platform/media/howto-add-branding-in-apps/ms-symbollockup_mssymbol_19.svg — linked by https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-branding-in-apps
Downloaded September 23, 2026. Trademarks belong to the respective providers. Preserve original colors and aspect ratio.
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="21" height="21" viewBox="0 0 21 21"><title>MS-SymbolLockup</title><rect x="1" y="1" width="9" height="9" fill="#f25022"/><rect x="1" y="11" width="9" height="9" fill="#00a4ef"/><rect x="11" y="1" width="9" height="9" fill="#7fba00"/><rect x="11" y="11" width="9" height="9" fill="#ffb900"/></svg>

After

Width:  |  Height:  |  Size: 343 B

+166 -99
View File
@@ -1,9 +1,9 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { demoLoginEnabled } from "@/lib/auth/demoSession";
import { WindowsLogo } from "@phosphor-icons/react";
import { useI18n } from "@/contexts/I18nContext";
import { GoogleLogo, SignOut } from "@/components/ui/icons";
import { SignOut } from "@/components/ui/icons";
import type { User } from "@supabase/supabase-js";
import { type FormEvent, useEffect, useState } from "react";
import { type FormEvent, useEffect, useState, useRef } from "react";
import {
Modal,
Button,
@@ -23,9 +23,12 @@ import {
signOutRecordly,
} from "@/lib/auth/recordlyAuth";
const MotionDialog = motion.create(Modal.Dialog);
export type SignInReason = "account" | "share";
type Props = {
variant?: "compact" | "wide";
open: boolean;
onOpenChange: (open: boolean) => void;
reason?: SignInReason;
@@ -52,15 +55,17 @@ function friendlyAuthError(
}
export function RecordlySignInDialog({
variant = "compact",
open,
onOpenChange,
reason = "account",
user,
configured,
callbackError,
onAuthenticated,
}: Props) {
const { t } = useI18n();
const awaitingSignIn = useRef(false);
const wide = variant === "wide";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState<string>();
@@ -70,6 +75,7 @@ export function RecordlySignInDialog({
useEffect(() => {
if (!open) {
setPassword("");
awaitingSignIn.current = false;
setBusy(undefined);
setMessage(undefined);
setResetSent(false);
@@ -77,8 +83,14 @@ export function RecordlySignInDialog({
}, [open]);
useEffect(() => {
if (open && user && reason === "share") onAuthenticated();
}, [onAuthenticated, open, reason, user]);
if (!open) return;
if (!user) awaitingSignIn.current = true;
else if (awaitingSignIn.current) {
awaitingSignIn.current = false;
setMessage(undefined);
onAuthenticated();
}
}, [open, user, onAuthenticated]);
const run = async (label: string, action: () => Promise<unknown>) => {
setBusy(label);
@@ -101,7 +113,6 @@ export function RecordlySignInDialog({
throw new Error("Email sign-in is not available yet.");
}
await signInWithEmail(email.trim(), password);
onAuthenticated();
});
};
@@ -117,37 +128,73 @@ export function RecordlySignInDialog({
});
};
const reduceMotion = useReducedMotion();
const transition = { duration: reduceMotion ? 0 : 0.28, ease: [0.22, 1, 0.36, 1] as const };
const reveal = {
initial: { opacity: 0, y: reduceMotion ? 0 : 10 },
animate: { opacity: 1, y: 0 },
transition,
};
const disabled = Boolean(busy);
const expanded = email.trim().length > 0;
const artwork = `${import.meta.env.BASE_URL}wallpapers/wallpaper1.jpg`;
return (
<Modal isOpen={open} onOpenChange={onOpenChange}>
<Modal.Backdrop
className="bg-cover bg-center"
style={{
backgroundImage: `linear-gradient(#10102066, #10102066), url(${artwork})`,
}}
>
<Modal.Backdrop>
<Modal.Container size="cover" placement="center" className="p-4 sm:p-8">
<Modal.Dialog className="grid h-[min(760px,calc(100dvh-64px))] min-h-0 w-full max-w-[1120px] grid-cols-1 gap-0 overflow-hidden rounded-[32px] p-2 md:grid-cols-2">
<MotionDialog
layout
transition={transition}
className={`h-auto min-h-0 w-full max-h-[calc(100dvh-48px)] gap-0 overflow-y-auto rounded-[32px] p-2 ${wide ? "max-w-[1120px] aspect-[28/19]" : "max-w-[600px]"}`}
>
<div
aria-hidden="true"
className="relative h-32 shrink-0 overflow-hidden rounded-[24px] sm:h-36"
>
<img
src={artwork}
alt=""
className="absolute inset-0 size-full object-cover"
/>
</div>
<Modal.CloseTrigger
aria-label={t("common.actions.close")}
className="z-20"
/>
<div className="flex min-h-0 items-center justify-center overflow-y-auto px-6 py-10 sm:px-10">
<div className="my-auto w-full max-w-[340px] space-y-7">
<Modal.Header className="items-center text-center">
<Modal.Heading className="text-4xl font-semibold tracking-tight">
{user ? "Your account" : "Welcome back"}
</Modal.Heading>
<Description className="text-sm">
{user
? user.email
: reason === "share"
? "Sign in to share your recordings."
: "Sign in to your Recordly account."}
</Description>
</Modal.Header>
<motion.div
layout="position"
transition={transition}
className="flex flex-1 flex-col justify-center px-6 py-7 sm:px-10"
>
<div className="w-full max-w-[420px] self-center space-y-5 @container">
<motion.div {...reveal}>
<Modal.Header className="items-center gap-4 text-center">
<div
className="flex items-center gap-3"
aria-label="Recordly"
>
<img
src={`${import.meta.env.BASE_URL}app-icons/recordly-128.png`}
alt=""
className="size-12 rounded-xl"
/>
<span className="text-3xl font-semibold tracking-tight">
Recordly
</span>
</div>
<Modal.Heading className="whitespace-nowrap text-[clamp(12px,5cqw,22px)] font-semibold leading-tight tracking-tight">
{user
? "Your account"
: "Beautiful, shareable screen recordings"}
</Modal.Heading>
{user && (
<Description className="text-sm">
{user.email}
</Description>
)}
</Modal.Header>
</motion.div>
{user ? (
<Button
variant="secondary"
@@ -162,10 +209,18 @@ export function RecordlySignInDialog({
</Button>
) : (
<>
<div className="grid grid-cols-2 gap-3">
<motion.div
{...reveal}
transition={{
...transition,
delay: reduceMotion ? 0 : 0.05,
}}
className="flex flex-col items-center gap-3"
>
<Button
variant="secondary"
className="h-11 w-full rounded-xl"
size="lg"
className="w-full gap-3"
isDisabled={disabled || !configured}
onPress={() =>
void run("google", () =>
@@ -173,12 +228,17 @@ export function RecordlySignInDialog({
)
}
>
<GoogleLogo className="size-5" />
Google
<img
src={`${import.meta.env.BASE_URL}auth/google-logo.png`}
alt=""
className="size-5 shrink-0"
/>
Continue with Google
</Button>
<Button
variant="secondary"
className="h-11 w-full rounded-xl"
size="lg"
className="w-full gap-3"
isDisabled={disabled || !configured}
onPress={() =>
void run("azure", () =>
@@ -186,10 +246,14 @@ export function RecordlySignInDialog({
)
}
>
<WindowsLogo className="size-5" />
Microsoft
<img
src={`${import.meta.env.BASE_URL}auth/microsoft-logo.svg`}
alt=""
className="size-5 shrink-0"
/>
Continue with Microsoft
</Button>
</div>
</motion.div>
<div className="flex items-center gap-4">
<Separator className="flex-1" />
<span className="text-xs text-muted">
@@ -197,10 +261,7 @@ export function RecordlySignInDialog({
</span>
<Separator className="flex-1" />
</div>
<Form
className="flex flex-col gap-5"
onSubmit={submitEmail}
>
<Form className="flex flex-col" onSubmit={submitEmail}>
<TextField
name="email"
type="email"
@@ -215,54 +276,76 @@ export function RecordlySignInDialog({
>
<Label>Email</Label>
<Input
className="h-12 rounded-xl bg-default/50 shadow-none"
className="h-12"
placeholder="you@example.com"
autoComplete="email"
/>
<FieldError />
</TextField>
{expanded && (
<div className="flex flex-col gap-5">
<TextField
name="password"
type="password"
value={password}
onChange={setPassword}
isRequired
isDisabled={disabled}
<AnimatePresence initial={false}>
{expanded && (
<motion.div
key="password-fields"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={transition}
className="overflow-hidden"
inert={!expanded}
>
<Label>Password</Label>
<Input
className="h-12 rounded-xl bg-default/50 shadow-none"
autoComplete="current-password"
/>
<FieldError />
</TextField>
{configured && (
<Button
variant="ghost"
size="sm"
className="-mt-3 self-end"
isDisabled={disabled}
onPress={forgotPassword}
<motion.div
initial={{ y: reduceMotion ? 0 : -8 }}
animate={{ y: 0 }}
exit={{ y: reduceMotion ? 0 : -8 }}
transition={transition}
className="flex flex-col gap-5 px-1 pb-1 pt-5"
>
{t("editor.cloud.forgotPassword")}
</Button>
)}
<Button
type="submit"
className="h-12 w-full rounded-xl"
isDisabled={
disabled ||
(!configured && !demoLoginEnabled)
}
>
{busy === "email"
? t("editor.cloud.signingIn")
: "Sign in"}
</Button>
</div>
)}
<TextField
name="password"
type="password"
value={password}
onChange={setPassword}
isRequired
isDisabled={disabled}
>
<Label>Password</Label>
<Input
className="h-12"
autoComplete="current-password"
/>
<FieldError />
</TextField>
{configured && (
<Button
variant="ghost"
size="sm"
className="-mt-3 self-end"
isDisabled={disabled}
onPress={forgotPassword}
>
{t(
"editor.cloud.forgotPassword",
)}
</Button>
)}
<Button
type="submit"
size="lg"
className="w-full"
isDisabled={
disabled ||
(!configured &&
!demoLoginEnabled)
}
>
{busy === "email"
? t("editor.cloud.signingIn")
: "Sign in"}
</Button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</Form>
</>
)}
@@ -284,24 +367,8 @@ export function RecordlySignInDialog({
</Description>
)}
</div>
</div>
<div className="relative hidden min-h-0 overflow-hidden rounded-[26px] md:block">
<img
src={artwork}
alt=""
className="absolute inset-0 size-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/65 via-transparent to-black/10" />
<span className="absolute left-8 top-8 text-lg font-semibold tracking-tight text-white">
Recordly
</span>
<p className="absolute bottom-10 left-8 right-8 text-4xl font-light leading-tight tracking-tight text-white">
Make something
<br />
<strong className="font-semibold">worth sharing.</strong>
</p>
</div>
</Modal.Dialog>
</motion.div>
</MotionDialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
@@ -1,14 +1,12 @@
import type { DashboardProps } from "./types";
import { Dropdown } from "@heroui/react";
import { CaretDown, Trash, UploadSimple } from "@/components/ui/icons";
import { CaretDown, Trash } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import type { DashboardModel } from "./useDashboardModel";
export function DashboardFilters({
onImportFile,
run,
isRaw,
period,
setPeriod,
@@ -23,8 +21,6 @@ export function DashboardFilters({
setConfirmDelete,
}: Pick<
DashboardModel & DashboardProps,
| "onImportFile"
| "run"
| "isRaw"
| "period"
| "setPeriod"
@@ -74,16 +70,6 @@ export function DashboardFilters({
<Trash weight="fill" className="size-4" />
</Button>
</div>
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => void run(onImportFile)}
className="h-7 shrink-0 gap-2 text-xs"
>
<UploadSimple className="size-3.5" />
Import
</Button>
<div className="ml-auto">
<Dropdown>
<Button
@@ -1,3 +1,4 @@
import { RecordNewButton } from "./RecordNewButton";
import { RawPreview } from "./RawRecordings";
import { Cloud, ImageSquare } from "@/components/ui/icons";
@@ -89,7 +90,7 @@ export function DashboardGrid({
) : visible.length ? (
<ul
aria-label={isRaw ? "Raw files" : "Your projects"}
className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-x-7 gap-y-10 lg:gap-x-9"
className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-x-7 gap-y-10 lg:gap-x-9"
>
{visible.map((entry) => (
<ProjectCard
@@ -114,7 +115,13 @@ export function DashboardGrid({
</ul>
) : (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<ImageSquare weight="fill" className="size-8 opacity-30" />
{!isRaw && !query ? (
<span aria-hidden="true" className="text-4xl">
🦗
</span>
) : (
<ImageSquare weight="fill" className="size-8 opacity-30" />
)}
<p>
{isRaw
? rawLoading
@@ -123,8 +130,11 @@ export function DashboardGrid({
(query ? "No matching raw files" : "No raw recordings yet")
: query
? "No matching projects"
: "No projects yet"}
: "It's looking empty in here..."}
</p>
{!isRaw && !query && (
<RecordNewButton busy={busy} run={run} first className="mt-2" />
)}
{isRaw && rawError && (
<Button onClick={() => void refreshRaw()}>Retry</Button>
)}
@@ -1,3 +1,4 @@
import { RecordNewButton } from "./RecordNewButton";
import { SidebarCards } from "./SidebarCards";
import { FolderRow } from "./FolderRow";
import { Cloud, File, GearSix, House, Plus, UserCircle } from "@/components/ui/icons";
@@ -10,6 +11,8 @@ import type { DashboardModel } from "./useDashboardModel";
import { FOLDER_COLORS } from "./useProjectFolders";
export function DashboardSidebar({
busy,
run,
folders,
save,
section,
@@ -21,6 +24,8 @@ export function DashboardSidebar({
accountLabel,
}: Pick<
DashboardProps & DashboardModel,
| "busy"
| "run"
| "folders"
| "save"
| "section"
@@ -37,7 +42,7 @@ export function DashboardSidebar({
aria-label="Library navigation"
className="flex w-48 shrink-0 flex-col bg-transparent px-4 pb-5 pt-12 lg:w-56"
>
<div className="mb-9 flex h-10 items-center gap-2.5 px-3">
<div className="mb-4 flex h-10 items-center gap-2.5 px-3">
<img
src={`${import.meta.env.BASE_URL}app-icons/recordly-64.png`}
alt=""
@@ -45,6 +50,7 @@ export function DashboardSidebar({
/>
<span className="text-[15px] font-semibold tracking-tight">Recordly</span>
</div>
<RecordNewButton busy={busy} run={run} className="mb-5 w-full" />
<nav className="space-y-1">
<Button
variant="ghost"
@@ -1,4 +1,4 @@
import { MagnifyingGlass, Plus } from "@/components/ui/icons";
import { MagnifyingGlass, UploadSimple } from "@/components/ui/icons";
import { type CSSProperties } from "react";
import { Button } from "@/components/ui/button";
@@ -9,12 +9,16 @@ import type { DashboardProps } from "./types";
import type { DashboardModel } from "./useDashboardModel";
export function DashboardToolbar({
onImportFile,
isRaw,
query,
setQuery,
run,
busy,
}: Pick<DashboardProps & DashboardModel, "query" | "setQuery" | "run" | "busy" | "isRaw">) {
}: Pick<
DashboardProps & DashboardModel,
"onImportFile" | "query" | "setQuery" | "run" | "busy" | "isRaw"
>) {
return (
<>
<header
@@ -34,16 +38,16 @@ export function DashboardToolbar({
className="h-11 w-full border-0 bg-default/30 pl-10 shadow-none"
/>
</div>
<Button
variant="default"
onClick={() => void run(() => window.electronAPI.showRecordingHud())}
disabled={busy}
className="h-10 gap-2 text-[13px]"
style={{ WebkitAppRegion: "no-drag" } as CSSProperties}
>
<Plus className="size-4" />
New
</Button>
<div style={{ WebkitAppRegion: "no-drag" } as CSSProperties}>
<Button
disabled={busy}
onClick={() => void run(onImportFile)}
className="h-10 shrink-0 gap-2 text-[13px]"
>
<UploadSimple className="size-4" />
Import
</Button>
</div>
</header>
</>
);
@@ -0,0 +1,27 @@
import { Plus } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function RecordNewButton({
busy,
run,
className,
first = false,
}: {
busy: boolean;
run: (action: () => Promise<void>) => Promise<void>;
className?: string;
first?: boolean;
}) {
return (
<Button
variant="default"
disabled={busy}
onClick={() => void run(() => window.electronAPI.showRecordingHud())}
className={cn("h-10 shrink-0 gap-2 text-[13px]", className)}
>
<Plus className="size-4" />
{first ? "Record your first video" : "Record new"}
</Button>
);
}
@@ -184,7 +184,7 @@ export function EditorHeader(props: Props) {
</Button>
)}
</div>
<Separator orientation="vertical" className="mx-3 h-5 shrink-0" />
<Separator orientation="vertical" className="mx-3 h-5 shrink-0 self-center" />
<Button
variant="secondary"
size="sm"
@@ -202,7 +202,7 @@ export function EditorHeader(props: Props) {
style={{ WebkitAppRegion: "no-drag" } as CSSProperties}
>
<div className="flex items-center gap-1">
<div className="mx-2 h-4 w-px shrink-0 bg-separator" />
<Separator orientation="vertical" className="mx-3 h-5 shrink-0 self-center" />
<Button
type="button"
variant="ghost"
@@ -11,7 +11,6 @@ import {
type SpringState,
stepSpringValue,
} from "./motionSmoothing";
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
import { computeRegionStrength, findDominantRegion } from "./zoomRegionUtils";
// ---------------------------------------------------------------------------
@@ -333,46 +332,12 @@ describe("computeRegionStrength", () => {
it("falls smoothly during zoom-out", () => {
// Sample the zoom-out ramp after the animation delay.
const zoomOutStart = region.endMs - 500;
const zoomOutStart = region.endMs - 150;
const s = computeRegionStrength(region, zoomOutStart + 700);
expect(s).toBeGreaterThan(0);
expect(s).toBeLessThan(1);
});
it("removes 500ms of hold without changing either ramp", () => {
const entranceStart = region.startMs + 1000 - ZOOM_IN_TRANSITION_WINDOW_MS + 500;
const exitStart = region.endMs - 500;
for (const fraction of [0, 0.25, 0.5, 0.75, 1]) {
expect(
computeRegionStrength(
region,
entranceStart + fraction * ZOOM_IN_TRANSITION_WINDOW_MS,
),
).toBeCloseTo(easeOutZoom(fraction));
expect(
computeRegionStrength(region, exitStart + fraction * TRANSITION_WINDOW_MS),
).toBeCloseTo(1 - easeOutZoom(fraction));
}
expect(computeRegionStrength(region, exitStart - 1)).toBe(1);
});
it("only removes the available hold for short blocks", () => {
const short = { ...region, endMs: 3700 };
// The original 200ms plateau is removed; neither ramp is shortened.
expect(computeRegionStrength(short, 3500)).toBe(1);
expect(computeRegionStrength(short, 3500 + TRANSITION_WINDOW_MS / 2)).toBeCloseTo(
1 - easeOutZoom(0.5),
);
});
it("uses the shortened exit for the final zoom in a connected sequence", () => {
const first = { ...region, id: "first", startMs: 0, endMs: 2000 };
const time = region.endMs - 500 + TRANSITION_WINDOW_MS / 2;
expect(
findDominantRegion([first, region], time, { connectZooms: true }).strength,
).toBeCloseTo(1 - easeOutZoom(0.5));
});
it("shifts zoom timing when custom durations are provided", () => {
const defaultStrength = computeRegionStrength(region, region.startMs);
const fasterStrength = computeRegionStrength(region, region.startMs, {
@@ -13,7 +13,6 @@ const CONNECTED_ZOOM_PAN_DURATION_MS = 1000;
const ZOOM_IN_OVERLAP_MS = 1000;
// Playback offset relative to timeline blocks; positive values delay the animation.
const ZOOM_ANIMATION_DELAY_MS = 500;
const ZOOM_HOLD_REDUCTION_MS = 500;
type DominantRegionOptions = {
connectZooms?: boolean;
@@ -36,12 +35,14 @@ type ConnectedPanTransition = {
endScale: number;
};
function getRegionTiming(
export function computeRegionStrength(
region: ZoomRegion,
timeMs: number,
options: Pick<DominantRegionOptions, "zoomInDurationMs" | "zoomOutDurationMs"> = {},
) {
const zoomInDurationMs = Math.max(1, options.zoomInDurationMs ?? ZOOM_IN_TRANSITION_WINDOW_MS);
const zoomOutDurationMs = Math.max(1, options.zoomOutDurationMs ?? TRANSITION_WINDOW_MS);
const adjustedTimeMs = timeMs - ZOOM_ANIMATION_DELAY_MS;
const leadInStart = region.startMs + ZOOM_IN_OVERLAP_MS - ZOOM_IN_TRANSITION_WINDOW_MS;
let zoomOutStart = region.endMs - ZOOM_OUT_EARLY_START_MS;
let zoomInEnd = leadInStart + zoomInDurationMs;
@@ -52,19 +53,6 @@ function getRegionTiming(
zoomOutStart = midpoint;
}
// Consume only the plateau: keep both animation durations and the entrance fixed.
zoomOutStart = Math.max(zoomInEnd, zoomOutStart - ZOOM_HOLD_REDUCTION_MS);
return { leadInStart, zoomInEnd, zoomOutStart, zoomInDurationMs, zoomOutDurationMs };
}
export function computeRegionStrength(
region: ZoomRegion,
timeMs: number,
options: Pick<DominantRegionOptions, "zoomInDurationMs" | "zoomOutDurationMs"> = {},
) {
const { leadInStart, zoomInEnd, zoomOutStart, zoomInDurationMs, zoomOutDurationMs } =
getRegionTiming(region, options);
const adjustedTimeMs = timeMs - ZOOM_ANIMATION_DELAY_MS;
const leadOutEnd = zoomOutStart + zoomOutDurationMs;
if (adjustedTimeMs < leadInStart || adjustedTimeMs > leadOutEnd) {
@@ -128,7 +116,8 @@ function getActiveRegion(
}
const zoomOutStart =
getRegionTiming(outgoingPair.currentRegion, options).zoomOutStart +
outgoingPair.currentRegion.endMs -
ZOOM_OUT_EARLY_START_MS +
ZOOM_ANIMATION_DELAY_MS;
if (timeMs >= zoomOutStart) {
return { region, strength: 1 };
@@ -142,7 +131,8 @@ function getActiveRegion(
}
const nextRegionZoomOutStart =
getRegionTiming(incomingPair.nextRegion, options).zoomOutStart +
incomingPair.nextRegion.endMs -
ZOOM_OUT_EARLY_START_MS +
ZOOM_ANIMATION_DELAY_MS;
if (timeMs < nextRegionZoomOutStart) {
return { region, strength: 1 };
+11 -14
View File
@@ -136,15 +136,19 @@
}
}
/* Visible keyboard focus for HeroUI controls and native focusable elements. */
/* HeroUI owns control focus rings; native controls use the same accent token.
Do not outline dialogs or other programmatically focused containers. */
:root,
.dark {
--focus: var(--foreground);
--focus: var(--accent);
}
[data-focus-visible="true"],
:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
:focus-visible:not([data-rac]):not([role="dialog"]):not([role="alertdialog"]):not([tabindex="-1"]) {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
::selection {
background: var(--accent-soft);
color: var(--foreground);
}
@container (max-width: 110px) {
@@ -184,13 +188,6 @@
}
}
/* Keep dashboard focus visible without the oversized default halo. */
.dashboard-surface :is(button, input, [role="combobox"]):focus-visible,
.dashboard-surface [data-focus-visible="true"] {
outline: 1px solid var(--accent);
outline-offset: 2px;
box-shadow: none;
}
.dashboard-surface .folder-row > button {
background: transparent;
}
@@ -205,7 +202,7 @@
color: inherit;
}
.inline-project-name:focus { outline: none; box-shadow: none; }
.inline-project-name:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.inline-project-name:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.dashboard-shell {
background-color: color-mix(in srgb, var(--background) 92%, var(--foreground) 8%);
+10
View File
@@ -69,6 +69,16 @@ test("header breadcrumb fits long names, native chrome and compact windows", asy
"padding-left",
trafficLightsVisible ? "76px" : "0px",
);
const header = await page.locator(".editor-header").boundingBox();
const dividers = await page.locator(".editor-header").getByRole("separator").all();
expect(dividers).toHaveLength(2);
for (const divider of dividers) {
const box = await divider.boundingBox();
expect(box!.height).toBe(20);
expect(
Math.abs(box!.y + box!.height / 2 - (header!.y + header!.height / 2)),
).toBeLessThanOrEqual(1);
}
const stage = await page.locator(".editor-preview-stage").boundingBox();
const frame = await page.locator(".editor-preview-frame").boundingBox();
expect(
+78 -5
View File
@@ -1,18 +1,62 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
test("split login expands email, rejects incorrect credentials, and persists the local account until sign-out", async ({
test("banner login animates email expansion, rejects incorrect credentials, and persists the local account until sign-out", async ({
page,
}) => {
await installDesktopBridge(page);
await page.goto("/?windowType=editor");
await page.getByRole("button", { name: "Home", exact: true }).click();
await page.getByRole("button", { name: "Sign in", exact: true }).click();
const login = page.getByRole("dialog", { name: "Welcome back", exact: true });
await expect(login.getByRole("button", { name: "Google", exact: true })).toBeVisible();
await expect(login.getByRole("button", { name: "Microsoft", exact: true })).toBeVisible();
const login = page.getByRole("dialog", {
name: "Beautiful, shareable screen recordings",
exact: true,
});
await expect(
login.getByRole("button", { name: "Continue with Google", exact: true }),
).toBeVisible();
await expect(
login.getByRole("button", { name: "Continue with Microsoft", exact: true }),
).toBeVisible();
await expect(login.getByLabel("Password", { exact: true })).toHaveCount(0);
await expect(login.getByText("Recordly", { exact: true })).toBeVisible();
await expect(login.getByText("worth sharing.", { exact: false })).toHaveCount(0);
for (const provider of ["Google", "Microsoft"]) {
await expect
.poll(() =>
login
.getByRole("button", { name: `Continue with ${provider}`, exact: true })
.locator("img")
.evaluate((image: HTMLImageElement) => image.naturalWidth),
)
.toBeGreaterThan(0);
}
const heading = await login.getByRole("heading").boundingBox();
const textSize = await login.getByRole("heading").evaluate((element) => ({
height: element.clientHeight,
width: element.clientWidth,
scrollWidth: element.scrollWidth,
lineHeight: parseFloat(getComputedStyle(element).lineHeight),
}));
expect(textSize.height).toBeLessThanOrEqual(textSize.lineHeight + 1);
expect(textSize.scrollWidth).toBeLessThanOrEqual(textSize.width);
const artwork = await login.locator('img[src$="wallpaper1.jpg"]').boundingBox();
expect(artwork!.y + artwork!.height).toBeLessThan(heading!.y);
await page.keyboard.press("Tab");
await login.evaluate((element: HTMLElement) => element.focus());
await expect(login).toHaveCSS("outline-style", "none");
await page.screenshot({ path: "test-results/login.png", animations: "disabled" });
const collapsed = (await login.boundingBox())!.height;
await login.getByLabel("Email", { exact: true }).fill("test@email.com");
await expect
.poll(async () => (await login.boundingBox())!.height)
.toBeGreaterThan(collapsed + 80);
await login.getByLabel("Email", { exact: true }).fill("");
await expect(login.getByLabel("Password", { exact: true })).toHaveCount(0);
await expect
.poll(async () => Math.abs((await login.boundingBox())!.height - collapsed))
.toBeLessThan(2);
await login.getByLabel("Email", { exact: true }).fill("test@email.com");
await login.getByLabel("Password", { exact: true }).fill("incorrect");
await login.getByRole("button", { name: "Sign in", exact: true }).click();
@@ -20,14 +64,43 @@ test("split login expands email, rejects incorrect credentials, and persists the
await login.getByLabel("Password", { exact: true }).fill("1234");
await page.screenshot({ path: "test-results/login-expanded.png", animations: "disabled" });
await page.setViewportSize({ width: 800, height: 600 });
await login
.getByRole("heading", { name: "Beautiful, shareable screen recordings", exact: true })
.scrollIntoViewIfNeeded();
await expect(
login.getByRole("heading", { name: "Beautiful, shareable screen recordings", exact: true }),
).toBeInViewport();
await login.getByRole("button", { name: "Sign in", exact: true }).scrollIntoViewIfNeeded();
await expect(login.getByRole("button", { name: "Sign in", exact: true })).toBeInViewport();
await login.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(login).not.toBeVisible();
await expect(page.getByRole("dialog", { name: "Projects dashboard" })).toBeVisible();
await page.reload();
await page.getByRole("button", { name: "Home", exact: true }).click();
await page.getByRole("button", { name: "test@email.com", exact: true }).click();
const account = page.getByRole("dialog", { name: "Your account", exact: true });
await expect(account.getByText("test@email.com")).toBeVisible();
await account.getByRole("button", { name: "Sign out", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Welcome back" })).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Beautiful, shareable screen recordings" }),
).toBeVisible();
});
test("login banner and email expansion work with reduced motion", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await installDesktopBridge(page);
await page.goto("/?windowType=editor");
await page.getByRole("button", { name: "Recordly account", exact: true }).click();
const login = page.getByRole("dialog", {
name: "Beautiful, shareable screen recordings",
exact: true,
});
await login.getByLabel("Email", { exact: true }).fill("test@email.com");
await expect(login.getByLabel("Password", { exact: true })).toBeVisible();
await login.getByLabel("Email", { exact: true }).fill("");
await expect(login.getByLabel("Password", { exact: true })).toHaveCount(0);
await login.getByLabel("Email", { exact: true }).fill("test@email.com");
await login.getByLabel("Password", { exact: true }).fill("1234");
await login.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(login).not.toBeVisible();
});
+18 -4
View File
@@ -55,8 +55,10 @@ test("home dashboard searches, sorts, opens projects and returns to the editor",
await home.getByRole("button", { name: "Sort projects" }).click();
await page.getByRole("menuitem", { name: "Name", exact: true }).click();
await expect(cards.nth(1)).toHaveAccessibleName("Getting started");
const normalCardWidth = (await cards.first().boundingBox())!.width;
await home.getByRole("textbox", { name: "Search projects" }).fill("launch");
await expect(cards).toHaveCount(1);
expect(Math.abs((await cards.first().boundingBox())!.width - normalCardWidth)).toBeLessThan(1);
await home.getByRole("textbox", { name: "Search projects" }).fill("missing");
await expect(home.getByText("No matching projects")).toBeVisible();
await home.getByRole("button", { name: "Clear search" }).click();
@@ -102,7 +104,7 @@ test("home dashboard searches, sorts, opens projects and returns to the editor",
await home.getByRole("button", { name: "Settings", exact: true }).click();
await expect(home.getByRole("region", { name: "Dashboard settings" })).toBeVisible();
await home.getByRole("button", { name: "Home", exact: true }).click();
await home.getByRole("button", { name: "New", exact: true }).click();
await home.getByRole("complementary", { name: "Library navigation" }).getByRole("button", { name: "Record new", exact: true }).click();
await expect(page.locator("html")).toHaveAttribute("data-hud-opened", "true");
await home.getByRole("button", { name: "Select projects to delete" }).click();
await cards.first().click();
@@ -136,7 +138,19 @@ test("home dashboard explains an empty library", async ({ page }) => {
await installDesktopBridge(page);
await page.goto("/?windowType=editor");
await page.getByRole("button", { name: "Home", exact: true }).click();
await expect(page.getByText("No projects yet")).toBeVisible();
await expect(page.getByText("It's looking empty in here...")).toBeVisible();
await expect(page.getByRole("button", { name: "Record new", exact: true })).toHaveCount(1);
await page.getByRole("button", { name: "Record your first video", exact: true }).click();
await expect(page.locator("html")).toHaveAttribute("data-hud-opened", "true");
await page.evaluate(() => {
delete document.documentElement.dataset.hudOpened;
});
await page
.getByRole("complementary", { name: "Library navigation" })
.getByRole("button", { name: "Record new", exact: true })
.click();
await expect(page.locator("html")).toHaveAttribute("data-hud-opened", "true");
await page.screenshot({ path: "test-results/dashboard-empty.png", animations: "disabled" });
await expect(page.getByRole("button", { name: "Back to editor" })).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "Rename project" })).toBeVisible();
@@ -694,8 +708,8 @@ test("sidebar cards, separate Import, and shortcut settings use the dashboard fl
const importButton = home.getByRole("button", { name: "Import", exact: true });
const all = home.getByRole("button", { name: "All", exact: true });
expect(
Math.abs((await importButton.boundingBox())!.y - (await all.boundingBox())!.y),
).toBeLessThan(2);
(await importButton.boundingBox())!.y - (await all.boundingBox())!.y,
).toBeLessThan(0);
expect(await all.evaluate((element) => element.parentElement!.textContent)).not.toContain(
"Import",
);