[^<]*<\/title>/, `${escapeHtml(meta.title)} `)
+ .replace(/ ]*>/, "")
+ .replace("", `${tags}\n`)
+ .replace('
', `${body}
`);
+ const folder = resolve(root, "dist", path.slice(1));
+ await mkdir(folder, {recursive:true});
+ await writeFile(resolve(folder, "index.html"), html);
+ }
+ const entries = ["/", ...blogPaths].map((path) => `${escapeHtml(`${origin}${path}`)} `).join("\n");
+ await writeFile(resolve(root, "dist/sitemap.xml"), `\n${entries} \n`);
+ console.log(`Prerendered ${blogPaths.length} blog pages with metadata, article content, and sitemap.`);
+} finally {
+ await server.close();
+ await rm(cacheDir, {recursive:true, force:true});
+}
diff --git a/aside-react/src/ActionWord.jsx b/aside-react/src/ActionWord.jsx
new file mode 100644
index 00000000..b9b5cc99
--- /dev/null
+++ b/aside-react/src/ActionWord.jsx
@@ -0,0 +1,12 @@
+import React from "react";
+
+const actionEmojis = { capture: "🎥", edit: "✂️", share: "📤" };
+
+export default function ActionWord({ action, children }) {
+ return (
+
+ {actionEmojis[action]}
+ {children}
+
+ );
+}
diff --git a/aside-react/src/BentoArtwork.jsx b/aside-react/src/BentoArtwork.jsx
new file mode 100644
index 00000000..26a1dd6e
--- /dev/null
+++ b/aside-react/src/BentoArtwork.jsx
@@ -0,0 +1,16 @@
+import React from "react";
+import SharingIllustration from "./SharingIllustration";
+import { WebcamIllustration, CaptionsIllustration, ExportIllustration, CloudIllustration } from "./FeatureIllustrations";
+
+const illustrations = {
+ webcam: WebcamIllustration,
+ captions: CaptionsIllustration,
+ export: ExportIllustration,
+ sharing: SharingIllustration,
+ cloud: CloudIllustration,
+};
+
+export default function BentoArtwork({ feature }) {
+ const Illustration = illustrations[feature];
+ return Illustration ? : null;
+}
diff --git a/aside-react/src/DemoVideo.jsx b/aside-react/src/DemoVideo.jsx
new file mode 100644
index 00000000..d399acfd
--- /dev/null
+++ b/aside-react/src/DemoVideo.jsx
@@ -0,0 +1,56 @@
+import React, { useEffect, useRef } from "react";
+
+export default function DemoVideo({ src, poster, label, className, width = 1920, height = 1198, enabled = true, controls = true, onPlaybackFrame }) {
+ const videoRef = useRef(null);
+ // biome-ignore lint/correctness/useExhaustiveDependencies: a new source needs a fresh playback-frame subscription.
+ useEffect(() => {
+ if (!onPlaybackFrame) return;
+ const video = videoRef.current;
+ let frame;
+ const sync = () => onPlaybackFrame(video.currentTime, video.duration);
+ const stop = () => {
+ cancelAnimationFrame(frame);
+ sync();
+ };
+ const tick = () => {
+ sync();
+ if (!video.paused && !video.ended) frame = requestAnimationFrame(tick);
+ };
+ const start = () => { cancelAnimationFrame(frame); tick(); };
+ const events = { play: start, pause: stop, ended: stop, loadedmetadata: sync, durationchange: sync, seeked: sync };
+ for (const [event, handler] of Object.entries(events)) video.addEventListener(event, handler);
+ start();
+ return () => {
+ cancelAnimationFrame(frame);
+ for (const [event, handler] of Object.entries(events)) video.removeEventListener(event, handler);
+ };
+ }, [src, onPlaybackFrame]);
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-evaluate visibility playback when the video source changes.
+ useEffect(() => {
+ const video = videoRef.current;
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
+ let inView = false;
+ const updatePlayback = () => {
+ if (!enabled || !inView || document.hidden || reducedMotion.matches) video.pause();
+ else video.play().catch(() => {
+ // Autoplay may be blocked; the video controls still allow manual playback.
+ });
+ };
+ const observer = new IntersectionObserver(([entry]) => {
+ inView = entry.isIntersecting && entry.intersectionRatio >= .1;
+ updatePlayback();
+ }, { threshold: .1 });
+ observer.observe(video);
+ reducedMotion.addEventListener("change", updatePlayback);
+ document.addEventListener("visibilitychange", updatePlayback);
+ return () => {
+ observer.disconnect();
+ reducedMotion.removeEventListener("change", updatePlayback);
+ document.removeEventListener("visibilitychange", updatePlayback);
+ };
+ }, [src, enabled]);
+ return
+ Watch {label}
+ ;
+}
diff --git a/aside-react/src/FeatureArtwork.jsx b/aside-react/src/FeatureArtwork.jsx
new file mode 100644
index 00000000..e3e05841
--- /dev/null
+++ b/aside-react/src/FeatureArtwork.jsx
@@ -0,0 +1,29 @@
+import React from "react";
+import ShareArtwork from "./ShareArtwork";
+import DemoVideo from "./DemoVideo";
+
+export default function FeatureArtwork({ feature, active = true }) {
+ if (feature === "Automatic zooms" || feature === "Smooth cursor") {
+ const name = feature === "Automatic zooms" ? "auto-zoom" : "cursor";
+ return
+
+
;
+ }
+ if (feature === "Record") return (
+
+
+
+ );
+ if (feature === "Preview") return (
+
+
+
+ );
+ if (feature === "Share" || feature === "Share anywhere") return (
+
+
+
+ );
+ return
;
+}
diff --git a/aside-react/src/FeatureBento.css b/aside-react/src/FeatureBento.css
new file mode 100644
index 00000000..8f9bca4c
--- /dev/null
+++ b/aside-react/src/FeatureBento.css
@@ -0,0 +1,139 @@
+.feature-bento {
+ margin-inline: 40px;
+}
+
+.feature-bento-heading {
+ max-width: 640px;
+ margin: 0 auto 40px;
+ text-align: center;
+}
+
+.feature-bento-heading h2 {
+ font-size: clamp(30px, 4vw, 42px);
+ text-wrap: balance;
+}
+
+.feature-bento-heading p {
+ margin-top: 16px;
+ color: var(--muted);
+ font-size: 18px;
+}
+
+.feature-bento-grid {
+ display: grid;
+ grid-template-columns: repeat(12, minmax(0, 1fr));
+ grid-auto-rows: minmax(400px, auto);
+ gap: 20px;
+}
+
+.feature-bento-card {
+ display: flex;
+ flex-direction: column;
+ grid-column: span 4;
+ min-width: 0;
+ overflow: hidden;
+ border: 1px solid #e7e9ed;
+ border-radius: 14px;
+ background: #fff;
+ box-shadow: 0 4px 14px #19212e03;
+}
+
+.feature-bento-card:first-child {
+ grid-column: span 7;
+}
+
+.feature-bento-card:nth-child(2) {
+ grid-column: span 5;
+}
+
+.feature-bento-copy {
+ min-width: 0;
+ flex-shrink: 0;
+ padding: 0 28px 28px;
+}
+
+.feature-bento-copy h3 {
+ font-size: 24px;
+ font-weight: 500;
+ line-height: 1.25;
+}
+
+.feature-bento-copy p {
+ max-width: 540px;
+ margin-top: 10px;
+ color: #737985;
+ font-size: 16px;
+ line-height: 1.5;
+}
+
+.feature-bento-media {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ contain: size;
+ flex: 1 1 0;
+ min-height: 220px;
+ overflow: hidden;
+ margin: 18px 18px 12px;
+}
+
+@media (max-width: 900px) {
+ .feature-bento {
+ margin-inline: 24px;
+ }
+
+ .feature-bento-grid {
+ grid-auto-rows: minmax(360px, auto);
+ gap: 16px;
+ }
+
+ .feature-bento-copy {
+ padding: 0 22px 24px;
+ }
+
+ .feature-bento-copy h3 {
+ font-size: 22px;
+ }
+
+ .feature-bento-media {
+ min-height: 190px;
+ margin: 16px 12px 10px;
+ }
+}
+
+@media (max-width: 620px) {
+ .feature-bento {
+ margin-inline: 16px;
+ }
+
+ .feature-bento-heading {
+ margin-bottom: 28px;
+ }
+
+ .feature-bento-heading p {
+ font-size: 16px;
+ }
+
+ .feature-bento-grid {
+ grid-template-columns: minmax(0, 1fr);
+ grid-auto-rows: auto;
+ gap: 14px;
+ }
+
+ .feature-bento-card,
+ .feature-bento-card:first-child,
+ .feature-bento-card:nth-child(2) {
+ grid-column: auto;
+ }
+
+ .feature-bento-copy {
+ padding: 0 22px 24px;
+ }
+
+ .feature-bento-media {
+ contain: none;
+ flex: none;
+ min-height: 0;
+ margin: 16px 12px 12px;
+ }
+}
diff --git a/aside-react/src/FeatureHighlights.css b/aside-react/src/FeatureHighlights.css
new file mode 100644
index 00000000..43c050ed
--- /dev/null
+++ b/aside-react/src/FeatureHighlights.css
@@ -0,0 +1,141 @@
+.recording-showcase {
+ height: 240svh;
+ margin-inline: 40px;
+ padding-bottom: 72px;
+}
+
+.showcase-stage {
+ position: sticky;
+ top: 10svh;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.85fr);
+ grid-template-areas: "features video";
+ align-items: center;
+ gap: 40px;
+ height: 80svh;
+}
+
+.showcase-player {
+ grid-area: video;
+ min-width: 0;
+}
+
+.showcase-example {
+ display: block;
+ width: 100%;
+ height: auto;
+ aspect-ratio: 1920 / 1222;
+ object-fit: contain;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: #fff;
+}
+
+.showcase-features {
+ grid-area: features;
+ --feature-height: 240px;
+ --feature-position: 0;
+ min-width: 0;
+}
+
+.showcase-feature-window {
+ height: var(--feature-height);
+ overflow: hidden;
+}
+
+.showcase-feature-track {
+ transform: translateY(calc(var(--feature-index) * var(--feature-height) * -1));
+ transition: transform .45s ease;
+}
+
+.showcase-feature-text {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ height: var(--feature-height);
+}
+
+.showcase-feature-text h3 {
+ font-size: 32px;
+ font-weight: 500;
+ line-height: 1.2;
+}
+
+.showcase-feature-text p {
+ margin-top: 16px;
+ color: #737985;
+ font-size: 17px;
+ line-height: 1.55;
+}
+
+.showcase-feature-controls {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.showcase-feature-progress {
+ display: flex;
+ flex: 1;
+ gap: 8px;
+}
+
+.showcase-feature-selector {
+ flex: 1;
+ min-width: 0;
+ min-height: 44px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+}
+
+.showcase-progress-track {
+ display: block;
+ height: 3px;
+ overflow: hidden;
+ border-radius: 3px;
+ background: #e7e9ed;
+}
+
+.showcase-progress-track > span {
+ display: block;
+ height: 100%;
+ background: var(--blue);
+ transform: scaleX(clamp(0, calc(var(--feature-position) - var(--segment-index)), 1));
+ transform-origin: left;
+}
+
+@media (max-width: 800px) {
+ .recording-showcase {
+ margin-inline: 24px;
+ }
+
+ .showcase-stage {
+ gap: 24px;
+ }
+
+ .showcase-feature-text h3 {
+ font-size: 28px;
+ }
+}
+
+@media (max-width: 680px) {
+ .recording-showcase {
+ margin-inline: 16px;
+ padding-bottom: 48px;
+ }
+
+ .showcase-stage {
+ top: 16px;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-areas: "video" "features";
+ align-content: center;
+ height: calc(100svh - 32px);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .showcase-feature-track {
+ transition: none;
+ }
+}
diff --git a/aside-react/src/FeatureHighlights.jsx b/aside-react/src/FeatureHighlights.jsx
new file mode 100644
index 00000000..6e8e9ce2
--- /dev/null
+++ b/aside-react/src/FeatureHighlights.jsx
@@ -0,0 +1,265 @@
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import DemoVideo from "./DemoVideo";
+import { resolveShowcaseRewind } from "./showcaseScroll";
+import "./FeatureHighlights.css";
+
+// The example video stays continuous while the feature text advances independently.
+const example = {
+ video: "/assets/recordly-hero.mp4?v=20261102-redacted",
+ poster: "/assets/recordly-hero-poster.jpg?v=20261102-redacted",
+ label: "An example video made with Recordly",
+};
+
+const highlights = [
+ {
+ id: "zoom",
+ title: "Automatic zoom",
+ description: "Bring every click into focus with automatic zooms that follow the action.",
+ },
+ {
+ id: "cursor",
+ title: "Smooth cursors",
+ description: "Keep every movement easy to follow with a smooth, polished cursor.",
+ },
+ {
+ id: "background",
+ title: "Beautiful backgrounds",
+ description: "Make it yours with backgrounds, padding, shadows, and rounded corners.",
+ },
+];
+
+const FEATURE_DURATION = 6000;
+const TIMELINE_DURATION = FEATURE_DURATION * highlights.length;
+
+export default function FeatureHighlights() {
+ const sectionRef = useRef(null);
+ const stageRef = useRef(null);
+ const featuresRef = useRef(null);
+ const timelineRef = useRef(0);
+ const activeIndexRef = useRef(0);
+ const completedRef = useRef(false);
+ const fullScrollDistanceRef = useRef(1);
+ const rewindUntilRef = useRef(0);
+ const rewoundToStartRef = useRef(false);
+ const [activeIndex, setActiveIndex] = useState(0);
+ const [completed, setCompleted] = useState(false);
+ const [visible, setVisible] = useState(false);
+ const [documentVisible, setDocumentVisible] = useState(!document.hidden);
+ const [reducedMotion, setReducedMotion] = useState(false);
+
+ const releaseSection = useCallback(() => {
+ const section = sectionRef.current;
+ const stage = stageRef.current;
+ const bounds = section.getBoundingClientRect();
+ const top = Number.parseFloat(getComputedStyle(stage).top) || 0;
+ const padding = Number.parseFloat(getComputedStyle(section).paddingBottom) || 0;
+ fullScrollDistanceRef.current = Math.max(1, bounds.height - stage.offsetHeight - padding);
+ const consumed = Math.max(0, top - bounds.top);
+ // End the sticky range at its current position, preserving the stage's location.
+ section.style.height = `${Math.min(bounds.height, consumed + stage.offsetHeight + padding)}px`;
+ }, []);
+
+ const updateTimeline = useCallback((time) => {
+ const position = Math.max(0, Math.min(TIMELINE_DURATION, time));
+ const index = Math.min(highlights.length - 1, Math.floor(position / FEATURE_DURATION));
+ timelineRef.current = position;
+ // All pill fills derive from this one value, independent of React's text update.
+ featuresRef.current?.style.setProperty("--feature-position", String(position / FEATURE_DURATION));
+ if (activeIndexRef.current !== index) {
+ activeIndexRef.current = index;
+ setActiveIndex(index);
+ }
+ const finished = position === TIMELINE_DURATION;
+ if (completedRef.current !== finished) {
+ completedRef.current = finished;
+ if (finished) releaseSection();
+ else sectionRef.current.style.removeProperty("height");
+ setCompleted(finished);
+ }
+ }, [releaseSection]);
+
+ const selectFeature = useCallback((index) => {
+ rewoundToStartRef.current = false;
+ rewindUntilRef.current = 0;
+ updateTimeline(index * FEATURE_DURATION);
+ }, [updateTimeline]);
+
+ useEffect(() => {
+ const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
+ const updateMotion = () => setReducedMotion(motion.matches);
+ const updateVisibility = () => setDocumentVisible(!document.hidden);
+ const observer = new IntersectionObserver(([entry]) => {
+ setVisible(entry.isIntersecting && entry.intersectionRatio >= .25);
+ }, { threshold: .25 });
+ observer.observe(stageRef.current);
+ updateMotion();
+ motion.addEventListener("change", updateMotion);
+ document.addEventListener("visibilitychange", updateVisibility);
+ return () => {
+ observer.disconnect();
+ motion.removeEventListener("change", updateMotion);
+ document.removeEventListener("visibilitychange", updateVisibility);
+ };
+ }, []);
+
+ useEffect(() => {
+ let frame = 0;
+ let previousScrollY = window.scrollY;
+ let touchY = null;
+ const geometry = () => {
+ const section = sectionRef.current.getBoundingClientRect();
+ const stage = stageRef.current;
+ const top = Number.parseFloat(getComputedStyle(stage).top) || 0;
+ const padding = Number.parseFloat(getComputedStyle(sectionRef.current).paddingBottom) || 0;
+ const distance = Math.max(1, section.height - stage.offsetHeight - padding);
+ const progressDistance = completedRef.current ? fullScrollDistanceRef.current : distance;
+ return { distance, progressDistance, start: window.scrollY + section.top - top };
+ };
+ const holdRewind = (deltaY, scrollY = window.scrollY) => {
+ const result = resolveShowcaseRewind({ ...geometry(), scrollY, deltaY, progress: timelineRef.current / TIMELINE_DURATION });
+ if (!result) return false;
+ cancelAnimationFrame(frame);
+ frame = 0;
+ rewindUntilRef.current = performance.now() + 300;
+ updateTimeline(result.progress * TIMELINE_DURATION);
+ rewoundToStartRef.current = result.progress === 0;
+ window.scrollTo({ top: result.scrollY, behavior: "instant" });
+ previousScrollY = window.scrollY;
+ return true;
+ };
+ const updateFromScroll = () => {
+ frame = 0;
+ const scrollY = window.scrollY;
+ if (scrollY < previousScrollY && holdRewind(scrollY - previousScrollY, previousScrollY)) return;
+ const { distance, progressDistance, start } = geometry();
+ // Count only the part of this scroll that moves through the sticky section.
+ const current = Math.max(0, Math.min(distance, scrollY - start));
+ const previous = Math.max(0, Math.min(distance, previousScrollY - start));
+ previousScrollY = scrollY;
+ if (current !== previous) {
+ if (current < previous) rewindUntilRef.current = performance.now() + 300;
+ else rewoundToStartRef.current = false;
+ updateTimeline(timelineRef.current + (current - previous) / progressDistance * TIMELINE_DURATION);
+ if (current < previous && timelineRef.current === 0) rewoundToStartRef.current = true;
+ }
+ };
+ const handleIntent = (event, deltaY) => {
+ if (event.defaultPrevented || !event.cancelable || deltaY >= 0) return;
+ if (frame) {
+ cancelAnimationFrame(frame);
+ updateFromScroll();
+ }
+ const { start, distance } = geometry();
+ if (window.scrollY >= start && window.scrollY + deltaY < start + distance) {
+ rewindUntilRef.current = performance.now() + 300;
+ if (timelineRef.current === 0) rewoundToStartRef.current = true;
+ }
+ if (holdRewind(deltaY)) event.preventDefault();
+ };
+ const onWheel = (event) => {
+ if (event.ctrlKey || event.metaKey) return;
+ const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1;
+ handleIntent(event, event.deltaY * unit);
+ };
+ const onKeyDown = (event) => {
+ if (event.ctrlKey || event.metaKey || event.altKey || event.target.closest?.("input, textarea, select, [contenteditable='true']")) return;
+ if (event.key === "ArrowUp") handleIntent(event, -40);
+ else if (event.key === "PageUp") handleIntent(event, -window.innerHeight * .875);
+ else if (event.key === "Home") handleIntent(event, -window.scrollY);
+ else if (event.key === " " && event.shiftKey && !event.target.closest?.("button, a")) handleIntent(event, -window.innerHeight * .875);
+ };
+ const onTouchStart = (event) => { touchY = event.touches.length === 1 ? event.touches[0].clientY : null; };
+ const onTouchMove = (event) => {
+ if (event.touches.length !== 1 || touchY === null) return;
+ const nextY = event.touches[0].clientY;
+ handleIntent(event, touchY - nextY);
+ touchY = nextY;
+ };
+ const onTouchEnd = () => { touchY = null; };
+ const scheduleUpdate = () => {
+ if (!frame) frame = requestAnimationFrame(updateFromScroll);
+ };
+ const resetScrollBaseline = () => {
+ cancelAnimationFrame(frame);
+ frame = 0;
+ if (completedRef.current) {
+ sectionRef.current.style.removeProperty("height");
+ releaseSection();
+ }
+ previousScrollY = window.scrollY;
+ };
+ window.addEventListener("scroll", scheduleUpdate, { passive: true });
+ window.addEventListener("resize", resetScrollBaseline);
+ window.addEventListener("wheel", onWheel, { passive: false });
+ window.addEventListener("keydown", onKeyDown);
+ window.addEventListener("touchstart", onTouchStart, { passive: true });
+ window.addEventListener("touchmove", onTouchMove, { passive: false });
+ window.addEventListener("touchend", onTouchEnd, { passive: true });
+ window.addEventListener("touchcancel", onTouchEnd, { passive: true });
+ return () => {
+ cancelAnimationFrame(frame);
+ window.removeEventListener("scroll", scheduleUpdate);
+ window.removeEventListener("resize", resetScrollBaseline);
+ window.removeEventListener("wheel", onWheel);
+ window.removeEventListener("keydown", onKeyDown);
+ window.removeEventListener("touchstart", onTouchStart);
+ window.removeEventListener("touchmove", onTouchMove);
+ window.removeEventListener("touchend", onTouchEnd);
+ window.removeEventListener("touchcancel", onTouchEnd);
+ };
+ }, [updateTimeline, releaseSection]);
+
+ useEffect(() => {
+ if (completed || !visible || !documentVisible || reducedMotion) return;
+ let frame;
+ let previousTime;
+ const tick = (time) => {
+ if (previousTime !== undefined && !rewoundToStartRef.current && time >= rewindUntilRef.current) {
+ updateTimeline(timelineRef.current + time - previousTime);
+ }
+ previousTime = time;
+ if (!completedRef.current) frame = requestAnimationFrame(tick);
+ };
+ frame = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(frame);
+ }, [completed, visible, documentVisible, reducedMotion, updateTimeline]);
+
+ return (
+
+
+
+
+
+
+
+
+ {highlights.map(({ id, title, description }, index) => (
+
+ {title}
+ {description}
+
+ ))}
+
+
+
+
+ {highlights.map(({ id, title }, index) => (
+ selectFeature(index)}>
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/aside-react/src/FeatureIllustrations.css b/aside-react/src/FeatureIllustrations.css
new file mode 100644
index 00000000..57e94606
--- /dev/null
+++ b/aside-react/src/FeatureIllustrations.css
@@ -0,0 +1,196 @@
+.coded-art {
+ position: relative;
+ width: 100%;
+ max-width: 375px;
+ aspect-ratio: 3 / 2;
+ flex: none;
+ isolation: isolate;
+ container-type: inline-size;
+ font-family: "DM Sans", Arial, sans-serif;
+ font-size: 9px;
+ letter-spacing: -.02em;
+ color: #7d8a9d;
+}
+
+.coded-art-export,
+.coded-art-cloud { max-width: 330px; }
+
+.coded-art-dots {
+ position: absolute;
+ inset: 3% -3%;
+ z-index: -1;
+ background-image: radial-gradient(#d5dce6 0.3333cqw, transparent 0.3333cqw);
+ background-size: 3cqw 3cqw;
+ mask-image: radial-gradient(ellipse at center, #0008, transparent 68%);
+}
+
+.coded-art-shadow {
+ position: absolute;
+ left: 12%;
+ bottom: 11%;
+ width: 77%;
+ height: 11%;
+ z-index: -1;
+ border-radius: 50%;
+ background: #bec8d71a;
+ filter: blur(3.3333cqw);
+}
+
+.coded-panel {
+ border: 0.3333cqw solid #dfe4eb;
+ border-radius: 3cqw;
+ background: #fff;
+ box-shadow: 0 2cqw 5.3333cqw #253c5012, 0 0.3333cqw 1cqw #253c5008;
+}
+
+.coded-connector {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ stroke: #d6dfeb;
+ stroke-width: 1;
+}
+
+.coded-connector circle { fill: #fff; }
+
+.coded-thumbnail {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ filter: saturate(.65);
+}
+
+.coded-window-chrome {
+ display: flex;
+ align-items: center;
+ gap: 3cqw;
+ height: 6.6667cqw;
+ flex: none;
+ padding: 0 2.6667cqw;
+ border-bottom: 0.3333cqw solid #edf0f4;
+ font-size: 2.3333cqw;
+}
+
+.coded-window-dots { display: flex; gap: 1cqw; }
+.coded-window-dots i { width: 1cqw; height: 1cqw; border-radius: 50%; background: #cbd5e4; }
+.coded-window-dots i:first-child { background: #e5c3bb; }
+.coded-window-dots i:nth-child(2) { background: #e5d7b3; }
+
+.coded-tag {
+ position: absolute;
+ display: inline-flex;
+ align-items: center;
+ gap: 1.6667cqw;
+ padding: 2cqw 2.6667cqw;
+ border: 0.3333cqw solid #e4eaf2;
+ border-radius: 2cqw;
+ background: #fff;
+ box-shadow: 0 1cqw 2.6667cqw #263f5e08;
+ font-size: 2.6667cqw;
+}
+
+.coded-webcam-preview {
+ position: absolute;
+ left: 7%;
+ top: 17%;
+ display: flex;
+ flex-direction: column;
+ width: 75%;
+ height: 60%;
+ overflow: hidden;
+ transform: rotate(-3deg);
+}
+
+.coded-webcam-screen { flex: 1; min-height: 0; overflow: hidden; padding: 1.6667cqw; }
+.coded-webcam-screen > img { border-radius: 1.3333cqw; }
+
+.coded-webcam-overlay {
+ position: absolute;
+ left: 62%;
+ top: 46%;
+ width: 24%;
+ aspect-ratio: 1;
+ padding: 1.3333cqw;
+ border: 0.3333cqw solid #88a9d2;
+ border-radius: 1.6667cqw;
+ box-shadow: 0 1.3333cqw 3.3333cqw #253c5012;
+}
+
+.coded-person { position: relative; width: 100%; height: 100%; overflow: hidden; border-radius: 50%; background: #eaf0f7; }
+.coded-person-head { position: absolute; left: 35%; top: 20%; width: 30%; height: 35%; border-radius: 46%; background: #b6c6da; }
+.coded-person-body { position: absolute; left: 20%; top: 61%; width: 60%; height: 54%; border-radius: 48% 48% 0 0; background: #8ba6c7; }
+.coded-resize { position: absolute; width: 1.3333cqw; height: 1.3333cqw; border: 0.3333cqw solid #7f9fc9; background: #fff; }
+.coded-resize-nw { left: -0.6667cqw; top: -0.6667cqw; }
+.coded-resize-ne { right: -0.6667cqw; top: -0.6667cqw; }
+.coded-resize-sw { left: -0.6667cqw; bottom: -0.6667cqw; }
+.coded-resize-se { right: -0.6667cqw; bottom: -0.6667cqw; }
+.coded-webcam-camera { position: absolute; right: -2cqw; bottom: -2cqw; display: grid; place-items: center; width: 6cqw; height: 6cqw; border: 0.3333cqw solid #e2e8f0; border-radius: 50%; background: #fff; color: #7794b9; }
+
+.coded-webcam-settings { position: absolute; left: 6%; top: 65%; width: 41%; padding: 3cqw; }
+.coded-webcam-settings > strong { display: flex; align-items: center; gap: 1.6667cqw; margin-bottom: 2.3333cqw; color: #71819a; font-size: 2.6667cqw; font-weight: 500; }
+.coded-webcam-settings > div { display: flex; justify-content: space-between; align-items: center; gap: 2.6667cqw; margin-top: 1.6667cqw; font-size: 2.3333cqw; }
+.coded-switch { display: inline-flex; justify-content: flex-end; align-items: center; width: 5.6667cqw; height: 3.3333cqw; padding: 0.6667cqw; border-radius: 2.6667cqw; background: #88a9d2; }
+.coded-switch > i { width: 2cqw; height: 2cqw; border-radius: 50%; background: #fff; }
+.coded-shapes { display: flex; align-items: center; gap: 2cqw; }
+.coded-shapes > i { width: 3cqw; height: 3cqw; border: 0.3333cqw solid #a5b3c7; border-radius: 1cqw; }
+.coded-shapes > i:first-child { border-color: #7f9fc9; border-radius: 50%; background: #e9f0fb; }
+.coded-webcam-tag { right: 3%; top: 10%; }
+.coded-webcam-tag > i { width: 1.3333cqw; height: 1.3333cqw; border-radius: 50%; background: #8ba6c7; }
+
+.coded-caption-preview { position: absolute; left: 5%; top: 13%; display: flex; flex-direction: column; width: 76%; height: 58%; overflow: hidden; transform: rotate(-3deg); }
+.coded-caption-screen { position: relative; flex: 1; min-height: 0; overflow: hidden; }
+.coded-caption-line { position: absolute; bottom: 32%; left: 50%; padding: 1.3333cqw 2.3333cqw; border-radius: 1.3333cqw; background: #242b39e8; color: #fff; white-space: nowrap; font-size: 2.6667cqw; transform: translateX(-50%); }
+.coded-caption-line > em { color: #b8d2fc; font-style: normal; }
+.coded-caption-wave { position: absolute; left: 8%; top: 72%; display: flex; align-items: center; gap: 1cqw; height: 10.6667cqw; }
+.coded-caption-wave > i { width: 0.6667cqw; border-radius: 0.6667cqw; background: #c2d1e6; }
+.coded-caption-list { position: absolute; left: 31%; top: 57%; width: 65%; padding: 2.6667cqw 0 1.6667cqw; }
+.coded-caption-list > strong { display: flex; align-items: center; gap: 2cqw; padding: 0 3cqw 2cqw; font-size: 2.6667cqw; font-weight: 500; }
+.coded-caption-list > strong > span { padding: 0.3333cqw 1cqw; border-radius: 1cqw; background: #f0f4fa; font-size: 2cqw; }
+.coded-caption-cue { display: flex; align-items: center; gap: 2.3333cqw; padding: 2cqw 2.6667cqw; font-size: 2.3333cqw; }
+.coded-caption-cue.is-active { background: #eef4fd; }
+.coded-caption-cue > span { flex: none; color: #93a5bd; font-size: 2cqw; }
+.coded-caption-cue > p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 1.2; }
+.coded-caption-cue em { border-bottom: 0.3333cqw solid #9ab6dc; color: #638abd; font-style: normal; }
+.coded-caption-generate { position: absolute; right: 3%; top: 7%; padding: 2cqw 2.6667cqw; border: 0.3333cqw solid #e1e8f2; border-radius: 2cqw; background: #fff; color: #7897bf; box-shadow: 0 1cqw 2.6667cqw #263f5e08; font-size: 2.6667cqw; }
+
+.coded-export-video { position: absolute; left: 4%; top: 29%; width: 43%; height: 45%; padding: 1.3333cqw; overflow: hidden; transform: rotate(-5deg); }
+.coded-export-video > img { height: calc(100% - 5.3333cqw); border-radius: 1.6667cqw; }
+.coded-export-video > span { display: flex; align-items: center; gap: 1.6667cqw; height: 5.3333cqw; padding-inline: 1cqw; font-size: 2cqw; }
+.coded-export-video > span > i { height: 0.6667cqw; flex: 1; border-radius: 0.6667cqw; background: linear-gradient(90deg, #91acd0 32%, #e8edf4 32%); }
+.coded-export-settings { position: absolute; left: 36%; top: 3%; width: 60%; padding: 3.3333cqw; }
+.coded-export-settings > strong { display: block; margin-bottom: 2.6667cqw; color: #6e8098; font-size: 3.3333cqw; font-weight: 500; }
+.coded-setting-label { display: block; margin: 2.3333cqw 0 1.3333cqw; color: #9aa6b6; font-size: 2cqw; }
+.coded-export-formats { display: flex; gap: 1.3333cqw; }
+.coded-export-formats > span { flex: 1; padding: 1.3333cqw 0; border: 0.3333cqw solid #edf0f5; border-radius: 1.3333cqw; text-align: center; font-size: 2.3333cqw; }
+.coded-export-formats > .is-selected,
+.coded-export-resolutions > .is-selected { border-color: #c0d1e8; background: #eef4fd; color: #7191bc; }
+.coded-export-resolutions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.3333cqw; }
+.coded-export-resolutions > span { display: flex; align-items: center; justify-content: center; gap: 1cqw; padding: 1.3333cqw 0; border: 0.3333cqw solid #edf0f5; border-radius: 1.3333cqw; font-size: 2.3333cqw; }
+.coded-export-fps { display: flex; justify-content: space-between; align-items: center; margin-top: 2.6667cqw; font-size: 2cqw; }
+.coded-export-fps b { margin-left: 2.3333cqw; padding: 1cqw 1.6667cqw; border-radius: 1cqw; background: #eef4fd; color: #7191bc; font-weight: 400; }
+.coded-export-action { display: flex; align-items: center; justify-content: center; gap: 1.3333cqw; margin-top: 3cqw; padding: 2cqw 1cqw; border-radius: 1.6667cqw; background: #7897c2; color: #fff; font-size: 2.3333cqw; }
+
+.coded-library { position: absolute; left: 4%; top: 20%; width: 88%; padding: 3.6667cqw 2.6667cqw 3.3333cqw; transform: rotate(-3deg); }
+.coded-library-heading { display: flex; align-items: center; justify-content: space-between; gap: 2.6667cqw; margin-bottom: 3.3333cqw; }
+.coded-library-heading > strong { color: #72829a; font-size: 3cqw; font-weight: 500; }
+.coded-library-heading > span { color: #b1bac8; font-size: 2cqw; }
+.coded-library-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2cqw; }
+.coded-library-card { min-width: 0; overflow: hidden; border: 0.3333cqw solid #edf0f4; border-radius: 1.3333cqw; }
+.coded-library-thumbnail { position: relative; height: 14cqw; overflow: hidden; background: #f2f5f9; }
+.coded-library-thumbnail > span { position: absolute; right: 1cqw; bottom: 1cqw; padding: 0.3333cqw 1cqw; border-radius: 0.6667cqw; background: #263246b3; color: #fff; font-size: 1.6667cqw; }
+.coded-library-card > strong { display: block; overflow: hidden; padding: 2cqw 1.3333cqw 0.6667cqw; text-overflow: ellipsis; white-space: nowrap; color: #8391a6; font-size: 2cqw; font-weight: 500; }
+.coded-library-card > div:last-child { display: flex; align-items: center; justify-content: space-between; padding: 0.6667cqw 1.3333cqw 1.6667cqw; color: #b0bac8; font-size: 1.6667cqw; }
+.coded-library-cloud { position: absolute; right: 1%; top: 9%; display: grid; place-items: center; width: 17%; aspect-ratio: 1; color: #8aa8d0; border-radius: 3.6667cqw; }
+.coded-library-tag { right: 3%; top: 78%; }
+.coded-library-tag > span { color: #7a9bc7; }
+
+
+.coded-art svg:not(.coded-connector) { width: 1.25em; height: 1.25em; flex: none; }
+.coded-library-cloud > svg { width: 60%; height: 60%; }
+.coded-caption-wave > i { height: calc(var(--bar-height) * 100cqw / 300); }
+
+@media (min-width: 621px) and (max-width: 900px) {
+ .coded-art-webcam { max-width: 285px; }
+}
diff --git a/aside-react/src/FeatureIllustrations.jsx b/aside-react/src/FeatureIllustrations.jsx
new file mode 100644
index 00000000..a8ec3326
--- /dev/null
+++ b/aside-react/src/FeatureIllustrations.jsx
@@ -0,0 +1,121 @@
+import React from "react";
+import { VideocameraIcon } from "@solar-icons/react/linear/videocamera";
+import { DownloadIcon } from "@solar-icons/react/linear/download";
+import { CloudCheckIcon } from "@solar-icons/react/linear/cloud-check";
+import { LinkIcon } from "@solar-icons/react/linear/link";
+import { PlayIcon } from "@solar-icons/react/bold/play";
+import "./FeatureIllustrations.css";
+
+const posters = {
+ walkthrough: "/assets/recordly-hero-poster.jpg",
+ zoom: "/assets/recordly-auto-zoom-poster.jpg",
+ cursor: "/assets/recordly-cursor-poster.jpg",
+};
+
+function IllustrationStage({ feature, children }) {
+ return (
+
+ );
+}
+
+function RecordingThumbnail({ poster = posters.walkthrough }) {
+ return ;
+}
+
+function WindowChrome({ title }) {
+ return {title}
;
+}
+
+function CameraPortrait() {
+ return
;
+}
+
+export function WebcamIllustration() {
+ return (
+
+
+
+
+
+ {["nw", "ne", "sw", "se"].map((corner) => )}
+
+
+
+
Webcam
+
Mirror
+
Shape
+
+ Webcam on
+
+ );
+}
+
+export function CaptionsIllustration() {
+ return (
+
+
+
+
Make your words count.
+
+
+ {[8, 14, 22, 12, 28, 18, 10, 24, 32, 15, 22, 12].map((height, index) => )}
+
+
+
Captions 3
+
0:01 Make your words count.
+
+
+ ✓ Captions generated
+
+ );
+}
+
+export function ExportIllustration() {
+ return (
+
+
+
+
+
Export
+
Format
+
MP4 GIF
+
Resolution
+
{["HD", "FHD", "QHD", "4K"].map((label) => {label} )}
+
FPS 30 60
+
Export video
+
+
+ );
+}
+
+export function CloudIllustration() {
+ const recordings = [
+ { title: "Walkthrough", poster: posters.walkthrough, time: "0:24" },
+ { title: "Quick update", poster: posters.zoom, time: "0:12" },
+ { title: "Product demo", poster: posters.cursor, time: "0:31" },
+ ];
+ return (
+
+
+
+
Shared links 3 recordings
+
{recordings.map((recording) => (
+
+
{recording.time}
+
{recording.title}
+
Today
+
+ ))}
+
+
+ ✓ Ready to share
+
+ );
+}
diff --git a/aside-react/src/FeatureStack.jsx b/aside-react/src/FeatureStack.jsx
new file mode 100644
index 00000000..d371e6ce
--- /dev/null
+++ b/aside-react/src/FeatureStack.jsx
@@ -0,0 +1,53 @@
+import React from "react";
+import BentoArtwork from "./BentoArtwork";
+import "./FeatureBento.css";
+
+const features = [
+ {
+ id: "webcam",
+ title: "Put a face to your story",
+ description: "Add a webcam overlay and make it your own with flexible sizing and positioning.",
+ },
+ {
+ id: "captions",
+ title: "Let your words show",
+ description: "Generate captions from your audio, then fine-tune the words to get them just right.",
+ },
+ {
+ id: "export",
+ title: "Export up to 4K",
+ description: "Save your recording as an MP4 video or a looping GIF, in resolutions up to 4K.",
+ },
+ {
+ id: "sharing",
+ title: "Share in high definition",
+ description: "Upload your video and share a link to watch it in high definition.",
+ },
+ {
+ id: "cloud",
+ title: "All your recordings",
+ description: "Keep your recordings together in the cloud and ready to share.",
+ },
+];
+
+export default function FeatureStack() {
+ return (
+
+
+
Everything for a better recording.
+
From the first click to the link you send.
+
+
+ {features.map(({ id, title, description }) => (
+
+
+
+
{title}
+
{description}
+
+
+ ))}
+
+
+ );
+}
diff --git a/aside-react/src/HeroVideo.css b/aside-react/src/HeroVideo.css
new file mode 100644
index 00000000..91d7dc07
--- /dev/null
+++ b/aside-react/src/HeroVideo.css
@@ -0,0 +1,46 @@
+.hero-demo-stage {
+ container-type: inline-size;
+}
+.hero-editor-frame-layer {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .hero-editor-frame-layer {
+ opacity: 1 !important;
+ }
+}
+.hero-editor-playhead {
+ position: absolute;
+ top: 75.355498%;
+ bottom: 2.097440%;
+ width: 0.083333cqw;
+ background: #ef4444;
+ pointer-events: none;
+}
+.hero-editor-playhead::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 50%;
+ width: 1.317007cqw;
+ height: 1.317007cqw;
+ border-radius: 50%;
+ background: inherit;
+ transform: translateX(-50%);
+}
+.hero-editor-time {
+ position: absolute;
+ left: 58.697917%;
+ top: 70.701855%;
+ width: 1.837798%;
+ color: oklch(0.5517 0.0138 285.94);
+ font-size: 0.823129cqw;
+ font-weight: 500;
+ line-height: 1.028912cqw;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ pointer-events: none;
+}
diff --git a/aside-react/src/HeroVideo.jsx b/aside-react/src/HeroVideo.jsx
new file mode 100644
index 00000000..512ff78c
--- /dev/null
+++ b/aside-react/src/HeroVideo.jsx
@@ -0,0 +1,88 @@
+import React, { useCallback, useLayoutEffect, useRef, useState } from "react";
+import { motion, useMotionValue, useScroll, useTransform } from "motion/react";
+import DemoVideo from "./DemoVideo";
+import editorFrameUrl from "./assets/hero-editor-current.png";
+import "./HeroVideo.css";
+
+// Preview bounds in the 1470 × 923 capture of the current editor (121% UI scale).
+// The demo timeline was reconstructed from the surviving hero recording.
+// Overlap by one CSS pixel to cover image filtering at fractional display scales.
+const CANVAS = {
+ left: "calc(41.118197% - 1px)", top: "calc(15.728264% - 1px)",
+ width: "calc(50.029762% + 2px)", height: "calc(50.710997% + 2px)",
+};
+const INTRO_HOLD_SECONDS = 1.3;
+
+export default function HeroVideo() {
+ const trackRef = useRef(null);
+ const stageRef = useRef(null);
+ const [stageHeight, setStageHeight] = useState(0);
+ const playbackProgress = useMotionValue(0);
+ const playbackTime = useMotionValue(0);
+ const syncPlayback = useCallback((currentTime, duration) => {
+ // The encoded first-frame hold is outside the editor's recording timeline.
+ const time = Number.isFinite(currentTime) ? Math.max(0, currentTime - INTRO_HOLD_SECONDS) : 0;
+ const recordingDuration = duration - INTRO_HOLD_SECONDS;
+ playbackProgress.set(Number.isFinite(recordingDuration) && recordingDuration > 0 ? Math.max(0, Math.min(1, time / recordingDuration)) : 0);
+ playbackTime.set(time);
+ }, [playbackProgress, playbackTime]);
+ // Clip bounds measured in the same capture; the one-pixel cursor is centered.
+ const playheadLeft = useTransform(playbackProgress, [0, 1], ["2.304422%", "97.694515%"]);
+ const playbackLabel = useTransform(playbackTime, (time) => `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`);
+ useLayoutEffect(() => {
+ const stage = stageRef.current;
+ const measure = () => {
+ const height = stage.getBoundingClientRect().height;
+ setStageHeight(height);
+ };
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(stage);
+ window.addEventListener("resize", measure);
+ return () => {
+ observer.disconnect();
+ window.removeEventListener("resize", measure);
+ };
+ }, []);
+ const { scrollYProgress } = useScroll({
+ target: trackRef,
+ // Use the video's natural center, before the sticky position holds it there.
+ offset: [`${stageHeight / 2}px center`, `${stageHeight / 2}px -0.30`],
+ });
+ // Complete the shrink over 80% of the viewport, then briefly hold the editor.
+ const progress = scrollYProgress;
+ const left = useTransform(progress, [0, 1], ["calc(0% - 0px)", CANVAS.left]);
+ const top = useTransform(progress, [0, 1], ["calc(0% - 0px)", CANVAS.top]);
+ const width = useTransform(progress, [0, 1], ["calc(100% + 0px)", CANVAS.width]);
+ const height = useTransform(progress, [0, 1], ["calc(100% + 0px)", CANVAS.height]);
+ const borderRadius = useTransform(progress, [0, 1], [6, 0]);
+ const boxShadow = useTransform(progress, [0, 1], [
+ "0px 16px 45px rgba(0, 0, 0, 0.1333)", "0px 16px 45px rgba(0, 0, 0, 0)",
+ ]);
+ const editorOpacity = useTransform(progress, [0, .65], [0, 1]);
+
+ return (
+
+
+
+
+
+
+
+
{playbackLabel}
+
+
+
+
+
+
+ );
+}
diff --git a/aside-react/src/RecordHudArtwork.jsx b/aside-react/src/RecordHudArtwork.jsx
new file mode 100644
index 00000000..22c965af
--- /dev/null
+++ b/aside-react/src/RecordHudArtwork.jsx
@@ -0,0 +1,102 @@
+import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
+import { MonitorIcon } from "@solar-icons/react/linear/monitor";
+import { MicrophoneIcon } from "@solar-icons/react/linear/microphone";
+import { VideocameraIcon } from "@solar-icons/react/linear/videocamera";
+import { HomeIcon } from "@solar-icons/react/bold/home";
+import { MenuDotsVerticalIcon } from "@solar-icons/react/bold/menu-dots-vertical";
+import { PauseIcon } from "@solar-icons/react/bold/pause";
+import { MinusIcon } from "@solar-icons/react/linear/minus";
+import { CloseIcon } from "@solar-icons/react/linear/close";
+
+function HudIcon({ icon: Icon, slash = false }) {
+ return
+
+ {slash && }
+ ;
+}
+
+function Control({ icon, slash = false, danger = false, children }) {
+ return
+ {icon ? : children}
+ ;
+}
+
+function Countdown() {
+ return
+
+
+ 0
+ ;
+}
+
+export default function RecordHudArtwork() {
+ const [seconds, setSeconds] = useState(-1);
+ const frameRef = useRef(null);
+ const barRef = useRef(null);
+ const [scale, setScale] = useState(.5);
+ useLayoutEffect(() => {
+ const frame = frameRef.current;
+ const idleWidth = barRef.current.offsetWidth;
+ const resize = () => setScale(Math.min(1, (frame.clientWidth - 24) / idleWidth));
+ resize();
+ const observer = new ResizeObserver(resize);
+ observer.observe(frame);
+ return () => observer.disconnect();
+ }, []);
+ useEffect(() => {
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
+ let timer;
+ const restart = () => {
+ clearInterval(timer);
+ setSeconds(-1);
+ if (reducedMotion.matches) return;
+ const started = performance.now();
+ timer = setInterval(() => {
+ const phase = (performance.now() - started) % 6000;
+ setSeconds(phase < 1500 ? -1 : Math.min(3, Math.floor((phase - 1500) / 1000)));
+ }, 100);
+ };
+ restart();
+ reducedMotion.addEventListener("change", restart);
+ return () => {
+ clearInterval(timer);
+ reducedMotion.removeEventListener("change", restart);
+ };
+ }, []);
+ const recording = seconds >= 0;
+ return (
+
+
+
+
+ {recording ? (
+
+
+ 00:0{seconds}
+
+
+
+
+
+
+
+ ) : (
+
+ Pick source
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/aside-react/src/ShareArtwork.jsx b/aside-react/src/ShareArtwork.jsx
new file mode 100644
index 00000000..38d2f460
--- /dev/null
+++ b/aside-react/src/ShareArtwork.jsx
@@ -0,0 +1,22 @@
+import React from "react";
+import { LinkIcon } from "@solar-icons/react/linear/link";
+import { CopyIcon } from "@solar-icons/react/linear/copy";
+import { PlayIcon } from "@solar-icons/react/bold/play";
+
+export default function ShareArtwork() {
+ return (
+
+
+
+
+
+
+
+
+ recordly.dev/s/demo
+
+
+
+
+ );
+}
diff --git a/aside-react/src/SharingIllustration.css b/aside-react/src/SharingIllustration.css
new file mode 100644
index 00000000..1ffc2b22
--- /dev/null
+++ b/aside-react/src/SharingIllustration.css
@@ -0,0 +1,197 @@
+.sharing-art {
+ position: relative;
+ width: 100%;
+ max-width: 330px;
+ aspect-ratio: 3 / 2;
+ flex: none;
+ isolation: isolate;
+ font-family: "DM Sans", Arial, sans-serif;
+ font-size: 10px;
+ letter-spacing: -.02em;
+}
+
+.sharing-art-dots {
+ position: absolute;
+ inset: 3% -3%;
+ z-index: -1;
+ background-image: radial-gradient(#d5dce6 1px, transparent 1px);
+ background-size: 9px 9px;
+ mask-image: radial-gradient(ellipse at center, #0008, transparent 68%);
+}
+
+.sharing-art-shadow {
+ position: absolute;
+ left: 12%;
+ bottom: 11%;
+ width: 77%;
+ height: 11%;
+ z-index: -1;
+ border-radius: 50%;
+ background: #bec8d71a;
+ filter: blur(10px);
+}
+
+.sharing-art-player {
+ position: absolute;
+ left: 6%;
+ top: 13%;
+ display: flex;
+ flex-direction: column;
+ width: 73%;
+ height: 65%;
+ padding: 5px;
+ border: 1px solid #dfe4eb;
+ border-radius: 10px;
+ background: #fff;
+ box-shadow: 0 6px 16px #253c5012, 0 1px 3px #253c5008;
+ transform: rotate(-4deg);
+}
+
+.sharing-art-screen {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ border-radius: 6px;
+ background: #e8eef8;
+}
+
+.sharing-art-screen > img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ filter: saturate(.65);
+}
+
+.sharing-art-play {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ display: grid;
+ place-items: center;
+ width: 30px;
+ height: 30px;
+ border: 1px solid #ffffff80;
+ border-radius: 50%;
+ background: #ffffffed;
+ color: #5e748e;
+ box-shadow: 0 2px 6px #172d4820;
+ transform: translate(-50%, -50%);
+}
+
+.sharing-art-play > svg {
+ margin-left: 2px;
+}
+
+.sharing-art-hd {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ padding: 2px 4px;
+ border: 1px solid #ffffffb3;
+ border-radius: 3px;
+ background: #ffffffe8;
+ color: #68788f;
+ font-size: 7px;
+ font-weight: 600;
+}
+
+.sharing-art-controls {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ height: 21px;
+ flex: none;
+ padding: 4px 3px 0;
+ color: #8b96a5;
+ font-size: 7px;
+}
+
+.sharing-art-track {
+ height: 2px;
+ flex: 1;
+ overflow: hidden;
+ border-radius: 2px;
+ background: #e9edf3;
+}
+
+.sharing-art-track > span {
+ display: block;
+ width: 36%;
+ height: 100%;
+ background: #7c9ecb;
+}
+
+.sharing-art-connector {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ stroke: #d6dfeb;
+ stroke-width: 1;
+}
+
+.sharing-art-connector circle {
+ fill: #fff;
+}
+
+.sharing-art-link {
+ position: absolute;
+ left: 18%;
+ top: 70%;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ width: 76%;
+ height: 19%;
+ padding: 5px 5px 5px 10px;
+ border: 1px solid #e0e5ed;
+ border-radius: 9px;
+ background: #fff;
+ color: #71829a;
+ box-shadow: 0 5px 13px #263f5e12, 0 1px 2px #263f5e05;
+}
+
+.sharing-art-link > svg {
+ flex: none;
+ color: #648ec3;
+}
+
+.sharing-art-link > span:first-of-type {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sharing-art-copy {
+ display: grid;
+ place-items: center;
+ width: 25px;
+ height: 25px;
+ flex: none;
+ border-radius: 6px;
+ background: #252b35;
+ color: #fff;
+}
+
+.sharing-art-confirmation {
+ position: absolute;
+ top: 8%;
+ right: 3%;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 6px 8px;
+ border: 1px solid #e4eaf2;
+ border-radius: 6px;
+ background: #fff;
+ color: #6f7f92;
+ box-shadow: 0 3px 8px #263f5e08;
+ font-size: 8px;
+}
+
+.sharing-art-confirmation > span {
+ color: #6089be;
+}
diff --git a/aside-react/src/SharingIllustration.jsx b/aside-react/src/SharingIllustration.jsx
new file mode 100644
index 00000000..527f2494
--- /dev/null
+++ b/aside-react/src/SharingIllustration.jsx
@@ -0,0 +1,48 @@
+import React from "react";
+import { LinkIcon } from "@solar-icons/react/linear/link";
+import { CopyIcon } from "@solar-icons/react/linear/copy";
+import { PlayIcon } from "@solar-icons/react/bold/play";
+import "./SharingIllustration.css";
+
+function RecordingPreview() {
+ return (
+
+
+
+
HD
+
+
+
+
+ );
+}
+
+function ShareLink() {
+ return (
+
+
+ recordly.dev/s/intro
+
+
+ );
+}
+
+export default function SharingIllustration() {
+ return (
+
+
+
+
+
+
+
+
+
+
✓ Link copied
+
+ );
+}
diff --git a/aside-react/src/SiteHeader.jsx b/aside-react/src/SiteHeader.jsx
new file mode 100644
index 00000000..58567670
--- /dev/null
+++ b/aside-react/src/SiteHeader.jsx
@@ -0,0 +1,176 @@
+import React, { useEffect, useId, useRef, useState } from "react";
+import { downloadPlatforms, getDownloadPlatform } from "./downloadPlatforms";
+
+const ASSET = "/assets/";
+const groups = {
+ Features: [
+ ["Automatic zoom", "#showcase"],
+ ["Recording and editing", "#capability"],
+ ["Cloud sharing", "#feature-sharing"],
+ ],
+ Blog: [
+ ["All posts", "/blog"],
+ ],
+ Resources: [
+ ["Source code", "https://github.com/webadderallorg/Recordly"],
+ ["Releases", "https://github.com/webadderallorg/Recordly/releases"],
+ ["Privacy policy", "/privacy"],
+ ["Terms of service", "/terms"],
+ ["Billing & refunds", "/billing"],
+ ["Contact", "/contact"],
+ ],
+};
+function Logo({ mark = false, light = false, ...props }) {
+ if (mark) return ;
+ return (
+
+
+ Recordly
+
+ );
+}
+function Download({ compact = false }) {
+ const [open, setOpen] = useState(false);
+ const [platform, setPlatform] = useState("macos");
+ const selected = downloadPlatforms.find(({ id }) => id === platform);
+ const ref = useRef(null);
+ const trigger = useRef(null);
+ const optionsId = useId();
+ useEffect(() => {
+ setPlatform(getDownloadPlatform(navigator));
+ }, []);
+ useEffect(() => {
+ if (!open) return;
+ const closeOutside = (event) => {
+ if (!ref.current?.contains(event.target)) setOpen(false);
+ };
+ const onEscape = (event) => {
+ if (event.key === "Escape") {
+ setOpen(false);
+ trigger.current?.focus();
+ }
+ };
+ document.addEventListener("pointerdown", closeOutside);
+ document.addEventListener("keydown", onEscape);
+ return () => {
+ document.removeEventListener("pointerdown", closeOutside);
+ document.removeEventListener("keydown", onEscape);
+ };
+ }, [open]);
+ const primary = (
+
+ {!compact && }
+ {compact ? "Download" : `Download for ${selected.label}`}
+
+ );
+ if (compact) return primary;
+ return (
+ {
+ if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
+ }}>
+ {primary}
+
setOpen(!open)}
+ >
+
+
+
+
+
+
+ );
+}
+function Nav({ light = false }) {
+ const [mobile, setMobile] = useState(false);
+ const [open, setOpen] = useState(null);
+ const ref = useRef(null);
+ useEffect(() => {
+ const close = (e) => {
+ if (!ref.current?.contains(e.target)) setOpen(null);
+ };
+ const onEscape = (e) => {
+ if (e.key === "Escape") {
+ setOpen(null);
+ setMobile(false);
+ }
+ };
+ document.addEventListener("pointerdown", close);
+ document.addEventListener("keydown", onEscape);
+ return () => {
+ document.removeEventListener("pointerdown", close);
+ document.removeEventListener("keydown", onEscape);
+ };
+ }, []);
+ return (
+
+ );
+}
+function SiteFooter() {
+ return ;
+}
+export { Logo, Download, Nav, SiteFooter };
diff --git a/aside-react/src/assets/hero-editor-account.png b/aside-react/src/assets/hero-editor-account.png
new file mode 100644
index 00000000..3aa76ec8
Binary files /dev/null and b/aside-react/src/assets/hero-editor-account.png differ
diff --git a/aside-react/src/assets/hero-editor-current.png b/aside-react/src/assets/hero-editor-current.png
new file mode 100644
index 00000000..1926f396
Binary files /dev/null and b/aside-react/src/assets/hero-editor-current.png differ
diff --git a/aside-react/src/assets/hero-editor-frame.png b/aside-react/src/assets/hero-editor-frame.png
new file mode 100644
index 00000000..6314c715
Binary files /dev/null and b/aside-react/src/assets/hero-editor-frame.png differ
diff --git a/aside-react/src/assets/hero-editor-neutral.jpg b/aside-react/src/assets/hero-editor-neutral.jpg
new file mode 100644
index 00000000..1bc58ff3
Binary files /dev/null and b/aside-react/src/assets/hero-editor-neutral.jpg differ
diff --git a/aside-react/src/blog/Blog.jsx b/aside-react/src/blog/Blog.jsx
new file mode 100644
index 00000000..99dfd08d
--- /dev/null
+++ b/aside-react/src/blog/Blog.jsx
@@ -0,0 +1,87 @@
+import React, { useEffect, useState } from "react";
+import { posts, readingMinutes, formatPostDate } from "./posts";
+import { blogMetadata, applyBlogMetadata } from "./seo";
+import { SiteFooter } from "../SiteHeader";
+import "./blog.css";
+
+const categories = ["All", ...new Set(posts.map((post) => post.category))];
+const categoryHref = (category) => category === "All" ? "/blog" : `/blog?category=${encodeURIComponent(category)}`;
+
+function PostCard({ post }) {
+ return (
+
+
+
+ {post.category} / {formatPostDate(post.datePublished)}
+ {post.title}
+ {post.description}
+ {readingMinutes(post)} min read ↗
+
+
+ );
+}
+
+function ArticleSection({ section }) {
+ return (
+
+ {section.title}
+ {section.text && {section.text}
}
+ {section.paragraphs?.map((text, i) => {text}
)}
+ {section.table && {section.table.headers.map((heading) => {heading} )} {section.table.rows.map((row, i) => {row.map((cell, j) => {cell} )} )}
}
+ {section.list && {section.list.map((text, i) => {text} )} }
+ {section.steps && {section.steps.map((text, i) => {text} )} }
+ {section.code && {section.code} }
+
+ );
+}
+
+export default function Blog({ header, path = "/blog" }) {
+ const meta = blogMetadata(path);
+ const { isIndex, post } = meta;
+ // The first client render matches the static HTML; query filtering applies after hydration.
+ const [category, setCategory] = useState("All");
+ useEffect(() => {
+ const requested = new URLSearchParams(window.location.search).get("category");
+ setCategory(categories.includes(requested) ? requested : "All");
+ applyBlogMetadata(blogMetadata(path));
+ }, [path]);
+ const visiblePosts = category === "All" ? posts : posts.filter((entry) => entry.category === category);
+ const related = post ? posts.filter((entry) => entry.slug !== post.slug).slice(0, 2) : [];
+ return (
+
+
Skip to content
+ {header}
+
+ {isIndex ? (
+ <>
+ Blog
+ {posts.length > 0 ? <>
+
+ {categories.map((item) => {item} )}
+
+ {visiblePosts.map((entry) =>
)}
+ > : }
+ >
+ ) : post ? (
+
+ Blog / {post.category}
+ {post.title}
+
+ By Recordly / {formatPostDate(post.datePublished)} / {readingMinutes(post)} min read
+
+
{post.description}
+
In this article
{post.sections.map((section) => {section.title} )}
+ {post.sections.map((section) =>
)}
+ {post.sources?.length > 0 &&
Sources and further reading }
+
+
+
+
+ ) : (
+ Post not found. This post may have moved. Return to the blog to see available posts.
← All posts
+ )}
+
+
+
+ );
+}
diff --git a/aside-react/src/blog/blog.css b/aside-react/src/blog/blog.css
new file mode 100644
index 00000000..03145aa3
--- /dev/null
+++ b/aside-react/src/blog/blog.css
@@ -0,0 +1,100 @@
+/* Adapted from Aside's blog: narrow editorial grid, pill filters, quiet metadata. */
+.blog-site {
+ min-height: 100svh;
+ display: flex;
+ flex-direction: column;
+ letter-spacing: -0.025em;
+}
+.blog-site .nav {
+ width: min(1120px, 100%);
+ margin: 12px auto 0;
+}
+.blog-index {
+ width: min(800px, calc(100% - 48px));
+ margin: 78px auto 100px;
+ flex: 1;
+}
+.blog-index > h1 { font-size: 44px; margin-bottom: 40px; }
+.blog-filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 32px; }
+.blog-filters a {
+ padding: 7px 16px;
+ border: 1px solid #ececec;
+ border-radius: 99px;
+ color: #727272;
+ font-size: 14px;
+ line-height: 1.3;
+}
+.blog-filters a:hover { color: var(--brand-accent); border-color: var(--brand-accent); background: var(--brand-accent-soft); }
+.blog-filters a[aria-current="page"] { background: #090b0c; color: #fff; border-color: #090b0c; }
+.blog-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 40px 24px; }
+.blog-card a { display: block; }
+.blog-card-image { aspect-ratio: 2 / 1; overflow: hidden; border-radius: 10px; border: 1px solid #ececec; background: #f4f7fa; }
+.blog-card-image img { width: 100%; height: 100%; object-fit: cover; transition: transform 200ms ease; }
+.blog-card a:hover img { transform: scale(1.025); }
+.blog-meta { color: #767676; font-size: 14px; margin: 18px 0 12px; }
+.blog-card h2 { font-size: 24px; line-height: 1.3; font-weight: 500; letter-spacing: -0.035em; }
+.blog-card a:hover h2 { color: var(--blue); }
+.blog-summary { color: #777; font-size: 16px; margin-top: 12px; line-height: 1.5; }
+.blog-article { width: min(672px, calc(100% - 48px)); margin: 42px auto 100px; flex: 1; }
+.blog-breadcrumb { display: flex; justify-content: center; gap: 10px; color: #767676; font-size: 14px; margin-bottom: 20px; }
+.blog-article h1 { font-size: clamp(32px, 4vw, 44px); line-height: 1.15; text-align: center; margin-bottom: 32px; }
+.blog-cover { width: 100%; aspect-ratio: 2 / 1; object-fit: cover; border-radius: 24px; background: #f4f7fa; }
+.blog-byline { text-align: center; margin: 18px 0 64px; }
+.blog-prose { font-size: 17px; letter-spacing: -0.015em; }
+.blog-prose p { line-height: 1.8; color: #454545; }
+.blog-prose .blog-lede { font-size: 20px; color: #151515; line-height: 1.65; }
+.blog-prose section { margin-top: 40px; }
+.blog-prose h2 { font-size: 28px; margin-bottom: 14px; line-height: 1.2; }
+.blog-article-end { display: flex; justify-content: space-between; gap: 24px; border-top: 1px solid var(--line); margin-top: 56px; padding-top: 24px; font-size: 15px; }
+.blog-not-found { padding: 60px 0; text-align: center; }
+.blog-not-found p { margin: 24px 0; color: #767676; }
+@media (max-width: 700px) {
+ .blog-index { margin-top: 56px; margin-bottom: 64px; }
+ .blog-index > h1 { font-size: 38px; margin-bottom: 28px; }
+ .blog-grid { grid-template-columns: 1fr; gap: 36px; }
+ .blog-article { margin-top: 40px; margin-bottom: 64px; }
+ .blog-cover { border-radius: 14px; }
+ .blog-byline { margin-bottom: 40px; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .blog-card-image img { transition: none; }
+}
+
+.blog-card-image, .blog-cover { aspect-ratio: 1200 / 630; }
+.blog-meta { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; font-size: 13px; }
+.blog-read-time { font-size: 13px; color: #777; margin-top: 16px; }
+.blog-read-time span { margin-left: 5px; }
+.blog-byline { justify-content: center; }
+.blog-prose p + p { margin-top: 18px; }
+.blog-prose ul, .blog-prose ol { padding-left: 24px; margin: 20px 0; color: #454545; }
+.blog-prose li { line-height: 1.75; padding-left: 4px; margin: 10px 0; }
+.blog-prose a { color: #0050ff; text-decoration: underline; text-underline-offset: 3px; }
+.blog-prose h2 { scroll-margin-top: 32px; }
+.blog-toc { margin-top: 32px; padding: 24px; background: #f7f9fc; border: 1px solid #e9eef5; border-radius: 14px; }
+.blog-toc > p { font-weight: 500; font-size: 15px; color: #151515; }
+.blog-toc ol { margin: 12px 0 0; font-size: 15px; }
+.blog-toc li { margin: 5px 0; }
+.blog-toc a { color: #454545; text-decoration: none; }
+.blog-toc a:hover { color: #0050ff; text-decoration: underline; }
+.blog-table { max-width: 100%; overflow-x: auto; margin: 24px 0; border: 1px solid #e5e9ef; border-radius: 10px; }
+.blog-table:focus-visible { outline: 3px solid #0050ff; outline-offset: 3px; }
+.blog-table table { border-collapse: collapse; width: 100%; min-width: 520px; font-size: 14px; line-height: 1.6; letter-spacing: -0.01em; }
+.blog-table th, .blog-table td { padding: 14px 16px; text-align: left; vertical-align: top; }
+.blog-table th { background: #f7f9fc; font-weight: 500; }
+.blog-table tr + tr td, .blog-table tbody tr:first-child td { border-top: 1px solid #e5e9ef; }
+.blog-template { padding: 24px; margin: 24px 0; background: #f7f9fc; border: 1px solid #e5e9ef; border-radius: 12px; font-size: 14px; line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; letter-spacing: 0; }
+.blog-template code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
+.blog-sources { padding-top: 28px; border-top: 1px solid #ececec; }
+.blog-sources h2 { font-size: 22px; }
+.blog-sources ul { font-size: 14px; }
+.blog-related { margin-top: 48px; border-top: 1px solid #ececec; padding-top: 28px; }
+.blog-related h2 { font-size: 22px; margin-bottom: 16px; }
+.blog-related a { display: block; padding: 12px 0; font-size: 16px; }
+@media (max-width: 700px) {
+ .blog-filters { gap: 8px; }
+ .blog-filters a { padding: 7px 12px; }
+ .blog-toc { padding: 18px; }
+ .blog-template { padding: 18px; font-size: 13px; }
+}
+
+.blog-empty { border-top: 1px solid var(--line); padding: 80px 24px; text-align: center; color: #767676; font-size: 17px; }
diff --git a/aside-react/src/blog/posts.js b/aside-react/src/blog/posts.js
new file mode 100644
index 00000000..42cf448a
--- /dev/null
+++ b/aside-react/src/blog/posts.js
@@ -0,0 +1,11 @@
+// Add hand-written articles here when ready. No placeholder or generated posts.
+export const posts = [];
+
+export function readingMinutes(post) {
+ const words = [post.title, post.description, ...post.sections.map((section) => [section.title, section.text, ...(section.paragraphs || []), ...(section.list || []), ...(section.steps || []), section.code, ...(section.table?.headers || []), ...(section.table?.rows.flat() || [])].filter(Boolean).join(" "))].join(" ").trim().split(/\s+/).length;
+ return Math.max(1, Math.ceil(words / 200));
+}
+
+export function formatPostDate(date) {
+ return new Intl.DateTimeFormat("en-AU", {day: "numeric", month: "short", year: "numeric", timeZone: "UTC"}).format(new Date(`${date}T00:00:00Z`));
+}
diff --git a/aside-react/src/blog/render.jsx b/aside-react/src/blog/render.jsx
new file mode 100644
index 00000000..a56303e7
--- /dev/null
+++ b/aside-react/src/blog/render.jsx
@@ -0,0 +1,14 @@
+import React from "react";
+import { renderToString } from "react-dom/server";
+import { Nav } from "../SiteHeader";
+import Blog from "./Blog";
+import { blogMetadata } from "./seo";
+import { posts } from "./posts";
+import Pricing from "../pricing/Pricing";
+import { pricingMetadata } from "../pricing/catalog.js";
+
+export const blogPaths = ["/blog", "/pricing", ...posts.map((post) => `/blog/${post.slug}`)];
+export function renderBlog(path, origin) {
+ if (path === "/pricing") return {body:renderToString( ), meta:pricingMetadata(origin)};
+ return {body: renderToString( } path={path} />), meta: blogMetadata(path, origin)};
+}
diff --git a/aside-react/src/blog/seo.js b/aside-react/src/blog/seo.js
new file mode 100644
index 00000000..186bc383
--- /dev/null
+++ b/aside-react/src/blog/seo.js
@@ -0,0 +1,60 @@
+import { posts } from "./posts";
+
+// Match the existing review host. Set VITE_SITE_URL when deploying to a different domain.
+export const siteOrigin = (import.meta.env?.VITE_SITE_URL || "https://recordly-beta-landing.youngchen3442.workers.dev").replace(/\/+$/, "");
+export function blogMetadata(path, origin = siteOrigin) {
+ const cleanPath = path.replace(/\/+$/, "");
+ const post = posts.find((entry) => cleanPath === `/blog/${entry.slug}`);
+ const isIndex = cleanPath === "/blog";
+ const canonical = `${origin}${isIndex ? "/blog" : cleanPath}`;
+ const title = isIndex ? "Blog | Recordly" : `${post?.title || "Post not found"} | Recordly`;
+ const description = post?.description || "Updates and writing from Recordly.";
+ const image = post?.image ? `${origin}${post.image}` : null;
+ const structuredData = post ? {
+ "@context": "https://schema.org", "@type": "BlogPosting",
+ headline: post.title, description, ...(image ? {image: [image]} : {}),
+ datePublished: post.datePublished, dateModified: post.dateModified,
+ author: {"@type": "Organization", name: "Recordly", url: origin},
+ publisher: {"@type": "Organization", name: "Recordly", url: origin},
+ mainEntityOfPage: {"@type": "WebPage", "@id": canonical},
+ articleSection: post.category, inLanguage: "en-AU"
+ } : isIndex ? {
+ "@context": "https://schema.org", "@type": "CollectionPage", name: "Recordly Blog", description, url: canonical,
+ mainEntity: {"@type": "ItemList", itemListElement: posts.map((entry, index) => ({"@type": "ListItem", position: index + 1, url: `${origin}/blog/${entry.slug}`, name: entry.title}))}
+ } : null;
+ return {title, description, canonical, image, post, isIndex, structuredData, robots: post || isIndex ? "index, follow, max-image-preview:large" : "noindex, follow"};
+}
+
+export function applyBlogMetadata(meta) {
+ document.title = meta.title;
+ const setMeta = (attribute, key, content) => {
+ let tag = document.head.querySelector(`meta[${attribute}="${key}"]`);
+ if (!tag) { tag = document.createElement("meta"); tag.setAttribute(attribute, key); document.head.append(tag); }
+ tag.content = content;
+ };
+ setMeta("name", "description", meta.description);
+ setMeta("name", "robots", meta.robots);
+ for (const [key, value] of Object.entries({"og:title":meta.title, "og:description":meta.description, "og:url":meta.canonical, "og:image":meta.image, "og:type":meta.post ? "article" : "website", "og:site_name":"Recordly", "og:image:width":meta.image ? "1200" : null, "og:image:height":meta.image ? "630" : null})) {
+ if (value) setMeta("property",key,value);
+ else document.head.querySelector(`meta[property="${key}"]`)?.remove();
+ }
+ for (const [key,value] of Object.entries({"twitter:card":meta.image ? "summary_large_image" : "summary", "twitter:title":meta.title, "twitter:description":meta.description, "twitter:image":meta.image})) {
+ if (value) setMeta("name",key,value);
+ else document.head.querySelector(`meta[name="${key}"]`)?.remove();
+ }
+ if (meta.post) {
+ setMeta("property", "article:published_time", meta.post.datePublished);
+ setMeta("property", "article:modified_time", meta.post.dateModified);
+ } else {
+ document.head.querySelector('meta[property="article:published_time"]')?.remove();
+ document.head.querySelector('meta[property="article:modified_time"]')?.remove();
+ }
+ let canonical = document.head.querySelector('link[rel="canonical"]');
+ if (!canonical) { canonical = document.createElement("link"); canonical.rel = "canonical"; document.head.append(canonical); }
+ canonical.href = meta.canonical;
+ let schema = document.head.querySelector('script[data-blog-schema]');
+ if (meta.structuredData) {
+ if (!schema) { schema = document.createElement("script"); schema.type = "application/ld+json"; schema.setAttribute("data-blog-schema", ""); document.head.append(schema); }
+ schema.textContent = JSON.stringify(meta.structuredData);
+ } else schema?.remove();
+}
diff --git a/aside-react/src/downloadPlatforms.js b/aside-react/src/downloadPlatforms.js
new file mode 100644
index 00000000..b982acff
--- /dev/null
+++ b/aside-react/src/downloadPlatforms.js
@@ -0,0 +1,17 @@
+const releases = "https://github.com/webadderallorg/Recordly/releases/latest";
+
+export const downloadPlatforms = [
+ { id: "macos", label: "macOS", href: releases },
+ { id: "windows", label: "Windows", href: `${releases}/download/Recordly-windows-x64.exe` },
+ { id: "linux", label: "Linux (experimental)", href: `${releases}/download/Recordly-linux-x64.AppImage` },
+];
+
+export function getDownloadPlatform(client = {}) {
+ const agent = client.userAgent || "";
+ const platform = client.userAgentData?.platform || client.platform || agent;
+ // Mobile browsers may identify as Linux or macOS; keep the desktop fallback.
+ if (/Android|iPhone|iPad|iPod/i.test(agent) || (/Mac/i.test(platform) && client.maxTouchPoints > 1)) return "macos";
+ if (/Win/i.test(platform)) return "windows";
+ if (/Linux/i.test(platform)) return "linux";
+ return "macos";
+}
diff --git a/aside-react/src/downloadPlatforms.test.js b/aside-react/src/downloadPlatforms.test.js
new file mode 100644
index 00000000..ae7566b6
--- /dev/null
+++ b/aside-react/src/downloadPlatforms.test.js
@@ -0,0 +1,23 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { getDownloadPlatform } from "./downloadPlatforms.js";
+
+test("desktop download selection supports modern and legacy browser identification", () => {
+ for (const [client, expected] of [
+ [{ userAgentData: { platform: "Windows" }, platform: "Linux x86_64" }, "windows"],
+ [{ platform: "Win32" }, "windows"],
+ [{ platform: "MacIntel" }, "macos"],
+ [{ platform: "Linux x86_64" }, "linux"],
+ [{ userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" }, "windows"],
+ [{ userAgent: "Mozilla/5.0 (X11; Linux x86_64)" }, "linux"],
+ ]) assert.equal(getDownloadPlatform(client), expected);
+});
+
+test("mobile, unknown and server-rendered clients retain the desktop fallback", () => {
+ for (const client of [
+ {}, { platform: "unknown" },
+ { platform: "Linux armv8l", userAgent: "Mozilla/5.0 (Linux; Android 15)" },
+ { platform: "MacIntel", maxTouchPoints: 5 },
+ { userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)" },
+ ]) assert.equal(getDownloadPlatform(client), "macos");
+});
diff --git a/aside-react/src/legal/BillingPolicy.jsx b/aside-react/src/legal/BillingPolicy.jsx
new file mode 100644
index 00000000..b5cb5378
--- /dev/null
+++ b/aside-react/src/legal/BillingPolicy.jsx
@@ -0,0 +1,95 @@
+import React from "react";
+import { LegalDocument as Legal } from "./LegalDocument";
+
+export default function BillingPolicy() {
+ return (
+
+ Cloud pricing
+
+
+
+ Plan
+ Charge
+ Renewal
+
+
+
+
+ Cloud monthly
+ As shown in Recordly and checkout
+ Every month until cancelled
+
+
+
+
+ Cloud is billed monthly. Check the currency, total, taxes if applicable and renewal
+ interval on Stripe’s checkout before subscribing. Currency-conversion or bank fees may be
+ charged by your provider.
+
+ What Cloud includes
+
+ Unlimited cloud storage and playback bandwidth, with up to 10 GB per recording
+ and no stored-recording count cap. Cloud allows 200 new uploads per rolling seven days, including admitted uploads later
+ canceled or deleted. Daily upload-capacity and abuse protections also apply.
+ Completed cloud recordings have no scheduled expiry while Cloud is active. Hosted
+ storage does not include automatic backup of editable project files.
+
+ How to cancel
+
+ Sign into the Recordly account used for your purchase.
+
+ Open Manage plan and choose{" "}
+ Manage subscription .
+
+
+ In Stripe’s secure portal, choose Cancel subscription and
+ complete the confirmation. Check the displayed end date.
+
+
+
+ Normal cancellation stops the next renewal. Paid access continues until the current
+ paid period ends. After that, new uploads use the Free limits, and existing cloud recordings expire seven
+ days later. Save a copy or renew before that deadline. Stored recordings continue to
+ count toward applicable limits.
+ Signing out, uninstalling Recordly or deleting a recording does not stop a
+ subscription.
+
+ Receipts and payment details
+
+ The same portal provides invoices and payment-method management. Recordly uses the
+ account’s server-created Stripe customer to link billing; changing your checkout
+ email does not move your subscription to a different Recordly account.
+
+ Refund requests
+
+ Contact support with your account email, invoice reference, transaction date and a
+ brief explanation. Do not send a password, full card number or card security code.
+ Tell us if a payment is duplicated, unauthorised, charged after a confirmed
+ cancellation or related to a service problem.
+
+
+ Refund and cancellation rights under applicable consumer law apply regardless of
+ this policy. In particular, Australian Consumer Law guarantees cannot be excluded.
+ We assess requests in light of those rights and the circumstances. We do not promise
+ an automatic change-of-mind or prorated refund for unused time unless required by
+ law or agreed with you.
+
+
+ A refund or payment dispute can restrict new paid upload privileges while the
+ account is reconciled. Contact support if access is incorrect. Refunding a charge
+ and cancelling future renewals are separate actions; ask us to confirm both when
+ that is what you want.
+
+ Billing support
+
+ If you cannot access the portal, have lost access to the purchasing account or need
+ help with a refund, contact us using the details below. Keep your confirmation and
+ invoices. These instructions do not limit your right to seek assistance from your
+ bank, payment provider or consumer regulator.
+
+
+ );
+}
diff --git a/aside-react/src/legal/Contact.jsx b/aside-react/src/legal/Contact.jsx
new file mode 100644
index 00000000..885f608c
--- /dev/null
+++ b/aside-react/src/legal/Contact.jsx
@@ -0,0 +1,47 @@
+import React from "react";
+import { legalDetails } from "./config";
+import { LegalDocument as Legal } from "./LegalDocument";
+
+export default function Contact() {
+ return (
+
+ Support and privacy requests
+ {legalDetails.email ? (
+
+ Email {legalDetails.email} . Include
+ the account email and a short description so we can route your request.
+
+ ) : (
+
+ The public support address is being verified. For now, use the in-app feedback
+ form while signed in. Do not post private account or billing information in a
+ public GitHub issue.
+
+ )}
+
+ For a billing question, include the invoice reference and date. For a problem with a
+ shared recording, include its link and explain what happened. To report illegal or
+ harmful content, identify the specific recording and concern. For an access,
+ correction or deletion request, describe the information or account involved.
+
+
+ Never send passwords, sign-in links, API keys, full card details or recordings you
+ do not need us to inspect. We may ask you to verify account ownership before
+ changing account data, disclosing information or handling a payment request.
+
+ Technical feedback
+
+ The desktop app’s feedback form accepts a description, attachments and relevant
+ diagnostic logs. Review what you attach, especially if your screen contains private
+ information. Public source-code issues belong in the{" "}
+
+ Recordly issue tracker
+
+ ; account, privacy and billing details should remain private.
+
+
+ );
+}
diff --git a/aside-react/src/legal/LegalDocument.jsx b/aside-react/src/legal/LegalDocument.jsx
new file mode 100644
index 00000000..fc36e4b7
--- /dev/null
+++ b/aside-react/src/legal/LegalDocument.jsx
@@ -0,0 +1,57 @@
+import React, { useEffect } from "react";
+import { LEGAL_VERSION, legalDetails, legalReady } from "./config";
+import { Nav, SiteFooter } from "../SiteHeader";
+import "./legal.css";
+
+export function LegalDocument({ title, description, children }) {
+ const path = window.location.pathname.replace(/\/$/, "").replace(/^\/policy/, "");
+ useEffect(() => {
+ document.title = `${title} · Recordly`;
+ const descriptionTag = document.querySelector('meta[name="description"]');
+ descriptionTag?.setAttribute("content", description);
+ const robots = document.createElement("meta");
+ robots.name = "robots";
+ robots.content = legalReady ? "index, follow" : "noindex, nofollow";
+ document.head.append(robots);
+ return () => robots.remove();
+ }, [title, description]);
+ return (
+
+
+ Skip to content
+
+
+
+
+ {title}
+ {description}
+
+
+ {!legalReady && (
+
+ Draft for review
+
+ These documents are not yet effective. Operator review, support
+ details and the effective date must be confirmed before publication
+ or live billing.
+
+
+ )}
+ {children}
+ {path !== "/contact" && (
+
+ )}
+
+ Version {LEGAL_VERSION}{legalReady && ` · Effective ${legalDetails.effectiveDate}`}
+
+
+
+
+
+ );
+}
diff --git a/aside-react/src/legal/PrivacyPolicy.jsx b/aside-react/src/legal/PrivacyPolicy.jsx
new file mode 100644
index 00000000..839bba8e
--- /dev/null
+++ b/aside-react/src/legal/PrivacyPolicy.jsx
@@ -0,0 +1,229 @@
+import React from "react";
+import { legalDetails } from "./config";
+import { LegalDocument as Legal } from "./LegalDocument";
+
+export default function PrivacyPolicy() {
+ return (
+
+ What this policy covers
+
+ This policy covers the Recordly desktop application and the account, cloud sharing,
+ billing and support services provided by Recordly. It does not replace the privacy
+ policies of a separate self-hosting operator or an external service you choose to
+ use.
+
+ Local recording and editing
+
+ The desktop app processes recordings and editable projects on your device. Depending
+ on the permissions and features you enable, these can contain screen or window
+ video, microphone and system audio, webcam footage, captions, cursor positions and
+ click timing. Project files, settings and diagnostic files may also be stored
+ locally.
+
+
+ Recording or editing does not by itself publish your recording. Cloud publishing
+ sends the media you choose to share. Raw project files and local cursor records are
+ not automatically uploaded as part of publishing an exported recording. Files you
+ explicitly attach to a support report are handled as support information.
+
+
+ HEIC wallpaper conversion and local transcription run on your device. Downloading an
+ application update or speech model still involves network requests to the relevant
+ download provider.
+
+ Information we handle
+
+
+ Account information: your account ID, email address, display
+ name, avatar and information returned by your chosen sign-in provider.
+ Authentication tokens allow the app to maintain your session.
+
+
+ Published recordings: video and audio you upload, thumbnails,
+ titles, duration, size, captions or other recording metadata you provide,
+ sharing settings, ownership and retention status.
+
+
+ Viewer interactions: view counts, reactions, comment text, the
+ name a commenter supplies and timestamps. Comments and names are visible to
+ people who can access that recording. IP addresses and request metadata may be
+ used or stored for delivery, abuse prevention and rate limiting.
+
+
+ Billing information: Stripe customer and subscription
+ identifiers, plan, payment status, billing period and payment-event records.
+ Stripe collects payment details on its own checkout and portal. Recordly’s
+ billing database does not store your full card number or security code.
+
+
+ Support information: messages, account identifiers, attachments
+ and diagnostic information sent with a report. The in-app feedback form
+ automatically attaches relevant diagnostic logs, which can include application,
+ device and export information. Review any files you attach and avoid including
+ passwords, payment details or confidential recordings in a report.
+
+
+ Technical information: request times, IP addresses, browser or
+ device information available with requests, errors and security logs needed to
+ operate and protect the service.
+
+
+ Abuse prevention: account age, upload admission counts and
+ sizes, and keyed network identifiers used to enforce cloud upload limits
+ across accounts. The admission ledger does not store raw IP addresses and
+ is pruned after 30 days. Cloudflare Turnstile may process browser security
+ signals during sign-in; aggregate limit alerts may be sent to the service operator.
+
+
+ Why we use it
+
+ We use this information to authenticate accounts, save and deliver published media,
+ manage access and storage limits, process subscriptions, prevent fraud and abuse,
+ investigate faults, answer requests and meet legal obligations. We do not sell
+ personal information or use your published recordings to train AI models.
+
+ Who can access a recording
+
+ A standard sharing link can be opened by anyone who obtains it, including someone to
+ whom it is forwarded. A link is not a private account login. If you enable password
+ protection, viewers also need the password. Share pages may expose the recording
+ title, thumbnail, comments and other presentation information to authorised viewers.
+
+
+ Cloud recordings are not end-to-end encrypted. The operator and service providers
+ may process content as needed to host it, provide support, protect the service or
+ comply with law. Do not publish a recording containing passwords, confidential
+ material or other people’s personal information without the necessary permission.
+
+ Service providers and overseas processing
+
+
+ Supabase: account authentication, account-related data and
+ submitted feedback, including attachments.
+
+
+ Cloudflare: hosting, media and metadata storage, network
+ delivery, security and request processing.
+
+
+ Google (Gmail): receiving and handling messages sent to our
+ support address.
+
+
+ Stripe: checkout, payment processing, subscriptions, invoices
+ and the customer billing portal.
+
+
+ Sign-in, email and download providers: the providers involved
+ when you use social sign-in, receive a sign-in or support email, or download
+ software or models.
+
+
+
+ These providers may process information outside your country.{" "}
+ {legalDetails.locations ||
+ "The configured processing countries and hosting locations are awaiting confirmation before launch."}{" "}
+ Providers also apply their own privacy policies when they act independently, such as
+ when Stripe processes payment information.
+
+
+ We may disclose information when reasonably necessary to comply with law, respond to
+ a valid legal request or protect people and the service. If the service changes
+ operator, any transfer of personal information must remain subject to applicable law
+ and notice requirements.
+
+ Storage, cookies and security
+
+ The desktop app stores settings and project information locally. Sign-in sessions
+ use the operating system’s encrypted credential storage when it is available, with a
+ browser-storage fallback in supported preview or development environments. Hosted
+ pages use local storage for preferences such as theme and a remembered commenter
+ name. Password-protected recording access uses an unlock cookie.
+
+
+ Service providers use security and operational technologies needed to deliver their
+ services. Blocking necessary storage may prevent sign-in or protected playback. We
+ do not add advertising trackers to Recordly-hosted recording pages. A separately
+ hosted marketing site or external page may have its own provider technologies.
+
+
+ We use access controls and authenticated server checks to protect accounts and
+ uploads. No storage or transmission system is completely secure, and a sharing link
+ or password can be redistributed by its recipient.
+
+ How long information remains
+
+
+ Local files: remain on your device until you remove them.
+ Removing a cloud copy does not remove your local project or another person’s
+ downloaded copy.
+
+
+ Free hosted recordings: normally expire after 7 days and
+ become unavailable. Cleanup removes expired media and associated recording
+ records. Incomplete uploads may be cleaned up after one day.
+
+
+ Paid retention: completed cloud recordings have no scheduled expiry while Cloud is active. They expire
+ seven days after Cloud ends. Upgrading protects eligible recordings still stored at that
+ time; expired or deleted media cannot be restored.
+
+
+ Deletion: deleting a cloud recording removes its media,
+ thumbnail and associated comments, reactions and recording metadata through the
+ service. Cached copies and provider recovery copies may take additional time to
+ disappear; copies held by recipients are outside our control.
+
+
+ Account, support, security and billing records: are retained as
+ needed for the service, unresolved requests, fraud prevention and legal or
+ accounting obligations. We review deletion requests individually and explain any
+ records we must retain.
+
+
+ Your choices and requests
+
+ You can change your profile, revoke app permissions in your operating system, sign
+ out, stop publishing, cancel a subscription and delete cloud recordings using the
+ controls available in Recordly. To request access, correction, account deletion or
+ help with another privacy right, contact us using the details below. We may verify
+ ownership before disclosing or deleting information.
+
+
+ If you request account deletion, tell us whether you also want all published media
+ removed and any subscription cancelled. We will explain the steps and any
+ information retained to meet legal obligations. Signing out or uninstalling the
+ desktop app does not cancel a subscription or delete hosted media.
+
+ Young users and complaints
+
+ The hosted service is not designed for children who cannot lawfully consent to its
+ use. If you need parental or guardian permission under applicable law, obtain it
+ before creating an account or sharing personal information. Contact us if you
+ believe a child’s information has been provided without the required permission.
+
+
+ Send privacy concerns in writing to the contact below. Describe what happened, the
+ account or recording involved and the outcome you are seeking, without including
+ passwords or full payment details. We will investigate and respond. If you are not
+ satisfied with our response, or have not received one within a reasonable time, you
+ may raise a complaint with the privacy regulator that has jurisdiction, including
+ the{" "}
+
+ Office of the Australian Information Commissioner
+ {" "}
+ where applicable. This policy does not limit rights available under applicable
+ privacy law.
+
+ Changes to this policy
+
+ We will publish an updated version and effective date when this policy changes.
+ Where required, we will give additional notice or obtain consent before a materially
+ different use of your information.
+
+
+ );
+}
diff --git a/aside-react/src/legal/TermsOfService.jsx b/aside-react/src/legal/TermsOfService.jsx
new file mode 100644
index 00000000..d777bf17
--- /dev/null
+++ b/aside-react/src/legal/TermsOfService.jsx
@@ -0,0 +1,164 @@
+import React from "react";
+import { LegalDocument as Legal } from "./LegalDocument";
+
+export default function TermsOfService() {
+ return (
+
+ 1. Scope and agreement
+
+ These terms govern the hosted Recordly services provided by Recordly. Read them
+ together with the privacy policy and{" "}
+ billing and refund policy before using a hosted account or
+ purchasing Cloud. The open-source desktop software remains governed by its applicable
+ software licences; these terms do not remove rights granted by those licences.
+
+
+ You must have the authority and legal capacity to agree to these terms. If you need
+ a parent’s or guardian’s permission under applicable law, involve them before using
+ a hosted account. A paid subscription must be purchased by someone authorised to
+ enter the payment agreement. If you use Recordly for an organisation, you must be
+ authorised to act for it.
+
+ 2. Your account
+
+ Provide accurate account information, protect your sign-in methods and tell us if
+ your account appears compromised. Account features and paid access are linked to
+ your verified Recordly account, not a particular installation or a matching checkout
+ email address. Do not share another person’s credentials, impersonate someone or try
+ to access another account’s private library.
+
+ 3. Your recordings and permissions
+
+ You retain ownership of the content you create or upload. You give the operator and
+ its service providers the limited permission needed to store, process, display and
+ deliver content through the features you use, including generating thumbnails,
+ serving sharing links and maintaining permitted recovery copies. We do not acquire
+ ownership of your recordings.
+
+
+ You are responsible for having the rights and permissions required to record
+ screens, people, voices, meetings, copyrighted works and personal or confidential
+ information. Recording consent requirements vary by location. Upload only content
+ you are authorised to share.
+
+ 4. Sharing and local copies
+
+ Anyone who obtains an unprotected sharing link may be able to watch and redistribute
+ it. Password protection adds an access check but cannot prevent an authorised viewer
+ from copying content. Do not treat a public link as confidential storage. The
+ service hosts published media; it is not a backup service for editable Recordly
+ projects. Keep your own copies of important work.
+
+ 5. Free and Cloud services
+
+ The desktop recording and editing features remain available under the software’s
+ licence. Optional hosted services have the limits displayed in the app and at
+ checkout. At launch, Free allows five stored cloud recordings of up to 1 GB each,
+ normally expiring after 7 days. Cloud includes unlimited cloud storage and playback
+ bandwidth, with up to 10 GB per recording. Cloud allows 200 new uploads per rolling seven days, including admitted uploads later
+ canceled or deleted. Daily upload-capacity and abuse protections also apply. GB means one billion bytes.
+ Reserved and incomplete uploads may count toward capacity.
+
+
+ Cloud recordings have no scheduled expiry while Cloud is active. A verified upgrade also
+ protects eligible recordings still stored at the time of upgrade. When Cloud ends, those
+ recordings expire after a seven-day grace period. Save a copy or renew before that
+ deadline. Upgrading cannot recover expired or deleted files. New uploads after Cloud
+ ends follow the Free limits; stored recordings continue to count toward applicable
+ limits.
+
+
+ “No expiry” applies only while Cloud is active. Cloud recordings expire seven days after
+ Cloud ends. It does not prevent deletion you request, lawful removal or removal
+ necessary to address serious abuse. It is not a guarantee of uninterrupted service
+ or a separate disaster-recovery copy.
+
+ 6. Prices, renewals and cancellation
+
+ Cloud is a recurring subscription. Current pricing and any promotional offers appear in Recordly. Checkout
+ displays the amount, currency, interval and any applicable taxes before you pay.
+ Your payment provider or bank may apply currency-conversion fees.
+
+
+ Your subscription renews automatically at its stated interval until cancelled. Use{" "}
+ Manage subscription in Recordly to access Stripe’s billing portal.
+ A normal cancellation stops renewal and keeps paid access until the end of the
+ current paid period. Uninstalling the app, signing out or deleting a recording does
+ not cancel billing. See billing and refunds for further
+ details.
+
+
+ We will give advance notice of a material price or plan change before it applies to
+ a renewal, with the opportunity to cancel. We will not charge a different price for
+ an already-paid period without your agreement. Cloud recording retention follows the Free and Cloud rules
+ above.
+
+ 7. Acceptable use
+
+ Do not use the service for unlawful content, child sexual abuse material, threats,
+ harassment, infringement, non-consensual intimate material, malware or disclosure of
+ information you are not entitled to publish. Do not evade storage limits or security
+ controls, conduct unauthorised testing, interfere with other users, automate abusive
+ traffic or resell access without permission.
+
+
+ We may investigate reports and restrict access or remove content where reasonably
+ necessary to meet legal obligations, protect people or prevent serious abuse. Where
+ lawful and practical, we will explain the reason and offer a way to challenge an
+ error. We will not use this power to remove statutory refund rights.
+
+ 8. Payments that fail or are disputed
+
+ Paid upload privileges depend on a valid, paid subscription. Failed payments,
+ cancellation taking effect, refunds or disputes may suspend those privileges while
+ billing is resolved. If Cloud access ends, existing cloud recordings receive the seven-day grace period
+ described above. Contact support if the account or
+ billing status is wrong; do not start multiple subscriptions to bypass a
+ restriction.
+
+ 9. Availability, changes and closure
+
+ We aim to operate the service reliably, but outages, maintenance and
+ service-provider failures can occur. We may make reasonable service changes. If we
+ plan to discontinue hosted storage, we will give reasonable advance notice and an
+ opportunity to export available media where practical, and address prepaid services
+ and refunds in accordance with applicable law.
+
+
+ Account closure, data deletion and subscription cancellation are different actions.
+ Contact support for help closing an account and removing associated hosted
+ information. We may retain records required by law or necessary to resolve an
+ outstanding matter as explained in the privacy policy.
+
+ 10. Consumer rights and responsibility
+
+ Nothing in these terms excludes, restricts or modifies a guarantee, right or remedy
+ that cannot lawfully be excluded, including rights under the Australian Consumer Law
+ where it applies. You may be entitled to a repair, replacement, refund, cancellation
+ or compensation when a service fails a statutory guarantee.
+
+
+ Subject to those rights, we do not promise that the service will be suitable for
+ every purpose or free of interruptions. Each party remains responsible for its
+ conduct as required by applicable law. These terms do not impose an unlimited
+ indemnity on individual users or require you to waive non-excludable rights.
+
+ 11. Concerns and updates
+
+ Contact us using the details below about billing, a privacy request, an access
+ restriction, illegal content or another dispute. Tell us enough to identify the
+ account or link without sending passwords or full payment details. Applicable law
+ determines any rights to contact a regulator or seek a legal remedy; these terms do
+ not remove them.
+
+
+ Material changes will be published with an updated effective date and any notice or
+ acceptance required by law. Changes will not retroactively remove an accrued
+ consumer right or a prepaid period of Cloud access.
+
+
+ );
+}
diff --git a/aside-react/src/legal/config.js b/aside-react/src/legal/config.js
new file mode 100644
index 00000000..9897392a
--- /dev/null
+++ b/aside-react/src/legal/config.js
@@ -0,0 +1,21 @@
+export const LEGAL_VERSION = "2026-10-18";
+export const legalDetails = {
+ email: import.meta.env.VITE_LEGAL_SUPPORT_EMAIL?.trim() || "support@recordly.dev",
+ locations:
+ import.meta.env.VITE_LEGAL_PROCESSING_LOCATIONS?.trim() ||
+ "The Supabase project used for accounts and feedback is hosted in US East (Northern Virginia, United States). Cloudflare media storage and sharing metadata are configured with Oceania placement hints; these hints do not guarantee Australia-only storage. Cloudflare delivers content through its global network. Stripe, Google and other service providers may process information in additional countries under their service arrangements.",
+ effectiveDate: import.meta.env.VITE_LEGAL_EFFECTIVE_DATE?.trim() || "",
+ approved: import.meta.env.VITE_LEGAL_APPROVED === "true",
+};
+const values = [legalDetails.email, legalDetails.locations, legalDetails.effectiveDate];
+export const legalReady = Boolean(
+ legalDetails.approved &&
+ legalDetails.email &&
+ legalDetails.locations &&
+ legalDetails.effectiveDate &&
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(legalDetails.email) &&
+ /^\d{4}-\d{2}-\d{2}$/.test(legalDetails.effectiveDate) &&
+ !values.some((value) =>
+ /(?:example\.(?:com|org)|\[|\]|TODO|TBD|pending|placeholder)/i.test(value),
+ ),
+);
diff --git a/aside-react/src/legal/legal.css b/aside-react/src/legal/legal.css
new file mode 100644
index 00000000..cc7c4861
--- /dev/null
+++ b/aside-react/src/legal/legal.css
@@ -0,0 +1,127 @@
+.policy-site {
+ min-height: 100svh;
+ display: flex;
+ flex-direction: column;
+}
+.policy-site > .nav {
+ width: min(1120px, 100%);
+ margin: 12px auto 0;
+}
+.policy-content {
+ width: min(672px, calc(100% - 48px));
+ margin: 60px auto 100px;
+ flex: 1;
+}
+.policy-heading {
+ text-align: center;
+ margin-bottom: 36px;
+}
+.policy-heading h1 {
+ font-size: clamp(34px, 4.5vw, 44px);
+ line-height: 1.15;
+}
+.policy-heading p {
+ max-width: 520px;
+ margin: 20px auto 0;
+ font-size: 18px;
+ color: var(--muted);
+}
+.policy-document {
+ font-size: 17px;
+ letter-spacing: -0.015em;
+ overflow-wrap: anywhere;
+ color: #454545;
+}
+.policy-document h2 {
+ font-size: 28px;
+ line-height: 1.2;
+ margin: 40px 0 14px;
+ color: #090b0c;
+}
+.policy-document p {
+ margin: 18px 0;
+ line-height: 1.8;
+}
+.policy-document a {
+ color: var(--blue);
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+.policy-document a:hover {
+ color: #090b0c;
+}
+.policy-document ul,
+.policy-document ol {
+ padding-left: 24px;
+ margin: 20px 0;
+}
+.policy-document li {
+ margin: 10px 0;
+ padding-left: 4px;
+ line-height: 1.75;
+}
+.policy-document table {
+ width: 100%;
+ overflow-wrap: normal;
+ border-collapse: collapse;
+ margin: 24px 0;
+ font-size: 14px;
+ line-height: 1.6;
+}
+.policy-document th,
+.policy-document td {
+ text-align: left;
+ padding: 14px 10px;
+ border-bottom: 1px solid var(--line);
+ vertical-align: top;
+}
+.policy-document th {
+ font-weight: 500;
+ color: #090b0c;
+}
+.policy-draft {
+ padding-left: 16px;
+ border-left: 2px solid var(--line);
+ margin-bottom: 36px;
+ font-size: 13px;
+}
+.policy-draft p {
+ color: var(--muted);
+ margin: 6px 0 0;
+ line-height: 1.65;
+}
+.policy-contact {
+ border-top: 1px solid var(--line);
+ margin-top: 40px;
+}
+.policy-document .policy-version {
+ font-size: 12px;
+ color: var(--muted);
+ margin: 40px 0 0;
+}
+@media (max-width: 700px) {
+ .policy-content {
+ margin-top: 48px;
+ margin-bottom: 64px;
+ }
+ .policy-heading p {
+ font-size: 16px;
+ }
+ .policy-document {
+ font-size: 16px;
+ }
+ .policy-document h2 {
+ font-size: 25px;
+ }
+}
+@media print {
+ .policy-site > .nav,
+ .policy-site > .site-footer,
+ .policy-site > .skip {
+ display: none;
+ }
+ .policy-content {
+ width: 100%;
+ margin: 0;
+ }
+}
diff --git a/aside-react/src/main.jsx b/aside-react/src/main.jsx
new file mode 100644
index 00000000..e53ea66e
--- /dev/null
+++ b/aside-react/src/main.jsx
@@ -0,0 +1,95 @@
+import React from "react";
+import { createRoot, hydrateRoot } from "react-dom/client";
+import "./styles.css";
+import HeroVideo from "./HeroVideo";
+import FeatureStack from "./FeatureStack";
+import FeatureHighlights from "./FeatureHighlights";
+import BillingPolicy from "./legal/BillingPolicy";
+import Contact from "./legal/Contact";
+import PrivacyPolicy from "./legal/PrivacyPolicy";
+import TermsOfService from "./legal/TermsOfService";
+import Blog from "./blog/Blog";
+import Pricing from "./pricing/Pricing";
+import { Logo, Download, Nav, SiteFooter } from "./SiteHeader";
+
+// Set to { title, href } when there is a post worth highlighting in the hero.
+const heroAnnouncement = null;
+
+function App() {
+ return (
+ <>
+
+ Skip to content
+
+
+
+
+
+ {heroAnnouncement && (
+
+ {heroAnnouncement.title} ›
+
+ )}
+
+ Beautiful, shareable
+ screen recordings.
+
+
+ An opinionated, open-source screen recorder for beautiful output.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Record and edit on your device. Export a video locally, or choose cloud
+ sharing when you want a link.
+
+
+
+
+
+
+ Record. Edit. Share.
+
+ Start using today.
+
+
+
+
+ >
+ );
+}
+
+const legalRoutes = {
+ "/privacy": PrivacyPolicy,
+ "/policy/privacy": PrivacyPolicy,
+ "/terms": TermsOfService,
+ "/policy/terms": TermsOfService,
+ "/billing": BillingPolicy,
+ "/policy/billing": BillingPolicy,
+ "/contact": Contact,
+};
+const LegalPage = legalRoutes[window.location.pathname.replace(/\/$/, "")];
+const isBlog = /^\/blog(?:\/|$)/.test(window.location.pathname);
+const isPricing = /^\/pricing\/?$/.test(window.location.pathname);
+const root = document.getElementById("root");
+const page = LegalPage ? : isPricing ? : isBlog ? } path={window.location.pathname} /> : ;
+if ((isBlog || isPricing) && root.hasAttribute("data-prerendered")) hydrateRoot(root, page);
+else createRoot(root).render(page);
diff --git a/aside-react/src/pricing/Pricing.jsx b/aside-react/src/pricing/Pricing.jsx
new file mode 100644
index 00000000..708d3421
--- /dev/null
+++ b/aside-react/src/pricing/Pricing.jsx
@@ -0,0 +1,84 @@
+import React, { useEffect, useState } from "react";
+import { Nav, SiteFooter } from "../SiteHeader";
+import { applyBlogMetadata, siteOrigin } from "../blog/seo";
+import { catalogUrl, parseCatalog, dollars, gigabytes, pricingMetadata } from "./catalog.js";
+import "./pricing.css";
+
+const download = "https://github.com/webadderallorg/Recordly/releases/latest";
+function Feature({ children, included = false }) {
+ return {included ? "+" : "✓"} {children} ;
+}
+
+export default function Pricing() {
+ const [catalog, setCatalog] = useState(null);
+ const [error, setError] = useState(false);
+ const [attempt, setAttempt] = useState(0);
+ useEffect(() => { applyBlogMetadata(pricingMetadata(siteOrigin)); }, []);
+ // biome-ignore lint/correctness/useExhaustiveDependencies: each retry attempt explicitly refetches the current catalog.
+ useEffect(() => {
+ const controller = new AbortController();
+ let active = true;
+ const timeout = setTimeout(() => controller.abort(), 10000);
+ setError(false);
+ setCatalog(null);
+ fetch(catalogUrl, {credentials:"omit", cache:"no-store", signal:controller.signal})
+ .then((response) => { if (!response.ok) throw new Error("Unavailable"); return response.json(); })
+ .then(parseCatalog)
+ .then((value) => { if (active) setCatalog(value); })
+ .catch(() => { if (active) setError(true); })
+ .finally(() => clearTimeout(timeout));
+ return () => { active = false; clearTimeout(timeout); controller.abort(); };
+ }, [attempt]);
+ return (
+
+
Skip to content
+
+
+
+ Record and edit for free. Upgrade your cloud sharing.
+ Free includes local video exports and limited cloud sharing. Cloud adds SRT and VTT caption-file exports, larger uploads, more storage, and links that stay live while your subscription is active.
+ Cloud is billed monthly
+
+
+
+ Free For getting started.
+ $0 / forever
+
+ Screen and webcam recording
+ Automatic zooms and smooth cursors
+ Backgrounds, captions and editing tools
+ MP4 and GIF exports
+ {catalog ? `${catalog.freeRecordings} cloud recordings` : "Limited cloud sharing"}
+ {catalog && Uploads up to {gigabytes(catalog.freeBytes)} each }
+
+ Download Recordly ↓
+
+
+ Cloud For sharing more.
+
+ {catalog ? <>
+
{dollars(catalog.amount)} USD / month
+ {catalog.regular &&
Early bird {dollars(catalog.regular)} USD / month
}
+ > :
{error ? "Pricing is temporarily unavailable." : "Loading current price…"}
}
+ {error &&
setAttempt((n) => n + 1)}>Retry pricing }
+
+
+ Everything in Free
+ Export captions as SRT and VTT files
+ Share videos in high-definition 1080p
+ {!catalog ? "Cloud storage and bandwidth" : catalog.storageBytes === null ? "Unlimited cloud storage and bandwidth" : `${gigabytes(catalog.storageBytes)} cloud storage`}
+ {catalog ? `Uploads up to ${gigabytes(catalog.cloudBytes)} each` : "Larger cloud uploads"}
+ Shared links stay live while Cloud is active
+ {catalog?.weeklyUploads && {catalog.weeklyUploads} uploads per rolling 7 days }
+ Support Recordly’s development
+
+ Download to get Cloud ↓
+
+
+ Cloud renews monthly until cancelled. Upload and fair-use limits apply. Confirm your total at checkout.
+ Billing & refunds · Questions? Get in touch
+
+
+
+ );
+}
diff --git a/aside-react/src/pricing/catalog.js b/aside-react/src/pricing/catalog.js
new file mode 100644
index 00000000..14a21234
--- /dev/null
+++ b/aside-react/src/pricing/catalog.js
@@ -0,0 +1,32 @@
+export const catalogUrl = "https://videos.recordly.dev/api/billing/catalog";
+
+export function parseCatalog(value) {
+ const price = value?.prices?.find((entry) => entry.interval === "monthly" && entry.currency === "usd");
+ const limits = value?.limits;
+ if (!price || !Number.isSafeInteger(price.unitAmount) || price.unitAmount <= 0 ||
+ !limits || ![limits.freeRecordingLimit, limits.freeMaxRecordingBytes, limits.plusMaxRecordingBytes].every((n) => Number.isSafeInteger(n) && n > 0) ||
+ limits.plusMaxRecordingBytes < limits.freeMaxRecordingBytes ||
+ (limits.storageLimitBytes !== null && (!Number.isSafeInteger(limits.storageLimitBytes) || limits.storageLimitBytes <= 0))) {
+ throw new Error("Pricing is temporarily unavailable.");
+ }
+ const regular = Number.isSafeInteger(price.regularUnitAmount) && price.regularUnitAmount > price.unitAmount ? price.regularUnitAmount : null;
+ return {
+ amount: price.unitAmount,
+ regular: regular && price.promotion === "early_bird" ? regular : null,
+ freeRecordings: limits.freeRecordingLimit,
+ freeBytes: limits.freeMaxRecordingBytes,
+ cloudBytes: limits.plusMaxRecordingBytes,
+ storageBytes: limits.storageLimitBytes,
+ weeklyUploads: Number.isSafeInteger(limits.plusWeeklyUploadLimit) && limits.plusWeeklyUploadLimit > 0 ? limits.plusWeeklyUploadLimit : null,
+ };
+}
+
+export const dollars = (cents) => new Intl.NumberFormat("en-US", {style:"currency",currency:"USD",minimumFractionDigits:cents % 100 ? 2 : 0,maximumFractionDigits:2}).format(cents / 100);
+export const gigabytes = (bytes) => `${new Intl.NumberFormat("en-US",{maximumFractionDigits:1}).format(bytes / 1e9)} GB`;
+
+export function pricingMetadata(origin) {
+ const canonical = `${origin}/pricing`;
+ const title = "Pricing | Recordly";
+ const description = "Record and edit for free. Compare Recordly Free and Cloud, with current monthly pricing and sharing limits.";
+ return {title, description, canonical, image:null, post:null, robots:"index, follow", structuredData:{"@context":"https://schema.org","@type":"WebPage",name:"Recordly pricing",description,url:canonical}};
+}
diff --git a/aside-react/src/pricing/pricing.css b/aside-react/src/pricing/pricing.css
new file mode 100644
index 00000000..9ece4724
--- /dev/null
+++ b/aside-react/src/pricing/pricing.css
@@ -0,0 +1,40 @@
+/* Aside pricing reference: centered intro, open columns, faint rules, quiet metadata. */
+.pricing-site { min-height: 100svh; display: flex; flex-direction: column; }
+.pricing-site > .nav { width: min(1120px, 100%); margin: 12px auto 0; }
+.pricing-content { width: min(960px, calc(100% - 48px)); margin: 0 auto; flex: 1; }
+.pricing-heading { text-align: center; margin: 60px auto 72px; }
+.pricing-heading h1 { font-size: clamp(36px, 4.5vw, 48px); line-height: 1.08; font-weight: 450; }
+.pricing-heading > p { color: #818181; margin: 28px auto 0; max-width: 670px; font-size: 18px; }
+.pricing-heading .pricing-interval { margin: 32px auto 0; padding: 9px 20px; border-radius: 12px; background: #f5f5f5; color: #777; font-size: 13px; width: fit-content; }
+.pricing-plans { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.pricing-plan { padding: 0 40px 0; display: flex; flex-direction: column; }
+.pricing-plan + .pricing-plan { border-left: 1px solid #f0f0f0; }
+.pricing-plan h2 { font-size: 18px; font-weight: 500; line-height: 1.6; letter-spacing: -0.025em; }
+.pricing-plan h2 > span { font-weight: 400; color: #999; }
+.plan-price { display: flex; gap: 8px; align-items: baseline; min-height: 67px; margin: 24px 0 22px; }
+.plan-price > span:first-child { font-size: 48px; font-weight: 400; letter-spacing: -0.04em; line-height: 1.2; }
+.plan-price .plan-period { font-size: 13px; color: #999; letter-spacing: -0.015em; }
+.cloud-price-area { min-height: 113px; }
+.cloud-price-area .plan-price { margin-bottom: 0; }
+.plan-offer { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; color: #999; font-size: 12px; margin-top: -6px; }
+.plan-offer > span { color: var(--brand-accent); background: var(--brand-accent-soft); padding: 2px 8px; border-radius: 20px; }
+.plan-price-status { color: #777; margin: 34px 0 12px; min-height: 44px; font-size: 16px; }
+.pricing-retry { border: 0; background: transparent; color: var(--brand-accent); padding: 0; text-decoration: underline; font-size: 13px; }
+.pricing-plan ul { list-style: none; padding: 0; margin: 8px 0 32px; font-size: 15px; line-height: 1.5; }
+.pricing-plan li { display: flex; align-items: flex-start; gap: 12px; margin: 16px 0; }
+.plan-check, .plan-included { width: 20px; height: 20px; flex: 0 0 20px; display: grid; place-items: center; border-radius: 50%; background: #f4f4f4; color: #9c9c9c; font-size: 12px; line-height: 20px; }
+.plan-included { background: #a0a0a0; color: #fff; font-size: 16px; }
+.plan-action { display: inline-flex; align-items: center; justify-content: center; gap: 10px; padding: 12px 20px; border-radius: 12px; line-height: 1.4; font-size: 14px; font-weight: 500; margin-top: auto; transition: background-color .2s, color .2s, border-color .2s; }
+.plan-action-free { border: 1px solid #ddd; color: #090b0c; background: #fff; }
+.plan-action-free:hover, .plan-action-free:focus-visible { border-color: var(--brand-accent); color: var(--brand-accent); background: var(--brand-accent-soft); }
+.plan-action-cloud { background: #090b0c; color: #fff; border: 1px solid #090b0c; }
+.plan-action-cloud:hover, .plan-action-cloud:focus-visible { background: var(--brand-accent); border-color: var(--brand-accent); color: #fff; }
+.pricing-note { color: #909090; font-size: 12px; text-align: center; margin: 28px 0 0; }
+.pricing-policy { text-align: center; font-size: 13px; color: #888; margin: 24px 0 64px; }
+@media (max-width:700px) {
+ .pricing-heading { margin: 48px auto; }
+ .pricing-heading > p { font-size: 16px; }
+ .pricing-plans { grid-template-columns: 1fr; gap: 44px; }
+ .pricing-plan { padding: 0; }
+ .pricing-plan + .pricing-plan { border-left: 0; border-top: 1px solid #eee; padding-top: 36px; }
+}
diff --git a/aside-react/src/showcaseScroll.js b/aside-react/src/showcaseScroll.js
new file mode 100644
index 00000000..ec58e29c
--- /dev/null
+++ b/aside-react/src/showcaseScroll.js
@@ -0,0 +1,13 @@
+// Preserve native scrolling except for progress still owed at the top boundary.
+export function resolveShowcaseRewind({ scrollY, deltaY, start, distance, progressDistance, progress }) {
+ // Browsers may round scrollTo to a device pixel below the fractional boundary.
+ if (deltaY >= 0 || scrollY < start - 1 || scrollY + deltaY >= start || progress <= 0) return null;
+ const nativeRewind = Math.max(0, Math.min(distance, scrollY - start));
+ const remaining = progress * progressDistance - nativeRewind;
+ if (remaining <= 0) return null;
+ const held = Math.min(start - scrollY - deltaY, remaining);
+ return {
+ scrollY: scrollY + deltaY + held,
+ progress: Math.max(0, remaining - held) / progressDistance,
+ };
+}
diff --git a/aside-react/src/showcaseScroll.test.js b/aside-react/src/showcaseScroll.test.js
new file mode 100644
index 00000000..60aa48d7
--- /dev/null
+++ b/aside-react/src/showcaseScroll.test.js
@@ -0,0 +1,60 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { resolveShowcaseRewind } from "./showcaseScroll.js";
+
+const rewind = (values) => resolveShowcaseRewind({
+ scrollY: 1000, start: 1000, distance: 1000, progressDistance: 1000, progress: .5,
+ ...values,
+});
+
+test("upward scrolling stays pinned while progress remains", () => {
+ assert.deepEqual(rewind({ deltaY: -100 }), { scrollY: 1000, progress: .4 });
+});
+
+test("rounded scroll positions still hold the fractional top boundary", () => {
+ const result = rewind({ scrollY: 999.5, deltaY: -100 });
+ assert.equal(result.scrollY, 1000);
+ assert.ok(result.progress > 0);
+});
+
+test("a gesture crossing the top uses its full movement to rewind", () => {
+ const result = rewind({ scrollY: 1120, progress: .8, deltaY: -200 });
+ assert.equal(result.scrollY, 1000);
+ assert.ok(Math.abs(result.progress - .6) < 1e-10);
+});
+
+test("remaining gesture distance scrolls above only after progress reaches zero", () => {
+ assert.deepEqual(rewind({ scrollY: 1120, progress: .2, deltaY: -300 }), { scrollY: 900, progress: 0 });
+});
+
+test("fractional progress reaches exactly zero when fully rewound", () => {
+ assert.equal(rewind({ progress: .387654321, deltaY: -500 }).progress, 0);
+});
+
+test("reentering a shortened completed section preserves its original rewind scale", () => {
+ const result = rewind({ scrollY: 2000, distance: 200, progress: 1, deltaY: -1100 });
+ assert.equal(result.scrollY, 1000);
+ assert.ok(Math.abs(result.progress - .7) < 1e-10);
+});
+
+test("native scrolling is preserved outside the upper boundary and at zero", () => {
+ for (const values of [
+ { deltaY: 100 }, { deltaY: -50, scrollY: 1200 },
+ { deltaY: -100, progress: 0 }, { deltaY: -100, scrollY: 900 },
+ { deltaY: -800, scrollY: 1500, progress: .1 },
+ ]) assert.equal(rewind(values), null);
+});
+
+test("no upward gesture exits above with unfinished progress", () => {
+ for (const progress of [.001, .25, .5, 1]) {
+ for (const offset of [0, 50, 500]) {
+ for (const deltaY of [-.5, -10, -100, -1000, -5000]) {
+ const result = rewind({ progress, scrollY: 1000 + offset, deltaY });
+ if (!result) continue;
+ assert.ok(result.progress >= 0 && result.progress <= progress);
+ if (result.progress > 1e-10) assert.ok(result.scrollY >= 1000);
+ if (result.scrollY < 1000) assert.equal(result.progress, 0);
+ }
+ }
+ }
+});
diff --git a/aside-react/src/styles.css b/aside-react/src/styles.css
new file mode 100644
index 00000000..bde3e8d5
--- /dev/null
+++ b/aside-react/src/styles.css
@@ -0,0 +1,1590 @@
+@font-face {
+ font-family: "DM Sans";
+ src: url("/assets/dm-sans-variable.ttf") format("truetype");
+ font-style: normal;
+ font-weight: 100 1000;
+ font-display: swap;
+}
+@font-face {
+ font-family: "DM Sans";
+ src: url("/assets/dm-sans-italic-variable.ttf") format("truetype");
+ font-style: italic;
+ font-weight: 100 1000;
+ font-display: swap;
+}
+:root {
+ font-family: "DM Sans", Arial, sans-serif;
+ letter-spacing: -0.03em;
+ color: #090b0c;
+ background: #fff;
+ font-synthesis: none;
+ --muted: #777;
+ --line: #ececec;
+ --brand-accent: #0050ff;
+ --brand-accent-soft: #edf2ff;
+ --blue: var(--brand-accent);
+}
+* {
+ box-sizing: border-box;
+}
+body {
+ margin: 0;
+}
+button,
+a {
+ -webkit-tap-highlight-color: transparent;
+}
+button,
+input {
+ font: inherit;
+}
+button,
+a {
+ touch-action: manipulation;
+}
+button {
+ cursor: pointer;
+}
+a {
+ color: inherit;
+ text-decoration: none;
+}
+button {
+ color: inherit;
+}
+a:hover {
+ color: var(--blue);
+}
+button:focus-visible,
+a:focus-visible {
+ outline: 3px solid var(--brand-accent);
+ outline-offset: 5px;
+}
+img {
+ max-width: 100%;
+ display: block;
+}
+html {
+ scroll-behavior: smooth;
+ scroll-padding-top: 80px;
+}
+h1,
+h2,
+h3,
+p {
+ margin: 0;
+}
+h1,
+h2,
+h3 {
+ font-family:
+ "DM Sans",
+ Arial,
+ sans-serif;
+ font-weight: 450;
+ letter-spacing: -0.025em;
+}
+h1 {
+ font-weight: 400;
+ font-size: 48px;
+ line-height: 1.08;
+}
+h2 {
+ font-size: 44px;
+ line-height: 1.1;
+}
+h3 {
+ font-size: 32px;
+ line-height: 1.15;
+}
+p {
+ line-height: 1.55;
+}
+strong {
+ font-weight: 500;
+ color: #090b0c;
+}
+.muted {
+ color: var(--muted);
+}
+.ink {
+ color: #090b0c;
+}
+.skip {
+ position: fixed;
+ top: 10px;
+ left: 10px;
+ z-index: 100;
+ background: white;
+ padding: 12px;
+ transform: translateY(-150%);
+}
+.skip:focus {
+ transform: none;
+}
+.hero {
+ --hero-inset: 16px;
+ margin: var(--hero-inset) var(--hero-inset) 0;
+ padding-bottom: 80px;
+ border-radius: 28px;
+ background: #fff;
+ isolation: isolate;
+ position: relative;
+ overflow: clip;
+}
+
+.hero-background-track {
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ pointer-events: none;
+}
+
+.hero-background {
+ /* Native sticky positioning stays in sync with compositor scrolling and
+ releases at the hero's bottom, without a JavaScript scroll transform. */
+ position: sticky;
+ top: calc(var(--hero-inset) - 18px);
+ margin: -18px -18px 0;
+ height: min(1100px, 110svh);
+ /* Mask the composited blur so the bottom reaches actual zero alpha. */
+ -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 20%, transparent 100%);
+ mask-image: linear-gradient(to bottom, #000 0%, #000 20%, transparent 100%);
+ mask-mode: alpha;
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+}
+
+.hero-background::before {
+ content: "";
+ position: absolute;
+ inset: -18px;
+ background: url("/assets/recordly-blue-satin.png") center top / cover no-repeat;
+ filter: blur(6px);
+}
+
+.nav {
+ height: 56px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 16px;
+ position: relative;
+ z-index: 10;
+}
+.nav > a img {
+ width: 100px;
+ height: 38px;
+}
+.nav nav {
+ display: flex;
+ gap: 38px;
+ align-items: center;
+ font-size: 14px;
+}
+.nav button {
+ background: transparent;
+ border: 0;
+ padding: 10px 0;
+}
+.nav-group {
+ position: relative;
+}
+.chevron {
+ display: none;
+}
+.dropdown {
+ position: absolute;
+ left: -20px;
+ top: 40px;
+ min-width: 230px;
+ background: #fffffff5;
+ border: 1px solid #ddd;
+ border-radius: 15px;
+ box-shadow: 0 10px 40px #0002;
+ padding: 8px;
+ backdrop-filter: blur(20px);
+}
+.dropdown a {
+ display: flex;
+ justify-content: space-between;
+ padding: 12px;
+ border-radius: 9px;
+}
+.dropdown a:hover {
+ background: var(--brand-accent-soft);
+ color: var(--brand-accent);
+}
+.nav-group > button:hover,
+.menu-toggle:hover { color: var(--brand-accent); }
+.dropdown span {
+ color: #999;
+}
+.menu-toggle {
+ display: none;
+}
+.download {
+ display: inline-flex;
+ align-items: center;
+ gap: 9px;
+ background: #080b0c;
+ color: #fff;
+ padding: 10px 15px;
+ border-radius: 24px;
+ font-size: 16px;
+ line-height: 20px;
+ transition:
+ transform 0.2s,
+ background 0.2s;
+}
+.download:hover,
+.download:focus-visible {
+ background: var(--brand-accent);
+ color: #fff;
+ transform: translateY(-2px);
+}
+.download-mark {
+ width: 20px;
+ height: 20px;
+ flex-shrink: 0;
+}
+.download.compact {
+ font-size: 14px;
+ padding: 8px 12px;
+ border-radius: 15px;
+}
+.download-split {
+ position: relative;
+ display: inline-flex;
+ align-items: stretch;
+ border-radius: 24px;
+ background: #080b0c;
+ color: #fff;
+ transition: background 0.2s;
+}
+.download-split:has(> .download:hover, > .download-toggle:hover, > .download:focus-visible, > .download-toggle:focus-visible) {
+ background: var(--brand-accent);
+}
+.download-split > .download {
+ border-radius: 24px 0 0 24px;
+ background: transparent;
+}
+.download-split > .download:hover,
+.download-split > .download:focus-visible {
+ transform: none;
+ background: transparent;
+}
+.download-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ padding: 0;
+ border: 0;
+ border-radius: 0 24px 24px 0;
+ background: transparent;
+ color: #fff;
+ transition: background 0.2s;
+}
+.download-toggle svg {
+ transition: transform 0.2s;
+}
+.download-toggle[aria-expanded="true"] svg {
+ transform: rotate(180deg);
+}
+.download-options {
+ position: absolute;
+ top: calc(100% + 8px);
+ right: 0;
+ z-index: 20;
+ min-width: 220px;
+ padding: 6px;
+ border: 1px solid #ffffff20;
+ border-radius: 14px;
+ background: #10151d;
+ color: #fff;
+ box-shadow: 0 12px 32px #0002;
+ text-align: left;
+ font-size: 14px;
+ line-height: 20px;
+}
+.download-options a {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px;
+ border-radius: 8px;
+ white-space: nowrap;
+}
+.download-options a:hover,
+.download-options a:focus-visible {
+ background: var(--brand-accent);
+ color: #fff;
+}
+.download-platform-icon {
+ width: 18px;
+ height: 18px;
+ flex-shrink: 0;
+}
+.hero-copy {
+ text-align: center;
+ padding: 64px 24px 24px;
+}
+.hero-actions {
+ position: relative;
+ z-index: 3;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.yc-badge {
+ font-size: 12px;
+ display: inline-flex;
+ gap: 6px;
+ border: 1px solid #0002;
+ padding: 4px 10px;
+ border-radius: 99px;
+ background: #b9eaf16b;
+ color: #304a53;
+ line-height: 16px;
+}
+.hero h1 {
+ color: #fff;
+ margin: 20px auto 18px;
+ max-width: 850px;
+}
+.hero-description {
+ color: rgb(255 255 255 / 76%);
+ font-size: 16px;
+ font-weight: 400;
+ line-height: 1.6;
+ max-width: 560px;
+ margin: 0 auto 28px;
+}
+.preview-wrap {
+ margin: 0 auto;
+ width: calc(100% - 116px);
+ max-width: 1250px;
+ position: relative;
+ height: 555px;
+}
+.hero-scroll-track {
+ position: relative;
+}
+.hero-scroll-track::after {
+ content: "";
+ display: block;
+ height: 85svh;
+}
+.hero-demo-sticky {
+ position: sticky;
+ top: calc((100svh - var(--hero-stage-height, 0px)) / 2);
+}
+.hero-demo-stage {
+ position: relative;
+ width: min(1250px, calc(100% - 116px), calc(84svh * 1470 / 923));
+ aspect-ratio: 1470 / 923;
+ margin-inline: auto;
+}
+.hero-editor-frame {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border-radius: 1.5% / 2.5%;
+ box-shadow: 0 20px 60px #14243524;
+ pointer-events: none;
+}
+.hero-video-window {
+ position: absolute;
+ overflow: hidden;
+ background: #101217;
+ box-shadow: 0 16px 45px #0002;
+}
+.hero-video {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+@media (max-width: 1000px) {
+ .hero-demo-stage { width: min(calc(100% - 48px), calc(84svh * 1470 / 923)); }
+}
+@media (max-width: 700px) {
+ .hero-demo-stage { width: min(calc(100% - 24px), calc(84svh * 1470 / 923)); }
+}
+@media (prefers-reduced-motion: reduce) {
+ .hero-background { position: relative; top: auto; }
+ .hero-scroll-track::after { display: none; }
+ .hero-demo-sticky { position: relative; top: auto; }
+ .hero-editor-frame { opacity: 1 !important; }
+ .hero-video-window {
+ left: var(--hero-canvas-left) !important;
+ top: var(--hero-canvas-top) !important;
+ width: var(--hero-canvas-width) !important;
+ height: var(--hero-canvas-height) !important;
+ border-radius: 0 !important;
+ box-shadow: none !important;
+ }
+}
+.browser-preview {
+ display: flex;
+ height: 625px;
+ border: 1px solid #0002;
+ border-radius: 18px 18px 0 0;
+ background: #fff9;
+ box-shadow: 0 16px 45px #0001;
+ overflow: hidden;
+ text-align: left;
+ font-size: 12px;
+}
+.browser-sidebar {
+ width: 230px;
+ flex: none;
+ padding: 12px 8px;
+ background: #e9f9f97d;
+}
+.traffic {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 0 6px 15px;
+}
+.traffic i {
+ height: 11px;
+ width: 11px;
+ border-radius: 50%;
+ background: #ff625c;
+}
+.traffic i:nth-child(2) {
+ background: #ffbd44;
+}
+.traffic i:nth-child(3) {
+ background: #41c956;
+}
+.traffic span {
+ margin-left: auto;
+ color: #879194;
+}
+.favorites {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 5px;
+}
+.favorites div {
+ height: 37px;
+ background: #93b7bd40;
+ border: 1px solid #abcbdc30;
+ border-radius: 9px;
+ display: grid;
+ place-items: center;
+ font-size: 16px;
+}
+.favorites img {
+ width: 17px;
+ height: 17px;
+ object-fit: contain;
+}
+.browser-sidebar small {
+ display: block;
+ color: #7b8285;
+ margin: 14px 6px 5px;
+ font-size: 12px;
+}
+.browser-sidebar p,
+.browser-sidebar button {
+ padding: 8px 6px;
+ margin: 0;
+ font-size: 12px;
+}
+.browser-sidebar button {
+ border: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ text-align: left;
+ background: none;
+ border-radius: 8px;
+}
+.browser-sidebar button:hover,
+.browser-sidebar button.selected {
+ background: #fff9;
+}
+.browser-sidebar .new-chat {
+ color: #777;
+}
+.activity-dot {
+ width: 6px;
+ height: 6px;
+ flex: none;
+ border-radius: 50%;
+ background: #46a1d2;
+ animation: pulse 2s infinite;
+}
+.browser-main {
+ background: #ffffffea;
+ flex: 1;
+ min-width: 0;
+ border-left: 1px solid #0001;
+ border-radius: 17px 0 0;
+}
+.browser-toolbar {
+ height: 38px;
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ padding: 0 16px;
+ color: #888;
+}
+.browser-toolbar > span:last-child {
+ margin-left: auto;
+}
+.browser-content {
+ padding: 84px 10% 20px;
+}
+.watermark {
+ width: 52px;
+ height: 52px;
+ opacity: 0.1;
+ margin: 0 auto 26px;
+}
+.prompt-illustration {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-height: 42px;
+ padding: 7px 8px 7px 12px;
+ border: 1px solid #e6e6e6;
+ border-radius: 16px;
+ box-shadow: 0 6px 9px #00000008;
+ color: #999;
+ white-space: nowrap;
+}
+.prompt-illustration > span:first-child {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.prompt-illustration a {
+ padding: 3px 18px;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ color: #111;
+ box-shadow: 0 1px 3px #0001;
+}
+.prompt-illustration kbd {
+ background: #eee;
+ border-radius: 4px;
+ padding: 2px 4px;
+ font-size: 10px;
+}
+.prompt-illustration small {
+ font-size: 10px;
+}
+.search-label {
+ background: #eee;
+ border-radius: 5px;
+ padding: 4px 9px;
+ color: #777;
+}
+.prompt-options {
+ display: flex;
+ justify-content: space-between;
+ font-size: 10px;
+ color: #777;
+ margin: 8px 9px 56px;
+}
+.recent-heading {
+ display: flex;
+ justify-content: space-between;
+ font-size: 14px;
+ font-weight: 500;
+ margin: 0 12px 10px;
+}
+.recent-heading button {
+ background: none;
+ border: 0;
+ color: var(--blue);
+ font-size: 12px;
+}
+.task-cards {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 6px;
+ margin: 0 -10%;
+}
+.task-card {
+ text-align: left;
+ background: #fff;
+ border: 1px solid #e8e8e8;
+ border-radius: 14px;
+ padding: 10px 9px 0;
+ overflow: hidden;
+ min-width: 0;
+ transition: transform 0.2s;
+}
+.task-card:hover {
+ transform: translateY(-4px);
+ border-color: #b8dbe8;
+}
+.task-card small {
+ font-size: 11px;
+ color: #999;
+}
+.task-card strong {
+ font-size: 12px;
+ display: block;
+ margin: 5px 0;
+}
+.task-card p {
+ height: 54px;
+ font-size: 11px;
+ color: #777;
+ overflow: hidden;
+}
+.task-card img {
+ width: 100%;
+ height: 180px;
+ object-fit: cover;
+ object-position: top;
+ border-radius: 10px 10px 0 0;
+ margin-top: 10px;
+}
+.task-cards.single-task {
+ grid-template-columns: 1fr;
+ max-width: 400px;
+ margin: auto;
+}
+.pause {
+ position: absolute;
+ right: 15px;
+ bottom: 14px;
+ border: 1px solid #fff8;
+ background: #fff9;
+ border-radius: 50%;
+ width: 30px;
+ height: 30px;
+}
+.paused .activity-dot {
+ animation-play-state: paused;
+}
+.page-body {
+ max-width: 1088px;
+ border-inline: 1px solid var(--line);
+ margin: auto;
+}
+.content-width {
+ max-width: 800px;
+ margin-inline: auto;
+}
+.intro {
+ padding-block: 105px;
+ text-align: center;
+}
+.intro > div {
+ max-width: 720px;
+ margin-inline: auto;
+}
+.eyebrow,
+.text-link {
+ color: var(--blue);
+ font-size: 16px;
+}
+.intro p {
+ font-family:
+ "DM Sans",
+ sans-serif;
+ font-size: 20px;
+ line-height: 1.4;
+ color: #777;
+}
+.intro p + p {
+ margin-top: 32px;
+}
+.intro-underline {
+ text-decoration: underline;
+ text-decoration-thickness: 1.5px;
+ text-underline-offset: .16em;
+}
+.intro-action {
+ display: inline-flex;
+ align-items: baseline;
+ gap: .3em;
+ margin-inline: .15em .08em;
+ white-space: nowrap;
+}
+.intro-action-emoji {
+ align-self: center;
+ font-size: 1.1em;
+ line-height: 1;
+ font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
+}
+.section {
+ padding: 70px 0 80px;
+ border-top: 1px solid var(--line);
+}
+.section-title {
+ text-align: center;
+ padding: 0 24px;
+}
+.section-title h2 {
+ margin: 28px auto 0;
+ max-width: 760px;
+}
+.app-universe {
+ margin: 48px auto 30px;
+ overflow: hidden;
+ height: 280px;
+ display: flex;
+ align-items: center;
+}
+.app-universe img {
+ width: 100%;
+ object-fit: contain;
+}
+.center-copy {
+ max-width: 580px;
+ margin: 35px auto;
+ font-size: 18px;
+ line-height: 1.6;
+ padding: 0 16px;
+}
+.text-features {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 35px;
+ margin-top: 0;
+}
+.feature-frame {
+ width: 100%;
+ aspect-ratio: 4 / 3;
+ margin-bottom: 22px;
+ border: 0;
+ border-radius: 12px;
+ background: transparent;
+ overflow: hidden;
+}
+.feature-artwork > svg,
+.feature-artwork > img,
+.feature-artwork > .feature-demo-video {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+.feature-artwork > .record-feature-screenshot {
+ object-fit: cover;
+}
+.feature-demo-video { border-radius: 12px; }
+.how-it-works-title {
+ text-align: center;
+ margin: 0 24px 48px;
+ font-size: clamp(30px, 4vw, 42px);
+}
+.share-artwork {
+ width: 100%;
+ height: 100%;
+ display: grid;
+ place-items: center;
+ container-type: inline-size;
+ background: #fff;
+}
+.share-demo-panel {
+ width: 94%;
+ font-size: clamp(10px, 3.2cqw, 20px);
+ color: #202126;
+ letter-spacing: -.025em;
+}
+.share-demo-thumbnail {
+ position: relative;
+ aspect-ratio: 1.98;
+ overflow: hidden;
+ border-radius: 14px;
+ background: #111;
+}
+.share-demo-thumbnail > img { display: block; width: 100%; height: 100%; object-fit: cover; }
+.share-demo-play {
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ width: 3em;
+ height: 3em;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ background: #66899ebd;
+ color: #fff;
+}
+.share-demo-play > svg { width: 1.4em; height: 1.4em; margin-left: .15em; }
+.share-demo-link {
+ display: flex;
+ align-items: center;
+ gap: .7em;
+ margin-top: 1em;
+ padding: .3em .3em .3em .7em;
+ border: 1px solid #dfe1e6;
+ border-radius: 14px;
+ white-space: nowrap;
+ background: #fff;
+}
+.share-demo-link > svg { width: 1.45em; height: 1.45em; flex: none; color: #7b8089; }
+.share-demo-link > span:first-of-type { flex: 1; }
+.share-demo-copy {
+ display: grid;
+ place-items: center;
+ width: 2.5em;
+ height: 2.5em;
+ flex: none;
+ border-radius: 10px;
+ background: #17181b;
+ color: #fff;
+}
+.share-demo-copy > svg { width: 1.3em; height: 1.3em; }
+.record-hud-demo {
+ position: relative;
+ width: 100%;
+ height: 100%;
+}
+.native-hud-position {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform-origin: center;
+}
+.native-hud-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: max-content;
+ padding: 8px 12px;
+ border: 1px solid #e3e3e8;
+ border-radius: 16px;
+ background: #fff;
+ color: #24242a;
+ box-shadow: 0 8px 30px #20243818, 0 2px 6px #20243808;
+ font: 14px "DM Sans", sans-serif;
+ letter-spacing: 0;
+}
+.native-hud-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 46px;
+}
+.native-hud-state { animation: native-hud-in 180ms ease both; }
+.native-hud-drag { display: flex; padding-inline: 2px; color: #6b6b78; }
+.native-hud-drag > svg { width: 18px; height: 18px; flex: none; }
+.native-hud-control {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ flex: none;
+ border-radius: 50%;
+}
+.native-hud-icon { position: relative; display: flex; width: 20px; height: 20px; flex: none; }
+.native-hud-icon > svg,
+.native-hud-countdown { display: block; width: 20px; height: 20px; flex: none; }
+.native-hud-icon > svg + svg { position: absolute; inset: 0; }
+.native-hud-source { display: flex; align-items: center; gap: 8px; padding: 0 12px; white-space: nowrap; height: 44px; }
+.native-hud-separator { width: 1px; height: 24px; margin-inline: 5px; background: #e6e6eb; flex: none; }
+.native-hud-danger { background: #ef4257; color: #fff; }
+.native-hud-record { width: 14px; height: 14px; border-radius: 50%; background: #fff; }
+.native-hud-stop { width: 12px; height: 12px; border-radius: 3px; background: #fff; }
+.native-hud-timer { display: flex; align-items: center; gap: 12px; padding: 0 8px; font-weight: 500; }
+.native-hud-timer > span { min-width: 56px; font-variant-numeric: tabular-nums; }
+.native-hud-timer > i { width: 8px; height: 8px; border-radius: 50%; background: #ef4257; animation: native-hud-pulse 1s ease-in-out infinite; }
+.native-hud-disabled { opacity: .45; }
+@keyframes native-hud-in { from { opacity: 0; } to { opacity: 1; } }
+@keyframes native-hud-pulse { 50% { opacity: .35; } }
+@media (prefers-reduced-motion: reduce) {
+ .native-hud-state,
+ .native-hud-timer > i { animation: none; }
+}
+.text-features p {
+ font-size: 15px;
+ color: #777;
+}
+.split-section {
+ padding-block: 84px;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 64px;
+ border-top: 1px solid var(--line);
+ align-items: center;
+}
+.split-section h3 {
+ margin-bottom: 24px;
+}
+.split-section p {
+ font-size: 16px;
+ color: #777;
+ line-height: 1.6;
+}
+.text-link {
+ display: inline-block;
+ margin-top: 20px;
+}
+em,
+.serif {
+ font-family: "DM Sans", sans-serif;
+ font-weight: 400;
+ font-style: italic;
+}
+.benchmark {
+ padding: 22px;
+ border: 1px solid #eee;
+ border-radius: 16px;
+ background: #fafafa;
+}
+.benchmark-heading {
+ font-size: 13px;
+ font-weight: 500;
+ padding-bottom: 16px;
+ border-bottom: 1px solid #ddd;
+}
+.benchmark-heading span {
+ float: right;
+ font-size: 11px;
+ color: #999;
+}
+.bar-row {
+ margin-top: 15px;
+}
+.bar-row > div:first-child {
+ display: flex;
+ justify-content: space-between;
+ font-size: 12px;
+}
+.bar-track {
+ height: 13px;
+ margin-top: 6px;
+ background: #eee;
+ border-radius: 4px;
+ overflow: hidden;
+}
+.bar {
+ height: 100%;
+ background: #ced0d1;
+ border-radius: 4px;
+}
+.aside-bar {
+ background: #b7e0ea;
+}
+.benchmark-source {
+ font-size: 12px;
+ color: #777;
+ display: block;
+ margin-top: 22px;
+}
+.memory-art {
+ position: relative;
+ background: #fafafa;
+ border-radius: 16px;
+ padding: 18px 0 0;
+ overflow: hidden;
+ min-height: 320px;
+}
+.memory-art > img {
+ width: 100%;
+ height: 290px;
+ object-fit: cover;
+ object-position: top;
+}
+.memory-prompt {
+ position: absolute;
+ bottom: 20px;
+ left: 15px;
+ right: 15px;
+ background: #fffffff0;
+ border: 1px solid #ddd;
+ border-radius: 12px;
+ padding: 14px 30px 14px 14px;
+ font-size: 12px;
+ box-shadow: 0 8px 20px #0001;
+}
+.memory-prompt span {
+ position: absolute;
+ right: 12px;
+ bottom: 15px;
+}
+.feature-cards {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 24px;
+ margin: 50px 40px 0;
+}
+.feature-cards img {
+ width: 100%;
+ aspect-ratio: 1.36;
+ object-fit: contain;
+}
+.feature-cards p {
+ font-size: 15px;
+ color: #777;
+ line-height: 1.6;
+ margin: 18px 8px 0;
+}
+.vault-banner {
+ margin: 65px 40px 0;
+ border: 1px solid #e4e4e4;
+ border-radius: 20px;
+ background: #f7f7f7;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ overflow: hidden;
+}
+.vault-banner > div {
+ padding: 40px;
+}
+.vault-banner h3 {
+ font-size: 28px;
+}
+.vault-banner h3 span {
+ color: #777;
+}
+.outlined {
+ display: inline-block;
+ margin: 24px 0;
+ border: 1px solid #ccc;
+ padding: 8px 14px;
+ border-radius: 9px;
+ font-size: 14px;
+ background: #fff;
+}
+.vault-banner p {
+ font-size: 13px;
+ color: #777;
+ margin: 15px 0;
+}
+.vault-banner img {
+ height: 100%;
+ width: 100%;
+ object-fit: cover;
+ object-position: left;
+}
+.subscription {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 35px;
+ margin: 70px 24px 0;
+ border-top: 1px solid #eee;
+ padding-top: 45px;
+}
+.subscription > span {
+ font-size: 32px;
+}
+.subscription p {
+ font-size: 15px;
+ color: #777;
+}
+.closing {
+ max-width: 1088px;
+ margin: auto;
+ border: 1px solid #eee;
+ padding: 110px 24px;
+ text-align: center;
+ background: linear-gradient(#fff, #f7fcfd);
+}
+.closing > img {
+ width: 55px;
+ margin: 0 auto 30px;
+}
+.closing h2 {
+ font-size: 40px;
+ margin-bottom: 30px;
+}
+.closing h2 span {
+ color: #999;
+}
+.footer {
+ max-width: 1088px;
+ margin: auto;
+ padding: 64px 40px 24px;
+}
+.footer-columns {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 20px;
+}
+.footer h4 {
+ font-size: 14px;
+ font-weight: 500;
+ margin: 0 0 15px;
+}
+.footer-columns a {
+ font-size: 13px;
+ color: #777;
+ display: block;
+ margin-bottom: 12px;
+}
+.footer-bottom {
+ display: flex;
+ justify-content: space-between;
+ gap: 24px;
+ color: #888;
+ font-size: 12px;
+ border-top: 1px solid #eee;
+ margin-top: 55px;
+ padding-top: 24px;
+}
+.footer-bottom > div {
+ display: flex;
+ gap: 22px;
+}
+@keyframes pulse {
+ 50% {
+ opacity: 0.25;
+ }
+}
+@media (min-width: 1500px) {
+ .hero-copy {
+ padding-top: 85px;
+ }
+ .hero h1 {
+ font-size: 56px;
+ }
+ .preview-wrap {
+ height: 620px;
+ }
+}
+@media (max-width: 1000px) {
+ .content-width {
+ margin-inline: 40px;
+ }
+ .preview-wrap {
+ width: calc(100% - 48px);
+ height: 500px;
+ }
+ .browser-sidebar {
+ width: 180px;
+ }
+ .browser-content {
+ padding-inline: 6%;
+ }
+ .split-section {
+ gap: 30px;
+ }
+ .task-cards {
+ margin-inline: 0;
+ }
+ .feature-cards {
+ gap: 14px;
+ }
+ .vault-banner > div {
+ padding: 25px;
+ }
+ .nav nav {
+ gap: 24px;
+ }
+}
+@media (max-width: 700px) {
+ h1 {
+ font-size: 38px;
+ line-height: 1.1;
+ }
+ h2 {
+ font-size: 34px;
+ }
+ h3 {
+ font-size: 28px;
+ }
+ .hero {
+ --hero-inset: 8px;
+ border-radius: 20px;
+ }
+ .nav {
+ padding: 0 10px;
+ }
+ .nav > a img {
+ width: 90px;
+ }
+ .menu-toggle {
+ display: block;
+ margin-left: auto;
+ margin-right: 16px;
+ font-size: 20px;
+ }
+ .nav nav {
+ display: none;
+ position: absolute;
+ top: 56px;
+ left: 8px;
+ right: 8px;
+ padding: 16px;
+ background: #fffffff5;
+ border: 1px solid #ddd;
+ border-radius: 14px;
+ box-shadow: 0 10px 30px #0002;
+ }
+ .nav nav.is-open {
+ display: flex;
+ align-items: stretch;
+ flex-direction: column;
+ gap: 5px;
+ }
+ .nav-group > button {
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ padding: 10px;
+ }
+ .chevron {
+ display: inline;
+ }
+ .nav nav > a {
+ padding: 10px;
+ }
+ .dropdown {
+ position: static;
+ box-shadow: none;
+ border: none;
+ background: #f3f8fa;
+ margin-bottom: 8px;
+ }
+ .hero-copy {
+ padding: 55px 20px 24px;
+ }
+ .hero h1 {
+ margin: 22px auto 18px;
+ max-width: 440px;
+ }
+ .desktop-break {
+ display: none;
+ }
+ .preview-wrap {
+ width: calc(100% - 24px);
+ height: 400px;
+ }
+ .browser-preview {
+ height: 500px;
+ }
+ .browser-sidebar {
+ display: none;
+ }
+ .browser-main {
+ border-left: 0;
+ }
+ .browser-toolbar {
+ font-size: 10px;
+ gap: 12px;
+ padding: 0 10px;
+ }
+ .browser-content {
+ padding: 45px 16px;
+ }
+ .watermark {
+ width: 42px;
+ margin-bottom: 20px;
+ }
+ .prompt-illustration {
+ font-size: 10px;
+ padding-left: 9px;
+ border-radius: 12px;
+ }
+ .prompt-illustration kbd,
+ .prompt-illustration small,
+ .search-label {
+ display: none;
+ }
+ .prompt-illustration a {
+ padding-inline: 12px;
+ }
+ .prompt-options {
+ font-size: 8px;
+ margin-bottom: 34px;
+ }
+ .task-cards {
+ gap: 5px;
+ }
+ .task-card {
+ padding: 8px 6px 0;
+ }
+ .task-card small {
+ font-size: 9px;
+ }
+ .task-card strong {
+ font-size: 10px;
+ min-height: 28px;
+ }
+ .task-card p {
+ display: none;
+ }
+ .task-card img {
+ height: 135px;
+ }
+ .recent-heading {
+ font-size: 12px;
+ margin-left: 0;
+ }
+ .content-width {
+ margin-inline: 24px;
+ }
+ .intro {
+ padding-block: 65px;
+ }
+ .intro p {
+ font-size: 19px;
+ }
+ .page-body {
+ margin-inline: 16px;
+ }
+ .section {
+ padding-block: 60px;
+ }
+ .section-title {
+ padding-inline: 16px;
+ }
+ .section-title h2 {
+ margin-top: 22px;
+ }
+ .app-universe {
+ height: 190px;
+ margin: 25px -1px;
+ }
+ .app-universe img {
+ max-width: none;
+ width: 170%;
+ margin-left: -35%;
+ }
+ .center-copy {
+ font-size: 16px;
+ margin: 28px auto;
+ padding-inline: 24px;
+ }
+ .text-features {
+ grid-template-columns: 1fr;
+ gap: 25px;
+ margin-top: 38px;
+ }
+
+ .split-section {
+ grid-template-columns: 1fr;
+ padding-block: 55px;
+ gap: 30px;
+ }
+ .benchmark {
+ padding: 20px;
+ }
+ .memory-art {
+ max-height: 350px;
+ }
+ .feature-cards {
+ grid-template-columns: 1fr;
+ margin: 30px 24px 0;
+ gap: 35px;
+ }
+ .feature-cards img {
+ max-height: 250px;
+ }
+ .feature-cards p {
+ margin-top: 8px;
+ }
+ .vault-banner {
+ margin: 45px 16px 0;
+ grid-template-columns: 1fr;
+ }
+ .vault-banner > img {
+ height: 300px;
+ object-position: top;
+ }
+ .subscription {
+ flex-direction: column;
+ text-align: center;
+ gap: 15px;
+ margin-top: 50px;
+ }
+ .closing {
+ margin-inline: 16px;
+ padding: 70px 20px;
+ }
+ .closing h2 {
+ font-size: 32px;
+ }
+ .footer {
+ padding: 45px 24px 24px;
+ }
+ .footer-columns {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 25px;
+ }
+ .footer-bottom {
+ flex-direction: column;
+ margin-top: 25px;
+ }
+ .footer-bottom > div {
+ flex-wrap: wrap;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+ *,
+ *::before,
+ *::after {
+ animation: none !important;
+ transition: none !important;
+ }
+}
+
+/* A duplicated track creates a seamless loop using the original app sprite. */
+.logo-ticker {
+ padding: 38px 0 42px;
+ border-bottom: 1px solid var(--line);
+}
+.ticker-heading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ margin: 0 24px 24px;
+}
+.ticker-heading p {
+ color: #777;
+ font-size: 13px;
+ line-height: 1.5;
+ text-align: center;
+}
+.ticker-window {
+ overflow: hidden;
+ mask-image: linear-gradient(to right, transparent, #000 8%, #000 92%, transparent);
+}
+.ticker-track {
+ display: flex;
+ width: max-content;
+ min-width: 200%;
+ animation: logo-scroll 36s linear infinite;
+ will-change: transform;
+}
+.ticker-group {
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+ flex: 1 0 auto;
+ gap: 56px;
+ padding: 0 56px 0 0;
+ margin: 0;
+ list-style: none;
+}
+.ticker-logo {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ white-space: nowrap;
+ font-family:
+ "DM Sans",
+ Arial,
+ sans-serif;
+ font-size: 23px;
+ line-height: 1.2;
+ letter-spacing: -0.025em;
+ color: #555;
+}
+.ticker-icon {
+ width: 40px;
+ height: 40px;
+ flex: none;
+ border-radius: 10px;
+ background: url("/assets/icons.07sng_y96t659.webp") no-repeat;
+ background-size: 383.2px 143.2px;
+ filter: grayscale(1);
+ opacity: 0.65;
+ transition:
+ filter 0.2s,
+ opacity 0.2s;
+}
+.ticker-logo:hover .ticker-icon {
+ filter: none;
+ opacity: 1;
+}
+@keyframes logo-scroll {
+ to {
+ transform: translate3d(-50%, 0, 0);
+ }
+}
+@media (max-width: 700px) {
+ .logo-ticker {
+ padding: 25px 0 30px;
+ }
+ .ticker-heading {
+ margin-bottom: 18px;
+ gap: 8px;
+ }
+ .ticker-heading p {
+ font-size: 12px;
+ }
+ .ticker-group {
+ gap: 32px;
+ padding-right: 32px;
+ }
+ .ticker-logo {
+ font-size: 20px;
+ gap: 10px;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .ticker-track {
+ animation: none;
+ width: auto;
+ min-width: 0;
+ will-change: auto;
+ }
+ .ticker-group {
+ flex: 1;
+ flex-wrap: wrap;
+ justify-content: center;
+ padding: 0 24px;
+ gap: 24px;
+ }
+ .ticker-group[aria-hidden="true"] {
+ display: none;
+ }
+ .ticker-window {
+ mask-image: none;
+ }
+}
+
+
+/* Vercel mark for the requested logo strip. */
+.ticker-vercel {
+ background: currentColor;
+ clip-path: polygon(50% 12%, 96% 88%, 4% 88%);
+ border-radius: 0;
+}
+
+/* Use the existing Recordly app mark and a text wordmark. */
+.recordly-logo { color: #fff; display: inline-flex; align-items: center; gap: 9px; font-size: 24px; font-weight: 500; letter-spacing: -0.04em; }
+.nav > a .recordly-logo img { width: 24px; height: 24px; }
+
+/* Aceternity Sticky Scroll Reveal: one frame and one text position. */
+.sticky-story { position: relative; height: calc(var(--story-stages) * 100svh); margin-inline: clamp(24px, 4vw, 48px); }
+.sticky-story-stage { position: sticky; top: 0; height: 100svh; display: grid; grid-template-columns: .72fr 1.28fr; align-items: center; gap: 36px; }
+.sticky-story-copy { position: relative; display: grid; }
+.sticky-story-text { grid-area: 1 / 1; pointer-events: none; }
+.sticky-story-step { color: #81858d; font-size: 13px; letter-spacing: .06em; }
+.sticky-story-text h2 { font-size: clamp(36px, 5vw, 58px); line-height: 1.05; letter-spacing: -.05em; margin: 20px 0; }
+.sticky-story-text p { font-size: 18px; line-height: 1.5; color: #777; max-width: 300px; margin: 0; }
+.sticky-story-frame { position: relative; aspect-ratio: 16 / 10; width: 100%; overflow: hidden; border-radius: 16px; background: #f9f9fa; box-shadow: 0 10px 35px #1823340c; }
+.sticky-story-visual { position: absolute; inset: 0; }
+.sticky-story-visual > img { width: 100%; height: 100%; object-fit: contain; display: block; }
+.sticky-story-visual > .share-artwork { background: #fff; }
+@media (max-width: 700px) {
+ .sticky-story-stage { grid-template-columns: 1fr; grid-template-rows: auto auto; align-content: center; gap: 32px; }
+ .sticky-story-text h2 { font-size: 40px; margin: 14px 0; }
+ .sticky-story-text p { max-width: 100%; font-size: 16px; }
+ .sticky-story-frame { max-height: 55svh; }
+}
+.sticky-story-static { margin-inline: clamp(24px, 4vw, 48px); }
+.sticky-story-static-step { display: grid; grid-template-columns: .72fr 1.28fr; gap: 36px; align-items: center; margin-bottom: 64px; }
+.sticky-story-static-visual { aspect-ratio: 16 / 10; overflow: hidden; border-radius: 16px; }
+.sticky-story-static-visual > img { width: 100%; height: 100%; object-fit: contain; }
+@media (max-width: 700px) { .sticky-story-static-step { grid-template-columns: 1fr; gap: 24px; } }
+
+/* Shared light-page brand and footer. */
+.nav-light .recordly-logo { color: #090b0c; }
+.site-footer { width: min(1088px, calc(100% - 48px)); margin: auto auto 0; padding: 28px 0; border-top: 1px solid var(--line); display: flex; justify-content: space-between; gap: 24px; color: #767676; font-size: 14px; }
+.site-footer nav { display: flex; flex-wrap: wrap; gap: 24px; }
+@media (max-width:700px) { .site-footer { flex-direction: column; gap: 18px; } }
diff --git a/aside-react/src/vendor/aceternity/ORIGIN.txt b/aside-react/src/vendor/aceternity/ORIGIN.txt
new file mode 100644
index 00000000..d9d53b34
--- /dev/null
+++ b/aside-react/src/vendor/aceternity/ORIGIN.txt
@@ -0,0 +1,5 @@
+Aceternity UI — Sticky Scroll Reveal
+https://ui.aceternity.com/components/sticky-scroll-reveal
+Source: https://ui.aceternity.com/registry/sticky-scroll-reveal.json
+Retrieved 2026-10-22. Original source preserved alongside adapted component.
+Adaptations: JSX/CSS instead of TypeScript/Tailwind; page scroll target per upstream guidance; sticky text and frame with crossfades; reduced motion fallback; equal-length stages.
diff --git a/aside-react/src/vendor/aceternity/StickyScroll.jsx b/aside-react/src/vendor/aceternity/StickyScroll.jsx
new file mode 100644
index 00000000..82f3ca05
--- /dev/null
+++ b/aside-react/src/vendor/aceternity/StickyScroll.jsx
@@ -0,0 +1,45 @@
+// Adapted from Aceternity UI Sticky Scroll Reveal. See ORIGIN.txt and original source.
+import React, { useRef, useState } from "react";
+import { motion, useMotionValueEvent, useReducedMotion, useScroll } from "motion/react";
+
+export function StickyScroll({ content }) {
+ const [activeCard, setActiveCard] = useState(0);
+ const ref = useRef(null);
+ const reduceMotion = useReducedMotion();
+ const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] });
+ useMotionValueEvent(scrollYProgress, "change", latest => {
+ const cardsBreakpoints = content.map((_, index) => index / (content.length - 1));
+ const closest = cardsBreakpoints.reduce((acc, breakpoint, index) =>
+ Math.abs(latest - breakpoint) < Math.abs(latest - cardsBreakpoints[acc]) ? index : acc, 0);
+ setActiveCard(closest);
+ });
+ if (reduceMotion) return
+ {content.map((item, index) =>
+ 0{index + 1} / 0{content.length} {item.title} {item.description}
+ {item.content}
+ )}
+
;
+ return
+
+
+ {content.map((item, index) =>
+ 0{index + 1} / 0{content.length}
+ {item.title}
+ {item.description}
+ )}
+
+
+ {content.map((item, index) =>
+ {item.content}
+ )}
+
+
+
;
+}
diff --git a/aside-react/src/vendor/aceternity/sticky-scroll-reveal.original.tsx b/aside-react/src/vendor/aceternity/sticky-scroll-reveal.original.tsx
new file mode 100644
index 00000000..055b3962
--- /dev/null
+++ b/aside-react/src/vendor/aceternity/sticky-scroll-reveal.original.tsx
@@ -0,0 +1,112 @@
+"use client";
+import React, { useEffect, useRef, useState } from "react";
+import { useMotionValueEvent, useScroll } from "motion/react";
+import { motion } from "motion/react";
+import { cn } from "@/lib/utils";
+
+export const StickyScroll = ({
+ content,
+ contentClassName,
+}: {
+ content: {
+ title: string;
+ description: string;
+ content?: React.ReactNode | any;
+ }[];
+ contentClassName?: string;
+}) => {
+ const [activeCard, setActiveCard] = React.useState(0);
+ const ref = useRef(null);
+ const { scrollYProgress } = useScroll({
+ // uncomment line 22 and comment line 23 if you DONT want the overflow container and want to have it change on the entire page scroll
+ // target: ref
+ container: ref,
+ offset: ["start start", "end start"],
+ });
+ const cardLength = content.length;
+
+ useMotionValueEvent(scrollYProgress, "change", (latest) => {
+ const cardsBreakpoints = content.map((_, index) => index / cardLength);
+ const closestBreakpointIndex = cardsBreakpoints.reduce(
+ (acc, breakpoint, index) => {
+ const distance = Math.abs(latest - breakpoint);
+ if (distance < Math.abs(latest - cardsBreakpoints[acc])) {
+ return index;
+ }
+ return acc;
+ },
+ 0,
+ );
+ setActiveCard(closestBreakpointIndex);
+ });
+
+ const backgroundColors = [
+ "#0f172a", // slate-900
+ "#000000", // black
+ "#171717", // neutral-900
+ ];
+ const linearGradients = [
+ "linear-gradient(to bottom right, #06b6d4, #10b981)", // cyan-500 to emerald-500
+ "linear-gradient(to bottom right, #ec4899, #6366f1)", // pink-500 to indigo-500
+ "linear-gradient(to bottom right, #f97316, #eab308)", // orange-500 to yellow-500
+ ];
+
+ const [backgroundGradient, setBackgroundGradient] = useState(
+ linearGradients[0],
+ );
+
+ useEffect(() => {
+ setBackgroundGradient(linearGradients[activeCard % linearGradients.length]);
+ }, [activeCard]);
+
+ return (
+
+
+
+ {content.map((item, index) => (
+
+
+ {item.title}
+
+
+ {item.description}
+
+
+ ))}
+
+
+
+
+ {content[activeCard].content ?? null}
+
+
+ );
+};
diff --git a/aside-react/wrangler.jsonc b/aside-react/wrangler.jsonc
new file mode 100644
index 00000000..36efc2d3
--- /dev/null
+++ b/aside-react/wrangler.jsonc
@@ -0,0 +1,9 @@
+{
+ "name": "recordly-beta-landing",
+ "compatibility_date": "2026-10-24",
+ "workers_dev": true,
+ "assets": {
+ "directory": "./dist",
+ "not_found_handling": "single-page-application"
+ }
+}
diff --git a/electron/peContentHash.test.mjs b/electron/peContentHash.test.mjs
new file mode 100644
index 00000000..1d331142
--- /dev/null
+++ b/electron/peContentHash.test.mjs
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { peContentHash } from "../scripts/pe-content-hash.mjs";
+
+function executable() {
+ const bytes = Buffer.alloc(512);
+ bytes.write("MZ");
+ bytes.writeUInt32LE(128, 0x3c);
+ bytes.writeUInt32LE(0x4550, 128);
+ bytes.writeUInt16LE(240, 148);
+ bytes.writeUInt16LE(0x20b, 152);
+ bytes.fill(0x90, 400);
+ return bytes;
+}
+function signedExecutable() {
+ const bytes = Buffer.concat([executable(), Buffer.alloc(32, 0xaa)]);
+ bytes.writeUInt32LE(12345, 152 + 64);
+ bytes.writeUInt32LE(512, 152 + 112 + 32);
+ bytes.writeUInt32LE(32, 152 + 112 + 36);
+ return bytes;
+}
+describe("Windows executable integrity across signing", () => {
+ it("keeps the payload hash stable when Authenticode adds a certificate and checksum", () => {
+ expect(peContentHash(signedExecutable())).toBe(peContentHash(executable()));
+ });
+ it("detects executable payload changes in a signed file", () => {
+ const bytes = signedExecutable();
+ bytes[400] ^= 1;
+ expect(peContentHash(bytes)).not.toBe(peContentHash(executable()));
+ });
+ it("rejects certificate ranges that could omit arbitrary trailing data", () => {
+ const bytes = signedExecutable();
+ bytes.writeUInt32LE(8, 152 + 112 + 36);
+ expect(() => peContentHash(bytes)).toThrow("certificate table");
+ });
+ it("rejects malformed executable headers", () => {
+ expect(() => peContentHash(Buffer.from("not a PE"))).toThrow("Invalid PE");
+ const bytes = executable();
+ bytes.writeUInt32LE(10000, 0x3c);
+ expect(() => peContentHash(bytes)).toThrow("Invalid PE header");
+ });
+});
diff --git a/src/components/video-editor/captionLayoutCache.test.ts b/src/components/video-editor/captionLayoutCache.test.ts
new file mode 100644
index 00000000..0fce1181
--- /dev/null
+++ b/src/components/video-editor/captionLayoutCache.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it, vi } from "vitest";
+import { prepareCaptionLayout } from "./captionLayout";
+import { type CaptionCue, DEFAULT_AUTO_CAPTION_SETTINGS } from "./types";
+
+const cues: CaptionCue[] = [
+ {
+ id: "a",
+ startMs: 0,
+ endMs: 1200,
+ text: "hello world",
+ words: [
+ { text: "hello", startMs: 80, endMs: 300 },
+ { text: "world", startMs: 600, endMs: 1000, leadingSpace: true },
+ ],
+ },
+ { id: "b", startMs: 1400, endMs: 2000, text: "next phrase" },
+];
+
+const options = {
+ cues,
+ settings: { ...DEFAULT_AUTO_CAPTION_SETTINGS, animationStyle: "fade" as const },
+ maxWidthPx: 1000,
+ measureText: (text: string) => text.length * 10,
+};
+
+describe("prepared caption layout", () => {
+ it("reuses wrapping and edit targets without measuring during playback or backward seeks", () => {
+ const measureText = vi.fn(options.measureText);
+ const sample = prepareCaptionLayout({ ...options, measureText });
+ expect(measureText).toHaveBeenCalled();
+ measureText.mockClear();
+ const first = sample(100)!;
+ const later = sample(700)!;
+ expect(first.visibleLines[0].words.map((word) => word.state)).toEqual([
+ "active",
+ "upcoming",
+ ]);
+ expect(later.visibleLines[0].words.map((word) => word.state)).toEqual(["spoken", "active"]);
+ expect(later.editTarget).toBe(first.editTarget);
+ expect(later.visibleLines[0].width).toBe(first.visibleLines[0].width);
+ expect(later.opacity).toBe(1);
+ expect(first.opacity).toBeLessThan(1);
+ expect(sample(100)).toEqual(first);
+ expect(sample(1399)?.editTarget.text).toBe("hello world");
+ expect(sample(1400)?.editTarget.text).toBe("next phrase");
+ expect(sample(2000)).toBeNull();
+ expect(sample(-1)).toBeNull();
+ expect(measureText).not.toHaveBeenCalled();
+ // Sampling a different frame must never mutate an earlier returned frame.
+ expect(first.visibleLines[0].words[0].state).toBe("active");
+ });
+
+ it("rebuilds wrapping and baseline for new width, font metrics, and row settings", () => {
+ const phrase = [{ id: "c", startMs: 0, endMs: 3000, text: "alpha bravo charlie delta" }];
+ const wide = prepareCaptionLayout({ ...options, cues: phrase, fontBaselinePx: 14 });
+ const narrow = prepareCaptionLayout({
+ ...options,
+ cues: phrase,
+ maxWidthPx: 65,
+ fontBaselinePx: 18,
+ });
+ const largerFont = prepareCaptionLayout({
+ ...options,
+ cues: phrase,
+ maxWidthPx: 120,
+ measureText: (text) => text.length * 20,
+ });
+ const twoRows = prepareCaptionLayout({
+ ...options,
+ cues: phrase,
+ maxWidthPx: 65,
+ settings: { ...options.settings, maxRows: 2 },
+ });
+ expect(wide(100)?.editTarget.text).toBe("alpha bravo charlie delta");
+ expect(narrow(100)?.editTarget.text).toBe("alpha");
+ expect(narrow(100)?.fontBaselinePx).toBe(18);
+ expect(wide(100)?.fontBaselinePx).toBe(14);
+ expect(largerFont(100)?.visibleLines[0].width).toBe(100);
+ expect(twoRows(100)?.visibleLines).toHaveLength(2);
+ });
+
+ it("rebuilds text and timings after a caption edit without changing an existing timeline", () => {
+ const original = prepareCaptionLayout(options);
+ const edited = prepareCaptionLayout({
+ ...options,
+ cues: [{ id: "a", startMs: 300, endMs: 900, text: "edited phrase" }],
+ });
+ expect(edited(100)).toBeNull();
+ expect(edited(400)?.editTarget.text).toBe("edited phrase");
+ expect(edited(400)?.editTarget.startMs).toBe(300);
+ expect(original(400)?.editTarget.text).toBe("hello world");
+ });
+});
diff --git a/src/components/video-editor/cursorIdleSettings.test.ts b/src/components/video-editor/cursorIdleSettings.test.ts
new file mode 100644
index 00000000..ee881026
--- /dev/null
+++ b/src/components/video-editor/cursorIdleSettings.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+import {
+ DEFAULT_EDITOR_PREFERENCES,
+ normalizeEditorPreferences,
+ serializeEditorPresetSnapshot,
+} from "./editorPreferences";
+import { normalizeProjectEditor } from "./projectPersistence";
+import { DEFAULT_AUTO_CAPTION_SETTINGS, DEFAULT_CROP_REGION } from "./types";
+import { DEFAULT_CURSOR_IDLE_HIDE } from "./videoPlayback/cursorIdleVisibility";
+
+const customized = {
+ enabled: false,
+ idleThresholdMs: 3500,
+ fadeDelayMs: 750,
+ fadeDurationMs: 500,
+ movementTolerancePx: 4,
+};
+
+describe("cursor idle settings persistence", () => {
+ it("enables idle hiding for new preferences and older projects without the setting", () => {
+ expect(DEFAULT_EDITOR_PREFERENCES.cursorIdleHide).toEqual(DEFAULT_CURSOR_IDLE_HIDE);
+ expect(normalizeProjectEditor({}).cursorIdleHide).toEqual(DEFAULT_CURSOR_IDLE_HIDE);
+ expect(
+ normalizeProjectEditor({ cursorIdleHide: { enabled: false, fadeDurationMs: 450 } })
+ .cursorIdleHide,
+ ).toMatchObject({ enabled: false, fadeDelayMs: 500, fadeDurationMs: 450 });
+ expect(normalizeEditorPreferences({}).cursorIdleHide.enabled).toBe(true);
+ });
+
+ it("preserves a disabled toggle and custom timing through project and preference reloads", () => {
+ const project = normalizeProjectEditor({ cursorIdleHide: customized });
+ expect(normalizeProjectEditor(JSON.parse(JSON.stringify(project))).cursorIdleHide).toEqual(
+ customized,
+ );
+ const preferences = normalizeEditorPreferences({ cursorIdleHide: customized });
+ expect(
+ normalizeEditorPreferences(JSON.parse(JSON.stringify(preferences))).cursorIdleHide,
+ ).toEqual(customized);
+ });
+
+ it("sanitizes stored timing and tolerance without discarding an explicit false", () => {
+ const raw = {
+ enabled: false,
+ idleThresholdMs: -10,
+ fadeDurationMs: 20000,
+ movementTolerancePx: Number.NaN,
+ };
+ expect(normalizeProjectEditor({ cursorIdleHide: raw }).cursorIdleHide).toEqual({
+ enabled: false,
+ idleThresholdMs: 500,
+ fadeDelayMs: 500,
+ fadeDurationMs: 1000,
+ movementTolerancePx: 2,
+ });
+ });
+
+ it("includes the cursor setting in reusable preset snapshots", () => {
+ const serialized = serializeEditorPresetSnapshot({
+ ...DEFAULT_EDITOR_PREFERENCES,
+ cursorIdleHide: customized,
+ cropRegion: DEFAULT_CROP_REGION,
+ autoCaptionSettings: DEFAULT_AUTO_CAPTION_SETTINGS,
+ });
+ expect(JSON.parse(serialized).cursorIdleHide).toEqual(customized);
+ });
+});
diff --git a/src/components/video-editor/dashboard/dashboardLocalization.test.tsx b/src/components/video-editor/dashboard/dashboardLocalization.test.tsx
new file mode 100644
index 00000000..7db0361e
--- /dev/null
+++ b/src/components/video-editor/dashboard/dashboardLocalization.test.tsx
@@ -0,0 +1,52 @@
+import { LocalizedError } from "@/components/feedback/LocalizedError";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { I18nProvider } from "@/contexts/I18nContext";
+import { SUPPORTED_LOCALES } from "@/i18n/config";
+import { DashboardToolbar } from "./DashboardToolbar";
+
+const catalogs = import.meta.glob("../../../i18n/locales/*/ui.json", {
+ eager: true,
+ import: "default",
+}) as Record>;
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe("dashboard localization", () => {
+ it.each(SUPPORTED_LOCALES)("renders the library in %s with localized counts", (locale) => {
+ vi.stubGlobal("window", { localStorage: { getItem: () => locale } });
+ const messages = catalogs[`../../../i18n/locales/${locale}/ui.json`];
+ const errorHtml = renderToStaticMarkup(
+
+
+ ,
+ );
+ expect(errorHtml).toContain(messages.couldNotCompleteAction);
+ expect(errorHtml).toContain(messages.technicalDetails);
+ expect(errorHtml).toContain('aria-expanded="false"');
+ expect(errorHtml).toContain('hidden=""');
+ for (const count of [0, 1, 2, 1200]) {
+ const html = renderToStaticMarkup(
+
+ undefined}
+ onImportFile={async () => undefined}
+ run={async (action) => action()}
+ busy={false}
+ count={count}
+ />
+ ,
+ );
+ expect(html).toContain(messages.yourProjects);
+ expect(html).toContain(messages.searchProjects);
+ const category = new Intl.PluralRules(locale).select(count);
+ const expected = (
+ messages[`projectCount_${category}`] ?? messages.projectCount_other
+ ).replace("{{number}}", new Intl.NumberFormat(locale).format(count));
+ expect(html).toContain(expected);
+ expect(html).not.toContain("ui.projectCount");
+ }
+ });
+});
diff --git a/src/components/video-editor/dashboard/projectThumbnailUrl.test.ts b/src/components/video-editor/dashboard/projectThumbnailUrl.test.ts
new file mode 100644
index 00000000..f51347fd
--- /dev/null
+++ b/src/components/video-editor/dashboard/projectThumbnailUrl.test.ts
@@ -0,0 +1,50 @@
+import { afterEach, expect, it, vi } from "vitest";
+import { resolveProjectThumbnailUrl } from "./projectThumbnailUrl";
+
+afterEach(() => vi.unstubAllGlobals());
+
+it.each([
+ "/Users/demo/Projects/Cat #1.recordly.preview.png",
+ "file:///Users/demo/Projects/Cat%20%231.recordly.preview.png",
+])("loads a local PNG through approved media transport and preserves its capability: %s", async (source) => {
+ const path = "/Users/demo/Projects/Cat #1.recordly.preview.png";
+ const serverUrl = new URL("http://127.0.0.1:1234/video");
+ serverUrl.searchParams.set("path", path);
+ serverUrl.searchParams.set("token", "approved-image-capability");
+ const getLocalMediaUrl = vi.fn().mockResolvedValue({ success: true, url: serverUrl.href });
+ vi.stubGlobal("window", { location: { protocol: "http:" }, electronAPI: { getLocalMediaUrl } });
+ const result = new URL(await resolveProjectThumbnailUrl(source, 123));
+ expect(getLocalMediaUrl).toHaveBeenCalledWith(path);
+ expect(result.origin).toBe("http://127.0.0.1:1234");
+ expect(result.searchParams.get("path")).toBe(path);
+ expect(result.searchParams.get("token")).toBe("approved-image-capability");
+ expect(result.searchParams.get("v")).toBe("123");
+});
+
+it("preserves remote queries and replaces an old cache revision", async () => {
+ const getLocalMediaUrl = vi.fn();
+ vi.stubGlobal("window", { location: { protocol: "http:" }, electronAPI: { getLocalMediaUrl } });
+ const result = new URL(
+ await resolveProjectThumbnailUrl("https://example.test/thumb?size=640&v=old", 7),
+ );
+ expect(result.searchParams.get("size")).toBe("640");
+ expect(result.searchParams.getAll("v")).toEqual(["7"]);
+ expect(getLocalMediaUrl).not.toHaveBeenCalled();
+});
+
+it("reports unavailable local transport without giving the HTTP renderer a blocked file URL", async () => {
+ vi.stubGlobal("window", {
+ location: { protocol: "http:" },
+ electronAPI: { getLocalMediaUrl: async () => ({ success: false }) },
+ });
+ await expect(resolveProjectThumbnailUrl("/private/preview.png", 1)).rejects.toThrow(
+ "transport unavailable",
+ );
+});
+
+it.each([
+ "data:image/png;base64,preview",
+ "blob:http://localhost/preview",
+])("keeps in-memory previews unchanged: %s", async (source) => {
+ expect(await resolveProjectThumbnailUrl(source, 123)).toBe(source);
+});
diff --git a/src/components/video-editor/settings/SettingsSections.test.tsx b/src/components/video-editor/settings/SettingsSections.test.tsx
new file mode 100644
index 00000000..9d9ce6a8
--- /dev/null
+++ b/src/components/video-editor/settings/SettingsSections.test.tsx
@@ -0,0 +1,271 @@
+import { Dropdown, ProgressBar } from "@heroui/react";
+import {
+ Children,
+ type ComponentProps,
+ type ElementType,
+ isValidElement,
+ type ReactElement,
+ type ReactNode,
+} from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { Button } from "@/components/ui/button";
+import { ChoiceGroup } from "@/components/ui/choice-group";
+import { Select } from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import { DEFAULT_EDITOR_PREFERENCES } from "../editorPreferences";
+import { SliderControl } from "../SliderControl";
+import {
+ type CropRegion,
+ DEFAULT_AUTO_CAPTION_SETTINGS,
+ type WebcamOverlaySettings,
+} from "../types";
+import { WebcamCropControl } from "../WebcamCropControl";
+import { CaptionGenerationSettings } from "./CaptionGenerationSettings";
+import { CursorClickEffectCards } from "./CursorAppearancePreviews";
+import { WebcamSettings } from "./WebcamSettings";
+
+const language = vi.hoisted(() => ({ locale: "en" }));
+vi.mock("@/contexts/I18nContext", () => ({
+ useI18n: () => ({
+ locale: language.locale,
+ t: (key: string, fallback?: string) => fallback ?? key,
+ }),
+ useScopedT: () => (key: string, fallback?: string) => fallback ?? key,
+}));
+
+// Inspect each section's controlled UI contract without substituting its UI components.
+function elements(tree: ReactNode): ReactElement<{ children?: ReactNode }>[] {
+ return Children.toArray(tree).flatMap((child) =>
+ isValidElement<{ children?: ReactNode }>(child)
+ ? [child, ...elements(child.props.children)]
+ : [],
+ );
+}
+
+function control(
+ tree: ReactNode,
+ type: T,
+ match: (props: ComponentProps) => boolean = () => true,
+): ComponentProps {
+ const element = elements(tree).find(
+ (node) => node.type === type && match(node.props as ComponentProps),
+ );
+ expect(element, "Expected the section to expose this control").toBeDefined();
+ return element!.props as ComponentProps;
+}
+
+function webcamView(overrides: Partial> = {}) {
+ const webcam: WebcamOverlaySettings = {
+ ...DEFAULT_EDITOR_PREFERENCES.webcam,
+ enabled: true,
+ sourcePath: "/recordings/camera.mp4",
+ shape: "squircle",
+ width: 40,
+ height: 40,
+ size: 40,
+ timeOffsetMs: 75,
+ visibleRanges: [{ startMs: 100, endMs: 2500 }],
+ };
+ return WebcamSettings({
+ advanced: true,
+ webcam,
+ webcamPreviewSrc: "preview.mp4",
+ webcamPreviewCurrentTime: 2,
+ webcamPreviewPlaying: true,
+ ...overrides,
+ });
+}
+
+function captionView(overrides: Partial> = {}) {
+ return CaptionGenerationSettings({
+ advanced: true,
+ captionCueCount: 2,
+ autoCaptionSettings: { ...DEFAULT_AUTO_CAPTION_SETTINGS, fontFamily: "Saved font" },
+ whisperModelPath: "/models/small.bin",
+ whisperModelDownloadStatus: "downloaded",
+ whisperModelDownloadProgress: 100,
+ isGeneratingCaptions: false,
+ ...overrides,
+ });
+}
+
+beforeEach(() => {
+ language.locale = "en";
+});
+
+describe("webcam settings controls", () => {
+ it("changes width and legacy size together while preserving footage and timing", () => {
+ const onWebcamChange = vi.fn();
+ const tree = webcamView({ onWebcamChange });
+ control(tree, SliderControl, (props) => props.label === "Webcam Width").onChange(55);
+ expect(onWebcamChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ width: 55,
+ size: 55,
+ height: 40,
+ sourcePath: "/recordings/camera.mp4",
+ timeOffsetMs: 75,
+ visibleRanges: [{ startMs: 100, endMs: 2500 }],
+ }),
+ );
+ });
+
+ it("applies named positions and switches to custom when adjusting coordinates", () => {
+ const onWebcamChange = vi.fn();
+ const tree = webcamView({ onWebcamChange });
+ control(tree, ChoiceGroup, (props) => props["aria-label"] === "Position").onValueChange?.(
+ "top-left",
+ );
+ expect(onWebcamChange).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ positionPreset: "top-left",
+ positionX: 0,
+ positionY: 0,
+ corner: "top-left",
+ }),
+ );
+ control(tree, SliderControl, (props) => props.label === "Horizontal").onChange(35);
+ expect(onWebcamChange).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ positionPreset: "custom",
+ positionX: 0.35,
+ positionY: DEFAULT_EDITOR_PREFERENCES.webcam.positionY,
+ sourcePath: "/recordings/camera.mp4",
+ timeOffsetMs: 75,
+ }),
+ );
+ });
+
+ it("forwards preview synchronization and adjusts crop height only with frame dimensions", () => {
+ const onWebcamChange = vi.fn();
+ const crop = control(webcamView({ onWebcamChange }), WebcamCropControl);
+ expect(crop).toMatchObject({
+ previewSrc: "preview.mp4",
+ previewCurrentTime: 2,
+ previewPlaying: true,
+ previewTimeOffsetMs: 75,
+ });
+ const region: CropRegion = { x: 0, y: 0, width: 0.5, height: 1 };
+ crop.onCropChange(region, { src: null, width: 100, height: 100 });
+ expect(onWebcamChange).toHaveBeenLastCalledWith(
+ expect.objectContaining({ cropRegion: region, width: 40, height: 80 }),
+ );
+ crop.onCropChange(region);
+ expect(onWebcamChange).toHaveBeenLastCalledWith(
+ expect.objectContaining({ cropRegion: region, width: 40, height: 40 }),
+ );
+ });
+
+ it("shows only the import action when no webcam video is linked", () => {
+ const onUploadWebcam = vi.fn();
+ const tree = webcamView({ webcam: undefined, onUploadWebcam });
+ control(tree, Button).onClick?.();
+ expect(onUploadWebcam).toHaveBeenCalledOnce();
+ expect(elements(tree).some((node) => node.type === SliderControl)).toBe(false);
+ });
+});
+
+describe("caption generation settings controls", () => {
+ it("automatically detects language and merges style edits into saved caption settings", () => {
+ const onAutoCaptionSettingsChange = vi.fn();
+ const tree = captionView({ onAutoCaptionSettingsChange });
+ control(tree, SliderControl, (props) => props.label === "Rows").onChange(2.6);
+ expect(onAutoCaptionSettingsChange).toHaveBeenLastCalledWith({
+ ...DEFAULT_AUTO_CAPTION_SETTINGS,
+ fontFamily: "Saved font",
+ maxRows: 3,
+ });
+ });
+
+ it.each([
+ { whisperModelPath: null, isGeneratingCaptions: false, disabled: false },
+ { whisperModelPath: "/models/small.bin", isGeneratingCaptions: true, disabled: true },
+ { whisperModelPath: "/models/small.bin", isGeneratingCaptions: false, disabled: false },
+ ])("guards generation for model availability and progress: %j", ({ disabled, ...state }) => {
+ const tree = captionView(state);
+ const label = state.isGeneratingCaptions
+ ? "Generating..."
+ : !state.whisperModelPath
+ ? "Download Model"
+ : "Regenerate Captions";
+ expect(control(tree, Button, (props) => props.children === label).disabled).toBe(disabled);
+ });
+
+ it("disables clearing an empty timeline and retains model download progress", () => {
+ const tree = captionView({
+ captionCueCount: 0,
+ whisperModelPath: null,
+ whisperModelDownloadStatus: "downloading",
+ whisperModelDownloadProgress: 42.7,
+ });
+ expect(
+ control(tree, Dropdown.Item, (props) => props.id === "clear-captions").isDisabled,
+ ).toBe(true);
+ expect(control(tree, Button, (props) => props.disabled === true).disabled).toBe(true);
+ expect(control(tree, ProgressBar).value).toBe(42.7);
+ expect(
+ control(tree, Dropdown.Item, (props) => props.id === "delete-model").isDisabled,
+ ).toBe(true);
+ });
+
+ it("routes the primary action to setup until the model is ready", () => {
+ const onDownloadWhisperSmallModel = vi.fn();
+ const onGenerateAutoCaptions = vi.fn();
+ const tree = captionView({
+ whisperModelPath: null,
+ whisperModelDownloadStatus: "idle",
+ onDownloadWhisperSmallModel,
+ onGenerateAutoCaptions,
+ });
+ control(tree, Button, (props) => props.children === "Download Model").onClick?.(
+ {} as never,
+ );
+ expect(onDownloadWhisperSmallModel).toHaveBeenCalledOnce();
+ expect(onGenerateAutoCaptions).not.toHaveBeenCalled();
+ });
+
+ it("changes animation without resetting visibility or transcription preferences", () => {
+ const onAutoCaptionSettingsChange = vi.fn();
+ const autoCaptionSettings = {
+ ...DEFAULT_AUTO_CAPTION_SETTINGS,
+ enabled: true,
+ language: "fr",
+ timelineQuickAdd: false,
+ fontSize: 52,
+ };
+ const tree = captionView({ autoCaptionSettings, onAutoCaptionSettingsChange });
+ control(tree, Select, (props) => props["aria-label"] === "Animation").onValueChange?.(
+ "rise",
+ );
+ expect(onAutoCaptionSettingsChange).toHaveBeenCalledWith({
+ ...autoCaptionSettings,
+ animationStyle: "rise",
+ });
+ });
+
+ it("keeps the selected locale for fractional caption styling values", () => {
+ language.locale = "fr";
+ const radius = control(
+ captionView(),
+ SliderControl,
+ (props) => props.label === "Box radius",
+ );
+ expect(radius.formatValue(17.5)).toBe("17,5px");
+ });
+});
+
+it("clears a click-effect choice to none and forwards a selected effect", () => {
+ const onApply = vi.fn();
+ const tree = CursorClickEffectCards({
+ title: "Click Effects",
+ activeEffectId: "none",
+ onApply,
+ tSettings: (key, fallback) => fallback ?? key,
+ });
+ const choices = control(tree, ChoiceGroup);
+ expect(choices.allowDeselect).toBe(true);
+ choices.onValueChange?.("");
+ expect(onApply).toHaveBeenCalledWith("none");
+ choices.onValueChange?.("echo");
+ expect(onApply).toHaveBeenCalledWith("echo");
+});
diff --git a/src/components/video-editor/videoPlayback/annotationFrameStore.test.ts b/src/components/video-editor/videoPlayback/annotationFrameStore.test.ts
new file mode 100644
index 00000000..b2e15d35
--- /dev/null
+++ b/src/components/video-editor/videoPlayback/annotationFrameStore.test.ts
@@ -0,0 +1,25 @@
+import { expect, it, vi } from "vitest";
+import { createAnnotationFrameStore } from "./annotationFrameStore";
+
+it("retains the latest camera frame without a React subscriber", () => {
+ const store = createAnnotationFrameStore();
+ const frame = { timeMs: 42, transform: { scale: 2, x: 10, y: -5 } };
+ expect(store.hasSubscribers()).toBe(false);
+ store.publish(frame);
+ expect(store.getSnapshot()).toBe(frame);
+});
+
+it("notifies only subscribed overlays and retains snapshot identity for an unchanged pose", () => {
+ const store = createAnnotationFrameStore();
+ const listener = vi.fn();
+ const unsubscribe = store.subscribe(listener);
+ const frame = { timeMs: 42, transform: { scale: 2, x: 10, y: -5 } };
+ store.publish(frame);
+ store.publish({ ...frame, transform: { ...frame.transform } });
+ expect(store.getSnapshot()).toBe(frame);
+ expect(listener).toHaveBeenCalledOnce();
+ unsubscribe();
+ store.publish({ ...frame, timeMs: 50 });
+ expect(store.hasSubscribers()).toBe(false);
+ expect(listener).toHaveBeenCalledOnce();
+});
diff --git a/src/components/video-editor/videoPlayback/pixelGloveMotion.test.ts b/src/components/video-editor/videoPlayback/pixelGloveMotion.test.ts
new file mode 100644
index 00000000..7aa4b30e
--- /dev/null
+++ b/src/components/video-editor/videoPlayback/pixelGloveMotion.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { resolvePixelGlovePose } from "./pixelGloveMotion";
+
+describe("pixel glove click poses", () => {
+ it("curls on click and returns to pointing after the press", () => {
+ expect(resolvePixelGlovePose("pixel-hand", "arrow", 40, 350, 1)).toBe("closed-hand");
+ expect(resolvePixelGlovePose("pixel-hand", "pointer", 120, 350, 1)).toBe("pointer");
+ expect(resolvePixelGlovePose("pixel-hand", "arrow", -1, 350, 1)).toBe("arrow");
+ });
+ it("respects bounce off and leaves other cursor styles alone", () => {
+ expect(resolvePixelGlovePose("pixel-hand", "pointer", 40, 350, 0)).toBe("pointer");
+ expect(resolvePixelGlovePose("tahoe", "arrow", 40, 350, 1)).toBe("arrow");
+ });
+ it("preserves text, resize and drag poses while clicking", () => {
+ for (const type of ["text", "resize-ew", "open-hand", "closed-hand"] as const) {
+ expect(resolvePixelGlovePose("pixel-hand", type, 40, 350, 1)).toBe(type);
+ }
+ });
+});
diff --git a/src/i18n/locale.test.ts b/src/i18n/locale.test.ts
new file mode 100644
index 00000000..278cd76a
--- /dev/null
+++ b/src/i18n/locale.test.ts
@@ -0,0 +1,66 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { getInitialLocale, getSystemLocale, matchLocale, normalizeLocale } from "./locale";
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe("locale negotiation", () => {
+ it("restores the native saved language across changing packaged loopback origins", () => {
+ vi.stubGlobal("navigator", { languages: ["en-AU"] });
+ vi.stubGlobal("window", {
+ electronAPI: { getAppSetting: () => "zh-TW" },
+ localStorage: { getItem: () => null },
+ });
+ expect(getInitialLocale()).toBe("zh-TW");
+ vi.stubGlobal("window", {
+ electronAPI: { getAppSetting: () => "invalid!" },
+ localStorage: { getItem: () => "de" },
+ });
+ expect(getInitialLocale()).toBe("de");
+ });
+ it.each([
+ ["zh-Hant", "zh-TW"],
+ ["zh-Hant-CN", "zh-TW"],
+ ["zh-Hans-TW", "zh-CN"],
+ ["zh-HK", "zh-TW"],
+ ["zh-MO", "zh-TW"],
+ ["zh-SG", "zh-CN"],
+ ["zh-cn", "zh-CN"],
+ ["zh_Hant_HK", "zh-TW"],
+ ["pt-PT", "pt-BR"],
+ ["es-MX", "es"],
+ ["ko-KR", "ko"],
+ ["ja-JP", "ja"],
+ ["ja_JP", "ja"],
+ ["en-AU", "en"],
+ ["de-DE-u-co-phonebk", "de"],
+ ])("maps %s to %s", (input, expected) => expect(normalizeLocale(input)).toBe(expected));
+ it("rejects malformed and unsupported tags", () => {
+ for (const input of ["!invalid", "ru", "", null, undefined])
+ expect(matchLocale(input)).toBeUndefined();
+ });
+ it("uses the first supported preferred language, including English", () => {
+ vi.stubGlobal("navigator", { languages: ["ru-RU", "en-AU", "ko-KR"] });
+ expect(getSystemLocale()).toBe("en");
+ vi.stubGlobal("navigator", { languages: ["ru-RU", "zh-Hant"] });
+ expect(getSystemLocale()).toBe("zh-TW");
+ vi.stubGlobal("navigator", { languages: ["ja-JP", "en-AU"] });
+ expect(getSystemLocale()).toBe("ja");
+ });
+ it("uses navigator.language when the preferred language list is empty", () => {
+ vi.stubGlobal("navigator", { languages: [], language: "fr-CA" });
+ expect(getSystemLocale()).toBe("fr");
+ });
+ it("honors saved selection and recovers from invalid or inaccessible storage", () => {
+ vi.stubGlobal("navigator", { languages: ["es-MX"] });
+ vi.stubGlobal("window", { localStorage: { getItem: () => "zh-Hant" } });
+ expect(getInitialLocale()).toBe("zh-TW");
+ vi.stubGlobal("window", { localStorage: { getItem: () => "bad!!" } });
+ expect(getInitialLocale()).toBe("es");
+ vi.stubGlobal("window", {
+ get localStorage() {
+ throw Error("Blocked");
+ },
+ });
+ expect(getInitialLocale()).toBe("es");
+ });
+});
diff --git a/src/i18n/messages.test.ts b/src/i18n/messages.test.ts
new file mode 100644
index 00000000..2210f7a3
--- /dev/null
+++ b/src/i18n/messages.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+import { SUPPORTED_LOCALES } from "./config";
+import { getMessageValue, translateForLocale } from "./messages";
+
+describe("translation lookup", () => {
+ it.each(SUPPORTED_LOCALES)("uses locale plural rules for numeric counts in %s", (locale) => {
+ const catalogs = import.meta.glob>("./locales/*/ui.json", {
+ eager: true,
+ import: "default",
+ });
+ const catalog = catalogs[`./locales/${locale}/ui.json`];
+ for (const count of [0, 1, 2, 1234]) {
+ const category = new Intl.PluralRules(locale).select(count);
+ const number = new Intl.NumberFormat(locale).format(count);
+ const expected = (
+ catalog[`projectCount_${category}`] ?? catalog.projectCount_other
+ ).replace("{{number}}", number);
+ expect(
+ translateForLocale(locale, "ui.projectCount", undefined, { count, number }),
+ ).toBe(expected);
+ }
+ });
+ it.each(SUPPORTED_LOCALES)("resolves localized common actions in %s", (locale) => {
+ const result = translateForLocale(locale, "common.actions.cancel");
+ expect(result).not.toBe("common.actions.cancel");
+ if (locale !== "en") expect(result).not.toBe("Cancel");
+ });
+ it.each(SUPPORTED_LOCALES)("preserves interpolation in %s", (locale) => {
+ expect(
+ translateForLocale(locale, "common.feedback.removeFile", undefined, {
+ name: "demo.mov",
+ }),
+ ).toContain("demo.mov");
+ });
+ it("uses explicit fallbacks and safely ignores inherited object properties", () => {
+ expect(
+ translateForLocale("ko", "common.unknown", "Fallback {{name}}", { name: "value" }),
+ ).toBe("Fallback value");
+ expect(getMessageValue({ nested: {} }, "nested.toString")).toBeUndefined();
+ expect(translateForLocale("en", "not.a.key")).toBe("not.a.key");
+ });
+});
diff --git a/src/i18n/sourceCheck.test.ts b/src/i18n/sourceCheck.test.ts
new file mode 100644
index 00000000..bb5deb67
--- /dev/null
+++ b/src/i18n/sourceCheck.test.ts
@@ -0,0 +1,60 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import { auditSourceKeys } from "../../scripts/i18n-source-check.mjs";
+
+describe("source translation coverage guard", () => {
+ it("finds missing keys even with English fallbacks, scoped aliases and passed translators", () => {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "recordly-i18n-check-"));
+ try {
+ fs.writeFileSync(
+ path.join(directory, "controls.tsx"),
+ `
+import { useI18n as useLocale, useScopedT as useText, translate as text } from "./I18nContext";
+function Controls() {
+ const { t: label } = useLocale();
+ const editorText = useText("editor");
+ label("actions.cancel"); editorText("missing", "English fallback");
+ text("common.missing", "Also missing"); helper(editorText);
+}
+function helper(t) { t("alsoMissing", "Fallback"); }
+function Passed({t}) { t("editor.fromProps", "Fallback"); }
+function Indirect(props) { const {t} = props; t("editor.indirect", "Fallback"); }
+function Property(input) { input.t("editor.property", "Fallback"); }
+`,
+ );
+ const result = auditSourceKeys(
+ { common: { actions: { cancel: "Cancel" } }, editor: {} },
+ directory,
+ );
+ expect(result.missing.map((entry: { key: string }) => entry.key).sort()).toEqual([
+ "common.missing",
+ "editor.alsoMissing",
+ "editor.fromProps",
+ "editor.indirect",
+ "editor.missing",
+ "editor.property",
+ ]);
+ } finally {
+ fs.rmSync(directory, { recursive: true, force: true });
+ }
+ });
+ it("recognizes plural families without requiring a redundant base key", () => {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "recordly-i18n-plural-"));
+ try {
+ fs.writeFileSync(
+ path.join(directory, "counts.ts"),
+ `import { translate } from "./I18nContext"; translate("ui.count", undefined, {count: 2});`,
+ );
+ expect(
+ auditSourceKeys(
+ { common: {}, ui: { count_one: "one", count_other: "many" } },
+ directory,
+ ).missing,
+ ).toEqual([]);
+ } finally {
+ fs.rmSync(directory, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/tests/ui/account-modal.spec.ts b/tests/ui/account-modal.spec.ts
new file mode 100644
index 00000000..2e94e91f
--- /dev/null
+++ b/tests/ui/account-modal.spec.ts
@@ -0,0 +1,205 @@
+import { expect, type Locator, type Page, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+async function openAccount(page: Page) {
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Manage account", exact: true })
+ .click();
+ return page.getByRole("dialog", { name: "Account settings", exact: true });
+}
+
+async function styles(element: Locator, properties: string[]) {
+ return element.evaluate((element, properties) => {
+ const computed = getComputedStyle(element);
+ return Object.fromEntries(
+ properties.map((property) => [property, computed.getPropertyValue(property)]),
+ );
+ }, properties);
+}
+
+for (const theme of ["light", "dark"] as const) {
+ test(`account modal matches shared Settings controls in ${theme} mode`, async ({ page }) => {
+ await page.emulateMedia({ colorScheme: theme });
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ const account = await openAccount(page);
+ await expect(account).not.toHaveAttribute("data-entering");
+ const type = ["font-family", "font-size", "font-weight", "line-height"];
+ const control = [...type, "border-radius", "height", "padding-top", "padding-bottom"];
+ const field = [
+ "font-family",
+ "font-size",
+ "border-radius",
+ "background-color",
+ "box-shadow",
+ ];
+ const navigation = [
+ "font-size",
+ "font-weight",
+ "color",
+ "background-color",
+ "border-radius",
+ ];
+ const modal = {
+ surface: await styles(account, ["background-color", "border-radius"]),
+ title: await styles(
+ account.getByRole("heading", { name: "Account", exact: true }),
+ type,
+ ),
+ button: await styles(
+ account.getByRole("button", { name: "Save profile", exact: true }),
+ control,
+ ),
+ field: await styles(account.getByRole("textbox", { name: /^Display name\*?$/ }), field),
+ tab: await styles(
+ account.getByRole("tab", { name: "Profile", exact: true }),
+ navigation,
+ ),
+ };
+ await account.getByRole("tab", { name: "Sign-in methods", exact: true }).click();
+ expect(await styles(account.getByLabel("New password", { exact: true }), field)).toEqual(
+ modal.field,
+ );
+ expect(
+ await styles(account.getByLabel("Confirm password", { exact: true }), field),
+ ).toEqual(modal.field);
+ await account.getByRole("button", { name: "Close", exact: true }).click();
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true })
+ .click();
+ const settings = page.getByRole("dialog", { name: "Settings", exact: true });
+ await expect(settings).not.toHaveAttribute("data-entering");
+ await settings.getByRole("tab", { name: "Account", exact: true }).click();
+ expect(modal.surface).toEqual(
+ await styles(settings, ["background-color", "border-radius"]),
+ );
+ expect(modal.title).toEqual(
+ await styles(settings.locator(".dashboard-settings-title"), type),
+ );
+ expect(modal.button).toEqual(
+ await styles(
+ settings.getByRole("button", { name: "Save profile", exact: true }),
+ control,
+ ),
+ );
+ expect(modal.field).toEqual(
+ await styles(settings.getByRole("textbox", { name: /^Display name\*?$/ }), field),
+ );
+ await expect
+ .poll(() =>
+ styles(settings.getByRole("tab", { name: "Account", exact: true }), navigation),
+ )
+ .toEqual(modal.tab);
+ });
+}
+
+test("profile modal groups account tasks and retains drafts across tabs", async ({ page }) => {
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ const account = await openAccount(page);
+ const profile = account.getByRole("tab", { name: "Profile", exact: true });
+ await expect(profile).toHaveAttribute("aria-selected", "true");
+ const name = account.getByRole("textbox", { name: /^Display name\*?$/ });
+ await expect(name).toHaveValue("Test User");
+ const save = account.getByRole("button", { name: "Save profile", exact: true });
+ const field = (await name.boundingBox())!;
+ const button = (await save.boundingBox())!;
+ expect(Math.abs(field.y + field.height - button.y - button.height)).toBeLessThan(1);
+ await name.fill("Draft profile");
+ await account.getByRole("tab", { name: "Sign-in methods", exact: true }).click();
+ const email = account.getByRole("textbox", { name: /^Email address\*?$/ });
+ await email.fill("draft@example.com");
+ await account.getByRole("tab", { name: "Plan & billing", exact: true }).click();
+ await expect(account.getByLabel("Cloud usage", { exact: true })).toBeVisible();
+ await profile.click();
+ await expect(name).toHaveValue("Draft profile");
+ await save.click();
+ await expect(account.getByRole("status")).toHaveText("Profile saved.");
+ await account.screenshot({
+ path: test.info().outputPath("account-profile.png"),
+ animations: "disabled",
+ });
+ await account.getByRole("tab", { name: "Sign-in methods", exact: true }).click();
+ await expect(email).toHaveValue("draft@example.com");
+ await account.screenshot({
+ path: test.info().outputPath("account-signin.png"),
+ animations: "disabled",
+ });
+ const signin = account.getByRole("tab", { name: "Sign-in methods", exact: true });
+ await signin.focus();
+ await page.keyboard.press("ArrowRight");
+ await expect(account.getByRole("tab", { name: "Plan & billing", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await account.screenshot({
+ path: test.info().outputPath("account-cloud.png"),
+ animations: "disabled",
+ });
+ await account.getByRole("button", { name: "Close", exact: true }).click();
+ await expect(account).not.toBeVisible();
+});
+
+test("account modal keeps email confirmation and password validation working", async ({ page }) => {
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ const account = await openAccount(page);
+ await account.getByRole("tab", { name: "Sign-in methods", exact: true }).click();
+ await account.getByRole("textbox", { name: /^Email address\*?$/ }).fill("new@example.com");
+ await account.getByRole("button", { name: "Change email", exact: true }).click();
+ await expect(account.getByText("Awaiting confirmation: new@example.com")).toBeVisible();
+ const form = account.getByRole("form", { name: "Password (optional)", exact: true });
+ await form.getByLabel("New password", { exact: true }).fill("test-only-password");
+ await form.getByLabel("Confirm password", { exact: true }).fill("different-password");
+ await form.getByRole("button", { name: "Save password", exact: true }).click();
+ await expect(form.getByRole("alert")).toHaveText("Passwords do not match.");
+ await form.getByLabel("Confirm password", { exact: true }).fill("test-only-password");
+ await form.getByRole("button", { name: "Save password", exact: true }).click();
+ await expect(form.getByRole("status")).toHaveText("Password saved.");
+ await expect(form.getByLabel("New password", { exact: true })).toHaveValue("");
+});
+
+for (const theme of ["light", "dark"] as const) {
+ for (const size of [
+ { width: 1280, height: 720 },
+ { width: 760, height: 560 },
+ { width: 390, height: 640 },
+ ]) {
+ test(`account modal fits ${size.width}x${size.height} in ${theme} mode`, async ({
+ page,
+ }) => {
+ await page.setViewportSize(size);
+ await page.emulateMedia({ colorScheme: theme });
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ const account = await openAccount(page);
+ const tabs = account.getByRole("tablist");
+ await expect(tabs).toBeInViewport();
+ for (const label of ["Profile", "Sign-in methods", "Plan & billing"]) {
+ await account.getByRole("tab", { name: label, exact: true }).click();
+ await expect(
+ account.getByRole("button", { name: "Close", exact: true }),
+ ).toBeInViewport();
+ expect(
+ await account.evaluate((element) => element.scrollWidth <= element.clientWidth),
+ ).toBe(true);
+ const panel = account.getByRole("tabpanel");
+ expect(
+ await panel.evaluate((element) => element.scrollWidth <= element.clientWidth),
+ ).toBe(true);
+ await expect(tabs).toBeInViewport();
+ }
+ await account.getByRole("tab", { name: "Profile", exact: true }).click();
+ await page.screenshot({
+ path: test.info().outputPath(`account-${theme}-${size.width}.png`),
+ animations: "disabled",
+ });
+ await page.keyboard.press("Escape");
+ await expect(account).not.toBeVisible();
+ });
+ }
+}
diff --git a/tests/ui/account-plan.spec.ts b/tests/ui/account-plan.spec.ts
new file mode 100644
index 00000000..0bd63cbe
--- /dev/null
+++ b/tests/ui/account-plan.spec.ts
@@ -0,0 +1,213 @@
+import { expect, type Page, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+type PlanMode =
+ | "free"
+ | "paid"
+ | "ending"
+ | "complimentary"
+ | "past_due"
+ | "unavailable"
+ | "loading"
+ | "portal_failure"
+ | "paid_no_portal";
+
+async function openPlan(page: Page, mode: PlanMode) {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(
+ page,
+ ({ mode }) => {
+ const prices = [{ interval: "monthly" as const, unitAmount: 900, currency: "usd" }];
+ let failedPortal = false;
+ window.electronAPI.cloudBilling = async ({ action }) => {
+ if (action === "catalog")
+ return {
+ success: true,
+ prices,
+ limits: {
+ freeRecordingLimit: 5,
+ freeMaxRecordingBytes: 1e9,
+ plusMaxRecordingBytes: 1e10,
+ storageLimitBytes: null,
+ },
+ };
+ if (action === "portal" || action === "checkout") {
+ document.documentElement.dataset.billingAction = action;
+ if (mode === "portal_failure" && !failedPortal) {
+ failedPortal = true;
+ return { success: false, error: "private provider diagnostic" };
+ }
+ return { success: true };
+ }
+ if (mode === "loading" && document.documentElement.dataset.planReady !== "true")
+ await new Promise((resolve) =>
+ document.addEventListener("release-plan", () => resolve(), { once: true }),
+ );
+ if (mode === "unavailable" && document.documentElement.dataset.planReady !== "true")
+ return { success: false, error: "Network unavailable" };
+ const plus = [
+ "paid",
+ "ending",
+ "complimentary",
+ "portal_failure",
+ "paid_no_portal",
+ ].includes(mode);
+ return {
+ success: true,
+ billing: {
+ plan: plus ? "pro" : "free",
+ subscriptionStatus:
+ mode === "past_due" ? "past_due" : plus ? "active" : null,
+ currentPeriodEnd: plus && mode !== "complimentary" ? 1893456000 : null,
+ cancelAtPeriodEnd: mode === "ending",
+ portalAvailable: ["paid", "ending", "portal_failure", "past_due"].includes(
+ mode,
+ ),
+ checkoutAvailable: !plus && mode !== "past_due",
+ checkoutIntervals: !plus && mode !== "past_due" ? ["monthly"] : [],
+ prices,
+ },
+ usage: {
+ recordings: 2,
+ recordingLimit: plus ? null : 5,
+ maxRecordingBytes: plus ? 1e10 : 1e9,
+ storageBytes: 200,
+ storageLimitBytes: plus ? null : 1e11,
+ },
+ };
+ };
+ window.electronAPI.cloudBillingCancel = async () => ({ success: true });
+ },
+ { mode },
+ );
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Manage account", exact: true })
+ .click();
+ const account = page.getByRole("dialog", { name: "Account settings", exact: true });
+ await account.getByRole("tab", { name: "Plan & billing", exact: true }).click();
+ return { account, card: account.locator(".account-plan-card") };
+}
+
+test("Free accounts can view their plan and upgrade options without starting checkout", async ({
+ page,
+}) => {
+ const { account, card } = await openPlan(page, "free");
+ await expect(card.getByText("Free", { exact: true })).toBeVisible();
+ const usage = account.getByLabel("Cloud usage", { exact: true });
+ expect(
+ Math.abs((await usage.boundingBox())!.width - (await card.boundingBox())!.width),
+ ).toBeLessThan(1);
+ await expect(
+ account.getByLabel("Cloud usage", { exact: true }).getByText("2 of 5 used"),
+ ).toBeVisible();
+ await card.getByRole("button", { name: "Upgrade to Cloud", exact: true }).click();
+ const pricing = page.getByRole("dialog", { name: "Recordly Cloud", exact: true });
+ await expect(pricing).toBeVisible();
+ await expect(page.locator("html")).not.toHaveAttribute("data-billing-action");
+ await pricing.getByRole("button", { name: "Close", exact: true }).click();
+ await expect(account.getByRole("tab", { name: "Plan & billing", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await account.screenshot({
+ path: test.info().outputPath("account-plan-free.png"),
+ animations: "disabled",
+ });
+});
+
+for (const mode of ["paid", "ending"] as const) {
+ test(`${mode} Cloud accounts can manage their existing subscription directly`, async ({
+ page,
+ }) => {
+ const { account, card } = await openPlan(page, mode);
+ await expect(card.getByText("Your Cloud plan is active.")).toBeVisible();
+ await expect(
+ card.getByText(mode === "ending" ? /^It ends on / : /^Current billing period ends on /),
+ ).toBeVisible();
+ await expect(card.getByRole("button", { name: "Upgrade to Cloud" })).toHaveCount(0);
+ await card.getByRole("button", { name: "Manage subscription", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-billing-action", "portal");
+ await expect(
+ page.getByRole("dialog", { name: "Recordly Cloud", exact: true }),
+ ).not.toBeVisible();
+ await account.screenshot({
+ path: test.info().outputPath(`account-plan-${mode}.png`),
+ animations: "disabled",
+ });
+ });
+}
+
+test("complimentary Cloud has no invented billing period or portal", async ({ page }) => {
+ const { card } = await openPlan(page, "complimentary");
+ await expect(card.getByText("Your Cloud plan is active.")).toBeVisible();
+ await expect(
+ card.getByRole("button", { name: "Manage subscription", exact: true }),
+ ).toHaveCount(0);
+ await expect(
+ card.getByText(/period ends|It ends on|Subscription management is not available/),
+ ).toHaveCount(0);
+});
+
+for (const mode of ["unavailable", "loading"] as const) {
+ test(`${mode} billing never labels an unverified account Free and can recover`, async ({
+ page,
+ }) => {
+ const { card } = await openPlan(page, mode);
+ await expect(
+ card.getByText(
+ mode === "loading"
+ ? "Loading your plan…"
+ : "We couldn’t load your plan. Refresh to try again.",
+ { exact: true },
+ ),
+ ).toBeVisible();
+ await expect(card.getByText("Free", { exact: true })).toHaveCount(0);
+ await expect(
+ card.getByRole("button", { name: "Upgrade to Cloud", exact: true }),
+ ).toHaveCount(0);
+ await page.evaluate(() => {
+ document.documentElement.dataset.planReady = "true";
+ document.dispatchEvent(new Event("release-plan"));
+ });
+ if (mode === "unavailable")
+ await card.getByRole("button", { name: "Refresh plan", exact: true }).click();
+ await expect(card.getByText("Free", { exact: true })).toBeVisible();
+ });
+}
+
+test("a subscription needing attention opens its existing portal", async ({ page }) => {
+ const { card } = await openPlan(page, "past_due");
+ await expect(card.getByText(/Your subscription needs attention/)).toBeVisible();
+ await card.getByRole("button", { name: "Manage subscription", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-billing-action", "portal");
+ await expect(card.getByRole("button", { name: "Upgrade to Cloud", exact: true })).toHaveCount(0);
+});
+
+test("portal errors keep management available for retry without exposing provider diagnostics", async ({
+ page,
+}) => {
+ const { card } = await openPlan(page, "portal_failure");
+ const manage = card.getByRole("button", { name: "Manage subscription", exact: true });
+ await manage.click();
+ await expect(card.getByRole("alert")).toContainText("We couldn’t connect to secure billing.");
+ await expect(card.getByRole("alert")).not.toContainText("private provider diagnostic");
+ await expect(manage).toBeEnabled();
+ await manage.click();
+ await expect(card.getByRole("alert")).toHaveCount(0);
+});
+
+test("a paid plan without a portal explains the limitation and offers refresh", async ({
+ page,
+}) => {
+ const { card } = await openPlan(page, "paid_no_portal");
+ await expect(
+ card.getByText("Subscription management is not available yet. Try refreshing your plan."),
+ ).toBeVisible();
+ await expect(
+ card.getByRole("button", { name: "Manage subscription", exact: true }),
+ ).toHaveCount(0);
+ await expect(card.getByRole("button", { name: "Refresh plan", exact: true })).toBeEnabled();
+});
diff --git a/tests/ui/account-settings.spec.ts b/tests/ui/account-settings.spec.ts
index 195542f9..8330af23 100644
--- a/tests/ui/account-settings.spec.ts
+++ b/tests/ui/account-settings.spec.ts
@@ -1,15 +1,26 @@
-import { expect, test, type Page } from "@playwright/test";
+import { expect, type Page, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
+async function openAppSettings(page: Page) {
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true })
+ .click();
+ return page.getByRole("dialog", { name: "Settings", exact: true });
+}
+
async function openAccountSettings(page: Page) {
- await page.getByRole("radio", { name: "Settings", exact: true }).click();
- await page.getByRole("row", { name: "Account", exact: true }).click();
- return page.getByRole("region", { name: "Account settings", exact: true });
+ const settings = await openAppSettings(page);
+ await settings.getByRole("tab", { name: "Account", exact: true }).click();
+ return settings.getByRole("tabpanel", { name: /^Account settings/ });
}
test("account settings save the Supabase profile and share it with the dashboard", async ({
page,
}) => {
+ // Cover cold startup, profile persistence after reload, and dashboard propagation.
+ test.setTimeout(60_000);
await installDesktopBridge(page);
await page.goto("/?windowType=editor");
let account = await openAccountSettings(page);
@@ -17,7 +28,7 @@ test("account settings save the Supabase profile and share it with the dashboard
"Test User",
);
await expect(account.getByRole("list", { name: "Connected sign-in methods" })).toHaveText(
- "Email magic link",
+ "Email code",
);
await expect(account.getByRole("button", { name: "Save profile" })).toBeDisabled();
await account.getByRole("textbox", { name: /^Display name\*?$/ }).fill(" Jordan Chen ");
@@ -26,10 +37,16 @@ test("account settings save the Supabase profile and share it with the dashboard
);
await account.getByRole("button", { name: "Save profile" }).click();
expect((await update).postDataJSON().data).toEqual({ full_name: "Jordan Chen" });
- await expect(account.getByRole("status")).toHaveText("Profile saved.");
+ await expect(
+ account.getByRole("form", { name: "Profile settings", exact: true }).getByRole("status"),
+ ).toHaveText("Profile saved.");
await expect(account.getByRole("textbox", { name: /^Display name\*?$/ })).toHaveValue(
"Jordan Chen",
);
+ await page
+ .getByRole("dialog", { name: "Settings", exact: true })
+ .getByRole("button", { name: "Close", exact: true })
+ .click();
await expect(
page
.getByRole("button", { name: "Recordly account", exact: true })
@@ -40,10 +57,18 @@ test("account settings save the Supabase profile and share it with the dashboard
await expect(account.getByRole("textbox", { name: /^Display name\*?$/ })).toHaveValue(
"Jordan Chen",
);
+ await page
+ .getByRole("dialog", { name: "Settings", exact: true })
+ .getByRole("button", { name: "Close", exact: true })
+ .click();
await page.getByRole("button", { name: "Home", exact: true }).click();
const dashboard = page.getByRole("dialog", { name: "Projects dashboard", exact: true });
- await dashboard.getByRole("button", { name: "Settings", exact: true }).click();
- await dashboard.getByRole("row", { name: "Account", exact: true }).click();
+ await dashboard.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true })
+ .click();
+ await dashboard.getByRole("tab", { name: "Account", exact: true }).click();
await expect(dashboard.getByRole("textbox", { name: /^Display name\*?$/ })).toHaveValue(
"Jordan Chen",
);
@@ -83,6 +108,17 @@ test("email changes use a PKCE confirmation and retain the current email until c
account.getByText("Check your current and new inboxes for confirmation instructions."),
).toBeVisible();
await expect(change).toBeDisabled();
+ // Feedback belongs to the action that produced it. Saving a profile must not
+ // dismiss the still-pending email change or its confirmation instructions.
+ const profileForm = account.getByRole("form", { name: "Profile settings" });
+ const emailForm = account.getByRole("form", { name: "Email settings" });
+ await profileForm.getByRole("textbox", { name: /^Display name\*?$/ }).fill("New Name");
+ await profileForm.getByRole("button", { name: "Save profile" }).click();
+ await expect(profileForm.getByRole("status")).toHaveText("Profile saved.");
+ await expect(emailForm.getByText("Awaiting confirmation: new@example.com")).toBeVisible();
+ await expect(
+ emailForm.getByText("Check your current and new inboxes for confirmation instructions."),
+ ).toBeVisible();
await page.reload();
const restored = await openAccountSettings(page);
await expect(restored.getByRole("textbox", { name: /^Email address\*?$/ })).toHaveValue(
@@ -106,15 +142,19 @@ test("account update errors preserve the session and allow retry", async ({ page
const account = await openAccountSettings(page);
await account.getByRole("textbox", { name: /^Display name\*?$/ }).fill("New Name");
await account.getByRole("button", { name: "Save profile" }).click();
- await expect(account.getByRole("alert")).toHaveText(
- "Please wait before updating your profile.",
- );
+ await expect(
+ account.getByRole("form", { name: "Profile settings", exact: true }).getByRole("alert"),
+ ).toContainText("Please wait before updating your profile.");
await expect(account.getByRole("textbox", { name: /^Display name\*?$/ })).toHaveValue(
"New Name",
);
await account.getByRole("button", { name: "Save profile" }).click();
- await expect(account.getByRole("status")).toHaveText("Profile saved.");
- await expect(account.getByRole("alert")).toHaveCount(0);
+ await expect(
+ account.getByRole("form", { name: "Profile settings", exact: true }).getByRole("status"),
+ ).toHaveText("Profile saved.");
+ await expect(
+ account.getByRole("form", { name: "Profile settings", exact: true }).getByRole("alert"),
+ ).toHaveCount(0);
});
test("signing out from Settings clears the session and brings back required sign-in", async ({
@@ -124,11 +164,15 @@ test("signing out from Settings clears the session and brings back required sign
await page.goto("/?windowType=editor");
const account = await openAccountSettings(page);
const logout = page.waitForRequest((request) => request.url().includes("/auth/v1/logout"));
- await account.getByRole("button", { name: "Sign out", exact: true }).click();
+ await account.getByRole("button", { name: "Sign out of all devices", exact: true }).click();
expect(new URL((await logout).url()).searchParams.get("scope")).toBe("global");
- await expect(page.locator('[data-onboarding-state="login"]')).toBeVisible();
+ await expect(
+ page.getByRole("dialog", { name: "Sign in to Recordly", exact: true }),
+ ).toBeVisible();
await page.reload();
- await expect(page.locator('[data-onboarding-state="login"]')).toBeVisible();
+ await expect(
+ page.getByRole("dialog", { name: "Sign in to Recordly", exact: true }),
+ ).toBeVisible();
});
test("Settings refreshes macOS permissions and opens the permissions carousel", async ({
@@ -147,17 +191,57 @@ test("Settings refreshes macOS permissions and opens the permissions carousel",
});
});
await page.goto("/?windowType=editor");
- await page.getByRole("radio", { name: "Settings", exact: true }).click();
- await page.getByRole("row", { name: "Permissions", exact: true }).click();
- const permissions = page.getByRole("region", { name: "Permissions settings", exact: true });
- await expect(permissions.getByText("Required", { exact: true })).toHaveCount(2);
+ const settings = await openAppSettings(page);
+ await settings.getByRole("tab", { name: "Recording", exact: true }).click();
+ const permissions = settings.getByRole("tabpanel", {
+ name: /^Recording settings/,
+ });
+ const permissionRow = (title: string) =>
+ permissions
+ .locator(".settings-row")
+ .filter({ has: page.getByText(title, { exact: true }) });
+ await expect(permissionRow("Screen Recording").getByRole("status")).toHaveText("Not allowed");
+ await expect(permissionRow("Accessibility").getByRole("status")).toHaveText("Required");
+ await expect(permissionRow("Microphone").getByRole("status")).toHaveText("Not requested");
+ await expect(permissionRow("Camera").getByRole("status")).toHaveText("Not allowed");
+ await expect(
+ permissions.getByRole("button", {
+ name: "Open System Settings: Screen Recording",
+ exact: true,
+ }),
+ ).toBeVisible();
await page.evaluate(() => {
document.documentElement.dataset.allowed = "true";
});
await permissions.getByRole("button", { name: "Check again", exact: true }).click();
- await expect(permissions.getByText("Allowed", { exact: true })).toHaveCount(2);
- await permissions.getByRole("button", { name: "Review permissions", exact: true }).click();
- const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
- await expect(tour).toHaveAttribute("data-onboarding-state", "permissions");
+ await expect(permissionRow("Screen Recording").getByRole("status")).toHaveText("Allowed");
+ await expect(permissionRow("Accessibility").getByRole("status")).toHaveText("Allowed");
+ await expect(permissionRow("Camera").getByRole("status")).toHaveText("Not allowed");
+ await permissions.getByRole("button", { name: "Review permission setup", exact: true }).click();
+ const tour = page.getByRole("dialog", { name: "Permissions", exact: true });
+ await expect(tour).toBeVisible();
+ await expect(tour).toHaveAttribute("data-recording-permissions", "true");
await expect(tour.getByRole("button", { name: "Start recording", exact: true })).toBeEnabled();
});
+
+test("optional passwords validate confirmation and clear sensitive inputs after saving", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ const account = await openAccountSettings(page);
+ const form = account.getByRole("form", { name: "Password (optional)", exact: true });
+ await form.getByLabel("New password", { exact: true }).fill("test-only-password");
+ await form.getByLabel("Confirm password", { exact: true }).fill("different-password");
+ await form.getByRole("button", { name: "Save password", exact: true }).click();
+ await expect(form.getByRole("alert")).toHaveText("Passwords do not match.");
+ await form.getByLabel("Confirm password", { exact: true }).fill("test-only-password");
+ const update = page.waitForRequest(
+ (request) => request.url().includes("/auth/v1/user") && request.method() === "PUT",
+ );
+ await form.getByRole("button", { name: "Save password", exact: true }).click();
+ expect((await update).postDataJSON()).toMatchObject({ password: "test-only-password" });
+ await expect(form.getByRole("status")).toHaveText("Password saved.");
+ await expect(form.getByLabel("New password", { exact: true })).toHaveValue("");
+ await expect(form.getByLabel("Confirm password", { exact: true })).toHaveValue("");
+});
diff --git a/tests/ui/annotation-interactions.spec.ts b/tests/ui/annotation-interactions.spec.ts
new file mode 100644
index 00000000..bac14fa7
--- /dev/null
+++ b/tests/ui/annotation-interactions.spec.ts
@@ -0,0 +1,212 @@
+import { expect, test, type Locator, type Page } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test.beforeEach(async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/?windowType=editor");
+ await expect(
+ page
+ .getByRole("toolbar", { name: "Annotation tools" })
+ .getByRole("button", { name: "Text", exact: true }),
+ ).toBeEnabled({ timeout: 20000 });
+ await expect(page.locator("[data-preview-overlay]")).toBeVisible({ timeout: 20000 });
+});
+
+async function add(page: Page, tool = "Text") {
+ await page
+ .getByRole("toolbar", { name: "Annotation tools" })
+ .getByRole("button", { name: tool, exact: true })
+ .click();
+ const item = page.locator("[data-annotation-id]").last();
+ await expect(item).toBeVisible();
+ return item;
+}
+async function drag(page: Page, locator: Locator, dx: number, dy: number) {
+ const box = (await locator.boundingBox())!;
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(box.x + box.width / 2 + dx, box.y + box.height / 2 + dy, { steps: 5 });
+ await page.mouse.up();
+}
+
+test("property text is live, owns typing, and a complete typing run undoes once", async ({
+ page,
+}) => {
+ const item = await add(page);
+ const text = page.locator("aside textarea").first();
+ await text.fill("First line");
+ await text.press("End");
+ await text.press("Enter");
+ await text.pressSequentially("Second line T M");
+ await expect(item.locator("[data-annotation-text]")).toHaveText("First line\nSecond line T M");
+ await expect(page.getByRole("button", { name: "Play", exact: true })).toBeVisible();
+ await text.press("Escape");
+ await expect(text).not.toBeFocused();
+ await item.click();
+ await page.getByRole("button", { name: "Undo", exact: true }).click();
+ await expect(item.locator("[data-annotation-text]")).toHaveText("Enter text...");
+ await page.getByRole("button", { name: "Redo", exact: true }).click();
+ await expect(item.locator("[data-annotation-text]")).toHaveText("First line\nSecond line T M");
+ await page.locator(".editor-preview-stage").click({ position: { x: 1, y: 1 } });
+ await page.getByRole("button", { name: "Undo", exact: true }).click();
+ await expect(item.locator("[data-annotation-text]")).toHaveText("Enter text...");
+ await page.getByRole("button", { name: "Undo", exact: true }).click();
+ await expect(page.locator("[data-annotation-id]")).toHaveCount(0);
+});
+
+test("double-click edits multiline text with focus and Escape commits one undoable change", async ({
+ page,
+}) => {
+ const item = await add(page);
+ const before = (await item.boundingBox())!;
+ await item.dblclick();
+ const editor = page.getByRole("textbox", { name: "Edit annotation text", exact: true });
+ await expect(editor).toBeFocused();
+ await expect
+ .poll(() =>
+ editor.evaluate((node: HTMLTextAreaElement) => node.selectionEnd - node.selectionStart),
+ )
+ .toBe("Enter text...".length);
+ await editor.fill("Inline T M");
+ await editor.dblclick();
+ await expect(editor).toHaveValue("Inline T M");
+ await editor.press("End");
+ await editor.press("Enter");
+ await editor.pressSequentially("Second line");
+ await editor.press("ArrowLeft");
+ await editor.press("Backspace");
+ await expect(item).toHaveCount(1);
+ expect(Math.abs((await item.boundingBox())!.x - before.x)).toBeLessThan(1);
+ await editor.press("Escape");
+ await expect(editor).toHaveCount(0);
+ await expect(item.locator("[data-annotation-text]")).toHaveText("Inline T M\nSecond lie");
+ await page.getByRole("button", { name: "Undo", exact: true }).click();
+ await expect(item.locator("[data-annotation-text]")).toHaveText("Enter text...");
+ await page.getByRole("button", { name: "Redo", exact: true }).click();
+ await expect(item.locator("[data-annotation-text]")).toHaveText("Inline T M\nSecond lie");
+ await expect(editor).toHaveCount(0);
+});
+
+test("click-away commits inline text, unchanged edits add no history, and blank deletion restores", async ({
+ page,
+}) => {
+ let item = await add(page);
+ await item.dblclick();
+ let editor = page.getByRole("textbox", { name: "Edit annotation text", exact: true });
+ await editor.fill("Kept after click-away");
+ await page.locator(".editor-preview-stage").click({ position: { x: 1, y: 1 } });
+ await expect(item.locator("[data-annotation-text]")).toHaveText("Kept after click-away");
+ await item.dblclick();
+ await editor.press("ControlOrMeta+Enter");
+ await page.getByRole("button", { name: "Undo", exact: true }).click();
+ await expect(item.locator("[data-annotation-text]")).toHaveText("Enter text...");
+ await item.dblclick();
+ await editor.fill(" \n ");
+ await editor.press("ControlOrMeta+Enter");
+ await expect(page.locator("[data-annotation-id]")).toHaveCount(0);
+ await page.getByRole("button", { name: "Undo", exact: true }).click();
+ item = page.locator("[data-annotation-id]");
+ await expect(item.locator("[data-annotation-text]")).toHaveText("Enter text...");
+ await expect(page.locator('[data-variant="annotation"]')).toHaveCount(1);
+});
+
+test("corner resize wraps text at constant font size and exposes only four corner handles", async ({
+ page,
+}) => {
+ const item = await add(page);
+ await page
+ .locator("aside textarea")
+ .first()
+ .fill("Many words in a long label which should wrap as its authored box gets narrower");
+ await item.click();
+ const label = item.locator("[data-annotation-text]");
+ const font = await label.evaluate((node) => getComputedStyle(node).fontSize);
+ const before = (await item.boundingBox())!;
+ const labelBefore = (await label.boundingBox())!;
+ await expect(item.locator("[data-annotation-resize]")).toHaveCount(4);
+ await drag(
+ page,
+ item.locator('[data-annotation-resize="bottom-right"]'),
+ -before.width * 0.45,
+ 10,
+ );
+ await expect
+ .poll(async () => (await item.boundingBox())!.width)
+ .toBeLessThan(before.width * 0.6);
+ await expect(label).toHaveCSS("font-size", font);
+ await expect
+ .poll(async () => (await label.boundingBox())!.height)
+ .toBeGreaterThan(labelBefore.height);
+ await page.getByRole("button", { name: "Undo", exact: true }).click();
+ await expect
+ .poll(async () => Math.abs((await item.boundingBox())!.width - before.width))
+ .toBeLessThan(1);
+});
+
+for (const tool of ["Text", "Mask"]) {
+ test(`${tool} arrow nudges use frame percentages and Escape dismisses selection`, async ({
+ page,
+ }) => {
+ const item = await add(page, tool);
+ await item.click();
+ const before = (await item.boundingBox())!;
+ await page.keyboard.press("ArrowRight");
+ const one = (await item.boundingBox())!;
+ expect(one.x).toBeGreaterThan(before.x);
+ await page.keyboard.press("Shift+ArrowRight");
+ const six = (await item.boundingBox())!;
+ expect(Math.abs(six.x - before.x - (one.x - before.x) * 6)).toBeLessThan(1);
+ await page.keyboard.press("Escape");
+ await expect(item).toHaveCSS("outline-style", "none");
+ await item.click();
+ await page.keyboard.press("Delete");
+ await expect(page.locator("[data-annotation-id]")).toHaveCount(0);
+ await page.getByRole("button", { name: "Undo", exact: true }).click();
+ await expect(page.locator("[data-annotation-id]")).toHaveCount(1);
+ });
+}
+
+test("sub-two-pixel movement is a click and adds no geometry undo step", async ({ page }) => {
+ const item = await add(page);
+ const before = (await item.boundingBox())!;
+ await drag(page, item, 1, 0);
+ expect(Math.abs((await item.boundingBox())!.x - before.x)).toBeLessThan(0.5);
+ await page.locator(".editor-preview-stage").click({ position: { x: 1, y: 1 } });
+ await item.click();
+ await page.getByRole("button", { name: "Undo", exact: true }).click();
+ await expect(page.locator("[data-annotation-id]")).toHaveCount(0);
+});
+
+test("font-size panel scales the authored box around its top center and undoes once", async ({
+ page,
+}) => {
+ const item = await add(page);
+ const before = (await item.boundingBox())!;
+ const originalFont = Number.parseFloat(
+ await item
+ .locator("[data-annotation-text]")
+ .evaluate((node) => getComputedStyle(node).fontSize),
+ );
+ await page
+ .locator("aside")
+ .getByRole("button", { name: "32 Select option", exact: true })
+ .click();
+ await page.getByRole("option", { name: /^64(?:px)?$/ }).click();
+ await expect
+ .poll(async () => (await item.boundingBox())!.width)
+ .toBeGreaterThan(before.width * 1.9);
+ const after = (await item.boundingBox())!;
+ expect(Math.abs(after.y - before.y)).toBeLessThan(1);
+ expect(Math.abs(after.x + after.width / 2 - before.x - before.width / 2)).toBeLessThan(1);
+ expect(Math.abs(after.height - before.height * 2)).toBeLessThan(1);
+ const newFont = Number.parseFloat(
+ await item
+ .locator("[data-annotation-text]")
+ .evaluate((node) => getComputedStyle(node).fontSize),
+ );
+ expect(Math.abs(newFont - originalFont * 2)).toBeLessThan(0.1);
+ await page.getByRole("button", { name: "Undo", exact: true }).click();
+ await expect
+ .poll(async () => Math.abs((await item.boundingBox())!.width - before.width))
+ .toBeLessThan(1);
+});
diff --git a/tests/ui/annotation-toolbar.spec.ts b/tests/ui/annotation-toolbar.spec.ts
new file mode 100644
index 00000000..079e9163
--- /dev/null
+++ b/tests/ui/annotation-toolbar.spec.ts
@@ -0,0 +1,216 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test.beforeEach(async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/?windowType=editor");
+ await expect(
+ page
+ .getByRole("toolbar", { name: "Annotation tools" })
+ .getByRole("button", { name: "Text", exact: true }),
+ ).toBeEnabled({ timeout: 20000 });
+ await expect(page.locator("[data-preview-overlay]")).toBeVisible({ timeout: 20000 });
+});
+
+test("toolbar creates editable annotations and masks on separate tracks with one-step undo", async ({
+ page,
+}) => {
+ const toolbar = page.getByRole("toolbar", { name: "Annotation tools" });
+ const blocks = page.locator('[data-variant="annotation"]');
+ const inspector = page.locator("aside");
+ for (const [index, label] of ["Text", "Mask"].entries()) {
+ await toolbar.getByRole("button", { name: label, exact: true }).click();
+ await expect(blocks).toHaveCount(index + 1);
+ if (label === "Text") await expect(inspector.getByRole("textbox").first()).toBeVisible();
+ else
+ await expect(
+ inspector.getByRole("slider", { name: "Blur Strength: 20", exact: true }),
+ ).toBeVisible();
+ }
+ await expect(page.locator("[data-annotation-id] [data-blur-redaction]")).toHaveCSS(
+ "backdrop-filter",
+ /blur\(/,
+ );
+ await expect(inspector.getByRole("button", { name: "None", exact: true })).toBeVisible();
+ await expect(inspector.getByRole("grid", { name: "Annotation type" })).toBeVisible();
+ const rows = await blocks.evaluateAll((nodes) =>
+ nodes.map((node) => node.getBoundingClientRect().y),
+ );
+ expect(new Set(rows).size).toBe(2);
+ for (const block of await blocks.all()) {
+ await expect(block).toHaveAttribute("data-start-ms", "0");
+ const end = Number(await block.getAttribute("data-end-ms"));
+ expect(end).toBeGreaterThan(0);
+ expect(end).toBeLessThanOrEqual(6000);
+ }
+ await page.screenshot({
+ path: "test-results/annotation-toolbar-light.png",
+ animations: "disabled",
+ });
+ await page.getByRole("button", { name: "Undo", exact: true }).click();
+ await expect(blocks).toHaveCount(1);
+ await page.getByRole("button", { name: "Redo", exact: true }).click();
+ await expect(blocks).toHaveCount(2);
+ await blocks.last().click();
+ await expect(inspector.getByRole("button", { name: "None", exact: true })).toBeVisible();
+});
+
+test("toolbar pauses playback and is unavailable during crop editing", async ({ page }) => {
+ const toolbar = page.getByRole("toolbar", { name: "Annotation tools" });
+ await expect(page.locator("[data-preview-overlay]")).toBeVisible({ timeout: 20000 });
+ await page.getByRole("button", { name: "Play", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Pause", exact: true })).toBeVisible();
+ await toolbar.getByRole("button", { name: "Mask", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Play", exact: true })).toBeVisible();
+ await page.getByRole("button", { name: "Crop", exact: true }).click();
+ await expect(page.locator("[data-inline-crop]")).toBeVisible();
+ for (const button of await toolbar.getByRole("button").all())
+ await expect(button).toBeDisabled();
+ await page
+ .locator("[data-inline-crop]")
+ .getByRole("button", { name: "Done", exact: true })
+ .click();
+ await expect(toolbar.getByRole("button", { name: "Mask", exact: true })).toBeEnabled();
+});
+
+test("clicking off a preview annotation deselects it and allows selecting it again", async ({
+ page,
+}) => {
+ await page
+ .getByRole("toolbar", { name: "Annotation tools" })
+ .getByRole("button", { name: "Text", exact: true })
+ .click();
+ const annotation = page.locator("[data-annotation-id]");
+ const inspector = page.locator("aside");
+ await expect(annotation).toHaveCount(1);
+ await expect(inspector.getByRole("textbox").first()).toBeVisible();
+ await expect(annotation).toHaveCSS("outline-style", "solid");
+ await page.locator(".editor-preview-frame").click({ position: { x: 8, y: 8 } });
+ await expect(inspector.getByRole("heading", { name: "Scene", exact: true })).toBeVisible();
+ await expect(annotation).toHaveCSS("outline-style", "none");
+ await annotation.click();
+ await expect(inspector.getByRole("textbox").first()).toBeVisible();
+ await expect(annotation).toHaveCSS("outline-style", "solid");
+ await page.locator(".editor-preview-stage").click({ position: { x: 1, y: 1 } });
+ await expect(annotation).toHaveCSS("outline-style", "none");
+});
+
+test("toolbar supports keyboard navigation and stays outside the preview at smaller sizes", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 1100, height: 760 });
+ const toolbar = page.getByRole("toolbar", { name: "Annotation tools" });
+ const text = toolbar.getByRole("button", { name: "Text", exact: true });
+ const mask = toolbar.getByRole("button", { name: "Mask", exact: true });
+ await text.hover();
+ await expect(page.getByRole("tooltip", { name: "Text", exact: true })).toBeVisible();
+ await mask.hover();
+ await expect(page.getByRole("tooltip", { name: "Mask", exact: true })).toBeVisible();
+ await text.focus();
+ await page.keyboard.press("ArrowDown");
+ await expect(mask).toBeFocused();
+ await page.keyboard.press("End");
+ await expect(mask).toBeFocused();
+ await page.keyboard.press("ArrowDown");
+ await expect(text).toBeFocused();
+ await page.keyboard.press("ArrowUp");
+ await expect(mask).toBeFocused();
+ await page.keyboard.press("Enter");
+ await expect(page.locator('[data-variant="annotation"]')).toHaveCount(1);
+ const toolbarBounds = (await toolbar.boundingBox())!;
+ const previewBounds = (await page.locator(".editor-preview-frame").boundingBox())!;
+ expect(toolbarBounds.x).toBeGreaterThan(previewBounds.x + previewBounds.width);
+ expect(toolbarBounds.y).toBeGreaterThan(0);
+ expect(toolbarBounds.y + toolbarBounds.height).toBeLessThan(760);
+ await page.evaluate(() => {
+ document.documentElement.classList.add("dark");
+ });
+ await page.screenshot({
+ path: "test-results/annotation-toolbar-dark-compact.png",
+ animations: "disabled",
+ });
+});
+
+for (const tool of ["Text", "Mask"])
+ test(`${tool} can be selected, resized from preview corners and dragged in one gesture`, async ({
+ page,
+ }) => {
+ await page
+ .getByRole("toolbar", { name: "Annotation tools" })
+ .getByRole("button", { name: tool, exact: true })
+ .click();
+ const item = page.locator("[data-annotation-id]");
+ const original = (await item.boundingBox())!;
+ const handle = item.locator('[data-annotation-resize="bottom-right"]');
+ const grip = (await handle.boundingBox())!;
+ await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(grip.x + grip.width / 2 + 45, grip.y + grip.height / 2 + 25, {
+ steps: 8,
+ });
+ await page.mouse.up();
+ await expect
+ .poll(async () => (await item.boundingBox())!.width)
+ .toBeGreaterThan(original.width + 40);
+ await expect
+ .poll(async () => (await item.boundingBox())!.height)
+ .toBeGreaterThan(original.height + 20);
+ await page.getByRole("button", { name: "Undo", exact: true }).click();
+ await expect
+ .poll(async () => Math.abs((await item.boundingBox())!.width - original.width))
+ .toBeLessThan(1);
+ await expect
+ .poll(async () => Math.abs((await item.boundingBox())!.height - original.height))
+ .toBeLessThan(1);
+ const topLeft = (await item.locator('[data-annotation-resize="top-left"]').boundingBox())!;
+ await page.mouse.move(topLeft.x + 6, topLeft.y + 6);
+ await page.mouse.down();
+ await page.mouse.move(topLeft.x + 6 - 30, topLeft.y + 6 - 15, { steps: 8 });
+ await page.mouse.up();
+ await expect.poll(async () => (await item.boundingBox())!.x).toBeLessThan(original.x - 25);
+ const resized = (await item.boundingBox())!;
+ expect(Math.abs(resized.x + resized.width - original.x - original.width)).toBeLessThan(1);
+ expect(Math.abs(resized.y + resized.height - original.y - original.height)).toBeLessThan(1);
+ await page.getByRole("button", { name: "Undo", exact: true }).click();
+ await expect
+ .poll(async () => Math.abs((await item.boundingBox())!.x - original.x))
+ .toBeLessThan(1);
+ await page.locator(".editor-preview-frame").click({ position: { x: 8, y: 8 } });
+ await expect(item).toHaveCSS("outline-style", "none");
+ const beforeDrag = (await item.boundingBox())!;
+ await page.mouse.move(
+ beforeDrag.x + beforeDrag.width / 2,
+ beforeDrag.y + beforeDrag.height / 2,
+ );
+ await page.mouse.down();
+ await page.mouse.move(
+ beforeDrag.x + beforeDrag.width / 2 - 50,
+ beforeDrag.y + beforeDrag.height / 2 - 30,
+ { steps: 8 },
+ );
+ await page.mouse.up();
+ await expect(item).toHaveCSS("outline-style", "solid");
+ await expect
+ .poll(async () => (await item.boundingBox())!.x)
+ .toBeLessThan(beforeDrag.x - 45);
+ await page.getByRole("button", { name: "Undo", exact: true }).click();
+ await expect
+ .poll(async () => Math.abs((await item.boundingBox())!.x - beforeDrag.x))
+ .toBeLessThan(1);
+ });
+
+test("clicking a selected text annotation does not select a different annotation", async ({
+ page,
+}) => {
+ const toolbar = page.getByRole("toolbar", { name: "Annotation tools" });
+ await toolbar.getByRole("button", { name: "Text", exact: true }).click();
+ await toolbar.getByRole("button", { name: "Mask", exact: true }).click();
+ await page.locator('[data-variant="annotation"]').first().click();
+ const text = page
+ .locator("[data-annotation-id]")
+ .filter({ hasNot: page.locator("[data-blur-redaction]") });
+ await text.click();
+ await text.click();
+ await expect(page.locator("aside").getByRole("textbox").first()).toBeVisible();
+ await expect(text).toHaveCSS("outline-style", "solid");
+});
diff --git a/tests/ui/annotation-types.spec.ts b/tests/ui/annotation-types.spec.ts
new file mode 100644
index 00000000..0105fb18
--- /dev/null
+++ b/tests/ui/annotation-types.spec.ts
@@ -0,0 +1,30 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test("annotations retain all type choices while the separate toolbar is hidden", async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/?windowType=editor");
+ await expect(page.locator("[data-preview-overlay]")).toBeVisible({ timeout: 20000 });
+ await expect(page.getByRole("toolbar", { name: "Annotation tools" })).toHaveCount(0);
+ await page.getByRole("button", { name: "Add Layer", exact: true }).click();
+ await page.getByRole("menuitem", { name: "Annotation", exact: true }).click();
+ const inspector = page.locator("aside");
+ const picker = inspector.getByRole("grid", { name: "Annotation type" });
+ await expect(picker).toBeVisible();
+ const text = inspector.getByRole("textbox").first();
+ await text.fill("Keep this annotation text");
+ await text.press("Tab");
+ for (const label of ["Text", "Image", "Arrow", "Blur"]) {
+ await expect(picker.getByRole("row", { name: label, exact: true })).toBeVisible();
+ }
+ await picker.getByRole("row", { name: "Image", exact: true }).click();
+ await expect(inspector.getByRole("button", { name: "Upload Image", exact: true })).toBeVisible();
+ await picker.getByRole("row", { name: "Arrow", exact: true }).click();
+ await expect(inspector.getByText("Arrow Direction", { exact: true })).toBeVisible();
+ await picker.getByRole("row", { name: "Blur", exact: true }).click();
+ await expect(inspector.getByRole("slider", { name: "Blur Strength: 20", exact: true })).toBeVisible();
+ await picker.getByRole("row", { name: "Text", exact: true }).click();
+ await expect(inspector.getByRole("textbox").first()).toHaveValue("Keep this annotation text");
+ await expect(page.locator('[data-variant="annotation"]')).toHaveCount(1);
+ await page.screenshot({ path: "test-results/annotation-types-restored.png", animations: "disabled" });
+});
diff --git a/tests/ui/annotation-visibility-zoom.spec.ts b/tests/ui/annotation-visibility-zoom.spec.ts
new file mode 100644
index 00000000..3a17382c
--- /dev/null
+++ b/tests/ui/annotation-visibility-zoom.spec.ts
@@ -0,0 +1,69 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test("new text and masks stay inside the visible preview during a corner-focused zoom", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.loadCurrentProjectFile = async () => ({
+ success: true,
+ path: "/projects/zoom-annotations.recordly",
+ project: {
+ version: 2,
+ videoPath: `${location.origin}/tests/ui/fixtures/filmstrip.mp4`,
+ editor: {
+ clipRegions: [{ id: "clip-1", startMs: 0, endMs: 6000, speed: 1 }],
+ zoomRegions: [
+ {
+ id: "zoom-1",
+ startMs: 0,
+ endMs: 6000,
+ depth: 6,
+ mode: "manual",
+ focus: { cx: 0.15, cy: 0.15 },
+ },
+ ],
+ annotationRegions: [],
+ zoomClassicMode: true,
+ padding: 0,
+ },
+ },
+ });
+ });
+ await page.goto("/?windowType=editor");
+ await expect(page.locator("[data-preview-overlay]")).toBeVisible({ timeout: 20000 });
+ const clip = (await page.locator('[data-variant="clip"] .timeline-block').boundingBox())!;
+ const row = (await page.locator('[data-timeline-row="row-clip"]').boundingBox())!;
+ await page.mouse.click(clip.x + clip.width * 0.5, row.y - 8);
+ await expect(page.getByTestId("playhead-cap")).toHaveAttribute("aria-label", "Playhead 3.0s");
+ for (const [index, tool] of ["Text", "Mask"].entries()) {
+ await page
+ .getByRole("toolbar", { name: "Annotation tools" })
+ .getByRole("button", { name: tool, exact: true })
+ .click();
+ const item = page.locator("[data-annotation-id]").nth(index);
+ await expect(item).toBeVisible();
+ const frame = (await page.locator(".editor-preview-frame").boundingBox())!;
+ const box = (await item.boundingBox())!;
+ expect(box.x).toBeGreaterThanOrEqual(frame.x);
+ expect(box.y).toBeGreaterThanOrEqual(frame.y);
+ expect(box.x + box.width).toBeLessThanOrEqual(frame.x + frame.width + 1);
+ expect(box.y + box.height).toBeLessThanOrEqual(frame.y + frame.height + 1);
+ await expect(page.locator('[data-variant="annotation"]').nth(index)).toHaveAttribute(
+ "data-start-ms",
+ "3000",
+ );
+ }
+ await expect(page.locator("[data-blur-redaction]")).toBeVisible();
+ // Selecting an interval outside the current playhead also brings its effect into view.
+ await page.getByRole("button", { name: "Skip Back", exact: true }).click();
+ await expect(page.locator("[data-blur-redaction]")).toHaveCount(0);
+ await page.locator('[data-variant="annotation"]').last().click();
+ await expect(page.locator("[data-blur-redaction]")).toBeVisible();
+ await expect(page.getByTestId("playhead-cap")).toHaveAttribute("aria-label", "Playhead 3.0s");
+ await page.screenshot({
+ path: "test-results/annotation-zoom-visible.png",
+ animations: "disabled",
+ });
+});
diff --git a/tests/ui/app-settings-navigation.spec.ts b/tests/ui/app-settings-navigation.spec.ts
new file mode 100644
index 00000000..599dc4fa
--- /dev/null
+++ b/tests/ui/app-settings-navigation.spec.ts
@@ -0,0 +1,311 @@
+import { expect, type Page, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+async function openSettings(page: Page, entry = "Settings") {
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: entry, exact: true })
+ .click();
+ return page.getByRole("dialog", { name: "Settings", exact: true });
+}
+
+test("Appearance shortcut opens compact General settings and preserves the theme", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ const settings = await openSettings(page, "Appearance");
+ await expect(settings.getByRole("tab", { name: "General", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await expect(settings.getByRole("tab")).toHaveText([
+ "General",
+ "Recording",
+ "Motion",
+ "Shortcuts",
+ "Account",
+ ...(process.env.RECORDLY_TEST_PRODUCTION === "1" ? [] : ["Dev"]),
+ ]);
+ await expect(settings.getByRole("radio")).toHaveCount(3);
+ await expect(
+ settings.getByRole("switch", { name: "Experimental updates", exact: true }),
+ ).toHaveCount(1);
+ await settings.getByText("Dark", { exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
+ await settings.screenshot({
+ path: "test-results/app-appearance-dark.png",
+ animations: "disabled",
+ });
+ await page.keyboard.press("Escape");
+ await openSettings(page);
+ await expect(settings.getByRole("tab", { name: "General", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await expect(settings.getByRole("radiogroup", { name: "Appearance" })).toBeVisible();
+ await page.setViewportSize({ width: 420, height: 800 });
+ await settings.getByRole("tab", { name: "General", exact: true }).click();
+ await expect(settings.getByRole("radio", { name: "Dark", exact: true })).toBeChecked();
+ const panel = settings.getByRole("tabpanel");
+ expect(await panel.evaluate((e) => e.scrollWidth <= e.clientWidth)).toBe(true);
+ await settings.screenshot({
+ path: "test-results/app-appearance-narrow.png",
+ animations: "disabled",
+ });
+});
+
+test("Storage shows both locations, opens each folder and explains future-only changes", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.openRecordingsFolder = async () => {
+ document.documentElement.dataset.openedFolder = "recordings";
+ return { success: true };
+ };
+ window.electronAPI.openProjectsDirectory = async () => {
+ document.documentElement.dataset.openedFolder = "projects";
+ return { success: true, path: "/projects" };
+ };
+ });
+ await page.goto("/?windowType=editor");
+ const settings = await openSettings(page);
+ await settings.getByRole("tab", { name: "General", exact: true }).click();
+ await expect(settings.getByText("/recordings", { exact: true })).toBeVisible();
+ await expect(settings.getByText("/projects", { exact: true })).toBeVisible();
+ await settings.getByRole("button", { name: "Open folder", exact: true }).nth(0).click();
+ await expect(page.locator("html")).toHaveAttribute("data-opened-folder", "recordings");
+ await settings.getByRole("button", { name: "Open folder", exact: true }).nth(1).click();
+ await expect(page.locator("html")).toHaveAttribute("data-opened-folder", "projects");
+ await settings.getByRole("button", { name: "Change folder", exact: true }).click();
+ await expect(settings.getByText("/new-recordings", { exact: true })).toBeVisible();
+ await expect(
+ settings.getByRole("status").filter({ hasText: "Existing recordings have not been moved" }),
+ ).toBeVisible();
+ await settings.screenshot({ path: "test-results/app-storage.png", animations: "disabled" });
+});
+
+test("Updates shows version, checks status and preserves channel on failure", async ({ page }) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getUpdateStatusSummary = async () => ({
+ status: document.documentElement.dataset.checked ? "up-to-date" : "idle",
+ currentVersion: "1.4.0",
+ availableVersion: null,
+ });
+ window.electronAPI.checkForAppUpdates = async () => {
+ document.documentElement.dataset.checked = "true";
+ return { success: true, logPath: "/logs" };
+ };
+ window.electronAPI.setExperimentalUpdatesEnabled = async () => ({
+ success: false,
+ enabled: false,
+ });
+ });
+ await page.goto("/?windowType=editor");
+ const settings = await openSettings(page);
+ await settings.getByRole("tab", { name: "General", exact: true }).click();
+ await expect(settings.getByText("1.4.0", { exact: true })).toBeVisible();
+ await settings.getByRole("button", { name: "Check for updates", exact: true }).click();
+ await expect(settings.getByRole("status")).toHaveText("Recordly is up to date.");
+ const toggle = settings.getByRole("switch", { name: "Experimental updates", exact: true });
+ await toggle.focus();
+ await toggle.press("Space");
+ await expect(settings.getByRole("alert")).toContainText("Failed to change the update channel.");
+ await expect(settings.getByRole("button", { name: "Report error", exact: true })).toBeVisible();
+ await expect(toggle).not.toBeChecked();
+ await settings.screenshot({ path: "test-results/app-updates.png", animations: "disabled" });
+});
+
+test("Motion represents custom settings truthfully before applying a preset", async ({ page }) => {
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ await page.getByRole("radio", { name: "Cursor", exact: true }).click();
+ const size = page.getByRole("slider", { name: "Cursor Size", exact: true });
+ await size.focus();
+ await size.press("Home");
+ await size.press("ArrowRight");
+ const settings = await openSettings(page);
+ await settings.getByRole("tab", { name: "Motion", exact: true }).click();
+ await expect(
+ settings.getByText("Custom · Your settings differ from these presets.", { exact: true }),
+ ).toBeVisible();
+ const focused = settings.getByRole("radio", { name: /^Focused/ });
+ await expect(focused).not.toBeChecked();
+ await settings.getByText("Focused", { exact: true }).click();
+ await expect(focused).toBeChecked();
+ await expect(
+ settings.getByText("Custom · Your settings differ from these presets.", { exact: true }),
+ ).toHaveCount(0);
+ await settings.screenshot({ path: "test-results/app-motion.png", animations: "disabled" });
+});
+
+test("dashboard profile deep links work repeatedly after internal category changes", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ const home = page.getByRole("dialog", { name: "Projects dashboard", exact: true });
+ const choose = async (entry: string) => {
+ await home.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: entry, exact: true })
+ .click();
+ };
+ await choose("Appearance");
+ await home.getByRole("tab", { name: "Motion", exact: true }).click();
+ await choose("Appearance");
+ await expect(home.getByRole("tab", { name: "General", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await home.getByRole("tab", { name: "Account", exact: true }).click();
+ await choose("Settings");
+ await expect(home.getByRole("tab", { name: "General", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+});
+
+test("Updates recovers from failed checks and failed channel loading", async ({ page }) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getExperimentalUpdatesEnabled = async () => {
+ if (document.documentElement.dataset.channelRetry !== "true")
+ throw Error("Unavailable");
+ return false;
+ };
+ window.electronAPI.checkForAppUpdates = async () => {
+ throw Error("Network unavailable");
+ };
+ });
+ await page.goto("/?windowType=editor");
+ const settings = await openSettings(page);
+ await settings.getByRole("tab", { name: "General", exact: true }).click();
+ const toggle = settings.getByRole("switch", { name: "Experimental updates", exact: true });
+ await expect(toggle).toBeDisabled();
+ await page.evaluate(() => {
+ document.documentElement.dataset.channelRetry = "true";
+ });
+ await settings.getByRole("button", { name: "Try again", exact: true }).click();
+ await expect(toggle).toBeEnabled();
+ const check = settings.getByRole("button", { name: "Check for updates", exact: true });
+ await check.click();
+ await expect(settings.getByRole("alert")).toContainText(
+ "Could not check for updates. Try again.",
+ );
+ await expect(settings.getByText("Network unavailable", { exact: true })).toBeHidden();
+ await settings.getByRole("button", { name: "Technical details", exact: true }).click();
+ await expect(settings.getByText("Network unavailable", { exact: true })).toBeVisible();
+ await expect(check).toBeEnabled();
+});
+
+test("Appearance and permission states use the selected application language", async ({ page }) => {
+ await installDesktopBridge(page);
+ await page.addInitScript(() => localStorage.setItem("recordly.locale", "ko"));
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Recordly 계정", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "계정 메뉴", exact: true })
+ .getByRole("button", { name: "모양", exact: true })
+ .click();
+ const settings = page.getByRole("dialog", { name: "설정", exact: true });
+ await expect(settings.getByRole("radio", { name: "시스템", exact: true })).toHaveCount(1);
+ await expect(settings.getByRole("radio", { name: "라이트", exact: true })).toHaveCount(1);
+ await expect(settings.getByRole("radio", { name: "다크", exact: true })).toHaveCount(1);
+ await settings.getByRole("tab", { name: "녹화", exact: true }).click();
+ await expect(settings.getByText("마이크", { exact: true })).toBeVisible();
+ await expect(settings.getByText("카메라", { exact: true })).toBeVisible();
+ await expect(settings.getByText("요청하지 않음", { exact: true })).toBeVisible();
+ await expect(settings.getByText("허용되지 않음", { exact: true })).toBeVisible();
+ await settings.screenshot({
+ path: "test-results/app-settings-korean.png",
+ animations: "disabled",
+ });
+});
+
+test("small success and error status text stays readable in both themes", async ({ page }) => {
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ for (const theme of ["Light", "Dark"]) {
+ const settings = await openSettings(page, "Appearance");
+ await settings.getByText(theme, { exact: true }).click();
+ await settings.getByRole("tab", { name: "Recording", exact: true }).click();
+ await expect(settings.getByText("Allowed", { exact: true })).toHaveCount(2);
+ const contrasts = await settings
+ .getByText("Allowed", { exact: true })
+ .first()
+ .evaluate((element) => {
+ const canvas = document.createElement("canvas");
+ canvas.width = canvas.height = 1;
+ const context = canvas.getContext("2d")!;
+ const luminance = (color: string) => {
+ context.clearRect(0, 0, 1, 1);
+ context.fillStyle = color;
+ context.fillRect(0, 0, 1, 1);
+ const pixel = context.getImageData(0, 0, 1, 1).data;
+ const linear = Array.from(pixel)
+ .slice(0, 3)
+ .map((v) => {
+ const c = v / 255;
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
+ };
+ const background = luminance(
+ getComputedStyle(element.closest('[role="dialog"]')!).backgroundColor,
+ );
+ const style = getComputedStyle(element);
+ const colors = [style.color, style.getPropertyValue("--danger")];
+ return colors.map((color) => {
+ const value = luminance(color);
+ return (
+ (Math.max(value, background) + 0.05) / (Math.min(value, background) + 0.05)
+ );
+ });
+ });
+ for (const ratio of contrasts) expect(ratio).toBeGreaterThanOrEqual(4.5);
+ await page.keyboard.press("Escape");
+ }
+});
+
+test("filled bottom-left settings button opens General without leaving the editor", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ const rail = page.getByRole("navigation", { name: "Editor tools", exact: true });
+ const trigger = rail.getByRole("button", { name: "Settings", exact: true });
+ await expect(trigger).toBeVisible({ timeout: 60000 });
+ await expect(trigger.locator("svg")).toHaveAttribute("data-icon-style", "bold");
+ const railBox = await rail.boundingBox();
+ const buttonBox = await trigger.boundingBox();
+ expect(buttonBox!.y + buttonBox!.height).toBeGreaterThan(railBox!.y + railBox!.height - 20);
+ await page.screenshot({
+ path: "test-results/editor-settings-button.png",
+ animations: "disabled",
+ });
+ await trigger.click();
+ const settings = page.getByRole("dialog", { name: "Settings", exact: true });
+ await expect(settings).toBeVisible();
+ await expect(settings.getByRole("tab", { name: "General", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await settings.getByRole("tab", { name: "Motion", exact: true }).click();
+ await page.keyboard.press("Escape");
+ await expect(settings).not.toBeVisible();
+ await expect(trigger).toBeFocused();
+ await trigger.press("Enter");
+ await expect(settings.getByRole("tab", { name: "General", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await expect(
+ page.getByRole("dialog", { name: "Projects dashboard", exact: true }),
+ ).not.toBeVisible();
+});
diff --git a/tests/ui/app-settings-spacing.spec.ts b/tests/ui/app-settings-spacing.spec.ts
new file mode 100644
index 00000000..b2c979f5
--- /dev/null
+++ b/tests/ui/app-settings-spacing.spec.ts
@@ -0,0 +1,253 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+for (const width of [1440, 760, 700, 420, 320]) {
+ for (const theme of ["Light", "Dark"]) {
+ test(`Settings spacing remains aligned at ${width}px in ${theme.toLowerCase()} mode`, async ({
+ page,
+ }) => {
+ test.setTimeout(60000);
+ await page.setViewportSize({ width, height: 900 });
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ let preferences = {
+ success: true,
+ showInDock: true,
+ hideWhileRecording: false,
+ launchAtLogin: false,
+ launchAtLoginSupported: true,
+ platform: "darwin",
+ };
+ window.electronAPI.getApplicationPreferences = async () => preferences;
+ window.electronAPI.setApplicationPreferences = async (patch) => {
+ preferences = { ...preferences, ...patch };
+ return preferences;
+ };
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Appearance", exact: true })
+ .click();
+ const settings = page.getByRole("dialog", { name: "Settings", exact: true });
+ await settings.getByText(theme, { exact: true }).click();
+ await expect(settings).not.toHaveAttribute("data-entering");
+ const baseline = (await settings.getByRole("tabpanel").boundingBox())!;
+ for (const category of [
+ "General",
+ "Shortcuts",
+ "Account",
+ "Recording",
+ "Motion",
+ ...(process.env.RECORDLY_TEST_PRODUCTION === "1" ? [] : ["Dev"]),
+ ]) {
+ await settings.getByRole("tab", { name: category, exact: true }).click();
+ const panel = settings.getByRole("tabpanel");
+ if (category === "General")
+ await expect(
+ panel.getByRole("switch", {
+ name: "Show Recordly in the Dock",
+ exact: true,
+ }),
+ ).toBeVisible();
+ const heading = panel.locator(".dashboard-settings-group h2:visible").first();
+ await expect(heading).toBeVisible();
+ const headingBox = (await heading.boundingBox())!;
+ expect(
+ Math.abs(headingBox.x - baseline.x),
+ `${category} heading left edge`,
+ ).toBeLessThan(1);
+ expect(
+ Math.abs(headingBox.y - baseline.y),
+ `${category} heading top edge`,
+ ).toBeLessThan(1);
+ expect(
+ await panel.evaluate((e) => e.scrollWidth <= e.clientWidth),
+ `${category} overflow`,
+ ).toBe(true);
+ const rowInsets = await panel.locator(".settings-row:visible").evaluateAll((rows) =>
+ rows
+ .filter(
+ (row) =>
+ getComputedStyle(row.querySelector(".settings-row-copy")!)
+ .display !== "contents",
+ )
+ .map((row) => {
+ const panel = row.closest('[role="tabpanel"]')!;
+ const copy = row.querySelector(".settings-row-copy")!;
+ return (
+ copy.getBoundingClientRect().left -
+ panel.getBoundingClientRect().left
+ );
+ }),
+ );
+ for (const inset of rowInsets)
+ expect(Math.abs(inset), `${category} row inset`).toBeLessThan(1);
+
+ for (const group of await panel.locator(".settings-group:visible").all()) {
+ const borders = await group.evaluate((element) => {
+ const style = getComputedStyle(element);
+ return [style.borderLeftWidth, style.borderRightWidth, style.borderRadius];
+ });
+ expect(borders, `${category} has no enclosing section box`).toEqual([
+ "0px",
+ "0px",
+ "0px",
+ ]);
+ }
+ if (category === "Account") {
+ for (const form of await panel.locator(".account-settings-form").all()) {
+ const title = (await form.locator("h3").boundingBox())!;
+ const field = (await form.locator(".textfield").boundingBox())!;
+ expect(Math.abs(field.y - title.y - title.height - 8)).toBeLessThan(1);
+ }
+ }
+ if (category === "Account" || category === "General") {
+ const panelColor = await settings.evaluate(
+ (element) => getComputedStyle(element).backgroundColor,
+ );
+ const fields = panel.locator(
+ category === "Account"
+ ? ".account-settings-form input"
+ : ".settings-language-trigger",
+ );
+ for (const field of await fields.all()) {
+ const color = await field.evaluate(
+ (element) => getComputedStyle(element).backgroundColor,
+ );
+ expect(color, `${category} editable field surface`).not.toBe(panelColor);
+ }
+ }
+ if (category === "Motion") {
+ const cards = await panel
+ .locator('.settings-motion-presets [data-slot="radio"]')
+ .all();
+ const first = (await cards[0].boundingBox())!;
+ const second = (await cards[1].boundingBox())!;
+ expect(Math.abs(second.y - first.y - first.height - 8)).toBeLessThan(1);
+ }
+ const dividers = await panel
+ .locator(".settings-item > .settings-row")
+ .evaluateAll((rows) =>
+ rows.map((row) => getComputedStyle(row).borderBottomWidth),
+ );
+ for (const divider of dividers) expect(divider).toBe("0px");
+ if (baseline.width <= 480) {
+ const toggles = await panel
+ .locator(".settings-row:has(.switch__control):visible")
+ .evaluateAll((rows) =>
+ rows.map((row) => {
+ const copy = row
+ .querySelector(".settings-row-copy")!
+ .getBoundingClientRect();
+ const toggle = row
+ .querySelector(".switch__control")!
+ .getBoundingClientRect();
+ return toggle.left >= copy.right && toggle.top < copy.bottom;
+ }),
+ );
+ for (const inline of toggles)
+ expect(inline, `${category} inline toggle`).toBe(true);
+ }
+ if (width <= 680) {
+ const scroller = (await settings
+ .locator(".tabs__list-container__scroller")
+ .boundingBox())!;
+ for (const direction of ["prev", "next"]) {
+ const arrow = settings.locator(
+ `.tabs__list-container__scroll-${direction}`,
+ );
+ if (await arrow.isVisible()) {
+ const box = (await arrow.boundingBox())!;
+ expect(
+ box.x + box.width <= scroller.x ||
+ box.x >= scroller.x + scroller.width,
+ ).toBe(true);
+ }
+ }
+ }
+ const heights = await panel
+ .locator(
+ ".button:visible:not(.theme-preview *), .theme-appearance-option:visible",
+ )
+ .evaluateAll((buttons) =>
+ buttons.map((button) => button.getBoundingClientRect().height),
+ );
+
+ for (const height of heights)
+ expect(height, `${category} action height`).toBeGreaterThanOrEqual(35.5);
+ await settings.screenshot({
+ path: `test-results/settings-spacing-${width}-${theme.toLowerCase()}-${category.toLowerCase()}.png`,
+ animations: "disabled",
+ });
+ }
+ await settings.getByRole("tab", { name: "Account", exact: true }).click();
+ const card = settings.locator(".account-settings-cloud .cloud-usage-card");
+ const geometry = await card.evaluate((element) => {
+ const header = element.querySelector(".card__header")!.getBoundingClientRect();
+ const content = element.querySelector(".card__content")!.getBoundingClientRect();
+ return {
+ width: element.getBoundingClientRect().width,
+ headerBottom: header.bottom,
+ contentTop: content.top,
+ };
+ });
+ if (geometry.width <= 560)
+ expect(geometry.contentTop).toBeGreaterThan(geometry.headerBottom);
+ await card.scrollIntoViewIfNeeded();
+ await settings.screenshot({
+ path: `test-results/settings-spacing-${width}-${theme.toLowerCase()}-account-storage.png`,
+ animations: "disabled",
+ });
+ await settings.getByRole("tabpanel").evaluate((element) => {
+ element.scrollTop = element.scrollHeight;
+ });
+ await expect(
+ settings.getByRole("button", { name: "Sign out of all devices", exact: true }),
+ ).toBeInViewport();
+ await settings.screenshot({
+ path: `test-results/settings-spacing-${width}-${theme.toLowerCase()}-account-bottom.png`,
+ animations: "disabled",
+ });
+ });
+ }
+}
+
+test("permission recovery errors align with their row and retain error emphasis", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.openMediaPermissionPreferences = async () => ({
+ success: false,
+ error: "System Settings unavailable",
+ });
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true })
+ .click();
+ const settings = page.getByRole("dialog", { name: "Settings", exact: true });
+ await settings.getByRole("tab", { name: "Recording", exact: true }).click();
+ await settings
+ .getByRole("button", { name: "Open System Settings: Camera", exact: true })
+ .click();
+ const error = settings.getByRole("alert");
+ await expect(error).toHaveText("Could not open System Settings. Try again. Report error");
+ await expect(error.getByRole("button", { name: "Report error", exact: true })).toBeVisible();
+ const style = await error.evaluate((element) => {
+ const description =
+ element.previousElementSibling!.querySelector(".settings-row-copy")!.lastElementChild!;
+ return {
+ errorColor: getComputedStyle(element).color,
+ descriptionColor: getComputedStyle(description).color,
+ errorX: element.getBoundingClientRect().x,
+ descriptionX: description.getBoundingClientRect().x,
+ };
+ });
+ expect(style.errorColor).not.toBe(style.descriptionColor);
+ expect(Math.abs(style.errorX - style.descriptionX)).toBeLessThan(1);
+});
diff --git a/tests/ui/app-update-flow.spec.ts b/tests/ui/app-update-flow.spec.ts
new file mode 100644
index 00000000..2c8b925c
--- /dev/null
+++ b/tests/ui/app-update-flow.spec.ts
@@ -0,0 +1,124 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test.setTimeout(90000);
+
+test("update available is a red header button to the right of the account in editor and Home", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ const editor = page.locator(".editor-header");
+ await expect(editor).toBeVisible({ timeout: 60000 });
+ async function checkPlacement(header: typeof editor) {
+ const account = await header
+ .getByRole("button", { name: "Recordly account", exact: true })
+ .boundingBox();
+ const button = header.getByRole("button", { name: "Update available", exact: true });
+ await expect(button).toBeVisible();
+ await expect(button).toHaveClass(/button--danger/);
+ const bounds = await button.boundingBox();
+ const frame = await header.boundingBox();
+ expect(bounds!.x).toBeGreaterThanOrEqual(account!.x + account!.width);
+ expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(frame!.x + frame!.width);
+ expect(frame!.height).toBe(56);
+ }
+ await checkPlacement(editor);
+ await expect(page.locator(".app-update-banner")).toHaveCount(0);
+ await page.screenshot({
+ path: "output/update-flow/editor-update-available.png",
+ animations: "disabled",
+ });
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ const home = page.getByRole("dialog", { name: "Projects dashboard", exact: true });
+ await expect(home).toBeVisible();
+ await expect(home).not.toHaveAttribute("data-entering");
+ await checkPlacement(home.locator(".app-frame-header"));
+ await home
+ .getByRole("button", { name: "Update available", exact: true })
+ .screenshot({ path: "output/update-flow/home-update-button.png", animations: "disabled" });
+ await page.screenshot({
+ path: "output/update-flow/home-update-available.png",
+ animations: "disabled",
+ });
+ await home.getByRole("button", { name: "Update available", exact: true }).click();
+ const details = page.getByRole("dialog", { name: "App update", exact: true });
+ await expect(details).toContainText("Recordly 1.4.1 is available.");
+ await expect(
+ details.getByRole("button", { name: "Download update", exact: true }),
+ ).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(details).not.toBeVisible();
+ await expect(home).toBeVisible();
+});
+
+test("update details retain background progress, an explicit restart and a blocked restart error", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ let payload: Awaited> = {
+ version: "2.0.0",
+ phase: "downloading",
+ progressPercent: 42,
+ detail: "Downloading",
+ delayMs: 60000,
+ };
+ window.electronAPI.getCurrentUpdateToastPayload = async () => payload;
+ window.electronAPI.onUpdateToastStateChanged = (callback) => {
+ const listener = (event: Event) => {
+ payload = (event as CustomEvent).detail;
+ callback(payload);
+ };
+ window.addEventListener("test-app-update", listener);
+ return () => window.removeEventListener("test-app-update", listener);
+ };
+ window.electronAPI.installDownloadedUpdate = async () => {
+ document.documentElement.dataset.restartRequested = "true";
+ return {
+ success: false,
+ code: "restart-blocked",
+ message: "Save your project before restarting to update.",
+ };
+ };
+ window.electronAPI.dismissUpdateToast = async () => {
+ window.dispatchEvent(new CustomEvent("test-app-update", { detail: null }));
+ return { success: true };
+ };
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Downloading · 42%", exact: true }).click();
+ const details = page.getByRole("dialog", { name: "App update", exact: true });
+ await expect(details.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "42");
+ await expect(details).toContainText("in the background");
+ await expect(details.getByRole("button", { name: "Restart to update" })).toHaveCount(0);
+ await page.evaluate(() =>
+ window.dispatchEvent(
+ new CustomEvent("test-app-update", {
+ detail: { version: "2.0.0", phase: "ready", detail: "Ready", delayMs: 60000 },
+ }),
+ ),
+ );
+ await expect(details).toContainText("Recordly 2.0.0 is ready");
+ await expect(page.locator("html")).not.toHaveAttribute("data-restart-requested");
+ await details.getByRole("button", { name: "Restart to update", exact: true }).click();
+ await expect(details.getByRole("alert")).toContainText("Save your project");
+ await details.getByRole("button", { name: "Later", exact: true }).click();
+ await expect(details).not.toBeVisible();
+ await expect(page.locator(".app-update-button")).toHaveCount(0);
+});
+
+test("no update button is shown when there is no available update", async ({ page }) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getCurrentUpdateToastPayload = async () => null;
+ });
+ await page.goto("/?windowType=editor");
+ await expect(page.locator(".editor-header")).toBeVisible();
+ await expect(page.locator(".app-update-button")).toHaveCount(0);
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ await expect(
+ page.getByRole("dialog", { name: "Projects dashboard", exact: true }),
+ ).toBeVisible();
+ await expect(page.locator(".app-update-button")).toHaveCount(0);
+});
diff --git a/tests/ui/appearance-proportions.spec.ts b/tests/ui/appearance-proportions.spec.ts
new file mode 100644
index 00000000..a0fc9f3b
--- /dev/null
+++ b/tests/ui/appearance-proportions.spec.ts
@@ -0,0 +1,33 @@
+import { test, expect } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+for (const width of [1440, 760, 420, 320]) {
+ test(`Appearance controls remain compact and accessible at ${width}px`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 900 });
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Appearance", exact: true })
+ .click();
+ const settings = page.getByRole("dialog", { name: "Settings", exact: true });
+ await expect(settings).not.toHaveAttribute("data-entering");
+ for (const theme of ["Light", "Dark"]) {
+ await settings.getByText(theme, { exact: true }).click();
+ const picker = settings.getByRole("radiogroup", { name: "Appearance", exact: true });
+ await expect(picker.getByRole("radio")).toHaveCount(3);
+ await expect(picker.getByRole("radio", { name: theme, exact: true })).toBeChecked();
+ await expect(picker.locator(".theme-preview")).toHaveCount(0);
+ const dimensions = await picker.boundingBox();
+ expect(dimensions!.height).toBeLessThanOrEqual(40);
+ const panel = settings.getByRole("tabpanel");
+ expect(await panel.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
+ await settings
+ .locator(".theme-appearance-picker")
+ .screenshot({
+ path: `test-results/appearance-proportions-${width}-${theme}.png`,
+ animations: "disabled",
+ });
+ }
+ });
+}
diff --git a/tests/ui/aspect-ratio-picker.spec.ts b/tests/ui/aspect-ratio-picker.spec.ts
new file mode 100644
index 00000000..7e466c08
--- /dev/null
+++ b/tests/ui/aspect-ratio-picker.spec.ts
@@ -0,0 +1,164 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test.setTimeout(90000);
+
+test("source ratio grid and platform presets change the canvas and persist without closing the panel", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await installDesktopBridgeOverrides(page, () => {
+ const save = window.electronAPI.saveProjectFile;
+ window.electronAPI.saveProjectFile = async (...args) => {
+ document.documentElement.dataset.ratioProject = JSON.stringify(args[0]);
+ return save(...args);
+ };
+ });
+ await page.goto("/?windowType=editor");
+ const trigger = page.getByRole("button", { name: /^Aspect ratio:/ });
+ await trigger.click();
+ const card = page.getByRole("dialog", { name: "Aspect ratio", exact: true });
+ await expect(card.getByRole("spinbutton", { name: "Width", exact: true })).toBeVisible();
+ const standard = card.locator('section[aria-label="Standard ratios"]');
+ await expect(standard.getByRole("radio")).toHaveCount(9);
+ await expect(card.locator(".aspect-ratio-picker__heading")).toHaveText([
+ "Twitter",
+ "YouTube",
+ "Instagram",
+ ]);
+ await expect(
+ card.locator(
+ 'section[aria-label="Appstore"], section[aria-label="Pinterest"], section[aria-label="Dribbble"]',
+ ),
+ ).toHaveCount(0);
+ const native = card.locator('section[aria-label="Recordly"]');
+ await expect(native.getByRole("radio", { name: "Auto", exact: true })).toBeChecked();
+ const nativeBox = await native.boundingBox();
+ const sizeBox = await card.locator("form").boundingBox();
+ const standardBox = await standard.boundingBox();
+ expect(sizeBox!.y + sizeBox!.height).toBeLessThan(nativeBox!.y);
+ expect(nativeBox!.y + nativeBox!.height).toBeLessThan(standardBox!.y);
+
+ const shapes = await standard.locator(".aspect-ratio-picker__shape").evaluateAll((elements) =>
+ elements.map((el) => {
+ const rect = el.getBoundingClientRect();
+ return {
+ width: rect.width,
+ height: rect.height,
+ ratio: Number(getComputedStyle(el).aspectRatio.split("/")[0]),
+ };
+ }),
+ );
+ for (const shape of shapes) expect(shape.width / shape.height).toBeCloseTo(shape.ratio, 2);
+ await page.screenshot({
+ path: "test-results/aspect-ratio-source-card.png",
+ animations: "disabled",
+ });
+ for (const [section, name, ratio] of [
+ ["Standard ratios", "3:2", "3:2"],
+ ["Instagram", "Story 9:16", "9:16"],
+ ["Twitter", "Cover 3:1", "3:1"],
+ ["YouTube", "Video 16:9", "16:9"],
+ ["Recordly", "16:10", "16:10"],
+ ["Recordly", "10:16", "10:16"],
+ ["Recordly", "Auto", "native"],
+ ]) {
+ const option = card
+ .locator(`section[aria-label="${section}"]`)
+ .getByRole("radio", { name, exact: true });
+ await card
+ .locator(".aspect-ratio-picker__option")
+ .filter({ has: page.getByRole("radio", { name, exact: true }) })
+ .locator('[data-slot="radio-content"]')
+ .click();
+ await expect(card).toBeVisible();
+ await expect(option).toBeChecked();
+ await expect(trigger).toHaveAccessibleName(
+ `Aspect ratio: ${ratio === "native" ? "Auto" : ratio}`,
+ );
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ JSON.parse(document.documentElement.dataset.ratioProject ?? "{}").editor
+ ?.aspectRatio,
+ ),
+ )
+ .toBe(ratio);
+ if (ratio !== "native") {
+ const [w, h] = ratio.split(":").map(Number);
+ await expect
+ .poll(async () => {
+ const box = await page.locator(".editor-preview-frame").boundingBox();
+ return box ? box.width / box.height : 0;
+ })
+ .toBeCloseTo(w / h, 2);
+ }
+ }
+ await page.keyboard.press("Escape");
+ await expect(card).not.toBeVisible();
+ await expect(trigger).toBeFocused();
+});
+
+test("source custom W/H form, keyboard selection and scrolling fit a shorter window", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 1024, height: 700 });
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/?windowType=editor");
+ const trigger = page.getByRole("button", { name: /^Aspect ratio:/ });
+ await trigger.press("Enter");
+ const card = page.getByRole("dialog", { name: "Aspect ratio", exact: true });
+ await expect(card).toBeVisible();
+ await expect(page.locator(".aspect-ratio-picker")).not.toHaveAttribute("data-entering");
+ const bounds = await page.locator(".aspect-ratio-picker").boundingBox();
+ expect(bounds!.width).toBe(320);
+ expect(bounds!.y).toBeGreaterThanOrEqual(0);
+ expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(700);
+ await expect(card.getByRole("button", { name: "Set", exact: true })).toHaveCount(0);
+ const width = card.getByRole("spinbutton", { name: "Width", exact: true });
+ const height = card.getByRole("spinbutton", { name: "Height", exact: true });
+ await expect(width).not.toHaveValue("");
+ await expect(height).not.toHaveValue("");
+ await width.fill("0");
+ await height.fill("800");
+ await expect(trigger).toHaveAccessibleName("Aspect ratio: Auto");
+ await width.fill("1200");
+ await expect(trigger).toHaveAccessibleName("Aspect ratio: 1200:800");
+ await expect(width).toBeFocused();
+ await height.fill("600");
+ await expect(trigger).toHaveAccessibleName("Aspect ratio: 1200:600");
+ await expect(height).toBeFocused();
+ for (const invalid of ["", "127", "7681", "1.5"]) {
+ await height.fill(invalid);
+ await expect(trigger).toHaveAccessibleName("Aspect ratio: 1200:600");
+ }
+ await height.fill("800");
+ await expect(trigger).toHaveAccessibleName("Aspect ratio: 1200:800");
+ await expect(card).toBeVisible();
+ await page.keyboard.press("Escape");
+ await trigger.click();
+ await expect(width).toHaveValue("1200");
+ await expect(height).toHaveValue("800");
+ const standard = card.locator('section[aria-label="Standard ratios"]');
+ await standard
+ .locator(".aspect-ratio-picker__option")
+ .filter({ has: page.getByRole("radio", { name: "16:9", exact: true }) })
+ .locator('[data-slot="radio-content"]')
+ .click();
+ await page.keyboard.press("ArrowRight");
+ await expect(standard.getByRole("radio", { name: "3:2", exact: true })).toBeChecked();
+ await card
+ .locator('section[aria-label="Instagram"]')
+ .locator(".aspect-ratio-picker__option")
+ .filter({ hasText: "Story" })
+ .locator('[data-slot="radio-content"]')
+ .click();
+ await page.screenshot({
+ path: "test-results/aspect-ratio-source-social.png",
+ animations: "disabled",
+ });
+ await page.keyboard.press("Escape");
+ await expect(card).not.toBeVisible();
+ await expect(trigger).toBeFocused();
+});
diff --git a/tests/ui/auth-bot-protection.spec.ts b/tests/ui/auth-bot-protection.spec.ts
new file mode 100644
index 00000000..4ef397f8
--- /dev/null
+++ b/tests/ui/auth-bot-protection.spec.ts
@@ -0,0 +1,230 @@
+import { expect, type Page } from "@playwright/test";
+import { productionTest as test } from "./production";
+import { installDesktopBridge } from "./bridge";
+
+const frameSelector = 'iframe[title="Recordly security check"]';
+const submit = (page: Page) => page.locator('[data-signin-form] button[type="submit"]');
+
+// Emit only from a test-owned frame. No real CAPTCHA provider or email is used.
+async function emitFrameEvent(page: Page, status: string, token?: string) {
+ await page
+ .frameLocator(frameSelector)
+ .locator("body")
+ .evaluate(
+ (_, data) => {
+ const params = new URLSearchParams(location.search);
+ parent.postMessage(
+ {
+ type: "recordly-auth-challenge",
+ requestId: params.get("request"),
+ ...data,
+ },
+ params.get("parent")!,
+ );
+ },
+ { status, token },
+ );
+}
+
+async function expectCollapsed(page: Page) {
+ await expect(page.locator("[data-auth-challenge]")).toHaveCSS("height", "0px");
+ await expect(page.locator(frameSelector)).toHaveCSS("height", "0px");
+ await expect(page.locator(frameSelector)).toHaveAttribute("aria-hidden", "true");
+}
+
+test.beforeEach(async ({ page }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await page.route("**/cloud/config", (route) =>
+ route.fulfill({
+ headers: { "Access-Control-Allow-Origin": "*" },
+ json: { authCaptcha: { enabled: true } },
+ }),
+ );
+ await page.route("**/cloud/challenge?**", (route) =>
+ route.fulfill({
+ contentType: "text/html",
+ body: "Test-owned challenge frame",
+ }),
+ );
+});
+
+async function openEmail(page: Page) {
+ await page.goto("/?windowType=onboarding");
+ await page.getByPlaceholder("you@example.com").fill("test@email.com");
+}
+
+test("starts checking only on submit, ignores forged results, and sends once automatically", async ({
+ page,
+}) => {
+ let configurations = 0;
+ let emails = 0;
+ page.on("request", (request) => {
+ if (new URL(request.url()).pathname === "/cloud/config") configurations++;
+ if (new URL(request.url()).pathname === "/auth/v1/otp") emails++;
+ });
+ await openEmail(page);
+ await expect(
+ page.getByRole("button", { name: "Continue with Google", exact: true }),
+ ).toBeEnabled();
+ await expect(page.locator(frameSelector)).toHaveCount(0);
+ await expect(page.getByRole("alert")).toHaveCount(0);
+ expect(configurations).toBe(0);
+ await expect(submit(page)).toBeEnabled();
+ await submit(page).click();
+ await expectCollapsed(page);
+ await expect(submit(page)).toBeDisabled();
+ const source = await page.locator(frameSelector).getAttribute("src");
+ for (const status of ["interactive", "verified"]) {
+ await page.evaluate(
+ ({ src, status }) => {
+ const url = new URL(src!);
+ window.postMessage(
+ {
+ type: "recordly-auth-challenge",
+ requestId: url.searchParams.get("request"),
+ status,
+ token: "forged",
+ },
+ location.origin,
+ );
+ },
+ { src: source, status },
+ );
+ }
+ await expectCollapsed(page);
+ expect(emails).toBe(0);
+ await page.getByPlaceholder("you@example.com").press("Enter");
+ const request = page.waitForRequest((r) => new URL(r.url()).pathname === "/auth/v1/otp");
+ await emitFrameEvent(page, "verified", "test-one-use-token");
+ expect((await request).postDataJSON().gotrue_meta_security.captcha_token).toBe(
+ "test-one-use-token",
+ );
+ await expect(page.getByText("Enter code sent to test@email.com")).toBeVisible();
+ await expect(page.locator(frameSelector)).toHaveCount(0);
+ expect(emails).toBe(1);
+ await expect(page.getByRole("button", { name: /Resend in/ })).toBeDisabled();
+});
+
+test("shows interaction only when requested and keeps the form in view", async ({ page }) => {
+ await page.setViewportSize({ width: 600, height: 600 });
+ await openEmail(page);
+ await submit(page).click();
+ await emitFrameEvent(page, "interactive");
+ await expect(page.locator(frameSelector)).toBeVisible();
+ await expect(page.locator(frameSelector)).toHaveCSS("height", "72px");
+ await expect(page.locator(frameSelector)).toHaveAttribute("aria-hidden", "false");
+ await expect(submit(page)).toBeDisabled();
+ for (const size of [
+ { width: 600, height: 600 },
+ { width: 390, height: 600 },
+ { width: 450, height: 450 },
+ ]) {
+ await page.setViewportSize(size);
+ await expect(submit(page)).toBeInViewport({ ratio: 1 });
+ await expect(page.locator(frameSelector)).toBeInViewport({ ratio: 1 });
+ await expect(page.locator(".pricing-legal-consent")).toBeInViewport({ ratio: 1 });
+ await expect
+ .poll(() =>
+ page
+ .locator("[data-onboarding-body]")
+ .evaluate((e) => e.scrollHeight - e.clientHeight),
+ )
+ .toBeLessThanOrEqual(1);
+ }
+ await emitFrameEvent(page, "background");
+ await expectCollapsed(page);
+ await emitFrameEvent(page, "verified", "test-one-use-token");
+ await expect(page.getByText("Enter code sent to test@email.com")).toBeVisible();
+});
+
+test("failure sends no email and retry resumes the original submission", async ({ page }) => {
+ let emails = 0;
+ page.on("request", (r) => {
+ if (new URL(r.url()).pathname === "/auth/v1/otp") emails++;
+ });
+ await openEmail(page);
+ await submit(page).click();
+ await emitFrameEvent(page, "error");
+ await expect(page.getByText("The security check couldn’t load. Try again.")).toBeVisible();
+ await expect(page.locator(frameSelector)).toHaveCount(0);
+ expect(emails).toBe(0);
+ await page.getByRole("button", { name: "Try again", exact: true }).click();
+ await expectCollapsed(page);
+ await emitFrameEvent(page, "verified", "retry-token");
+ await expect(page.getByText("Enter code sent to test@email.com")).toBeVisible();
+ expect(emails).toBe(1);
+ await expect(page.getByRole("alert")).toHaveCount(0);
+});
+
+test("retry cannot forget a previously required check when configuration is unavailable", async ({
+ page,
+}) => {
+ let emails = 0;
+ page.on("request", (r) => {
+ if (new URL(r.url()).pathname === "/auth/v1/otp") emails++;
+ });
+ await openEmail(page);
+ await submit(page).click();
+ await emitFrameEvent(page, "error");
+ await expect(page.getByRole("button", { name: "Try again", exact: true })).toBeVisible();
+ await page.route("**/cloud/config", (route) =>
+ route.fulfill({ status: 503, body: "unavailable" }),
+ );
+ await page.getByRole("button", { name: "Try again", exact: true }).click();
+ await expect(page.getByText("The security check couldn’t load. Try again.")).toBeVisible();
+ await expect(submit(page)).toBeEnabled();
+ expect(emails).toBe(0);
+ await expect(page.locator(frameSelector)).toHaveCount(0);
+});
+
+test("editing email after failure discards the previous pending submission", async ({ page }) => {
+ await openEmail(page);
+ await submit(page).click();
+ await emitFrameEvent(page, "error");
+ await expect(page.getByRole("button", { name: "Try again", exact: true })).toBeVisible();
+ await page.getByPlaceholder("you@example.com").fill("changed@example.test");
+ await expect(page.getByRole("button", { name: "Try again", exact: true })).toHaveCount(0);
+ await submit(page).click();
+ const request = page.waitForRequest((r) => new URL(r.url()).pathname === "/auth/v1/otp");
+ await emitFrameEvent(page, "verified", "changed-token");
+ expect((await request).postDataJSON().email).toBe("changed@example.test");
+ await expect(page.getByText("Enter code sent to changed@example.test")).toBeVisible();
+});
+
+test("password sign-in also starts its check on submit and forwards the verified token", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 600, height: 600 });
+ await openEmail(page);
+ await page.getByRole("button", { name: "Or use password to sign in", exact: true }).click();
+ await page.getByLabel("Password", { exact: true }).fill("test-only-password");
+ await expect(page.locator(frameSelector)).toHaveCount(0);
+ await submit(page).click();
+ await expectCollapsed(page);
+ await emitFrameEvent(page, "interactive");
+ await expect(page.locator(frameSelector)).toBeInViewport({ ratio: 1 });
+ await expect(submit(page)).toBeInViewport({ ratio: 1 });
+ await expect(page.locator(".pricing-legal-consent")).toBeInViewport({ ratio: 1 });
+ const request = page.waitForRequest((r) => new URL(r.url()).pathname === "/auth/v1/token");
+ await emitFrameEvent(page, "verified", "password-token");
+ expect((await request).postDataJSON().gotrue_meta_security.captcha_token).toBe(
+ "password-token",
+ );
+ await expect(page.getByRole("heading", { name: "Allow Recordly to record" })).toBeVisible();
+});
+
+test("resending starts a fresh check only after its button is clicked", async ({ page }) => {
+ await page.clock.install();
+ await openEmail(page);
+ await submit(page).click();
+ await emitFrameEvent(page, "verified", "first-token");
+ await expect(page.getByText("Enter code sent to test@email.com")).toBeVisible();
+ await page.clock.fastForward(60000);
+ await expect(page.locator(frameSelector)).toHaveCount(0);
+ await page.getByRole("button", { name: "Resend code", exact: true }).click();
+ await expectCollapsed(page);
+ const request = page.waitForRequest((r) => new URL(r.url()).pathname === "/auth/v1/otp");
+ await emitFrameEvent(page, "verified", "resend-token");
+ expect((await request).postDataJSON().gotrue_meta_security.captcha_token).toBe("resend-token");
+ await expect(page.locator(frameSelector)).toHaveCount(0);
+});
diff --git a/tests/ui/auto-zoom-nudge.spec.ts b/tests/ui/auto-zoom-nudge.spec.ts
new file mode 100644
index 00000000..f2104f5a
--- /dev/null
+++ b/tests/ui/auto-zoom-nudge.spec.ts
@@ -0,0 +1,73 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test("auto zoom nudge repeats until a project edit, and stays dismissed after undo", async ({
+ page,
+}) => {
+ test.setTimeout(90000);
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/?windowType=editor");
+ const apply = page.getByRole("button", { name: "Apply auto zooms", exact: true });
+ await expect(apply).toBeVisible({ timeout: 20000 });
+ await expect(apply).toHaveCSS("animation-duration", "3s");
+ await expect(apply).toHaveCSS("animation-delay", "0s");
+ await expect(apply).toHaveCSS("animation-iteration-count", "infinite");
+ const transforms = await apply.evaluate((button) => {
+ const animation = button.getAnimations()[0];
+ animation.pause();
+ animation.currentTime = 90;
+ const shaking = getComputedStyle(button).transform;
+ animation.currentTime = 600;
+ const resting = getComputedStyle(button).transform;
+ animation.currentTime = 3090;
+ const repeated = getComputedStyle(button).transform;
+ animation.play();
+ return { shaking, resting, repeated };
+ });
+ expect(transforms.shaking).not.toBe(transforms.resting);
+ expect(transforms.repeated).toBe(transforms.shaking);
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await expect(apply).toHaveCSS("animation-name", "none");
+ await page.emulateMedia({ reducedMotion: "no-preference" });
+ await expect(apply).toHaveCSS("animation-duration", "3s");
+
+ await page.locator('[data-timeline-row="row-zoom"]').click({ position: { x: 250, y: 20 } });
+ await page.locator('[data-variant="clip"]').click();
+ await expect(apply).toHaveCSS("animation-duration", "3s");
+ await page.getByRole("button", { name: /^Split Clip \(/ }).click();
+ await expect(page.locator('[data-variant="clip"]')).toHaveCount(2);
+ await expect(apply).toHaveCSS("animation-name", "none");
+ await page.keyboard.press("Meta+z");
+ await expect(page.locator('[data-variant="clip"]')).toHaveCount(1);
+ await expect(apply).toHaveCSS("animation-name", "none");
+ await expect(apply).toBeEnabled();
+});
+
+test("appearance edits also stop the auto zoom nudge", async ({ page }) => {
+ test.setTimeout(90000);
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/?windowType=editor");
+ const apply = page.getByRole("button", { name: "Apply auto zooms", exact: true });
+ await expect(apply).toHaveCSS("animation-duration", "3s", { timeout: 20000 });
+ const padding = page.getByRole("slider", { name: "Padding", exact: true });
+ await padding.focus();
+ await page.keyboard.press("ArrowRight");
+ await expect(apply).toHaveCSS("animation-name", "none");
+});
+
+test("auto zoom nudge keeps repeating without an edit", async ({ page }) => {
+ test.setTimeout(90000);
+ await page.clock.install();
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/?windowType=editor");
+ const apply = page.getByRole("button", { name: "Apply auto zooms", exact: true });
+ await expect(apply).toHaveCSS("animation-duration", "3s", { timeout: 20000 });
+ await page.clock.fastForward(60000);
+ await expect(apply).toHaveCSS("animation-duration", "3s");
+ await expect(apply).toHaveCSS("animation-iteration-count", "infinite");
+ await expect(apply).toBeEnabled();
+ await page.locator('[data-timeline-row="row-zoom"]').click({ position: { x: 250, y: 20 } });
+ await page.clock.fastForward(3000);
+ await expect(apply).toHaveCSS("animation-duration", "3s");
+ await expect(page.locator('[data-variant="zoom"]')).toHaveCount(0);
+});
diff --git a/tests/ui/auto-zoom-placement.spec.ts b/tests/ui/auto-zoom-placement.spec.ts
new file mode 100644
index 00000000..b28601b2
--- /dev/null
+++ b/tests/ui/auto-zoom-placement.spec.ts
@@ -0,0 +1,190 @@
+import { expect, test } from "@playwright/test";
+import type { CursorTelemetryPoint } from "@/components/video-editor/types";
+import { installDesktopBridge } from "./bridge";
+
+for (const scenario of [
+ {
+ name: "recorded press and release",
+ click: 3000,
+ samples: [
+ { timeMs: 3000, cx: 0.5, cy: 0.5, interactionType: "click" },
+ { timeMs: 3080, cx: 0.5, cy: 0.5, interactionType: "mouseup" },
+ ],
+ start: 2000,
+ end: 4000,
+ },
+ {
+ name: "strongest click in a group",
+ click: 3000,
+ samples: [
+ { timeMs: 2000, cx: 0.3, cy: 0.5, interactionType: "click" },
+ { timeMs: 3000, cx: 0.7, cy: 0.5, interactionType: "double-click" },
+ ],
+ start: 1500,
+ end: 4500,
+ },
+ {
+ name: "recording boundary fallback",
+ click: null,
+ samples: [
+ { timeMs: 200, cx: 0.5, cy: 0.5, interactionType: "click" },
+ { timeMs: 250, cx: 0.5, cy: 0.5, interactionType: "mouseup" },
+ ],
+ start: 0,
+ end: 2000,
+ },
+] satisfies Array<{
+ name: string;
+ click: number | null;
+ samples: CursorTelemetryPoint[];
+ start: number;
+ end: number;
+}>) {
+ test(`auto zoom placement: ${scenario.name}`, async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.addInitScript((samples) => {
+ const getAppSetting = window.electronAPI.getAppSetting;
+ window.electronAPI.getAppSetting = (key) =>
+ key === "recordly.editor.preferences"
+ ? { autoApplyFreshRecordingAutoZooms: true }
+ : getAppSetting(key);
+ window.electronAPI.getCurrentRecordingSession = async () => ({
+ success: true,
+ session: { videoPath: `${location.origin}/tests/ui/fixtures/filmstrip.mp4` },
+ });
+ window.electronAPI.getCursorTelemetry = async () => ({
+ success: true,
+ samples: [
+ { timeMs: 0, cx: 0.5, cy: 0.5, interactionType: "move" },
+ ...samples,
+ { timeMs: 6000, cx: 0.5, cy: 0.5, interactionType: "move" },
+ ],
+ });
+ }, scenario.samples);
+ await page.goto("/?windowType=editor");
+ const suggest = page.getByRole("button", {
+ name: "Apply auto zooms",
+ exact: true,
+ });
+ await expect(suggest).toBeVisible({ timeout: 20000 });
+ await expect(
+ suggest.locator('xpath=ancestor::*[@data-timeline-row="row-zoom"]'),
+ ).toHaveCount(1);
+ await expect(page.locator('[data-variant="clip"]')).toHaveCount(1);
+ // Fresh recordings must stay unzoomed, even with the retired preference enabled.
+ await page.waitForTimeout(900);
+ await expect(page.locator('[data-variant="zoom"]')).toHaveCount(0);
+ const zoomLane = page.locator('[data-timeline-row="row-zoom"]');
+ await zoomLane.hover({ position: { x: 80, y: 20 } });
+ await expect(zoomLane.getByTestId("timeline-add-preview")).toHaveCount(0);
+ await zoomLane.click({ position: { x: 80, y: 20 } });
+ await expect(page.locator('[data-variant="zoom"]')).toHaveCount(0);
+ await expect(suggest).toBeVisible();
+ if (scenario.name === "recorded press and release") {
+ await page.screenshot({
+ path: "test-results/manual-auto-zoom-before.png",
+ animations: "disabled",
+ });
+ }
+ await suggest.click();
+ const zoom = page.locator('[data-variant="zoom"]');
+ await expect(zoom).toHaveCount(1);
+ await expect(zoom).toHaveAttribute("data-start-ms", String(scenario.start));
+ await expect(zoom).toHaveAttribute("data-stored-end-ms", String(scenario.end));
+ await expect(zoom.locator(".zoom-duration")).toHaveText(
+ `${(scenario.end - scenario.start) / 1000}s`,
+ );
+ const block = (await zoom.locator(".timeline-block").boundingBox())!;
+ const description = (await zoom.locator(".zoom-description").boundingBox())!;
+ expect(description.x + description.width / 2).toBeCloseTo(block.x + block.width / 2, 0);
+ expect(description.y + description.height / 2).toBeCloseTo(block.y + block.height / 2, 0);
+ await expect(suggest).not.toBeVisible();
+ if (scenario.click !== null) {
+ const start = Number(await zoom.getAttribute("data-start-ms"));
+ const end = Number(await zoom.getAttribute("data-stored-end-ms"));
+ expect((start + end) / 2).toBe(scenario.click);
+ }
+ if (scenario.name === "recorded press and release") {
+ const laneBounds = (await zoomLane.boundingBox())!;
+ await zoomLane.hover({ position: { x: laneBounds.width * 0.7, y: 20 } });
+ // A full two-second block does not fit between this point and the clip end.
+ await expect(zoomLane.getByTestId("timeline-add-preview")).toHaveCount(0);
+ await page.screenshot({
+ path: "test-results/manual-auto-zoom-after.png",
+ animations: "disabled",
+ });
+ await page.keyboard.press("Meta+z");
+ await expect(zoom).toHaveCount(0);
+ await expect(suggest).toBeVisible();
+ await expect(zoomLane.getByTestId("timeline-add-preview")).toHaveCount(0);
+ await page.waitForTimeout(900);
+ await expect(zoom).toHaveCount(0);
+ await suggest.click();
+ await expect(zoom).toHaveCount(1);
+ }
+ });
+}
+
+test("auto zooms explain missing telemetry without adding a zoom", async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/?windowType=editor");
+ const apply = page.getByRole("button", { name: "Apply auto zooms", exact: true });
+ await expect(apply).toBeVisible({ timeout: 20000 });
+ await apply.click();
+ await expect(page.getByText("No cursor telemetry available", { exact: true })).toBeVisible();
+ await expect(page.locator('[data-variant="zoom"]')).toHaveCount(0);
+ await expect(apply).toBeVisible();
+});
+
+for (const scenario of [
+ { name: "trimmed", speed: 1, duration: 3000, click: 3500, start: 500, end: 2500 },
+ { name: "sped up", speed: 2, duration: 2000, click: 4000, start: 0, end: 2000 },
+]) {
+ test(`auto zoom uses retained ${scenario.name} footage timestamps`, async ({ page }) => {
+ test.setTimeout(90000);
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.addInitScript(({ speed, duration, click }) => {
+ window.electronAPI.loadCurrentProjectFile = async () => ({
+ success: true,
+ path: "/projects/trimmed.recordly",
+ project: {
+ version: 2,
+ videoPath: `${location.origin}/tests/ui/fixtures/filmstrip.mp4`,
+ editor: {
+ clipRegions: [
+ {
+ id: "clip-1",
+ startMs: 0,
+ endMs: duration,
+ sourceStartMs: 2000,
+ speed,
+ },
+ ],
+ },
+ },
+ });
+ window.electronAPI.getCursorTelemetry = async () => ({
+ success: true,
+ samples: [
+ { timeMs: 0, cx: 0.5, cy: 0.5, interactionType: "move" },
+ { timeMs: 100, cx: 0.1, cy: 0.1, interactionType: "click" },
+ { timeMs: 180, cx: 0.1, cy: 0.1, interactionType: "mouseup" },
+ { timeMs: click, cx: 0.7, cy: 0.5, interactionType: "click" },
+ { timeMs: click + 80, cx: 0.7, cy: 0.5, interactionType: "mouseup" },
+ { timeMs: 6000, cx: 0.5, cy: 0.5, interactionType: "move" },
+ ],
+ });
+ }, scenario);
+ await page.goto("/?windowType=editor");
+ await expect(page.locator('[data-variant="clip"]')).toHaveAttribute(
+ "data-end-ms",
+ String(scenario.duration),
+ { timeout: 20000 },
+ );
+ await page.getByRole("button", { name: "Apply auto zooms", exact: true }).click();
+ const zoom = page.locator('[data-variant="zoom"]');
+ await expect(zoom).toHaveCount(1);
+ await expect(zoom).toHaveAttribute("data-start-ms", String(scenario.start));
+ await expect(zoom).toHaveAttribute("data-stored-end-ms", String(scenario.end));
+ });
+}
diff --git a/tests/ui/automatic-update-downloads.spec.ts b/tests/ui/automatic-update-downloads.spec.ts
new file mode 100644
index 00000000..dae52cd3
--- /dev/null
+++ b/tests/ui/automatic-update-downloads.spec.ts
@@ -0,0 +1,212 @@
+import { expect, type Page, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+async function openUpdates(page: Page) {
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true })
+ .click();
+ const settings = page.getByRole("dialog", { name: "Settings", exact: true });
+ await settings.getByRole("tab", { name: "General", exact: true }).click();
+ return settings;
+}
+
+test("automatic downloads are off initially and save either choice across reloads", async ({
+ page,
+}) => {
+ // This scenario reloads the full editor three times, including a cold lazy import.
+ test.setTimeout(90_000);
+ await installDesktopBridge(page);
+ let settings = await openUpdates(page);
+ let toggle = settings.getByRole("switch", {
+ name: "Automatically download updates",
+ exact: true,
+ });
+ await expect(toggle).toBeEnabled();
+ await expect(toggle).not.toBeChecked();
+ const updates = settings.getByRole("region", { name: "App update", exact: true });
+ await updates.scrollIntoViewIfNeeded();
+ await expect(updates.getByRole("status")).toHaveCount(0);
+ await expect(
+ settings.getByText("Choose how updates download. Restart when you’re ready.", {
+ exact: true,
+ }),
+ ).not.toBeVisible();
+ const version = await updates
+ .getByRole("heading", { name: "Installed version", exact: true })
+ .locator("..")
+ .boundingBox();
+ const actions = await updates
+ .getByRole("button", { name: "Check for updates", exact: true })
+ .locator("..")
+ .boundingBox();
+ expect(
+ Math.abs(version!.y + version!.height / 2 - actions!.y - actions!.height / 2),
+ ).toBeLessThan(1);
+ await settings
+ .locator(".settings-row")
+ .filter({
+ has: page.getByRole("switch", { name: "Automatically download updates", exact: true }),
+ })
+ .scrollIntoViewIfNeeded();
+ await settings.screenshot({ path: "output/update-flow/automatic-downloads-off.png" });
+ await toggle.focus();
+ await toggle.press("Space");
+ await expect(toggle).toBeChecked();
+ settings = await openUpdates(page);
+ toggle = settings.getByRole("switch", {
+ name: "Automatically download updates",
+ exact: true,
+ });
+ await expect(toggle).toBeChecked();
+ await settings
+ .locator(".settings-row")
+ .filter({
+ has: page.getByRole("switch", { name: "Automatically download updates", exact: true }),
+ })
+ .locator('[data-slot="switch-control"]')
+ .click();
+ await expect(toggle).not.toBeChecked();
+ settings = await openUpdates(page);
+ await expect(
+ settings.getByRole("switch", { name: "Automatically download updates", exact: true }),
+ ).not.toBeChecked();
+});
+
+test("a failed save keeps the previous automatic download choice", async ({ page }) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.setAutomaticUpdateDownloadsEnabled = async () => ({
+ success: false,
+ enabled: false,
+ });
+ });
+ const settings = await openUpdates(page);
+ const toggle = settings.getByRole("switch", {
+ name: "Automatically download updates",
+ exact: true,
+ });
+ await expect(toggle).toBeEnabled();
+ await settings
+ .locator(".settings-row")
+ .filter({
+ has: page.getByRole("switch", { name: "Automatically download updates", exact: true }),
+ })
+ .locator('[data-slot="switch-control"]')
+ .click();
+ await expect(toggle).not.toBeChecked();
+ await expect(settings.getByRole("alert")).toContainText(
+ "Could not load or save automatic update downloads",
+ );
+});
+
+test("failed preference loading can be retried without enabling automatic downloads", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getAutomaticUpdateDownloadsEnabled = async () => {
+ if (document.documentElement.dataset.preferenceLoadReady !== "true")
+ throw Error("Unavailable");
+ return false;
+ };
+ });
+ const settings = await openUpdates(page);
+ const toggle = settings.getByRole("switch", {
+ name: "Automatically download updates",
+ exact: true,
+ });
+ await expect(toggle).toBeDisabled();
+ await page.evaluate(() => {
+ document.documentElement.dataset.preferenceLoadReady = "true";
+ });
+ await settings.getByRole("button", { name: "Try again", exact: true }).click();
+ await expect(toggle).toBeEnabled();
+ await expect(toggle).not.toBeChecked();
+});
+
+test("Settings keeps update actions available after notification dismissal and shows progress", async ({
+ page,
+}) => {
+ test.setTimeout(90_000);
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ let summary: Awaited> = {
+ status: "available",
+ currentVersion: "1.4.0",
+ availableVersion: "1.5.0",
+ };
+ window.electronAPI.getAppVersion = async () => "1.4.0";
+ window.electronAPI.getUpdateStatusSummary = async () => summary;
+ // The notification is dismissed; Settings must use the durable updater status.
+ window.electronAPI.getCurrentUpdateToastPayload = async () => null;
+ window.electronAPI.onUpdateToastStateChanged = (callback) => {
+ const listener = (event: Event) => {
+ summary = (event as CustomEvent).detail;
+ callback(null);
+ };
+ window.addEventListener("test-settings-update", listener);
+ return () => window.removeEventListener("test-settings-update", listener);
+ };
+ window.electronAPI.downloadAvailableUpdate = async (restart) => {
+ document.documentElement.dataset.updateRestart = String(restart);
+ window.dispatchEvent(
+ new CustomEvent("test-settings-update", {
+ detail: { ...summary, status: "downloading", progressPercent: 42 },
+ }),
+ );
+ return { success: true };
+ };
+ window.electronAPI.installDownloadedUpdate = async () => ({
+ success: false,
+ code: "restart-blocked",
+ message:
+ "Finish recording or exporting and save your project before restarting to update.",
+ });
+ });
+ const settings = await openUpdates(page);
+ const status = settings.getByRole("region", { name: "App update", exact: true });
+ await expect(status).toContainText("Recordly 1.5.0 is available.");
+ await status.getByRole("button", { name: "Download update", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-update-restart", "false");
+ await expect(status.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "42");
+ await settings.screenshot({ path: "output/update-flow/settings-downloading.png" });
+ await page.evaluate(() =>
+ window.dispatchEvent(
+ new CustomEvent("test-settings-update", {
+ detail: { status: "ready", currentVersion: "1.4.0", availableVersion: "1.5.0" },
+ }),
+ ),
+ );
+ await expect(status).toContainText("Recordly 1.5.0 is ready to install.");
+ await settings.screenshot({ path: "output/update-flow/settings-ready.png" });
+ await page.setViewportSize({ width: 760, height: 720 });
+ await expect(
+ status.getByRole("button", { name: "Restart to update", exact: true }),
+ ).toBeVisible();
+ expect(await settings.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(
+ true,
+ );
+ await settings.screenshot({ path: "output/update-flow/settings-ready-narrow.png" });
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.emulateMedia({ colorScheme: "dark" });
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
+ await settings.screenshot({ path: "output/update-flow/settings-ready-dark.png" });
+ await page.emulateMedia({ colorScheme: "light" });
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
+ await status.getByRole("button", { name: "Restart to update", exact: true }).click();
+ await expect(
+ status
+ .getByRole("alert")
+ .getByText(
+ "Finish recording or exporting and save your project before restarting to update.",
+ { exact: true },
+ ),
+ ).toBeVisible();
+ await expect(
+ status.getByRole("button", { name: "Restart to update", exact: true }),
+ ).toBeEnabled();
+ await settings.screenshot({ path: "output/update-flow/settings-ready-blocked.png" });
+});
diff --git a/tests/ui/billing-recovery.spec.ts b/tests/ui/billing-recovery.spec.ts
new file mode 100644
index 00000000..f86be4e0
--- /dev/null
+++ b/tests/ui/billing-recovery.spec.ts
@@ -0,0 +1,329 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+async function prepare(
+ page: Parameters[0],
+ mode:
+ | "recover"
+ | "unauthorized"
+ | "unavailable"
+ | "past_due"
+ | "unpaid"
+ | "held"
+ | "canceled"
+ | "action_failure"
+ | "portal_failure"
+ | "catalog_failure"
+ | "price_mismatch"
+ | "slow_catalog"
+ | "paid_no_portal"
+ | "checkout_needs_attention",
+) {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(
+ page,
+ ({ mode }) => {
+ let calls = 0;
+ const prices = [{ interval: "monthly", unitAmount: 900, currency: "usd" }];
+ window.electronAPI.cloudBilling = async ({ action }) => {
+ if (action === "catalog") {
+ if (
+ mode === "catalog_failure" &&
+ document.documentElement.dataset.recovered !== "true"
+ )
+ return { success: false, error: "catalog provider diagnostic" };
+ if (
+ mode === "slow_catalog" &&
+ document.documentElement.dataset.recovered !== "true"
+ )
+ await new Promise((resolve) =>
+ document.addEventListener("release-pricing-catalog", resolve, {
+ once: true,
+ }),
+ );
+ return {
+ success: true,
+ prices,
+ limits: {
+ freeRecordingLimit: 5,
+ freeMaxRecordingBytes: 1e9,
+ plusMaxRecordingBytes: 1e10,
+ storageLimitBytes: 1e11,
+ },
+ };
+ }
+ if (action === "status") {
+ calls++;
+ document.documentElement.dataset.billingStatusCalls = String(calls);
+ if (mode === "unauthorized" || (mode === "recover" && calls === 1))
+ return {
+ success: false,
+ httpStatus: 401,
+ error: "Sign in again to manage your cloud plan.",
+ };
+ const needsAttention =
+ mode === "checkout_needs_attention" &&
+ document.documentElement.dataset.billingAction === "checkout";
+ return {
+ success: true,
+ billing: {
+ plan: mode === "paid_no_portal" ? "pro" : "free",
+ subscriptionStatus:
+ mode === "paid_no_portal"
+ ? "active"
+ : [
+ "past_due",
+ "unpaid",
+ "held",
+ "canceled",
+ "portal_failure",
+ ].includes(mode)
+ ? mode === "held" || mode === "portal_failure"
+ ? "active"
+ : mode
+ : null,
+ currentPeriodEnd:
+ mode === "paid_no_portal"
+ ? Math.floor(Date.now() / 1000) + 86400
+ : null,
+ cancelAtPeriodEnd: false,
+ checkoutAvailable:
+ ![
+ "unavailable",
+ "past_due",
+ "unpaid",
+ "held",
+ "portal_failure",
+ ].includes(mode) ||
+ document.documentElement.dataset.recovered === "true",
+ checkoutIntervals:
+ ![
+ "unavailable",
+ "past_due",
+ "unpaid",
+ "held",
+ "portal_failure",
+ ].includes(mode) ||
+ document.documentElement.dataset.recovered === "true"
+ ? ["monthly"]
+ : [],
+ portalAvailable: [
+ "past_due",
+ "unpaid",
+ "held",
+ "canceled",
+ "portal_failure",
+ ].includes(mode),
+ prices:
+ mode === "price_mismatch" &&
+ document.documentElement.dataset.recovered !== "true"
+ ? [{ ...prices[0], unitAmount: 1500 }]
+ : prices,
+ ...(needsAttention
+ ? {
+ subscriptionStatus: "past_due",
+ checkoutAvailable: false,
+ checkoutIntervals: [],
+ portalAvailable: true,
+ }
+ : {}),
+ },
+ usage: {
+ recordings: 0,
+ recordingLimit: mode === "paid_no_portal" ? null : 5,
+ maxRecordingBytes: 1e9,
+ storageBytes: 0,
+ storageLimitBytes: 1e11,
+ },
+ };
+ }
+ document.documentElement.dataset.billingAction = action;
+ document.documentElement.dataset.billingActionCount = String(
+ Number(document.documentElement.dataset.billingActionCount ?? 0) + 1,
+ );
+ if (mode === "action_failure" || mode === "portal_failure")
+ return { success: false, error: "provider diagnostic must not appear" };
+ return { success: true };
+ };
+ },
+ { mode },
+ );
+ await page.goto("/tests/ui/billing-usage.html");
+ await page.getByRole("button", { name: "Open test pricing", exact: true }).click();
+ return page.getByRole("dialog", { name: "Recordly Cloud", exact: true });
+}
+
+test("billing recovers a rejected session without showing background errors", async ({ page }) => {
+ const dialog = await prepare(page, "recover");
+ await expect(dialog.getByRole("button", { name: "Get started", exact: true })).toBeEnabled();
+ await expect
+ .poll(() => page.locator("html").getAttribute("data-billing-status-calls"))
+ .not.toBe("1");
+ await expect(dialog.getByText("Sign in again to manage your cloud plan.")).toHaveCount(0);
+ await expect(dialog.getByText("Cloud checkout is not available yet.")).toHaveCount(0);
+ await expect(dialog.locator(".pricing-error")).toHaveCount(0);
+});
+
+test("unverified checkout can refresh without exposing diagnostics or opening billing", async ({
+ page,
+}) => {
+ const dialog = await prepare(page, "unavailable");
+ const refresh = dialog.getByRole("button", { name: "Get started", exact: true });
+ await expect(refresh).toBeEnabled();
+ await expect(dialog.getByText(/We couldn’t load your billing options/)).toHaveCount(0);
+ await refresh.click();
+ await expect(page.locator("html")).not.toHaveAttribute("data-billing-action");
+ await page.evaluate(() => {
+ document.documentElement.dataset.recovered = "true";
+ });
+ await refresh.click();
+ await expect(page.getByTestId("verified-checkout")).toHaveText("true");
+ await expect(dialog.getByRole("button", { name: "Get started", exact: true })).toBeEnabled();
+ await expect(dialog.getByText("Cloud checkout is not available yet.")).toHaveCount(0);
+ await page.screenshot({
+ path: test.info().outputPath("pricing-recovered.png"),
+ animations: "disabled",
+ });
+});
+
+test("persistent authorization failures offer sign-in without error banners", async ({ page }) => {
+ const dialog = await prepare(page, "unauthorized");
+ await expect(
+ dialog.getByRole("button", { name: "Sign in to continue", exact: true }),
+ ).toBeEnabled();
+ await expect(dialog.locator(".pricing-error")).toHaveCount(0);
+ await expect(dialog.getByText("Cloud checkout is not available yet.")).toHaveCount(0);
+ await dialog.getByRole("button", { name: "Sign in to continue", exact: true }).click();
+ await expect(dialog).not.toBeVisible();
+});
+
+for (const mode of ["past_due", "unpaid", "held"] as const) {
+ test(`${mode} subscriptions retain portal access after paid entitlement is lost`, async ({
+ page,
+ }) => {
+ const dialog = await prepare(page, mode);
+ await expect(dialog.locator(".pricing-price-offer")).toHaveCount(0);
+ await expect(dialog.locator(".pricing-heading-offer")).toHaveCount(0);
+ await dialog.getByRole("button", { name: "Manage subscription", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-billing-action", "portal");
+ await expect(dialog.getByText(/Your subscription needs attention/)).toBeVisible();
+ await expect(dialog.getByRole("button", { name: "Get started", exact: true })).toHaveCount(
+ 0,
+ );
+ });
+}
+
+test("canceled customers can resubscribe and manage their previous billing separately", async ({
+ page,
+}) => {
+ const dialog = await prepare(page, "canceled");
+ await expect(dialog.getByRole("button", { name: "Get started", exact: true })).toBeEnabled();
+ await dialog.getByRole("button", { name: "Manage previous subscription", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-billing-action", "portal");
+ await dialog.getByRole("button", { name: "Get started", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-billing-action", "checkout");
+});
+
+test("checkout failures show safe feedback and keep retry available", async ({ page }) => {
+ const dialog = await prepare(page, "action_failure");
+ await dialog.getByRole("button", { name: "Get started", exact: true }).click();
+ await expect(dialog.getByRole("alert")).toContainText("We couldn’t connect to secure billing");
+ await expect(dialog.getByText("provider diagnostic must not appear")).toHaveCount(0);
+ await expect(dialog.getByRole("button", { name: "Get started", exact: true })).toBeEnabled();
+ await expect(dialog.locator(".pricing-renewal")).toHaveCount(0);
+ await expect(dialog.getByRole("navigation", { name: "Legal and support" })).toHaveCount(0);
+ await expect(dialog.locator(".pricing-legal-consent")).toHaveCount(0);
+});
+
+test("portal failures keep subscription management available with safe retry feedback", async ({
+ page,
+}) => {
+ const dialog = await prepare(page, "portal_failure");
+ await dialog.getByRole("button", { name: "Manage subscription", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-billing-action", "portal");
+ await expect(dialog.getByRole("alert")).toContainText("We couldn’t connect to secure billing");
+ await expect(dialog.getByText("provider diagnostic must not appear")).toHaveCount(0);
+ await expect(
+ dialog.getByRole("button", { name: "Manage subscription", exact: true }),
+ ).toBeEnabled();
+});
+
+for (const mode of ["catalog_failure", "price_mismatch"] as const) {
+ test(`${mode} blocks checkout until verified pricing recovers`, async ({ page }) => {
+ const dialog = await prepare(page, mode);
+ const retry = dialog.getByRole("button", { name: "Get started", exact: true });
+ await expect(retry).toBeEnabled();
+ await retry.click();
+ await expect(page.locator("html")).not.toHaveAttribute("data-billing-action");
+ await expect(dialog.getByText("catalog provider diagnostic", { exact: true })).toHaveCount(
+ 0,
+ );
+ await expect(page.locator("html")).not.toHaveAttribute("data-billing-action");
+ await page.evaluate(() => {
+ document.documentElement.dataset.recovered = "true";
+ });
+ await retry.click();
+ await expect(dialog.locator(".pricing-price")).toContainText("$9");
+ await expect(retry).toBeEnabled();
+ await retry.click();
+ await expect(page.locator("html")).toHaveAttribute("data-billing-action", "checkout");
+ });
+}
+
+test("a slow catalog shows disabled loading instead of an unavailable or checkout action", async ({
+ page,
+}) => {
+ const dialog = await prepare(page, "slow_catalog");
+ await expect(page.getByTestId("usage-loading")).toHaveText("false");
+ await expect(dialog.getByRole("button", { name: "Get started", exact: true })).toBeDisabled();
+ await expect(dialog.getByText(/We couldn’t load your billing options/)).toHaveCount(0);
+ await page.evaluate(() => {
+ document.documentElement.dataset.recovered = "true";
+ document.dispatchEvent(new Event("release-pricing-catalog"));
+ });
+ await expect(dialog.getByRole("button", { name: "Get started", exact: true })).toBeEnabled();
+});
+
+test("returning from checkout checks payment without creating another checkout", async ({
+ page,
+}) => {
+ const dialog = await prepare(page, "recover");
+ await dialog.getByRole("button", { name: "Get started", exact: true }).click();
+ const check = dialog.getByRole("button", { name: "Check payment status", exact: true });
+ await expect(check).toBeEnabled();
+ await expect(dialog.getByText(/Complete checkout in your browser/)).toBeVisible();
+ await check.click();
+ await expect(check).toBeEnabled();
+ await expect(page.locator("html")).toHaveAttribute("data-billing-action-count", "1");
+ await dialog.getByRole("button", { name: "Return to checkout", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-billing-action-count", "2");
+});
+
+test("an existing paid customer cannot start a second subscription when the portal is unavailable", async ({
+ page,
+}) => {
+ const dialog = await prepare(page, "paid_no_portal");
+ await expect(
+ dialog.getByRole("button", { name: "Manage subscription", exact: true }),
+ ).toBeEnabled();
+ await expect(dialog.getByText(/Your Cloud plan is active/)).toBeVisible();
+ await expect(dialog.locator(".pricing-price-offer")).toHaveCount(0);
+ await expect(dialog.locator(".pricing-heading-offer")).toHaveCount(0);
+ await expect(dialog.getByRole("button", { name: "Get started", exact: true })).toHaveCount(0);
+ await dialog.getByRole("button", { name: "Manage subscription", exact: true }).click();
+ await expect(page.locator("html")).not.toHaveAttribute("data-billing-action");
+});
+
+test("a subscription needing attention after checkout retains management access", async ({
+ page,
+}) => {
+ const dialog = await prepare(page, "checkout_needs_attention");
+ await dialog.getByRole("button", { name: "Get started", exact: true }).click();
+ const manage = dialog.getByRole("button", { name: "Manage subscription", exact: true });
+ await expect(manage).toBeEnabled();
+ await expect(
+ dialog.getByRole("button", { name: "Check payment status", exact: true }),
+ ).toHaveCount(0);
+ await manage.click();
+ await expect(page.locator("html")).toHaveAttribute("data-billing-action", "portal");
+});
diff --git a/tests/ui/billing-session-retry.spec.ts b/tests/ui/billing-session-retry.spec.ts
new file mode 100644
index 00000000..f5d8e98a
--- /dev/null
+++ b/tests/ui/billing-session-retry.spec.ts
@@ -0,0 +1,180 @@
+import { expect, type Page, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+async function prepare(page: Page, mode: "transient" | "rejected") {
+ await installDesktopBridge(page);
+ // Exercise the real provider around a controlled recovery boundary. Tokens never
+ // reach a service: the desktop bridge below implements the authenticated reads.
+ await page.route("**/src/lib/auth/recoverBillingSession.ts*", (route) =>
+ route.fulfill({
+ contentType: "text/javascript",
+ body: `export async function recoverBillingSession(_token, owner) {
+ const calls = Number(document.documentElement.dataset.recoveryCalls || 0) + 1;
+ document.documentElement.dataset.recoveryCalls = String(calls);
+ document.documentElement.dataset.recoveryOwners = [document.documentElement.dataset.recoveryOwners, owner].filter(Boolean).join(",");
+ if (${JSON.stringify(mode)} === "rejected" || owner === "second-account") return undefined;
+ if (calls === 1) throw new Error("Temporary connection failure");
+ return new Promise((resolve, reject) => window.addEventListener("release-session-recovery", event => {
+ if (event.detail === "fail") reject(new Error("Old account connection failure"));
+ else resolve("recovered-session-fixture");
+ }, { once: true }));
+ }`,
+ }),
+ );
+ await installDesktopBridgeOverrides(page, () => {
+ const prices = [{ interval: "monthly", currency: "usd", unitAmount: 900 }];
+ window.electronAPI.cloudBilling = async ({ action, token }) => {
+ if (action === "catalog")
+ return {
+ success: true,
+ prices,
+ limits: {
+ freeRecordingLimit: 5,
+ freeMaxRecordingBytes: 1e9,
+ plusMaxRecordingBytes: 1e10,
+ storageLimitBytes: null,
+ },
+ };
+ if (action !== "status") throw new Error("No billing mutation expected");
+ if (token !== "recovered-session-fixture")
+ return { success: false, httpStatus: 401, error: "Sign in again" };
+ return {
+ success: true,
+ billing: {
+ plan: "pro",
+ subscriptionStatus: "active",
+ currentPeriodEnd: Math.floor(Date.now() / 1000) + 86400,
+ cancelAtPeriodEnd: false,
+ checkoutAvailable: false,
+ checkoutIntervals: [],
+ portalAvailable: true,
+ prices,
+ },
+ usage: {
+ recordings: 1,
+ recordingLimit: null,
+ maxRecordingBytes: 1e10,
+ storageBytes: 100,
+ storageLimitBytes: null,
+ },
+ };
+ };
+ });
+ await page.goto("/tests/ui/billing-usage.html");
+ await expect(page.locator("html")).toHaveAttribute("data-recovery-calls", "1");
+ await expect(page.getByTestId("usage-loading")).toHaveText("false");
+}
+
+test("explicit retry recovers after a transient refresh failure and shares concurrent reads", async ({
+ page,
+}) => {
+ await prepare(page, "transient");
+ await expect(page.getByTestId("verified-plan")).toHaveText("unverified");
+ await page.getByRole("button", { name: "Refresh usage", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-recovery-calls", "2");
+ await page.evaluate(() => {
+ for (let i = 0; i < 4; i++) {
+ window.dispatchEvent(new Event("focus"));
+ document.dispatchEvent(new Event("visibilitychange"));
+ }
+ });
+ await expect(page.getByTestId("usage-loading")).toHaveText("true");
+ await expect(page.locator("html")).toHaveAttribute("data-recovery-calls", "2");
+ await page.evaluate(() => window.dispatchEvent(new Event("release-session-recovery")));
+ await expect(page.getByTestId("verified-plan")).toHaveText("pro");
+ await expect(page.getByTestId("usage-loading")).toHaveText("false");
+});
+
+test("definitively rejected recovery is not repeated by manual refresh", async ({ page }) => {
+ await prepare(page, "rejected");
+ await page.getByRole("button", { name: "Refresh usage", exact: true }).click();
+ await expect(page.getByTestId("usage-loading")).toHaveText("false");
+ await expect(page.locator("html")).toHaveAttribute("data-recovery-calls", "1");
+ await page.getByRole("button", { name: "Open test pricing", exact: true }).click();
+ await expect(
+ page.getByRole("button", { name: "Sign in to continue", exact: true }),
+ ).toBeVisible();
+});
+
+test("a late old-account failure cannot reset the new account's recovery rejection", async ({
+ page,
+}) => {
+ await prepare(page, "transient");
+ await page.getByRole("button", { name: "Refresh usage", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-recovery-calls", "2");
+ await page.route("https://auth.recordly.test/auth/v1/user", (route) =>
+ route.fulfill({
+ json: {
+ id: "second-account",
+ email: "second@example.test",
+ aud: "authenticated",
+ role: "authenticated",
+ app_metadata: {},
+ user_metadata: {},
+ created_at: "2026-10-25T00:00:00Z",
+ },
+ }),
+ );
+ await page.getByRole("button", { name: "Switch account", exact: true }).click();
+ await expect(page.getByTestId("usage-owner")).toHaveText("second-account");
+ await expect(page.locator("html")).toHaveAttribute("data-recovery-calls", "3");
+ await expect(page.getByTestId("usage-loading")).toHaveText("false");
+ await page.evaluate(() =>
+ window.dispatchEvent(new CustomEvent("release-session-recovery", { detail: "fail" })),
+ );
+ await page.getByRole("button", { name: "Refresh usage", exact: true }).click();
+ await expect(page.getByTestId("usage-loading")).toHaveText("false");
+ await expect(page.locator("html")).toHaveAttribute("data-recovery-calls", "3");
+ await expect(page.getByTestId("verified-plan")).toHaveText("unverified");
+});
+
+test("an obsolete account's delayed 401 cannot consume the current account's recovery attempt", async ({
+ page,
+}) => {
+ await prepare(page, "rejected");
+ await page.evaluate(() => {
+ window.electronAPI.cloudBilling = async ({ action, token }) => {
+ if (action !== "status") throw new Error("Only status reads expected");
+ const owner = JSON.parse(atob(token!.split(".")[1])).sub;
+ const second = owner === "second-account";
+ document.documentElement.dataset.pendingStatusOwner = owner;
+ await new Promise((resolve) =>
+ window.addEventListener(
+ second ? "release-current-status" : "release-obsolete-status",
+ resolve,
+ { once: true },
+ ),
+ );
+ return { success: false, httpStatus: 401, error: "Sign in again" };
+ };
+ });
+ await page.getByRole("button", { name: "Refresh usage", exact: true }).click();
+ await expect(page.getByTestId("usage-loading")).toHaveText("true");
+ await page.route("https://auth.recordly.test/auth/v1/user", (route) =>
+ route.fulfill({
+ json: {
+ id: "second-account",
+ email: "second@example.test",
+ aud: "authenticated",
+ role: "authenticated",
+ app_metadata: {},
+ user_metadata: {},
+ created_at: "2026-10-25T00:00:00Z",
+ },
+ }),
+ );
+ await page.getByRole("button", { name: "Switch account", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-pending-status-owner",
+ "second-account",
+ );
+ await page.evaluate(() => window.dispatchEvent(new Event("release-obsolete-status")));
+ // Separate browser tasks allow the obsolete response's promise chain to settle
+ // before the current account's rejected response is delivered.
+ await page.evaluate(() => window.dispatchEvent(new Event("release-current-status")));
+ await expect(page.getByTestId("usage-loading")).toHaveText("false");
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-recovery-owners",
+ "00000000-0000-4000-8000-000000000001,second-account",
+ );
+});
diff --git a/tests/ui/billing-usage.html b/tests/ui/billing-usage.html
new file mode 100644
index 00000000..f0643109
--- /dev/null
+++ b/tests/ui/billing-usage.html
@@ -0,0 +1 @@
+Billing usage test
diff --git a/tests/ui/billing-usage.spec.ts b/tests/ui/billing-usage.spec.ts
new file mode 100644
index 00000000..a76f43f6
--- /dev/null
+++ b/tests/ui/billing-usage.spec.ts
@@ -0,0 +1,1034 @@
+import { expect, type Page, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+declare global {
+ interface Window {
+ billingUsageTest: {
+ state: {
+ statusCalls: number;
+ catalogCalls: number;
+ holdStatus: boolean;
+ pendingStatus: ((value: unknown) => void)[];
+ };
+ releaseStatus: (recordings: number) => void;
+ releaseCatalog: () => void;
+ failStatus: () => void;
+ releaseLegacy: () => void;
+ };
+ }
+}
+
+async function openUsage(
+ page: Page,
+ plan: "free" | "paid" | "complimentary" = "free",
+ monthlyAmount = 900,
+ paidPeriodDays = 1,
+ cancelAtPeriodEnd = false,
+) {
+ await installDesktopBridge(page);
+ await page.addInitScript(
+ ({ plan, monthlyAmount, paidPeriodDays, cancelAtPeriodEnd }) => {
+ const prices = [
+ {
+ interval: "monthly",
+ unitAmount: monthlyAmount,
+ currency: "usd",
+ ...(monthlyAmount < 1500
+ ? { regularUnitAmount: 1500, promotion: "early_bird" }
+ : {}),
+ },
+ { interval: "yearly", unitAmount: 10800, currency: "usd" },
+ ];
+ const status = (recordings: number) => ({
+ success: true,
+ billing: {
+ plan: plan === "free" ? "free" : "pro",
+ subscriptionStatus: plan === "free" ? null : "active",
+ currentPeriodEnd:
+ plan === "paid"
+ ? Math.floor(Date.now() / 1000) + 86400 * paidPeriodDays
+ : null,
+ cancelAtPeriodEnd,
+ checkoutAvailable: plan === "free",
+ checkoutIntervals: plan === "free" ? ["monthly", "yearly"] : [],
+ portalAvailable: plan === "paid",
+ prices,
+ },
+ usage: {
+ recordings,
+ recordingLimit: plan === "free" ? 5 : null,
+ maxRecordingBytes: plan === "free" ? 1e9 : 1e10,
+ storageBytes: recordings * 100,
+ storageLimitBytes: plan === "free" ? 1e11 : null,
+ },
+ });
+ const catalog = {
+ success: true,
+ prices,
+ limits: {
+ freeRecordingLimit: 5,
+ freeMaxRecordingBytes: 1e9,
+ plusMaxRecordingBytes: 1e10,
+ storageLimitBytes: 1e11,
+ },
+ };
+ const state = {
+ statusCalls: 0,
+ catalogCalls: 0,
+ holdStatus: true,
+ holdCatalog: true,
+ pendingStatus: [] as ((value: unknown) => void)[],
+ pendingCatalog: [] as (() => void)[],
+ };
+ Object.assign(window, {
+ billingUsageTest: {
+ state,
+ releaseStatus: (recordings: number) => {
+ state.holdStatus = false;
+ for (const resolve of state.pendingStatus.splice(0))
+ resolve(status(recordings));
+ },
+ releaseLegacy: () => {
+ state.holdStatus = false;
+ for (const resolve of state.pendingStatus.splice(0))
+ resolve({ success: false, httpStatus: 404, error: "Not found" });
+ },
+ failStatus: () => {
+ state.holdStatus = false;
+ for (const resolve of state.pendingStatus.splice(0))
+ resolve({ success: false, error: "Network unavailable" });
+ },
+ releaseCatalog: () => {
+ state.holdCatalog = false;
+ for (const resolve of state.pendingCatalog.splice(0)) resolve();
+ },
+ },
+ });
+ window.electronAPI.cloudBilling = async ({ action }) => {
+ if (action === "checkout" || action === "portal") {
+ document.documentElement.dataset.billingAction = action;
+ return { success: true };
+ }
+ if (action === "catalog") {
+ state.catalogCalls++;
+ if (state.holdCatalog)
+ await new Promise((resolve) => state.pendingCatalog.push(resolve));
+ return catalog;
+ }
+ state.statusCalls++;
+ return state.holdStatus
+ ? ((await new Promise((resolve) =>
+ state.pendingStatus.push(resolve),
+ )) as ReturnType)
+ : status(3);
+ };
+ window.electronAPI.cloudBillingCancel = async () => ({ success: true });
+ window.electronAPI.cloudShareManage = async () => ({
+ success: true,
+ videos: [
+ { code: "legacy-one", size: 100 },
+ { code: "legacy-two", size: 200 },
+ ],
+ });
+ },
+ { plan, monthlyAmount, paidPeriodDays, cancelAtPeriodEnd },
+ );
+ // Keep regression coverage of the original design retained as a fallback.
+ await page.goto("/tests/ui/billing-usage.html?upgradeModal=classic");
+ await expect.poll(() => page.evaluate(() => window.billingUsageTest.state.statusCalls)).toBe(1);
+}
+
+test("export offers a commercial-use tip and Cloud upgrade without the old disclaimer", async ({
+ page,
+}) => {
+ const notice = "Commercial use requires an active Recordly Cloud subscription.";
+ await openUsage(page);
+ await page.evaluate(() => {
+ window.billingUsageTest.releaseStatus(2);
+ window.billingUsageTest.releaseCatalog();
+ });
+ await page.getByRole("button", { name: "Open test pricing", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "Recordly Cloud" });
+ await expect(dialog).toBeVisible();
+ await expect(dialog.getByText(notice, { exact: true })).toHaveCount(0);
+ await expect(dialog.getByText("Commercial use included", { exact: true })).toBeVisible();
+ await page.goto("/tests/ui/export-resolution-presets.html?free");
+ await expect(page.getByText(notice, { exact: true })).toHaveCount(0);
+ await expect(
+ page.getByRole("checkbox", { name: "I’ll use this for personal use.", exact: true }),
+ ).toHaveCount(0);
+ await expect(page.getByText("Upgrade to Cloud for commercial use", { exact: true })).toBeVisible();
+ await page.getByRole("button", { name: "Export Video", exact: true }).click();
+ await expect(page.getByTestId("export-result")).toHaveText("1920 × 1080");
+ await page.goto("/tests/ui/export-resolution-presets.html?free&captions");
+ await page.getByRole("button", { name: "Upgrade to Cloud", exact: true }).click();
+ await expect(page.getByRole("dialog", { name: "Recordly Cloud", exact: true })).toBeVisible();
+});
+
+for (const viewport of [
+ { width: 1440, height: 1000 },
+ { width: 390, height: 844 },
+]) {
+ test(`upgrade benefits are browsable at ${viewport.width}px`, async ({ page }) => {
+ await page.setViewportSize(viewport);
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await openUsage(page);
+ await page.evaluate(() => {
+ window.billingUsageTest.releaseStatus(2);
+ window.billingUsageTest.releaseCatalog();
+ });
+ await page.getByRole("button", { name: "Open test pricing", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "Recordly Cloud" });
+ await expect(dialog.locator(".pricing-heading-offer")).toContainText("40% OFF");
+ await expect(dialog.locator(".pricing-heading-offer")).toHaveCSS("transform", "none");
+ await expect(
+ dialog.getByRole("heading", { name: "Commercial use included" }),
+ ).toBeVisible();
+ await expect(dialog.locator(".upgrade-modal-head .plus-badge")).toHaveCount(0);
+ await expect(
+ dialog.getByText("Commercial Use, Shareable Links, Cloud Storage and More."),
+ ).toBeVisible();
+ const benefits = dialog.getByRole("region", { name: "Cloud benefits", exact: true });
+ await expect(benefits.getByRole("heading")).toHaveText([
+ "Commercial use included",
+ "Share without limits",
+ "Keep your links alive",
+ "Support solo development",
+ ]);
+ await expect(dialog.getByRole("radiogroup", { name: "Billing interval" })).toHaveCount(0);
+ await expect(dialog.locator(".pricing-price")).toHaveText("$9 USD / month");
+ await expect(dialog.locator(".pricing-period")).toHaveCount(0);
+ await expect(dialog.getByText("More features to come...", { exact: true })).toBeVisible();
+ const features = dialog.getByRole("region", { name: "Cloud benefits and pricing" });
+ await expect(
+ features.getByText("Commercial use requires an active Recordly Cloud subscription.", {
+ exact: true,
+ }),
+ ).toHaveCount(0);
+ await expect(dialog.getByText(/4K/)).toHaveCount(0);
+ await expect(features.locator(".plus-badge")).toHaveText("CLOUD");
+ await expect(dialog.locator(".pricing-regular-price del")).toHaveText("$15");
+ await expect(
+ dialog.getByText("Save 40% with early bird pricing", { exact: true }),
+ ).toHaveCount(0);
+ await expect(features.locator(".pricing-price-banner")).toHaveText("EARLY BIRD 40% OFF");
+ await expect(features.locator(".pricing-early-bird-label")).toHaveCount(0);
+ await expect(dialog.locator(".pricing-heading-offer")).toHaveText("EARLY BIRD 40% OFF");
+ await expect(dialog.locator(".pricing-heading-offer")).toHaveCSS("font-family", /DM Sans/);
+ expect(
+ await page.evaluate(async () => {
+ await document.fonts.ready;
+ return Array.from(document.fonts).some(
+ (font) => font.family === "DM Sans" && font.status === "loaded",
+ );
+ }),
+ ).toBe(true);
+ expect(
+ await features.evaluate((section) => {
+ const badge = section.querySelector(".plus-plan-badges")!;
+ const price = section.querySelector(".plus-plan-pricing")!;
+ const action = section.querySelector(".plus-plan-action")!;
+ const list = section.querySelector(".plus-feature-group")!;
+ const feature = section.querySelector("li")!;
+ const icon = feature.querySelector("svg")!;
+ const currentPrice = section.querySelector(".pricing-price")!;
+ return (
+ badge.nextElementSibling === price &&
+ price.nextElementSibling === action &&
+ action.nextElementSibling === list &&
+ getComputedStyle(currentPrice).color === getComputedStyle(feature).color &&
+ getComputedStyle(icon).color === getComputedStyle(feature).color
+ );
+ }),
+ ).toBe(true);
+ await expect(
+ features.getByRole("list", { name: "Your Cloud benefits" }).locator("li"),
+ ).toHaveCount(8);
+ await expect(features.getByRole("list")).toHaveCount(1);
+ await expect(dialog.getByText(/Renews automatically until cancelled/)).toHaveCount(0);
+ await expect(features.getByRole("heading")).toHaveCount(0);
+ await expect(benefits.getByRole("heading", { name: "Share without limits" })).toHaveCount(
+ 1,
+ );
+ await expect(
+ benefits.getByRole("heading", { name: "Support solo development" }),
+ ).toHaveCount(1);
+ expect(
+ await benefits
+ .locator("article")
+ .first()
+ .evaluate((element) => element.getBoundingClientRect().width),
+ ).toBe(240);
+ await expect(
+ dialog.getByText("Unlimited cloud storage and bandwidth", { exact: true }),
+ ).toHaveCount(1);
+ await expect(
+ dialog.getByText("Upload recordings up to 10 GB each", { exact: true }),
+ ).toHaveCount(1);
+ await page.screenshot({ path: test.info().outputPath("upgrade-features.png") });
+ await dialog.getByRole("button", { name: "Next benefits" }).click();
+ await expect
+ .poll(() => benefits.evaluate((element) => element.scrollLeft))
+ .toBeGreaterThan(0);
+ await expect(
+ dialog.getByRole("button", { name: "Get started", exact: true }),
+ ).toBeEnabled();
+ await dialog
+ .getByRole("button", { name: "Get started", exact: true })
+ .scrollIntoViewIfNeeded();
+ await features.scrollIntoViewIfNeeded();
+ await page.screenshot({ path: test.info().outputPath("upgrade-plan-features.png") });
+ });
+
+ test(`bento community posts are browsable at ${viewport.width}px`, async ({ page }) => {
+ await page.setViewportSize(viewport);
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await openUsage(page);
+ await page.evaluate(() => {
+ window.billingUsageTest.releaseStatus(2);
+ window.billingUsageTest.releaseCatalog();
+ });
+ await page.getByRole("button", { name: "Open test pricing", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "Recordly Cloud" });
+ const posts = dialog.getByRole("region", { name: "Community testimonials" });
+ await expect(dialog.locator(".pricing-legal-consent")).toHaveCount(0);
+ await expect(dialog.getByText(/By continuing, you agree/)).toHaveCount(0);
+ await posts.scrollIntoViewIfNeeded();
+ await expect(posts.getByText("Excerpt", { exact: false })).toHaveCount(0);
+ await expect(posts.locator("figure").filter({ visible: true })).toHaveCount(9);
+ const redditPosts = posts.locator(
+ '.testimonial-group:not(.testimonial-duplicates) figure[data-platform="Reddit"]',
+ );
+ await expect(redditPosts).toHaveCount(6);
+ for (const card of await redditPosts.all()) {
+ await expect(card.locator(".testimonial-avatar img")).toHaveAttribute(
+ "src",
+ /^https:\/\//,
+ );
+ await expect(card.locator(".testimonial-author a")).toHaveCount(0);
+ }
+ const cardSizes = await posts
+ .locator(".testimonial-group:not(.testimonial-duplicates) figure")
+ .evaluateAll((cards) =>
+ cards.map((card) => ({
+ height: card.getBoundingClientRect().height,
+ width: card.getBoundingClientRect().width,
+ fits: card.scrollHeight <= card.clientHeight,
+ })),
+ );
+ expect(new Set(cardSizes.map((card) => card.height)).size).toBeGreaterThan(3);
+ expect(
+ Math.max(...cardSizes.map((card) => card.width)) -
+ Math.min(...cardSizes.map((card) => card.width)),
+ ).toBeLessThan(1);
+ await expect(dialog.locator(".upgrade-testimonials")).toHaveCSS("border-top-width", "0px");
+ expect(cardSizes.every((card) => card.fits)).toBe(true);
+ for (const author of ["d3mueller", "MaterialWooden9544", "Ihtesham Ali"]) {
+ await expect(posts.locator("figure").filter({ hasText: author })).toHaveCount(0);
+ }
+ await expect(posts.locator(".testimonial-column")).toHaveCount(3);
+ const xPosts = posts.locator(
+ '.testimonial-group:not(.testimonial-duplicates) figure[data-platform="X"]',
+ );
+ await expect(xPosts).toHaveCount(3);
+ const likeCounts = await xPosts
+ .locator(".testimonial-post-engagement > span:first-child")
+ .evaluateAll((elements) =>
+ elements.map((element) =>
+ Number(element.getAttribute("aria-label")?.split(" ")[0].replace(/,/g, "")),
+ ),
+ );
+ expect(likeCounts.every((count) => count > 500)).toBe(true);
+ await expect(xPosts.getByLabel("4,695 likes", { exact: true })).toHaveText("4,695 likes");
+ const popularPost = posts
+ .locator(".testimonial-group:not(.testimonial-duplicates) figure")
+ .filter({ hasText: "Peter Agboola" })
+ .first();
+ await expect(popularPost.getByLabel("4,695 likes", { exact: true })).toHaveText(
+ "4,695 likes",
+ );
+ expect(
+ await popularPost.evaluate((card) => {
+ const engagement = card
+ .querySelector(".testimonial-post-engagement")!
+ .getBoundingClientRect();
+ const quote = card.querySelector("blockquote")!.getBoundingClientRect();
+ const bounds = card.getBoundingClientRect();
+ return engagement.top >= quote.bottom && engagement.bottom <= bounds.bottom;
+ }),
+ ).toBe(true);
+ await expect(
+ posts
+ .locator(".testimonial-column")
+ .first()
+ .locator(".testimonial-group:not(.testimonial-duplicates) figure")
+ .first(),
+ ).toContainText("Peter Agboola");
+ await expect(posts.locator("a")).toHaveCount(0);
+ await expect(popularPost.locator("time")).toHaveAttribute(
+ "datetime",
+ "2026-09-06T21:05:38.000Z",
+ );
+ await expect(dialog.getByRole("button", { name: /testimonial scrolling/ })).toHaveCount(0);
+ await page.screenshot({ path: test.info().outputPath("upgrade-testimonials.png") });
+ await posts.screenshot({ path: test.info().outputPath("testimonial-bento.png") });
+ await page.evaluate(() => {
+ document.documentElement.classList.add("dark");
+ document.documentElement.dataset.theme = "dark";
+ });
+ await page.screenshot({ path: test.info().outputPath("upgrade-testimonials-dark.png") });
+ await page.evaluate(() => {
+ document.documentElement.classList.remove("dark");
+ document.documentElement.dataset.theme = "light";
+ });
+ const column = posts.getByRole("region", { name: "Testimonial column 1", exact: true });
+ await column.focus();
+ await page.keyboard.press("ArrowDown");
+ await expect(column).toHaveCSS("overflow-y", "clip");
+ await expect(posts).toHaveCSS("overflow-x", "clip");
+ expect(
+ await column.evaluate((element) => {
+ element.scrollTop = 100;
+ return element.scrollTop;
+ }),
+ ).toBe(0);
+ expect(
+ await posts.evaluate((element) => {
+ element.scrollLeft = 100;
+ return element.scrollLeft;
+ }),
+ ).toBe(0);
+ await expect(dialog.getByRole("heading", { name: "Loved by the community" })).toHaveCount(
+ 0,
+ );
+ await expect(dialog.getByRole("heading", { name: "Your Cloud benefits" })).toHaveCount(0);
+ const size = await dialog.evaluate((element) => ({
+ width: element.getBoundingClientRect().width,
+ viewport: window.innerWidth,
+ }));
+ expect(size.width).toBeLessThanOrEqual(size.viewport);
+
+ if (viewport.width === 1440) {
+ await page.emulateMedia({ reducedMotion: "no-preference" });
+ await dialog.locator(".pricing-close").focus();
+ await page.mouse.move(0, 0);
+ const track = column.locator(".testimonial-track");
+ const before = await track.evaluate((element) => getComputedStyle(element).transform);
+ await expect
+ .poll(() => track.evaluate((element) => getComputedStyle(element).transform))
+ .not.toBe(before);
+ // Pointer hover leaves the marquee moving.
+ await column.hover({ force: true });
+ await expect
+ .poll(() =>
+ track.evaluate((element) => getComputedStyle(element).animationPlayState),
+ )
+ .toBe("running");
+ await page.mouse.move(0, 0);
+ await expect
+ .poll(() =>
+ track.evaluate((element) => getComputedStyle(element).animationPlayState),
+ )
+ .toBe("running");
+ }
+ });
+}
+
+test("bento testimonials keep moving on hover and pause for keyboard focus", async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: "no-preference" });
+ await openUsage(page);
+ await page.evaluate(() => {
+ window.billingUsageTest.releaseStatus(2);
+ window.billingUsageTest.releaseCatalog();
+ });
+ await page.getByRole("button", { name: "Open test pricing", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "Recordly Cloud" });
+ const posts = dialog.getByRole("region", { name: "Community testimonials" });
+ await dialog.locator(".pricing-close").focus();
+ await page.mouse.move(0, 0);
+ for (const [index, column] of (await posts.locator(".testimonial-column").all()).entries()) {
+ const track = column.locator(".testimonial-track");
+ await column.scrollIntoViewIfNeeded();
+ const motion = await track.evaluate((element) => {
+ const animation = element.getAnimations()[0];
+ const duration = Number(animation.effect!.getTiming().duration);
+ animation.currentTime = duration * 0.25;
+ const before = new DOMMatrixReadOnly(getComputedStyle(element).transform);
+ animation.currentTime = duration * 0.3;
+ const after = new DOMMatrixReadOnly(getComputedStyle(element).transform);
+ return { x: after.m41 - before.m41, y: after.m42 - before.m42, duration };
+ });
+ expect(motion.x).toBe(0);
+ expect(motion.duration).toBeLessThanOrEqual(24000);
+ if (index === 1) expect(motion.y).toBeLessThan(0);
+ else expect(motion.y).toBeGreaterThan(0);
+ // Hover must also leave the loop moving while its copies are visible.
+ await track.evaluate((element) => {
+ const animation = element.getAnimations()[0];
+ const duration = Number(animation.effect!.getTiming().duration);
+ animation.currentTime = duration * 0.95;
+ });
+ await column
+ .locator(".testimonial-duplicates .testimonial-card")
+ .first()
+ .hover({ force: true });
+ await expect(track).toHaveCSS("animation-play-state", "running");
+ const positions = await track.evaluate(async (element) => {
+ const before = getComputedStyle(element).transform;
+ await new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
+ );
+ return [before, getComputedStyle(element).transform];
+ });
+ expect(positions[1]).not.toBe(positions[0]);
+ await page.mouse.move(0, 0);
+ await expect(track).toHaveCSS("animation-play-state", "running");
+ await expect
+ .poll(() => track.evaluate((element) => getComputedStyle(element).transform))
+ .not.toBe(positions[1]);
+ await column.focus();
+ await expect(track).toHaveCSS("animation-play-state", "paused");
+ await dialog.locator(".pricing-close").focus();
+ }
+});
+
+test("regular pricing follows the catalog and hides ended promotions", async ({ page }) => {
+ await openUsage(page, "free", 1500);
+ await page.evaluate(() => {
+ window.billingUsageTest.releaseStatus(2);
+ window.billingUsageTest.releaseCatalog();
+ });
+ await page.getByRole("button", { name: "Open test pricing", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "Recordly Cloud" });
+ await expect(dialog.locator(".pricing-price")).toHaveText("$15 USD / month");
+ await expect(dialog.locator(".pricing-heading-offer")).toHaveCount(0);
+ await expect(dialog.locator(".pricing-regular-price")).toHaveCount(0);
+ await expect(dialog.locator(".pricing-early-bird-label")).toHaveCount(0);
+ await expect(dialog.locator(".pricing-price")).toContainText("$15");
+ await expect(dialog.locator(".pricing-renewal")).toHaveCount(0);
+ await expect(dialog.getByRole("button", { name: "Get started", exact: true })).toBeEnabled();
+});
+
+for (const plan of ["paid", "complimentary"] as const) {
+ test(`${plan} Cloud shows only applicable subscription controls`, async ({ page }) => {
+ await openUsage(page, plan);
+ await page.evaluate(() => {
+ window.billingUsageTest.releaseStatus(2);
+ window.billingUsageTest.releaseCatalog();
+ });
+ await expect(page.getByTestId("verified-plan")).toHaveText("pro");
+ await expect(page.getByRole("button", { name: "Manage plan", exact: true })).toHaveCount(0);
+ await expect(
+ page.getByRole("button", { name: "Upgrade to Cloud", exact: true }),
+ ).toHaveCount(0);
+ await page.getByRole("button", { name: "Open test pricing", exact: true }).click();
+ await expect(
+ page.getByRole("heading", { name: "Unlock the full power of Recordly" }),
+ ).toBeVisible();
+ await expect(page.locator(".pricing-price-offer")).toHaveCount(0);
+ await expect(page.locator(".pricing-heading-offer")).toHaveCount(0);
+ const portal = page.getByRole("button", { name: "Manage subscription", exact: true });
+ if (plan === "paid") {
+ await expect(portal).toBeEnabled();
+ await expect(page.getByText(/Subscriptions renew automatically/)).toHaveCount(0);
+ await expect(page.getByRole("radiogroup", { name: "Billing interval" })).toHaveCount(0);
+ } else {
+ await expect(portal).toHaveCount(0);
+ await expect(page.getByText(/Subscriptions renew automatically/)).toHaveCount(0);
+ await expect(page.getByRole("radiogroup", { name: "Billing interval" })).toHaveCount(0);
+ }
+ });
+}
+
+test("publishing refreshes the dashboard usage without requiring an app focus change", async ({
+ page,
+}) => {
+ await openUsage(page);
+ await page.evaluate(() => {
+ window.billingUsageTest.releaseStatus(2);
+ window.billingUsageTest.releaseCatalog();
+ window.electronAPI.onCloudShareProgress = () => () => undefined;
+ window.electronAPI.cloudShareCancel = async () => ({ success: true });
+ window.electronAPI.cloudShareUpload = async () => ({
+ success: true,
+ shareUrl: "https://example.test/s/new-recording",
+ });
+ });
+ await expect(page.getByText("2 of 5 used")).toBeVisible();
+ await page.getByRole("button", { name: "Open test publish", exact: true }).click();
+ await page.getByRole("button", { name: "Share", exact: true }).click();
+ await expect(page.getByLabel("Share link", { exact: true })).toHaveValue(
+ "https://example.test/s/new-recording",
+ );
+ await expect(page.getByText("3 of 5 used")).toBeVisible();
+});
+
+test("a library mutation during an in-flight usage read fetches a new snapshot afterward", async ({
+ page,
+}) => {
+ await openUsage(page);
+ await page.evaluate(() => {
+ window.billingUsageTest.releaseStatus(2);
+ window.billingUsageTest.releaseCatalog();
+ });
+ await expect(page.getByText("2 of 5 used")).toBeVisible();
+ await page.evaluate(() => {
+ window.billingUsageTest.state.holdStatus = true;
+ });
+ await page.getByRole("button", { name: "Refresh usage", exact: true }).click();
+ await expect.poll(() => page.evaluate(() => window.billingUsageTest.state.statusCalls)).toBe(2);
+ await page.getByRole("button", { name: "Cloud library changed", exact: true }).click();
+ await page.evaluate(() => window.billingUsageTest.releaseStatus(2));
+ await expect(page.getByText("3 of 5 used")).toBeVisible();
+ expect(await page.evaluate(() => window.billingUsageTest.state.statusCalls)).toBe(3);
+});
+
+test("usage displays before delayed pricing; focus events share pending requests and retain visible usage", async ({
+ page,
+}) => {
+ await openUsage(page);
+ await page.evaluate(() => {
+ for (let i = 0; i < 6; i++) {
+ window.dispatchEvent(new Event("focus"));
+ document.dispatchEvent(new Event("visibilitychange"));
+ }
+ });
+ expect(await page.evaluate(() => window.billingUsageTest.state.statusCalls)).toBe(1);
+ await page.evaluate(() => window.billingUsageTest.releaseStatus(2));
+ await expect(page.getByText("2 of 5 used")).toBeVisible();
+ await expect(page.getByTestId("usage-loading")).toHaveText("false");
+ expect(await page.evaluate(() => window.billingUsageTest.state.catalogCalls)).toBe(1);
+ await page.evaluate(() => {
+ window.billingUsageTest.state.holdStatus = true;
+ });
+ await page.getByRole("button", { name: "Refresh usage", exact: true }).click();
+ await expect(page.getByText("2 of 5 used")).toBeVisible();
+ await page.evaluate(() => {
+ window.dispatchEvent(new Event("focus"));
+ document.dispatchEvent(new Event("visibilitychange"));
+ });
+ expect(await page.evaluate(() => window.billingUsageTest.state.statusCalls)).toBe(2);
+ await page.evaluate(() => window.billingUsageTest.releaseStatus(4));
+ await expect(page.getByText("4 of 5 used")).toBeVisible();
+ await page.evaluate(() => window.billingUsageTest.releaseCatalog());
+});
+
+test("fresh usage and pricing survive repeated focus events without more cloud reads", async ({
+ page,
+}) => {
+ await openUsage(page);
+ await page.evaluate(() => {
+ window.billingUsageTest.releaseStatus(2);
+ window.billingUsageTest.releaseCatalog();
+ });
+ await expect(page.getByTestId("usage-loading")).toHaveText("false");
+ for (let i = 0; i < 3; i++) {
+ await page.evaluate(async () => {
+ window.dispatchEvent(new Event("focus"));
+ document.dispatchEvent(new Event("visibilitychange"));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ await expect(page.getByTestId("usage-loading")).toHaveText("false");
+ }
+ expect(await page.evaluate(() => window.billingUsageTest.state.statusCalls)).toBe(1);
+ expect(await page.evaluate(() => window.billingUsageTest.state.catalogCalls)).toBe(1);
+ await page.getByRole("button", { name: "Refresh usage", exact: true }).click();
+ await expect.poll(() => page.evaluate(() => window.billingUsageTest.state.statusCalls)).toBe(2);
+ await expect
+ .poll(() => page.evaluate(() => window.billingUsageTest.state.catalogCalls))
+ .toBe(2);
+});
+
+test("an account change hides cached usage and rejects the old account's late response", async ({
+ page,
+}) => {
+ await openUsage(page);
+ await page.evaluate(() => window.billingUsageTest.releaseStatus(2));
+ await expect(page.getByText("2 of 5 used")).toBeVisible();
+ await page.evaluate(() => {
+ window.billingUsageTest.state.holdStatus = true;
+ });
+ await page.getByRole("button", { name: "Refresh usage", exact: true }).click();
+ await page.route("https://auth.recordly.test/auth/v1/user", (route) =>
+ route.fulfill({
+ json: {
+ id: "second-account",
+ email: "second@example.test",
+ aud: "authenticated",
+ role: "authenticated",
+ app_metadata: {},
+ user_metadata: {},
+ created_at: "2026-10-16T00:00:00Z",
+ },
+ }),
+ );
+ await page.getByRole("button", { name: "Switch account", exact: true }).click();
+ await expect(page.getByTestId("usage-owner")).toHaveText("second-account");
+ await expect(page.getByText("2 of 5 used")).toHaveCount(0);
+ await expect(page.getByText("Loading usage…")).toBeVisible();
+ await page.evaluate(() => {
+ const state = window.billingUsageTest.state;
+ const old = state.pendingStatus.shift();
+ old?.({ success: true, billing: { plan: "pro" } });
+ });
+ await expect(page.getByText("Loading usage…")).toBeVisible();
+ await page.evaluate(() => window.billingUsageTest.releaseStatus(1));
+ await expect(page.getByText("1 of 5 used")).toBeVisible();
+ await expect(page.getByText("Cloud storage")).toHaveCount(0);
+});
+
+async function usageCacheKey(page: Page) {
+ const endpoint = await page.getByTestId("usage-endpoint").textContent();
+ const user = await page.getByTestId("usage-owner").textContent();
+ return `recordly:cloud-usage:v1:${encodeURIComponent(endpoint ?? "")}:${encodeURIComponent(user ?? "")}`;
+}
+
+test("a second renderer session shows saved usage before internet responds and then saves the correction", async ({
+ page,
+}) => {
+ await openUsage(page);
+ await page.evaluate(() => window.billingUsageTest.releaseStatus(2));
+ await expect(page.getByText("2 of 5 used")).toBeVisible();
+ const key = await usageCacheKey(page);
+ await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), key)).not.toBeNull();
+ await page.reload();
+ await expect(page.getByText("2 of 5 used")).toBeVisible();
+ await expect(page.getByText("Loading usage…")).toHaveCount(0);
+ await expect(page.getByTestId("verified-plan")).toHaveText("unverified");
+ await expect(page.getByTestId("verified-checkout")).toHaveText("false");
+ await page.evaluate(() => window.billingUsageTest.releaseStatus(4));
+ await expect(page.getByText("4 of 5 used")).toBeVisible();
+ await expect(page.getByTestId("verified-plan")).toHaveText("free");
+ await page.reload();
+ await expect(page.getByText("4 of 5 used")).toBeVisible();
+ await expect(page.getByTestId("verified-plan")).toHaveText("unverified");
+});
+
+test("an offline renderer keeps the last saved usage without granting billing access", async ({
+ page,
+}) => {
+ await openUsage(page);
+ await page.evaluate(() => window.billingUsageTest.releaseStatus(2));
+ await expect(page.getByText("2 of 5 used")).toBeVisible();
+ await page.reload();
+ await expect(page.getByText("2 of 5 used")).toBeVisible();
+ await page.evaluate(() => window.billingUsageTest.failStatus());
+ await expect(page.getByTestId("usage-loading")).toHaveText("false");
+ await expect(page.getByText("2 of 5 used")).toBeVisible();
+ await expect(page.getByTestId("verified-plan")).toHaveText("unverified");
+ await expect(page.getByTestId("verified-checkout")).toHaveText("false");
+});
+
+test("editing a saved Cloud preview cannot authorize Cloud and the server replaces it", async ({
+ page,
+}) => {
+ await openUsage(page);
+ const key = await usageCacheKey(page);
+ await page.evaluate(
+ (key) =>
+ localStorage.setItem(
+ key,
+ JSON.stringify({
+ version: 1,
+ updatedAt: Date.now(),
+ plan: "pro",
+ usage: {
+ recordings: 10,
+ recordingLimit: null,
+ maxRecordingBytes: 1e10,
+ storageBytes: 1000,
+ storageLimitBytes: 1e11,
+ },
+ }),
+ ),
+ key,
+ );
+ await page.reload();
+ await expect(page.getByTestId("usage-owner")).not.toHaveText("signed-out");
+ await expect(page.getByTestId("verified-plan")).toHaveText("unverified");
+ await expect(page.getByTestId("verified-checkout")).toHaveText("false");
+ await page.evaluate(() => window.billingUsageTest.releaseStatus(1));
+ await expect(page.getByText("1 of 5 used")).toBeVisible();
+ await expect(page.getByTestId("verified-plan")).toHaveText("free");
+ await expect(page.getByText("Cloud storage")).toHaveCount(0);
+});
+
+test("invalid saved usage is ignored while the account read is pending", async ({ page }) => {
+ await openUsage(page);
+ const key = await usageCacheKey(page);
+ await page.evaluate(
+ (key) =>
+ localStorage.setItem(
+ key,
+ JSON.stringify({
+ version: 1,
+ updatedAt: Date.now(),
+ plan: "free",
+ usage: {
+ recordings: -1,
+ recordingLimit: 5,
+ maxRecordingBytes: 1e9,
+ storageBytes: 0,
+ storageLimitBytes: 1e11,
+ },
+ }),
+ ),
+ key,
+ );
+ await page.reload();
+ await expect(page.getByText("Loading usage…")).toBeVisible();
+ await expect(page.getByText(/of 5 used/)).toHaveCount(0);
+ await expect(page.getByTestId("verified-plan")).toHaveText("unverified");
+ await page.evaluate(() => window.billingUsageTest.releaseStatus(3));
+ await expect(page.getByText("3 of 5 used")).toBeVisible();
+});
+
+test("legacy billing fallback also saves usage for the next renderer session", async ({ page }) => {
+ await openUsage(page);
+ await page.evaluate(() => window.billingUsageTest.releaseLegacy());
+ await expect(page.getByText("2 of 5 used")).toBeVisible();
+ await expect(page.getByTestId("verified-plan")).toHaveText("unverified");
+ const key = await usageCacheKey(page);
+ await expect
+ .poll(() =>
+ page.evaluate((key) => {
+ const raw = localStorage.getItem(key);
+ return raw ? JSON.parse(raw).usage.recordings : null;
+ }, key),
+ )
+ .toBe(2);
+ await page.reload();
+ await expect(page.getByText("2 of 5 used")).toBeVisible();
+ await expect(page.getByText("Loading usage…")).toHaveCount(0);
+ await expect(page.getByTestId("verified-plan")).toHaveText("unverified");
+ await expect(page.getByTestId("verified-checkout")).toHaveText("false");
+});
+
+test("Cloud displays unlimited storage without a capacity bar", async ({ page }) => {
+ await openUsage(page, "paid");
+ await page.evaluate(() => window.billingUsageTest.releaseStatus(8));
+ await expect(page.getByText("Cloud storage", { exact: true })).toBeVisible();
+ await expect(page.getByText(/used · Unlimited storage/)).toBeVisible();
+ await expect(page.getByRole("progressbar", { name: "Cloud storage used" })).toHaveCount(0);
+});
+
+test("changed offer amounts update price, renewal copy and promotion percentage", async ({
+ page,
+}) => {
+ await openUsage(page, "free", 1200);
+ await page.evaluate(() => {
+ window.billingUsageTest.releaseStatus(2);
+ window.billingUsageTest.releaseCatalog();
+ });
+ await page.getByRole("button", { name: "Open test pricing", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "Recordly Cloud" });
+ await expect(dialog.locator(".pricing-price")).toHaveText("$12 USD / month");
+ await expect(dialog.locator(".pricing-heading-offer")).toHaveText("EARLY BIRD 20% OFF");
+ await expect(dialog.locator(".pricing-price-banner")).toHaveText("EARLY BIRD 20% OFF");
+ await expect(dialog.locator(".pricing-renewal")).toHaveCount(0);
+});
+
+test("pending catalog does not advertise a fabricated price or promotion", async ({ page }) => {
+ await openUsage(page);
+ await page.evaluate(() => window.billingUsageTest.releaseStatus(2));
+ await page.getByRole("button", { name: "Open test pricing", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "Recordly Cloud" });
+ await expect(dialog.locator(".pricing-price")).toHaveCount(0);
+ await expect(dialog.locator(".pricing-heading-offer")).toHaveCount(0);
+ await expect(dialog.locator(".pricing-renewal")).toHaveCount(0);
+});
+
+test("checkout return stays Free until confirmed, then enables subscription management", async ({
+ page,
+}) => {
+ await openUsage(page);
+ await page.evaluate(() => {
+ window.billingUsageTest.releaseStatus(2);
+ window.billingUsageTest.releaseCatalog();
+ const read = window.electronAPI.cloudBilling;
+ const state = { actions: [] as string[], paid: false, cancelAtPeriodEnd: false };
+ Object.assign(window, { billingFlowTest: state });
+ window.electronAPI.cloudBilling = async (input) => {
+ if (input.action === "checkout" || input.action === "portal") {
+ state.actions.push(input.action);
+ return { success: true };
+ }
+ const result = await read(input);
+ if (input.action === "status" && state.paid && result.billing && result.usage) {
+ return {
+ ...result,
+ billing: {
+ ...result.billing,
+ plan: "pro",
+ subscriptionStatus: "active",
+ currentPeriodEnd: Math.floor(Date.now() / 1000) + 86400,
+ cancelAtPeriodEnd: state.cancelAtPeriodEnd,
+ checkoutAvailable: false,
+ checkoutIntervals: [],
+ portalAvailable: true,
+ },
+ usage: {
+ ...result.usage,
+ recordingLimit: null,
+ storageLimitBytes: null,
+ maxRecordingBytes: 1e10,
+ },
+ };
+ }
+ return result;
+ };
+ });
+ await page.getByRole("button", { name: "Open test pricing", exact: true }).click();
+ const dialog = page.getByRole("dialog");
+ for (const name of ["Visa / Mastercard", "Apple Pay", "Google Pay", "Link"]) {
+ const icon = dialog.getByRole("img", { name, exact: true });
+ await expect(icon).toBeVisible();
+ await expect
+ .poll(() => icon.evaluate((node: HTMLImageElement) => node.naturalWidth))
+ .toBeGreaterThan(0);
+ }
+ await dialog.getByRole("button", { name: "Get started", exact: true }).click();
+ await expect(
+ dialog.getByText(
+ "Complete checkout in your browser, then return here. Your plan will update after payment is confirmed.",
+ { exact: true },
+ ),
+ ).toBeVisible();
+ await expect(page.getByTestId("verified-plan")).toHaveText("free");
+ await expect(
+ dialog.getByRole("button", { name: "Manage subscription", exact: true }),
+ ).toHaveCount(0);
+ await page.evaluate(() => {
+ (window as Window & { billingFlowTest: { paid: boolean } }).billingFlowTest.paid = true;
+ });
+ await dialog.getByRole("button", { name: "Check payment status", exact: true }).click();
+ await expect(page.getByTestId("verified-plan")).toHaveText("pro");
+ await expect(dialog.getByText(/Your Cloud plan is active/)).toBeVisible();
+ await expect(dialog.getByRole("button", { name: "Get started", exact: true })).toHaveCount(0);
+ await dialog.getByRole("button", { name: "Manage subscription", exact: true }).click();
+ expect(
+ await page.evaluate(
+ () =>
+ (window as Window & { billingFlowTest: { actions: string[] } }).billingFlowTest
+ .actions,
+ ),
+ ).toEqual(["checkout", "portal"]);
+ await page.evaluate(() => {
+ (
+ window as Window & { billingFlowTest: { cancelAtPeriodEnd: boolean } }
+ ).billingFlowTest.cancelAtPeriodEnd = true;
+ });
+ await dialog.getByRole("button", { name: "Manage subscription", exact: true }).click();
+ await expect(dialog.getByText(/It ends on/)).toBeVisible();
+ await expect(page.getByTestId("verified-plan")).toHaveText("pro");
+});
+
+test("a failed checkout preserves Free and offers retry without claiming payment success", async ({
+ page,
+}) => {
+ await openUsage(page);
+ await page.evaluate(() => {
+ window.billingUsageTest.releaseStatus(2);
+ window.billingUsageTest.releaseCatalog();
+ const read = window.electronAPI.cloudBilling;
+ window.electronAPI.cloudBilling = async (input) =>
+ input.action === "checkout"
+ ? { success: false, error: "Checkout could not open. Please retry." }
+ : read(input);
+ });
+ await page.getByRole("button", { name: "Open test pricing", exact: true }).click();
+ const dialog = page.getByRole("dialog");
+ await dialog.getByRole("button", { name: "Get started", exact: true }).click();
+ await expect(dialog.getByRole("alert")).toContainText("connect to secure billing");
+ await expect(dialog.getByRole("button", { name: "Get started", exact: true })).toBeEnabled();
+ await expect(page.getByTestId("verified-plan")).toHaveText("free");
+ await expect(dialog.getByText(/Complete checkout in your browser/)).toHaveCount(0);
+ await page.evaluate(() => {
+ const read = window.electronAPI.cloudBilling;
+ window.electronAPI.cloudBilling = async (input) =>
+ input.action === "checkout" ? { success: true } : read(input);
+ });
+ await dialog.getByRole("button", { name: "Get started", exact: true }).click();
+ await expect(dialog.getByText(/Complete checkout in your browser/)).toBeVisible();
+ await expect(dialog.getByRole("alert")).toHaveCount(0);
+ await expect(page.getByTestId("verified-plan")).toHaveText("free");
+});
+
+for (const width of [1440, 390]) {
+ test(`unavailable billing keeps Get started and hides the availability callout at ${width}px`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width, height: 1000 });
+ await openUsage(page);
+ await page.evaluate(() => {
+ window.billingUsageTest.releaseStatus(2);
+ window.billingUsageTest.releaseCatalog();
+ const read = window.electronAPI.cloudBilling;
+ window.electronAPI.cloudBilling = async (input) => {
+ const result = await read(input);
+ return input.action === "status" && result.billing
+ ? {
+ ...result,
+ billing: {
+ ...result.billing,
+ checkoutAvailable: false,
+ checkoutIntervals: [],
+ },
+ }
+ : result;
+ };
+ });
+ await page.getByRole("button", { name: "Open test pricing", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "Recordly Cloud" });
+ const action = dialog.getByRole("button", { name: "Get started", exact: true });
+ await expect(action).toBeEnabled();
+ await expect(dialog.getByText(/We couldn’t load your billing options/)).toHaveCount(0);
+ await expect(
+ dialog.getByRole("button", {
+ name: /Refresh options|Retry loading pricing|Loading pricing/,
+ }),
+ ).toHaveCount(0);
+ await action.click();
+ await expect(action).toBeEnabled();
+ await expect(page.getByTestId("verified-checkout")).toHaveText("false");
+ await action.scrollIntoViewIfNeeded();
+ await page.screenshot({ path: test.info().outputPath("unavailable-billing.png") });
+ });
+}
+
+for (const subscription of [
+ { label: "legacy annual", periodDays: 365, cancelAtPeriodEnd: true },
+ { label: "legacy monthly", periodDays: 30, cancelAtPeriodEnd: false },
+]) {
+ test(`${subscription.label} subscribers manage their plan without being shown the new monthly offer as their price`, async ({
+ page,
+ }) => {
+ await openUsage(page, "paid", 900, subscription.periodDays, subscription.cancelAtPeriodEnd);
+ await page.evaluate(() => {
+ window.billingUsageTest.releaseStatus(2);
+ window.billingUsageTest.releaseCatalog();
+ });
+ await page.getByRole("button", { name: "Open test pricing", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "Recordly Cloud", exact: true });
+ await expect(
+ dialog.getByText("Your Cloud plan is active.", { exact: false }),
+ ).toBeVisible();
+ await expect(dialog.locator(".pricing-price-offer")).toHaveCount(0);
+ await expect(dialog.locator(".pricing-heading-offer")).toHaveCount(0);
+ await expect(dialog.getByText(/USD \/ month/)).toHaveCount(0);
+ if (subscription.cancelAtPeriodEnd) {
+ await expect(dialog.getByText(/It ends on/)).toBeVisible();
+ } else {
+ await expect(dialog.getByText(/It ends on/)).toHaveCount(0);
+ }
+ await dialog
+ .getByRole("button", { name: "Manage subscription", exact: true })
+ .scrollIntoViewIfNeeded();
+ await page.screenshot({
+ path: test.info().outputPath("existing-subscription.png"),
+ animations: "disabled",
+ });
+ await dialog.getByRole("button", { name: "Manage subscription", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-billing-action", "portal");
+ });
+}
diff --git a/tests/ui/billing-usage.tsx b/tests/ui/billing-usage.tsx
new file mode 100644
index 00000000..c83166c6
--- /dev/null
+++ b/tests/ui/billing-usage.tsx
@@ -0,0 +1,69 @@
+import { StrictMode, useState } from "react";
+import { createRoot } from "react-dom/client";
+import { BillingProvider, useBilling } from "@/components/billing/BillingProvider";
+import { CloudUsageCard } from "@/components/billing/CloudUsageCard";
+import { PricingModal } from "@/components/billing/PricingModal";
+import { CloudShareButton } from "@/components/video-editor/cloud/CloudShareButton";
+import { CLOUD_SHARE_ENDPOINT } from "@/components/video-editor/cloud/endpoint";
+import { notifyShareCapacityChanged } from "@/components/video-editor/cloud/shareCapacity";
+import { I18nProvider } from "@/contexts/I18nContext";
+import { recordlyAuth } from "@/lib/auth/recordlyAuth";
+import "@/index.css";
+
+function Controls() {
+ const { refresh, loading, user, status, openPricing } = useBilling();
+ const [publishing, setPublishing] = useState(false);
+ return (
+ <>
+ {CLOUD_SHARE_ENDPOINT}
+ {status?.billing.plan ?? "unverified"}
+
+ {String(status?.billing.checkoutAvailable ?? false)}
+
+ {String(loading)}
+ {user?.id ?? "signed-out"}
+ void refresh()}>Refresh usage
+ Open test pricing
+ Cloud library changed
+ setPublishing(true)}>Open test publish
+ {publishing && user && (
+
+ )}
+ void recordlyAuth?.auth.signOut()}>Sign out
+ {
+ const encode = (value: object) =>
+ btoa(JSON.stringify(value))
+ .replace(/=/g, "")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_");
+ const token = `${encode({ alg: "HS256" })}.${encode({ sub: "second-account", exp: Math.floor(Date.now() / 1000) + 3600 })}.test`;
+ void recordlyAuth?.auth.setSession({
+ access_token: token,
+ refresh_token: "second-refresh",
+ });
+ }}
+ >
+ Switch account
+
+ >
+ );
+}
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+ undefined} />
+
+
+ ,
+);
diff --git a/tests/ui/blur-seeking.html b/tests/ui/blur-seeking.html
new file mode 100644
index 00000000..3dc62a47
--- /dev/null
+++ b/tests/ui/blur-seeking.html
@@ -0,0 +1 @@
+Blur and seek preview
diff --git a/tests/ui/blur-seeking.spec.ts b/tests/ui/blur-seeking.spec.ts
new file mode 100644
index 00000000..d784db39
--- /dev/null
+++ b/tests/ui/blur-seeking.spec.ts
@@ -0,0 +1,448 @@
+import { expect, test } from "@playwright/test";
+import type {} from "./blur-seeking";
+import { installDesktopBridge } from "./bridge";
+
+test("both export renderers blur the recording silhouette before encoding", async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/blur-seeking.html");
+ const results = await page.evaluate(async () => {
+ const results = [];
+ const source = document.createElement("canvas");
+ source.width = 640;
+ source.height = 360;
+ const context = source.getContext("2d")!;
+ context.fillStyle = "white";
+ context.fillRect(0, 0, 640, 360);
+ for (const name of ["modernFrameRenderer", "frameRenderer"]) {
+ const { FrameRenderer } = await import(`/src/lib/exporter/${name}.ts`);
+ const renderer = new FrameRenderer({
+ width: 640,
+ height: 360,
+ videoWidth: 640,
+ videoHeight: 360,
+ wallpaper: "#111111",
+ showShadow: false,
+ shadowIntensity: 0,
+ backgroundBlur: 0,
+ zoomMotionBlur: 1,
+ zoomClassicMode: true,
+ padding: 100,
+ borderRadius: 12,
+ showCursor: false,
+ cropRegion: { x: 0, y: 0, width: 1, height: 1 },
+ zoomRegions: [
+ {
+ id: "zoom",
+ startMs: 0,
+ endMs: 2000,
+ depth: 2,
+ focus: { cx: 0.5, cy: 0.5 },
+ mode: "manual",
+ },
+ ],
+ preferredRenderBackend: "webgl",
+ });
+ await renderer.initialize();
+ const frame = new VideoFrame(source, { timestamp: 0 });
+ try {
+ for (let i = 0; i <= 12; i++)
+ await renderer.renderFrame(frame, Math.round((i * 1_000_000) / 60));
+ const sample = () => {
+ const sprite = renderer.scene?.videoSprite ?? renderer.videoSprite;
+ const bounds = sprite.getBounds();
+ const copy = document.createElement("canvas");
+ copy.width = 640;
+ copy.height = 360;
+ const context = copy.getContext("2d")!;
+ context.drawImage(renderer.getCanvas(), 0, 0);
+ const { data } = context.getImageData(0, 0, 640, 360);
+ let outside = 0;
+ for (const edge of [bounds.x, bounds.x + bounds.width]) {
+ const direction = edge === bounds.x ? -1 : 1;
+ for (let offset = 3; offset <= 12; offset++) {
+ const x = Math.round(edge + direction * offset);
+ for (let row = -5; row <= 5; row++) {
+ const y = Math.round(bounds.y + bounds.height / 2 + row);
+ if (
+ x >= 0 &&
+ x < 640 &&
+ y >= 0 &&
+ y < 360 &&
+ data[(y * 640 + x) * 4] > 25
+ )
+ outside++;
+ }
+ }
+ }
+ return outside;
+ };
+ const blurred = sample();
+ renderer.config.zoomMotionBlur = 0;
+ await renderer.renderFrame(frame, 200_000);
+ results.push({ name, blurred, disabled: sample() });
+ } finally {
+ frame.close();
+ renderer.destroy();
+ }
+ }
+ return results;
+ });
+ for (const result of results) {
+ expect(result.blurred, result.name).toBeGreaterThan(10);
+ expect(result.disabled, result.name).toBe(0);
+ }
+});
+
+test("paused zoom blur softens the recording silhouette beyond its mask", async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/blur-seeking.html?motion&edges");
+ await expect
+ .poll(() => page.evaluate(() => !!window.blurPreview), { timeout: 60000 })
+ .toBe(true);
+ await page.evaluate(() => window.blurPreview!.api!.seekTimeline(0.65));
+ await expect
+ .poll(() => page.evaluate(() => window.blurPreview!.api!.video!.seeking))
+ .toBe(false);
+ const sample = () =>
+ page.evaluate(() => {
+ const api = window.blurPreview!.api!;
+ const app = api.app!;
+ app.render();
+ const bounds = api.videoSprite!.getBounds();
+ const copy = document.createElement("canvas");
+ copy.width = app.canvas.width;
+ copy.height = app.canvas.height;
+ const context = copy.getContext("2d")!;
+ context.drawImage(app.canvas, 0, 0);
+ const {
+ data: pixels,
+ width,
+ height,
+ } = context.getImageData(0, 0, copy.width, copy.height);
+ const resolution = app.renderer.resolution;
+ let outside = 0;
+ for (const edge of [bounds.x, bounds.x + bounds.width]) {
+ const direction = edge === bounds.x ? -1 : 1;
+ for (let offset = 3; offset <= 12; offset++) {
+ const x = Math.round((edge + direction * offset) * resolution);
+ for (let row = -5; row <= 5; row++) {
+ const y = Math.round((bounds.y + bounds.height / 2 + row) * resolution);
+ if (
+ x >= 0 &&
+ x < width &&
+ y >= 0 &&
+ y < height &&
+ pixels[(y * width + x) * 4 + 3] > 2
+ )
+ outside++;
+ }
+ }
+ }
+ return { outside, time: api.video!.currentTime, visible: api.videoSprite!.visible };
+ });
+ await expect.poll(async () => (await sample()).outside).toBeGreaterThan(10);
+ expect((await sample()).visible).toBe(true);
+ await page.screenshot({
+ path: test.info().outputPath("motion-blur-edges.png"),
+ clip: { x: 0, y: 0, width: 900, height: 510 },
+ });
+});
+
+for (const solid of [false, true]) {
+ test(`seeking holds the last composed ${solid ? "redacted " : ""}frame instead of flashing the background`, async ({
+ page,
+ }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto(`/tests/ui/blur-seeking.html${solid ? "?solid&crop" : ""}`);
+ await expect
+ .poll(() => page.evaluate(() => !!window.blurPreview), { timeout: 60000 })
+ .toBe(true);
+ const samples = await page.evaluate(async () => {
+ const api = window.blurPreview!.api!;
+ const video = api.video!;
+ const app = api.app!;
+ const tick = () =>
+ new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
+ );
+ await tick();
+ const samples = [];
+ for (const time of [1.2, 3.1, 5.2, 0.7]) {
+ app.render();
+ const before = app.canvas.toDataURL();
+ api.seekTimeline(time);
+ app.render();
+ samples.push({
+ seeking: video.seeking,
+ visible: api.videoSprite!.visible,
+ held: app.canvas.toDataURL() === before,
+ });
+ if (video.seeking)
+ await new Promise((resolve) =>
+ video.addEventListener("seeked", () => resolve(), { once: true }),
+ );
+ await tick();
+ }
+ return samples;
+ });
+ expect(samples).toHaveLength(4);
+ for (const sample of samples) {
+ expect(sample.seeking).toBe(true);
+ expect(sample.visible).toBe(true);
+ expect(sample.held).toBe(true);
+ }
+ });
+}
+
+test("privacy edits invalidate a held seek frame until the new mask is composed", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/blur-seeking.html?solid");
+ await expect
+ .poll(() => page.evaluate(() => !!window.blurPreview), { timeout: 60000 })
+ .toBe(true);
+ const region = page.locator('[data-annotation-id="blur"]');
+ await region.click();
+ await page.evaluate(() => {
+ const video = window.blurPreview!.api!.video!;
+ // Hold the decoder pending while exercising an actual annotation edit.
+ Object.defineProperty(video, "seeking", { configurable: true, get: () => true });
+ video.dispatchEvent(new Event("seeking"));
+ });
+ const box = (await region.boundingBox())!;
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(box.x + box.width / 2 + 30, box.y + box.height / 2 + 15, { steps: 4 });
+ await page.mouse.up();
+ await expect
+ .poll(() => page.evaluate(() => window.blurPreview!.api!.videoSprite!.visible))
+ .toBe(false);
+ await page.evaluate(() => {
+ const video = window.blurPreview!.api!.video!;
+ Reflect.deleteProperty(video, "seeking");
+ video.dispatchEvent(new Event("seeked"));
+ });
+ await expect
+ .poll(() => page.evaluate(() => window.blurPreview!.api!.videoSprite!.visible))
+ .toBe(true);
+ const sample = await page.evaluate(() => window.blurPreview!.samplePrivacy());
+ expect(sample.sourceIsCanvas).toBe(true);
+ expect(Math.max(...sample.pixel.slice(0, 3))).toBeLessThan(5);
+});
+
+test("paused source redaction includes the end boundary and restores raw video immediately after", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/blur-seeking.html?solid&crop");
+ await expect
+ .poll(() => page.evaluate(() => !!window.blurPreview), { timeout: 60000 })
+ .toBe(true);
+ const video = page.locator("video").first();
+ const sampleAt = async (time: number) => {
+ await page.evaluate((time) => window.blurPreview!.api!.seekTimeline(time), time);
+ await expect
+ .poll(() => video.evaluate((video: HTMLVideoElement) => video.seeking))
+ .toBe(false);
+ return page.evaluate(() => window.blurPreview!.samplePrivacy());
+ };
+ const active = await sampleAt(4.5);
+ expect(active.sourceIsCanvas).toBe(true);
+ expect(Math.max(...active.pixel.slice(0, 3))).toBeLessThan(5);
+ expect(active.pixel[3]).toBe(255);
+ await expect(page.locator('[data-annotation-id="blur"]')).toHaveCount(1);
+ const inactive = await sampleAt(4.51);
+ expect(inactive.sourceIsCanvas).toBe(false);
+ expect(Math.max(...inactive.raw.slice(0, 3))).toBeGreaterThan(80);
+ for (let channel = 0; channel < 3; channel++) {
+ expect(Math.abs(inactive.pixel[channel] - inactive.raw[channel])).toBeLessThan(20);
+ }
+ await expect(page.locator('[data-annotation-id="blur"]')).toHaveCount(0);
+ const restored = await sampleAt(4.5);
+ expect(restored.pixel).toEqual(active.pixel);
+ expect(restored.sourceIsCanvas).toBe(true);
+});
+
+for (const crop of [false, true]) {
+ test(`moving ${crop ? "cropped " : ""}frames redact video pixels before camera motion filters`, async ({
+ page,
+ }) => {
+ const errors: string[] = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto(`/tests/ui/blur-seeking.html?solid&motion${crop ? "&crop" : ""}`);
+ await expect
+ .poll(() => page.evaluate(() => !!window.blurPreview), { timeout: 60000 })
+ .toBe(true);
+ await page.getByRole("button", { name: "Play", exact: true }).click();
+ await expect
+ .poll(() => page.evaluate(() => window.blurPreview!.probe.privateFrames))
+ .toBeGreaterThan(50);
+ await page.getByRole("button", { name: "Pause", exact: true }).click();
+ const probe = await page.evaluate(() => window.blurPreview!.probe);
+ // These are actual Pixi canvas pixels; the DOM coverage pass cannot hide a failure.
+ expect(probe.maxRawControl).toBeGreaterThan(80);
+ expect(probe.motionFrames).toBeGreaterThan(5);
+ expect(probe.maxPrivatePixel).toBeLessThan(20);
+ expect(probe.minPrivateAlpha).toBeGreaterThan(250);
+ expect(probe.maxError, probe.detail).toBeLessThan(1);
+ expect(errors).toEqual([]);
+ });
+}
+
+for (const crop of [false, true]) {
+ test(`blur stays aligned with the rendered camera on every ${crop ? "cropped " : ""}zoom frame`, async ({
+ page,
+ }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto(`/tests/ui/blur-seeking.html${crop ? "?crop" : ""}`);
+ await expect(page.locator("main")).toHaveAttribute("data-ready", "true");
+ await expect
+ .poll(() => page.evaluate(() => !!window.blurPreview), { timeout: 60000 })
+ .toBe(true);
+ await page.getByRole("button", { name: "Play", exact: true }).click();
+ await expect
+ .poll(() => page.evaluate(() => window.blurPreview!.probe.frames))
+ .toBeGreaterThan(50);
+ await page.getByRole("button", { name: "Pause", exact: true }).click();
+ const probe = await page.evaluate(() => window.blurPreview!.probe);
+ expect(probe.maxError, probe.detail).toBeLessThan(1);
+ await expect(
+ page.locator('[data-annotation-id="blur"] [style*="backdrop-filter"]'),
+ ).toHaveCSS("border-radius", "0px");
+ });
+}
+
+test("rapid paused seeks render the final frame and its blur visibility consistently", async ({
+ page,
+}) => {
+ const errors: string[] = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/blur-seeking.html");
+ await expect(page.locator("main")).toHaveAttribute("data-ready", "true");
+ await expect
+ .poll(() => page.evaluate(() => !!window.blurPreview), { timeout: 60000 })
+ .toBe(true);
+ const video = page.locator("video").first();
+ const canvas = page.locator("canvas").first();
+ const seek = async (time: number) => {
+ await page.evaluate((time) => window.blurPreview!.api!.seekTimeline(time), time);
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.seeking)).toBe(false);
+ await expect
+ .poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime))
+ .toBeCloseTo(time, 4);
+ };
+ await seek(1.2);
+ const snapshot = await canvas.screenshot();
+ await page.evaluate(() => {
+ const api = window.blurPreview!.api!;
+ for (const time of [5.2, 0.7, 3.1, 5.7, 1.2]) api.seekTimeline(time);
+ });
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.seeking)).toBe(false);
+ await expect.poll(async () => (await canvas.screenshot()).equals(snapshot)).toBe(true);
+ await expect(page.locator('[data-annotation-id="blur"]')).toHaveCount(1);
+ await seek(5.2);
+ await expect(page.locator('[data-annotation-id="blur"]')).toHaveCount(0);
+ expect((await canvas.screenshot()).equals(snapshot)).toBe(false);
+ await seek(1.2);
+ await expect.poll(async () => (await canvas.screenshot()).equals(snapshot)).toBe(true);
+ expect(errors).toEqual([]);
+});
+
+test("seeking between repeated source frames recomposes the timeline camera without a decoder seek", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/blur-seeking.html?repeat");
+ await expect
+ .poll(() => page.evaluate(() => !!window.blurPreview), { timeout: 60000 })
+ .toBe(true);
+ const video = page.locator("video").first();
+ const canvas = page.locator("canvas").first();
+ const seek = async (time: number) => {
+ await page.evaluate((time) => window.blurPreview!.api!.seekTimeline(time), time);
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.seeking)).toBe(false);
+ };
+ await seek(2.2);
+ const zoomed = await canvas.screenshot();
+ await video.evaluate((v) => {
+ v.dataset.seekCount = "0";
+ v.addEventListener("seeking", () => {
+ v.dataset.seekCount = String(Number(v.dataset.seekCount) + 1);
+ });
+ });
+ await seek(5.2);
+ await expect.poll(async () => (await canvas.screenshot()).equals(zoomed)).toBe(false);
+ await expect(page.locator('[data-annotation-id="blur"]')).toHaveCount(0);
+ await expect(video).toHaveAttribute("data-seek-count", "0");
+ await seek(2.2);
+ await expect.poll(async () => (await canvas.screenshot()).equals(zoomed)).toBe(true);
+ await expect(video).toHaveAttribute("data-seek-count", "0");
+});
+
+test("zoomed blur samples scene pixels, remains editable, and exports square corners", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/blur-seeking.html?autoZoom");
+ await expect
+ .poll(() => page.evaluate(() => !!window.blurPreview), { timeout: 60000 })
+ .toBe(true);
+ await page.getByRole("button", { name: "Play", exact: true }).click();
+ await expect
+ .poll(() =>
+ page
+ .locator("video")
+ .first()
+ .evaluate((video: HTMLVideoElement) => video.currentTime),
+ )
+ .toBeGreaterThan(2.2);
+ await page.getByRole("button", { name: "Pause", exact: true }).click();
+ const cameraPose = () =>
+ page.evaluate(() => {
+ const api = window.blurPreview!.api!;
+ const camera = api.videoContainer!.parent!.parent!;
+ return {
+ x: camera.x,
+ y: camera.y,
+ scale: camera.scale.x,
+ width: api.app!.screen.width,
+ };
+ });
+ const cameraBefore = await cameraPose();
+ expect(
+ Math.abs(cameraBefore.x + (cameraBefore.width / 2) * (cameraBefore.scale - 1)),
+ ).toBeGreaterThan(20);
+ const region = page.locator('[data-annotation-id="blur"]');
+ const content = region.locator('[style*="backdrop-filter"]');
+ const box = (await region.boundingBox())!;
+ const clip = { x: box.x + 2, y: box.y + 2, width: box.width - 4, height: box.height - 4 };
+ const blurred = await page.screenshot({ clip });
+ const filter = await content.evaluate((el: HTMLElement) => {
+ const filter = el.style.backdropFilter;
+ el.style.backdropFilter = "none";
+ return filter;
+ });
+ expect(await page.screenshot({ clip })).not.toEqual(blurred);
+ await content.evaluate((el: HTMLElement, filter) => {
+ el.style.backdropFilter = filter;
+ }, filter);
+ await region.click();
+ const before = (await region.boundingBox())!;
+ await page.mouse.move(before.x + before.width / 2, before.y + before.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(before.x + before.width / 2 + 40, before.y + before.height / 2 + 20, {
+ steps: 8,
+ });
+ await page.mouse.up();
+ await expect.poll(async () => (await region.boundingBox())!.x - before.x).toBeCloseTo(40, 0);
+ await expect.poll(async () => (await region.boundingBox())!.y - before.y).toBeCloseTo(20, 0);
+ // Dragging a privacy region must not recenter the paused cursor-follow camera.
+ expect(await cameraPose()).toEqual(cameraBefore);
+ await page.screenshot({ path: test.info().outputPath("blur-preview-zoomed.png") });
+ const corner = await page.evaluate(() => window.blurPreview!.exportCorner());
+ expect(corner.before).toBe(0);
+ expect(corner.after).toBeGreaterThan(80);
+});
diff --git a/tests/ui/blur-seeking.tsx b/tests/ui/blur-seeking.tsx
new file mode 100644
index 00000000..6e7a464d
--- /dev/null
+++ b/tests/ui/blur-seeking.tsx
@@ -0,0 +1,294 @@
+import { Point } from "pixi.js";
+import { useEffect, useRef, useState } from "react";
+import { createRoot } from "react-dom/client";
+import { type AnnotationRegion, DEFAULT_ANNOTATION_STYLE } from "@/components/video-editor/types";
+import VideoPlayback, { type VideoPlaybackRef } from "@/components/video-editor/VideoPlayback";
+import { I18nProvider } from "@/contexts/I18nContext";
+import { renderAnnotations } from "@/lib/exporter/annotationRenderer";
+// Discover export dependencies before testing seeks; late optimization reloads
+// the development page and destroys its pending media operations.
+import "@/lib/exporter/frameRenderer";
+import "@/lib/exporter/modernFrameRenderer";
+import "@/index.css";
+
+declare global {
+ interface Window {
+ blurPreview?: {
+ readonly api: VideoPlaybackRef | null;
+ probe: {
+ frames: number;
+ maxError: number;
+ detail: string;
+ privateFrames: number;
+ maxPrivatePixel: number;
+ minPrivateAlpha: number;
+ maxRawControl: number;
+ motionFrames: number;
+ };
+ exportCorner: () => Promise<{ before: number; after: number }>;
+ samplePrivacy: () => Promise<{
+ pixel: number[];
+ raw: number[];
+ sourceIsCanvas: boolean;
+ }>;
+ };
+ }
+}
+
+const noop = () => undefined;
+const onError = (error: string) => {
+ throw new Error(error);
+};
+const params = new URLSearchParams(location.search);
+const crop = params.has("crop") ? { x: 0.2, y: 0.1, width: 0.7, height: 0.8 } : undefined;
+const clips = params.has("repeat")
+ ? [
+ { id: "clip", startMs: 0, endMs: 3000, speed: 1 },
+ { id: "repeat", startMs: 3000, endMs: 6000, sourceStartMs: 0, speed: 1 },
+ ]
+ : [{ id: "clip", startMs: 0, endMs: 6000, speed: 1 }];
+const zooms = [
+ {
+ id: "zoom",
+ startMs: 500,
+ endMs: 4000,
+ depth: 2 as const,
+ focus: params.has("autoZoom") ? { cx: 0.5, cy: 0.5 } : { cx: 0.65, cy: 0.45 },
+ mode: params.has("autoZoom") ? ("auto" as const) : ("manual" as const),
+ },
+];
+const telemetry = params.has("autoZoom")
+ ? [
+ { timeMs: 0, cx: 0.1, cy: 0.5 },
+ { timeMs: 1000, cx: 0.9, cy: 0.5 },
+ { timeMs: 4000, cx: 0.9, cy: 0.5 },
+ { timeMs: 6000, cx: 0.5, cy: 0.5 },
+ ]
+ : [];
+const annotations: AnnotationRegion[] = [
+ {
+ id: "blur",
+ type: "blur",
+ startMs: 0,
+ endMs: 4500,
+ content: "",
+ position: { x: 35, y: 30 },
+ size: { width: 30, height: 25 },
+ style: { ...DEFAULT_ANNOTATION_STYLE, borderRadius: 24 },
+ zIndex: 1,
+ blurIntensity: 20,
+ blurColor: params.has("solid") ? "#000000" : undefined,
+ },
+];
+
+function Preview() {
+ const playback = useRef(null);
+ const [ready, setReady] = useState(false);
+ const [time, setTime] = useState(0.2);
+ const [playing, setPlaying] = useState(false);
+ const [selected, setSelected] = useState(null);
+ const [regions, setRegions] = useState(annotations);
+ useEffect(() => {
+ if (!ready) return;
+ let request = 0;
+ let dispose = () => undefined;
+ const install = () => {
+ const api = playback.current;
+ if (!api?.app || !api.videoContainer) {
+ request = requestAnimationFrame(install);
+ return;
+ }
+ const probe = {
+ frames: 0,
+ maxError: 0,
+ detail: "",
+ privateFrames: 0,
+ maxPrivatePixel: 0,
+ minPrivateAlpha: 255,
+ maxRawControl: 0,
+ motionFrames: 0,
+ };
+ const pixelCanvas = document.createElement("canvas");
+ pixelCanvas.width = pixelCanvas.height = 1;
+ const pixelContext = pixelCanvas.getContext("2d", { willReadFrequently: true })!;
+ const samplePixel = (source: CanvasImageSource, x: number, y: number) => {
+ pixelContext.clearRect(0, 0, 1, 1);
+ pixelContext.drawImage(source, x, y, 1, 1, 0, 0, 1, 1);
+ return pixelContext.getImageData(0, 0, 1, 1).data;
+ };
+ const samplePrivacy = () => {
+ const current = playback.current!;
+ const sprite = current.videoSprite!;
+ const video = current.video!;
+ const source = crop ?? { x: 0, y: 0, width: 1, height: 1 };
+ const centerX = (source.x + source.width * 0.5) * video.videoWidth;
+ const centerY = (source.y + source.height * 0.425) * video.videoHeight;
+ const point = sprite.toGlobal(new Point(centerX, centerY));
+ const resolution = api.app!.renderer.resolution;
+ return {
+ pixel: Array.from(
+ samplePixel(api.app!.canvas, point.x * resolution, point.y * resolution),
+ ),
+ raw: Array.from(samplePixel(video, centerX, centerY)),
+ sourceIsCanvas: sprite.texture.source.resource instanceof HTMLCanvasElement,
+ };
+ };
+ const sample = () => {
+ const current = playback.current!;
+ const sprite = current.videoSprite;
+ const element = document.querySelector('[data-annotation-id="blur"]');
+ if (!sprite || !element || !current.isPlaying) return;
+ const video = current.video!;
+ const source = crop ?? { x: 0, y: 0, width: 1, height: 1 };
+ const topLeft = sprite.toGlobal(
+ new Point(
+ (source.x + source.width * 0.35) * video.videoWidth,
+ (source.y + source.height * 0.3) * video.videoHeight,
+ ),
+ );
+ const bottomRight = sprite.toGlobal(
+ new Point(
+ (source.x + source.width * 0.65) * video.videoWidth,
+ (source.y + source.height * 0.55) * video.videoHeight,
+ ),
+ );
+ const canvas = api.app!.canvas.getBoundingClientRect();
+ const actual = element.getBoundingClientRect();
+ probe.frames++;
+ const error = Math.max(
+ Math.abs(actual.x - canvas.x - topLeft.x),
+ Math.abs(actual.y - canvas.y - topLeft.y),
+ Math.abs(actual.width - (bottomRight.x - topLeft.x)),
+ Math.abs(actual.height - (bottomRight.y - topLeft.y)),
+ );
+ if (error > probe.maxError) {
+ probe.maxError = error;
+ probe.detail = JSON.stringify({
+ actual: actual.toJSON(),
+ topLeft,
+ bottomRight,
+ canvas: canvas.toJSON(),
+ time: video.currentTime,
+ });
+ }
+ if (params.has("solid")) {
+ const { pixel, raw } = samplePrivacy();
+ probe.privateFrames++;
+ probe.maxPrivatePixel = Math.max(
+ probe.maxPrivatePixel,
+ pixel[0],
+ pixel[1],
+ pixel[2],
+ );
+ probe.minPrivateAlpha = Math.min(probe.minPrivateAlpha, pixel[3]);
+ probe.maxRawControl = Math.max(probe.maxRawControl, raw[0], raw[1], raw[2]);
+ const filters = current.videoContainer?.parent?.filters;
+ if (
+ Array.isArray(filters) &&
+ filters.some((filter) => {
+ const motion = filter as {
+ strength?: number;
+ velocity?: { x: number; y: number };
+ };
+ return (
+ (motion.strength ?? 0) > 0 ||
+ Math.hypot(motion.velocity?.x ?? 0, motion.velocity?.y ?? 0) > 0
+ );
+ })
+ )
+ probe.motionFrames++;
+ }
+ };
+ Object.assign(window, {
+ blurPreview: {
+ get api() {
+ return playback.current;
+ },
+ probe,
+ samplePrivacy: () =>
+ new Promise((resolve) => {
+ requestAnimationFrame(() => {
+ // A paused preview intentionally has no ticker callbacks.
+ api.app!.render();
+ resolve(samplePrivacy());
+ });
+ }),
+ async exportCorner() {
+ const canvas = document.createElement("canvas");
+ canvas.width = 200;
+ canvas.height = 100;
+ const context = canvas.getContext("2d")!;
+ for (let x = 0; x < canvas.width; x += 4) {
+ context.fillStyle = x % 8 ? "black" : "white";
+ context.fillRect(x, 0, 4, canvas.height);
+ }
+ const before = context.getImageData(71, 31, 1, 1).data[0];
+ await renderAnnotations(context, annotations, 200, 100, 1000);
+ return { before, after: context.getImageData(71, 31, 1, 1).data[0] };
+ },
+ },
+ });
+ // Pixel probes run after Pixi's render priority (-50), before browser compositing.
+ api.app.ticker.add(sample, undefined, -60);
+ dispose = () => {
+ api.app?.ticker.remove(sample);
+ };
+ };
+ install();
+ return () => {
+ cancelAnimationFrame(request);
+ dispose();
+ };
+ }, [ready]);
+ return (
+
+ playback.current?.play()}>Play
+ playback.current?.pause()}>Pause
+
+ setRegions((current) =>
+ current.map((region) =>
+ region.id === id ? { ...region, position } : region,
+ ),
+ )
+ }
+ onAnnotationSizeChange={(id, size) =>
+ setRegions((current) =>
+ current.map((region) => (region.id === id ? { ...region, size } : region)),
+ )
+ }
+ />
+
+ );
+}
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/tests/ui/camera-permissions.spec.ts b/tests/ui/camera-permissions.spec.ts
index c8432ab4..29058f5a 100644
--- a/tests/ui/camera-permissions.spec.ts
+++ b/tests/ui/camera-permissions.spec.ts
@@ -44,8 +44,94 @@ test("editor mode never opens the camera and releases an active recorder preview
expect(await page.locator("html").getAttribute("data-camera-requests")).toBeNull();
await page.evaluate(() => window.dispatchEvent(new Event("test-leave-editor")));
await expect(page.locator("html")).toHaveAttribute("data-camera-requests", "1");
+ // The floating HUD preview is circular; editor mode still controls camera ownership.
+ await expect(page.locator("[data-floating-webcam-preview] video")).toHaveCSS(
+ "clip-path",
+ /^circle\(50%(?: at 50% 50%)?\)$/,
+ );
await page.evaluate(() => window.dispatchEvent(new Event("test-enter-editor")));
await expect(page.locator("html")).toHaveAttribute("data-camera-stopped", "true");
await page.evaluate(() => navigator.mediaDevices.dispatchEvent(new Event("devicechange")));
await expect(page.locator("html")).toHaveAttribute("data-camera-requests", "1");
});
+
+test("camera options preview preserves saved squircle rounding", async ({ page }) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ const getAppSetting = window.electronAPI.getAppSetting;
+ window.electronAPI.getAppSetting = (key) =>
+ key === "recordly.editor.preferences"
+ ? { webcam: { shape: "squircle", roundness: 69 } }
+ : getAppSetting(key);
+ window.electronAPI.getRecordingPreferences = async () => ({
+ success: true,
+ webcamEnabled: true,
+ microphoneEnabled: false,
+ systemAudioEnabled: false,
+ });
+ window.electronAPI.getEditorMode = async () => false;
+ Object.defineProperty(navigator.mediaDevices, "enumerateDevices", {
+ value: async () => [],
+ });
+ Object.defineProperty(navigator.mediaDevices, "getUserMedia", {
+ value: async () => {
+ document.documentElement.dataset.shapeCameraRequests = String(
+ Number(document.documentElement.dataset.shapeCameraRequests ?? 0) + 1,
+ );
+ const canvas = document.createElement("canvas");
+ canvas.width = 96;
+ canvas.height = 96;
+ const context = canvas.getContext("2d")!;
+ const stream = canvas.captureStream(24);
+ const paint = () => {
+ context.fillStyle = "#2874ff";
+ context.fillRect(0, 0, 96, 96);
+ };
+ paint();
+ const timer = setInterval(paint, 100);
+ for (const track of stream.getTracks()) {
+ const stop = track.stop.bind(track);
+ track.stop = () => {
+ clearInterval(timer);
+ document.documentElement.dataset.shapeCameraStopped = "true";
+ stop();
+ };
+ }
+ return stream;
+ },
+ });
+ // Platforms without mouse passthrough show the shaped preview inside camera options.
+ window.electronAPI.getHudOverlayMousePassthroughSupported = async () => ({
+ success: true,
+ supported: false,
+ });
+ });
+ await page.goto("/?windowType=hud-overlay");
+ expect(await page.locator("html").getAttribute("data-shape-camera-requests")).toBeNull();
+ await page.getByRole("button", { name: "Disable webcam overlay", exact: true }).click();
+ await expect(page.locator("[data-floating-webcam-preview]")).toHaveCount(0);
+ const camera = page.getByRole("dialog", { name: "Options", exact: true }).locator("video");
+ await expect(camera).toBeVisible();
+ await expect(page.locator("html")).toHaveAttribute("data-shape-camera-requests", "1");
+ await expect
+ .poll(() =>
+ camera.evaluate(
+ (video: HTMLVideoElement) =>
+ video.srcObject instanceof MediaStream && video.videoWidth === 96,
+ ),
+ )
+ .toBe(true);
+ await expect
+ .poll(() =>
+ camera.evaluate((node) => {
+ const mask = node.parentElement!;
+ const radius = Number(getComputedStyle(mask).clipPath.match(/M\s+([\d.]+)/)?.[1]);
+ const box = mask.getBoundingClientRect();
+ return Math.round(100 * (radius / (Math.min(box.width, box.height) / 2)) ** 4);
+ }),
+ )
+ .toBe(69);
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("dialog", { name: "Options", exact: true })).toHaveCount(0);
+ await expect(page.locator("html")).toHaveAttribute("data-shape-camera-stopped", "true");
+});
diff --git a/tests/ui/caption-file-cloud.spec.ts b/tests/ui/caption-file-cloud.spec.ts
new file mode 100644
index 00000000..4edce36d
--- /dev/null
+++ b/tests/ui/caption-file-cloud.spec.ts
@@ -0,0 +1,119 @@
+import { expect, type Page, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+async function openCaptionExport(page: Page, mode: "free" | "pro", signedIn = true) {
+ await installDesktopBridge(page, "preview.mp4", { signedIn });
+ await installDesktopBridgeOverrides(
+ page,
+ ({ mode }) => {
+ localStorage.setItem(
+ "recordly.editor.preferences",
+ JSON.stringify({ includeCaptionSidecar: true }),
+ );
+ document.addEventListener("DOMContentLoaded", () => {
+ document.documentElement.dataset.captionPlan = mode;
+ });
+ const prices = [{ interval: "monthly" as const, unitAmount: 900, currency: "usd" }];
+ window.electronAPI.cloudBilling = async ({ action }) => {
+ if (action === "catalog")
+ return {
+ success: true,
+ prices,
+ limits: {
+ freeRecordingLimit: 5,
+ freeMaxRecordingBytes: 1e9,
+ plusMaxRecordingBytes: 1e10,
+ storageLimitBytes: null,
+ },
+ };
+ const plus = document.documentElement.dataset.captionPlan === "pro";
+ return {
+ success: true,
+ billing: {
+ plan: plus ? "pro" : "free",
+ subscriptionStatus: plus ? "active" : null,
+ currentPeriodEnd: null,
+ cancelAtPeriodEnd: false,
+ checkoutAvailable: !plus,
+ checkoutIntervals: plus ? [] : ["monthly"],
+ portalAvailable: plus,
+ prices,
+ },
+ usage: {
+ recordings: 0,
+ recordingLimit: plus ? null : 5,
+ maxRecordingBytes: plus ? 1e10 : 1e9,
+ storageBytes: 0,
+ storageLimitBytes: plus ? null : 1e11,
+ },
+ };
+ };
+ window.electronAPI.finalizeExportedVideo = async ({ captionSidecar }) => {
+ document.documentElement.dataset.savedCaptionFiles = String(
+ Boolean(captionSidecar),
+ );
+ return { success: true, path: "/saved/caption-test.mp4" };
+ };
+ },
+ { mode },
+ );
+ await page.goto("/tests/ui/export-resolution-presets.html?captions");
+ await expect(page.getByTestId("caption-plan")).toHaveText(signedIn ? mode : "unverified");
+ return page.getByRole("switch", { name: /^Export captions file/ });
+}
+
+for (const signedIn of [true, false]) {
+ test(`${signedIn ? "Free" : "signed-out"} users see Cloud upgrade instead of enabling caption files`, async ({
+ page,
+ }) => {
+ const toggle = await openCaptionExport(page, "free", signedIn);
+ await expect(toggle).not.toBeChecked();
+ await toggle.press("Space");
+ const pricing = page.getByRole("dialog", { name: "Recordly Cloud", exact: true });
+ await expect(pricing).toBeVisible();
+ await expect(
+ pricing.getByRole("list", { name: "Your Cloud benefits", exact: true }),
+ ).toContainText("SRT and VTT caption-file exports");
+ await pricing.getByRole("button", { name: "Close", exact: true }).click();
+ await expect(toggle).not.toBeChecked();
+ await page.getByRole("button", { name: "Export Video", exact: true }).click();
+ await expect(page.getByTestId("export-settings-result")).toContainText(
+ '"includeCaptionSidecar":false',
+ );
+ await page.getByRole("button", { name: "Retry caption save", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-saved-caption-files", "false");
+ });
+}
+
+test("Cloud enables caption files, and losing access clears them from exports and retries", async ({
+ page,
+}) => {
+ const toggle = await openCaptionExport(page, "pro");
+ await expect(page.getByText("for commercial use", { exact: false })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Upgrade to Cloud", exact: true })).toHaveCount(0);
+ await expect(toggle).toBeChecked();
+ await toggle.press("Space");
+ await expect(toggle).not.toBeChecked();
+ await toggle.press("Space");
+ await expect(toggle).toBeChecked();
+ await page.getByRole("button", { name: "Export Video", exact: true }).click();
+ await expect(page.getByTestId("export-settings-result")).toContainText(
+ '"includeCaptionSidecar":true',
+ );
+ await page.getByRole("button", { name: "Retry caption save", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-saved-caption-files", "true");
+ await page.screenshot({ path: test.info().outputPath("cloud-caption-export.png") });
+ await page.evaluate(() => {
+ document.documentElement.dataset.captionPlan = "free";
+ });
+ await page.getByRole("button", { name: "Refresh caption plan", exact: true }).click();
+ await expect(page.getByTestId("caption-plan")).toHaveText("free");
+ await expect(page.getByText("Upgrade to Cloud for commercial use", { exact: true })).toBeVisible();
+ await expect(toggle).not.toBeChecked();
+ await page.getByRole("button", { name: "Export Video", exact: true }).click();
+ await expect(page.getByTestId("export-settings-result")).toContainText(
+ '"includeCaptionSidecar":false',
+ );
+ await page.getByRole("button", { name: "Retry caption save", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-saved-caption-files", "false");
+});
diff --git a/tests/ui/caption-generation-settings.spec.ts b/tests/ui/caption-generation-settings.spec.ts
new file mode 100644
index 00000000..d2aae3fb
--- /dev/null
+++ b/tests/ui/caption-generation-settings.spec.ts
@@ -0,0 +1,148 @@
+import path from "node:path";
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test("caption setup leads to generation, with maintenance actions kept in a menu", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(60000);
+ const errors: string[] = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getCurrentVideoPath = async () => ({
+ success: true,
+ path: "/recordings/caption-test.mp4",
+ });
+ window.electronAPI.getLocalMediaUrl = async () => ({
+ success: true,
+ url: `${location.origin}/video?path=%2Frecordings%2Fcaption-test.mp4`,
+ });
+ window.electronAPI.getCaptionModelStatus = async () => ({ success: true, exists: false });
+ window.electronAPI.onCaptionModelDownloadProgress = (callback) => {
+ const listener = (event: Event) => callback((event as CustomEvent).detail);
+ window.addEventListener("caption-model-progress", listener);
+ return () => window.removeEventListener("caption-model-progress", listener);
+ };
+ window.electronAPI.downloadCaptionModel = (modelId) =>
+ new Promise((resolve) => {
+ window.addEventListener(
+ "complete-caption-model-download",
+ () => resolve({ success: true, path: `/models/${modelId}` }),
+ { once: true },
+ );
+ });
+ window.electronAPI.generateAutoCaptions = async (options) => {
+ document.documentElement.dataset.captionGeneratedModel = options.captionModelId;
+ return {
+ success: true,
+ cues: [
+ {
+ id: "caption-1",
+ startMs: 0,
+ endMs: 5000,
+ text: "Captions follow your words",
+ words: [
+ { text: "Captions", startMs: 0, endMs: 1000 },
+ { text: "follow", startMs: 1000, endMs: 2000, leadingSpace: true },
+ { text: "your", startMs: 2000, endMs: 3000, leadingSpace: true },
+ { text: "words", startMs: 3000, endMs: 5000, leadingSpace: true },
+ ],
+ },
+ ],
+ };
+ };
+ window.electronAPI.deleteCaptionModel = async (modelId) => {
+ document.documentElement.dataset.captionModelDeleted = modelId;
+ return { success: true };
+ };
+ });
+ await page.route("**/video?path=*", (route) =>
+ route.fulfill({
+ path: path.resolve("tests/ui/fixtures/filmstrip.mp4"),
+ contentType: "video/mp4",
+ }),
+ );
+ await page.goto("/?windowType=editor");
+ await expect(page.locator('[data-variant="clip"]')).toHaveAttribute("data-end-ms", "6000", {
+ timeout: 20000,
+ });
+ await page.getByRole("radio", { name: "Captions", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Generate Captions", exact: true })).toHaveCount(
+ 0,
+ );
+ const sidebar = page.getByRole("complementary");
+ for (const label of ["Generation", "Appearance"])
+ await expect(sidebar.getByText(label, { exact: true })).toHaveCount(0);
+ await expect(sidebar.getByRole("button", { name: "Reset", exact: true })).toHaveCount(0);
+ await page.getByRole("button", { name: /Speech model/ }).click();
+ for (const label of ["Parakeet v3", "Whisper Base", "Whisper Small"])
+ await expect(page.getByRole("option", { name: label, exact: true })).toBeVisible();
+ await page.getByRole("option", { name: "Whisper Base", exact: true }).click();
+
+ await page.getByRole("button", { name: "Download Model", exact: true }).click();
+ await expect(
+ page.getByRole("button", { name: "Downloading... 0%", exact: true }),
+ ).toBeDisabled();
+ await page.evaluate(() =>
+ window.dispatchEvent(
+ new CustomEvent("caption-model-progress", {
+ detail: { modelId: "whisper-base", status: "downloading", progress: 47 },
+ }),
+ ),
+ );
+ await expect(
+ page.getByRole("progressbar", { name: "Downloading...", exact: true }),
+ ).toHaveAttribute("aria-valuenow", "47");
+ await page.evaluate(() => window.dispatchEvent(new Event("complete-caption-model-download")));
+ await expect(
+ page.getByRole("button", { name: "Generate Captions", exact: true }),
+ ).toBeEnabled();
+ await page.getByRole("button", { name: "Generate Captions", exact: true }).click();
+ await expect(
+ page.getByRole("button", { name: "Regenerate Captions", exact: true }),
+ ).toBeVisible();
+ const show = page.getByRole("switch", { name: "Show", exact: true });
+ await expect(show).toBeChecked();
+ await expect(show).toBeChecked();
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-caption-generated-model",
+ "whisper-base",
+ );
+ await expect(sidebar.getByText("1 caption", { exact: true })).toBeVisible();
+ await page.getByRole("button", { name: "Karaoke", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Karaoke", exact: true })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ const presets = await sidebar
+ .getByRole("group", { name: "Caption style" })
+ .getByRole("button")
+ .evaluateAll((nodes) => nodes.map((node) => node.getBoundingClientRect().y));
+ expect(new Set(presets).size).toBe(2);
+ expect(await sidebar.evaluate((node) => node.scrollWidth <= node.clientWidth + 1)).toBe(true);
+ await expect(page.getByRole("button", { name: "Clear Captions", exact: true })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Delete Model", exact: true })).toHaveCount(0);
+ await page.getByRole("complementary").screenshot({
+ path: testInfo.outputPath("captions-ui.png"),
+ animations: "disabled",
+ });
+ await page.getByRole("button", { name: "Caption actions", exact: true }).click();
+ await expect(page.getByRole("menuitem", { name: "Delete Model", exact: true })).toHaveCount(0);
+ await page.getByRole("menuitem", { name: "Clear Captions", exact: true }).click();
+ await expect(page.locator('[data-variant="caption"]')).toHaveCount(0);
+ await expect(
+ page.getByRole("button", { name: "Generate Captions", exact: true }),
+ ).toBeVisible();
+ await expect(page.locator("html")).not.toHaveAttribute("data-caption-model-deleted");
+ await page.getByRole("switch", { name: "Advanced settings" }).press("Space");
+ await expect(page.getByRole("slider", { name: "Rows", exact: true })).toBeVisible();
+ await page.getByRole("button", { name: "Caption actions", exact: true }).click();
+ await page.getByRole("menuitem", { name: "Delete Model", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-caption-model-deleted",
+ "whisper-base",
+ );
+ await expect(page.getByRole("button", { name: "Download Model", exact: true })).toBeEnabled();
+ expect(errors).toEqual([]);
+});
diff --git a/tests/ui/caption-highlights.spec.ts b/tests/ui/caption-highlights.spec.ts
new file mode 100644
index 00000000..e754f839
--- /dev/null
+++ b/tests/ui/caption-highlights.spec.ts
@@ -0,0 +1,225 @@
+import { expect, test, type Page } from "@playwright/test";
+
+async function seek(page: Page, timeMs: number) {
+ await page.getByRole("spinbutton", { name: "Time", exact: true }).fill(String(timeMs));
+}
+
+test("fixed-size word highlights follow speech without reviving captions after a pause", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(120000);
+ const errors: string[] = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.goto("/tests/ui/caption-preview.html");
+ const caption = page.getByRole("button", { name: "Edit current caption", exact: true });
+ const highlight = caption.locator("[data-caption-word-highlight]");
+ await seek(page, 700);
+ await expect(caption).toHaveText("Follow every word");
+ await expect(highlight).toHaveCount(1);
+ await expect(caption.locator('[data-caption-word-state="active"]')).toHaveText("every");
+ await expect(highlight).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
+ await expect(caption).toHaveCSS("background-color", "rgba(0, 0, 0, 0.7)");
+ const initialBox = await caption.boundingBox();
+ const typography = await caption.evaluate((node) => {
+ const active = node.querySelector("[data-caption-word-highlight]")!;
+ const inactive = node.querySelector('[data-caption-word-state="spoken"]')!;
+ const highlightBox = active.querySelector('[aria-hidden="true"]')!;
+ const a = getComputedStyle(active),
+ b = getComputedStyle(inactive);
+ const visual = active.querySelector("[data-caption-word-visual]")!;
+ const canvas = document.createElement("canvas"),
+ ctx = canvas.getContext("2d")!;
+ ctx.font = `${b.fontWeight} ${b.fontSize} ${b.fontFamily}`;
+ const expectedSpace = ctx.measureText(" ").width;
+ const gap = active.getBoundingClientRect().left - inactive.getBoundingClientRect().right;
+ return {
+ visualScale: new DOMMatrix(getComputedStyle(visual).transform).a,
+ activeTransform: a.transform,
+ verticalAlign: a.verticalAlign,
+ radius: parseFloat(getComputedStyle(highlightBox).borderRadius),
+ highlightHeight: highlightBox.getBoundingClientRect().height,
+ gap,
+ expectedSpace,
+ };
+ });
+ expect(typography.visualScale).toBe(1);
+ expect(typography.activeTransform).toBe("none");
+ expect(typography.verticalAlign).toBe("baseline");
+ expect(typography.radius).toBeLessThan(typography.highlightHeight / 4);
+ expect(typography.gap).toBeCloseTo(typography.expectedSpace, 0);
+ await page.screenshot({ path: testInfo.outputPath("word-highlight-preview.png") });
+ await seek(page, 1200);
+ await expect(caption.locator('[data-caption-word-state="active"]')).toHaveText("word");
+ for (const [time, word] of [
+ [400, "Follow"],
+ [499, "Follow"],
+ [500, "every"],
+ [900, "every"],
+ [999, "every"],
+ [1000, "word"],
+ [1499, "word"],
+ ] as const) {
+ await seek(page, time);
+ await expect(caption.locator('[data-caption-word-state="active"]')).toHaveText(word);
+ await expect(highlight).toHaveCount(1);
+ const box = await caption.boundingBox();
+ expect(box!.width).toBeCloseTo(initialBox!.width, 2);
+ expect(box!.x).toBeCloseTo(initialBox!.x, 2);
+ }
+
+ await seek(page, 2500);
+ await expect(caption).toHaveCount(0);
+ await seek(page, 3100);
+ await expect(caption).toHaveText("Clean caption transitions");
+ await expect(highlight).toHaveCount(0);
+ await seek(page, 3500);
+ await expect(caption.locator('[data-caption-word-state="active"]')).toHaveText("Clean");
+ await expect(highlight).toHaveCount(1);
+ await seek(page, 3750);
+ await expect(highlight).toHaveCount(1);
+ await expect(caption.locator('[data-caption-word-state="active"]')).toHaveText("Clean");
+ await seek(page, 3800);
+ await expect(caption.locator('[data-caption-word-state="active"]')).toHaveText("caption");
+ await expect(caption).toHaveText("Clean caption transitions");
+ expect(errors).toEqual([]);
+});
+
+test("legacy and modern export highlight the active word and round the black background", async ({
+ page,
+}) => {
+ await page.goto("/tests/ui/caption-rendering.html");
+ await expect(page.locator("body")).toHaveAttribute("data-result", /samples/);
+ const result = JSON.parse((await page.locator("body").getAttribute("data-result"))!);
+ for (const sample of result.samples) {
+ expect(sample.heldMatchesActive).toBe(true);
+ expect(sample.activePixelsDiffer).toBe(true);
+ expect(sample.cornerAlpha).toBe(0);
+ expect(sample.pause[3]).toBeGreaterThanOrEqual(178);
+ expect(sample.pause[3]).toBeLessThanOrEqual(179);
+ expect(
+ sample.backgroundBounds.every(
+ (bounds: number[]) =>
+ JSON.stringify(bounds) === JSON.stringify(sample.backgroundBounds[0]),
+ ),
+ ).toBe(true);
+ expect(sample.silence[3]).toBe(0);
+ }
+ expect(result.nextText).toBe("Next phrase");
+ expect(result.nextActiveWord).toBe(-1);
+});
+
+test("caption styles keep the box compact and steady while previewing each preset", async ({
+ page,
+}, testInfo) => {
+ await page.goto("/tests/ui/caption-preview.html");
+ const picker = page.getByRole("group", { name: "Caption style", exact: true });
+ await expect(picker.getByRole("button")).toHaveCount(6);
+ const caption = page.getByRole("button", { name: "Edit current caption", exact: true });
+ for (const [name, weight, color, hasPill] of [
+ ["Classic", "400", "rgb(255, 255, 255)", true],
+ ["TikTok", "800", "rgb(255, 230, 0)", false],
+ ["Karaoke", "700", "rgb(20, 32, 0)", true],
+ ["Minimal", "500", "rgb(255, 255, 255)", false],
+ ["Neon", "700", "rgb(101, 255, 255)", true],
+ ["Paper", "600", "rgb(31, 41, 55)", true],
+ ] as const) {
+ const option = picker.getByRole("button", { name, exact: true });
+ await option.click();
+ await expect(option).toHaveAttribute("aria-pressed", "true");
+ await seek(page, 700);
+ await expect(caption).toHaveCSS("font-weight", weight);
+ if (name === "TikTok") {
+ await expect(caption).toHaveCSS("font-family", /TikTok Sans/);
+ expect(
+ await page.evaluate(() =>
+ Array.from(document.fonts).some(
+ (face) =>
+ face.family.replaceAll('"', "") === "TikTok Sans" &&
+ face.status === "loaded",
+ ),
+ ),
+ ).toBe(true);
+ }
+ const active = caption.locator('[data-caption-word-state="active"]');
+ await expect(active).toHaveCSS("color", color);
+ await expect(active.locator('[aria-hidden="true"]')).toHaveCount(hasPill ? 1 : 0);
+ const size = await caption.evaluate((node) => parseFloat(getComputedStyle(node).fontSize));
+ const box = await caption.boundingBox();
+ expect(box!.height / size).toBeCloseTo(2.12, 1);
+ for (const time of [400, 700, 1200]) {
+ await seek(page, time);
+ const next = await caption.boundingBox();
+ expect(next!.width).toBeCloseTo(box!.width, 2);
+ expect(next!.height).toBeCloseTo(box!.height, 2);
+ }
+ await seek(page, 700);
+ await page.screenshot({ path: testInfo.outputPath(`caption-${name.toLowerCase()}.png`) });
+ }
+ await picker.getByRole("button", { name: "Classic", exact: true }).click();
+ await expect(caption).toHaveCSS("background-color", "rgba(0, 0, 0, 0.7)");
+});
+
+test("all caption styles render their foreground and background in both export paths", async ({
+ page,
+}) => {
+ await page.goto("/tests/ui/caption-rendering.html");
+ await expect(page.locator("body")).toHaveAttribute("data-result", /styles/);
+ const result = JSON.parse((await page.locator("body").getAttribute("data-result"))!);
+ expect(result.styles).toHaveLength(6);
+ for (const style of result.styles) {
+ expect(new Set(style.widths).size, style.id).toBe(1);
+ expect(style.heightInEm, style.id).toBeCloseTo(2.12, 2);
+ for (const sample of style.samples) {
+ expect(sample.foregroundPixels, style.id).toBeGreaterThan(5);
+ expect(
+ Math.abs(sample.backgroundAlpha - style.backgroundOpacity * 255),
+ style.id,
+ ).toBeLessThanOrEqual(1);
+ if (style.id === "paper") {
+ expect(sample.backgroundPixel.slice(0, 3)).toEqual([255, 251, 235]);
+ }
+ }
+ }
+});
+
+test("six style cards fill equal columns and keep previews inset at narrow widths", async ({
+ page,
+}, testInfo) => {
+ await page.goto("/tests/ui/caption-preview.html");
+ const picker = page.getByRole("group", { name: "Caption style", exact: true });
+ for (const width of [280, 240, 200]) {
+ await page.locator("aside").evaluate((node, width) => {
+ node.style.width = `${width}px`;
+ node.style.flexShrink = "0";
+ }, width);
+ const geometry = await picker.evaluate((group) => {
+ const grid = group.getBoundingClientRect();
+ return Array.from(group.querySelectorAll("button")).map((button) => {
+ const card = button.getBoundingClientRect();
+ const preview = button
+ .querySelector('[aria-hidden="true"]')!
+ .getBoundingClientRect();
+ const sample = button
+ .querySelector('[aria-hidden="true"] > span')!
+ .getBoundingClientRect();
+ return {
+ width: card.width,
+ height: card.height,
+ expectedWidth: (grid.width - 8) / 2,
+ leftInset: sample.left - preview.left,
+ rightInset: preview.right - sample.right,
+ };
+ });
+ });
+ for (const card of geometry) {
+ expect(card.width).toBeCloseTo(card.expectedWidth, 1);
+ expect(card.height).toBe(84);
+ expect(card.leftInset).toBeGreaterThanOrEqual(6);
+ expect(card.rightInset).toBeGreaterThanOrEqual(6);
+ }
+ }
+ await page.locator("aside").evaluate((node) => {
+ node.style.width = "280px";
+ });
+ await picker.screenshot({ path: testInfo.outputPath("six-caption-styles.png") });
+});
diff --git a/tests/ui/caption-preview.html b/tests/ui/caption-preview.html
new file mode 100644
index 00000000..4935d178
--- /dev/null
+++ b/tests/ui/caption-preview.html
@@ -0,0 +1 @@
+Caption preview styling
diff --git a/tests/ui/caption-preview.tsx b/tests/ui/caption-preview.tsx
new file mode 100644
index 00000000..3f20995c
--- /dev/null
+++ b/tests/ui/caption-preview.tsx
@@ -0,0 +1,118 @@
+import { useCaptionFont } from "@/components/video-editor/useCaptionFont";
+import { useRef, useState } from "react";
+import { createRoot } from "react-dom/client";
+import { buildActiveCaptionLayout } from "@/components/video-editor/captionLayout";
+import { getCaptionPreset } from "@/components/video-editor/captionPresets";
+import { CaptionGenerationSettings } from "@/components/video-editor/settings/CaptionGenerationSettings";
+import { I18nProvider } from "@/contexts/I18nContext";
+import {
+ getCaptionFontBaseline,
+ getCaptionScaledFontSize,
+ getCaptionTextMaxWidth,
+} from "@/components/video-editor/captionStyle";
+import {
+ type AutoCaptionSettings,
+ DEFAULT_AUTO_CAPTION_SETTINGS,
+} from "@/components/video-editor/types";
+import { PlaybackCaptions } from "@/components/video-editor/videoPlayback/PlaybackCaptions";
+import "@/index.css";
+
+function Preview() {
+ const [time, setTime] = useState(700);
+ const overlayRef = useRef(null);
+ const captionBoxRef = useRef(null);
+ const [settings, setSettings] = useState({
+ ...DEFAULT_AUTO_CAPTION_SETTINGS,
+ enabled: true,
+ animationStyle: "none" as const,
+ });
+ const captionFont = useCaptionFont(settings);
+ const cues = [
+ {
+ id: "a",
+ startMs: 0,
+ endMs: 1500,
+ text: "Follow every word",
+ words: [
+ { text: "Follow", startMs: 0, endMs: 400 },
+ { text: "every", startMs: 500, endMs: 900, leadingSpace: true },
+ { text: "word", startMs: 1000, endMs: 1400, leadingSpace: true },
+ ],
+ },
+ {
+ id: "b",
+ startMs: 3000,
+ endMs: 5000,
+ text: "Clean caption transitions",
+ words: [
+ { text: "Clean", startMs: 3300, endMs: 3700 },
+ { text: "caption", startMs: 3800, endMs: 4200, leadingSpace: true },
+ { text: "transitions", startMs: 4300, endMs: 4900, leadingSpace: true },
+ ],
+ },
+ ];
+ const fontSize = getCaptionScaledFontSize(settings.fontSize, 960, settings.maxWidth);
+ const ctx = document.createElement("canvas").getContext("2d")!;
+ ctx.font = `${getCaptionPreset(settings.style).fontWeight} ${fontSize}px ${captionFont.fontFamily}`;
+ const layout = !captionFont.ready
+ ? null
+ : buildActiveCaptionLayout({
+ fontBaselinePx: getCaptionFontBaseline(fontSize, ctx.measureText("Hg")),
+ cues,
+ timeMs: time,
+ settings,
+ maxWidthPx: getCaptionTextMaxWidth(960, settings.maxWidth, fontSize),
+ measureText: (text) => ctx.measureText(text).width,
+ });
+ const input = {
+ isGap: false,
+ activeCaptionLayout: layout,
+ autoCaptionSettings: settings,
+ onEditAutoCaption: () => undefined,
+ captionBoxRef,
+ isCaptionEditing: false,
+ te: (_key: string, fallback: string) => fallback,
+ beginCaptionEdit: () => undefined,
+ captionFontFamily: captionFont.fontFamily,
+ overlayRef,
+ captionEditSession: null,
+ } as Parameters[0];
+ return (
+ <>
+
+ Time
+ setTime(Number(e.target.value))}
+ />
+
+
+ >
+ );
+}
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/tests/ui/caption-rendering.html b/tests/ui/caption-rendering.html
new file mode 100644
index 00000000..c3325a43
--- /dev/null
+++ b/tests/ui/caption-rendering.html
@@ -0,0 +1 @@
+Caption export rendering
diff --git a/tests/ui/caption-rendering.ts b/tests/ui/caption-rendering.ts
new file mode 100644
index 00000000..ebf0556a
--- /dev/null
+++ b/tests/ui/caption-rendering.ts
@@ -0,0 +1,175 @@
+import { ensureCaptionFontLoaded } from "@/components/video-editor/captionFonts";
+import { Container } from "pixi.js";
+import { renderCaptions } from "@/lib/exporter/captionRenderer";
+import { FrameCaptions } from "@/lib/exporter/modernFrameCaptions";
+import { DEFAULT_AUTO_CAPTION_SETTINGS } from "@/components/video-editor/types";
+import { applyCaptionPreset, CAPTION_PRESETS } from "@/components/video-editor/captionPresets";
+import { getCaptionPadding } from "@/components/video-editor/captionStyle";
+import type { FrameRenderContext } from "@/lib/exporter/modernFrameRenderer";
+
+async function run() {
+ await ensureCaptionFontLoaded({ style: "tiktok", fontFamily: "" });
+ const width = 960,
+ height = 540;
+ const settings = {
+ ...DEFAULT_AUTO_CAPTION_SETTINGS,
+ enabled: true,
+ animationStyle: "none",
+ };
+ const cues = [
+ {
+ id: "one",
+ startMs: 0,
+ endMs: 1000,
+ text: "Follow every word",
+ words: [
+ { text: "Follow", startMs: 80, endMs: 300 },
+ { text: "every", startMs: 400, endMs: 700, leadingSpace: true },
+ { text: "word", startMs: 750, endMs: 950, leadingSpace: true },
+ ],
+ },
+ {
+ id: "two",
+ startMs: 2000,
+ endMs: 3000,
+ text: "Next phrase",
+ words: [
+ { text: "Next", startMs: 2100, endMs: 2400 },
+ { text: "phrase", startMs: 2500, endMs: 2900, leadingSpace: true },
+ ],
+ },
+ ];
+ const frame = new FrameCaptions({
+ config: { width, height, autoCaptions: cues, autoCaptionSettings: settings },
+ scene: { captionContainer: new Container() },
+ } as unknown as Pick);
+ frame.setupCaptionResources();
+ function draw(time: number, modern: boolean) {
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext("2d")!;
+ if (modern) {
+ frame.updateCaptionLayer(time);
+ frame.drawCaptionOverlay(ctx);
+ } else renderCaptions(ctx, cues, settings, width, height, time);
+ return ctx;
+ }
+ const state = frame.buildCaptionRenderState(500)!;
+ const line = state.layout.visibleLines[0];
+ const measure = frame.captionMeasureCtx;
+ const sampleX =
+ state.centerX -
+ line.width / 2 +
+ measure.measureText("Follow ").width +
+ measure.measureText("every").width / 2;
+ const sampleY = state.centerY + state.fontSize * 0.48;
+ const pixel = (ctx: CanvasRenderingContext2D) =>
+ Array.from(ctx.getImageData(Math.floor(sampleX), Math.floor(sampleY), 1, 1).data);
+ const samples = [false, true].map((modern) => {
+ const backgroundBounds = [0, 150, 500, 800, 999].map((time) => {
+ const row = draw(time, modern).getImageData(
+ 0,
+ Math.floor(state.centerY),
+ width,
+ 1,
+ ).data;
+ const filled = Array.from({ length: width }, (_, x) => x).filter(
+ (x) => row[x * 4 + 3] > 0,
+ );
+ return [filled[0], filled[filled.length - 1]];
+ });
+ const active = draw(500, modern),
+ // Only the cue lead-in is unhighlighted now; gaps hold the previous word.
+ inactive = draw(0, modern);
+ const held = draw(725, modern);
+ const activePixels = active.getImageData(0, 0, width, height).data;
+ const heldPixels = held.getImageData(0, 0, width, height).data;
+ const inactivePixels = inactive.getImageData(0, 0, width, height).data;
+ return {
+ backgroundBounds,
+ heldMatchesActive: activePixels.every((value, index) => value === heldPixels[index]),
+ activePixelsDiffer: activePixels.some(
+ (value, index) => value !== inactivePixels[index],
+ ),
+ cornerAlpha: active.getImageData(
+ Math.floor(state.centerX - state.boxWidth / 2 + 1),
+ Math.floor(state.centerY - state.boxHeight / 2 + 1),
+ 1,
+ 1,
+ ).data[3],
+ pause: pixel(inactive),
+ silence: pixel(draw(1500, modern)),
+ };
+ });
+ const next = frame.buildCaptionRenderState(2000);
+ const styles = CAPTION_PRESETS.map((preset) => {
+ const styledSettings = {
+ ...applyCaptionPreset(settings, preset.id),
+ animationStyle: "none" as const,
+ };
+ const styledFrame = new FrameCaptions({
+ config: { width, height, autoCaptions: cues, autoCaptionSettings: styledSettings },
+ scene: { captionContainer: new Container() },
+ } as unknown as Pick);
+ styledFrame.setupCaptionResources();
+ const state = styledFrame.buildCaptionRenderState(500)!;
+ const foreground = preset.highlightTextColor ?? styledSettings.textColor;
+ const target = [1, 3, 5].map((i) => parseInt(foreground.slice(i, i + 2), 16));
+ const samples = [false, true].map((modern) => {
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext("2d")!;
+ if (modern) {
+ styledFrame.updateCaptionLayer(500);
+ styledFrame.drawCaptionOverlay(ctx);
+ } else renderCaptions(ctx, cues, styledSettings, width, height, 500);
+ const pixels = ctx.getImageData(0, 0, width, height).data;
+ let foregroundPixels = 0;
+ for (let i = 0; i < pixels.length; i += 4) {
+ if (
+ pixels[i + 3] > 200 &&
+ // Small fixed-size text is antialiased rather than solid foreground pixels.
+ target.every((value, channel) => Math.abs(value - pixels[i + channel]) < 24)
+ )
+ foregroundPixels++;
+ }
+ const backgroundPixel = Array.from(
+ ctx.getImageData(
+ Math.floor(
+ state.centerX -
+ state.boxWidth / 2 +
+ getCaptionPadding(state.fontSize).x / 4,
+ ),
+ Math.floor(state.centerY),
+ 1,
+ 1,
+ ).data,
+ );
+ return { foregroundPixels, backgroundAlpha: backgroundPixel[3], backgroundPixel };
+ });
+ return {
+ id: preset.id,
+ samples,
+ widths: [0, 150, 500, 800, 999].map(
+ (time) => styledFrame.buildCaptionRenderState(time)!.boxWidth,
+ ),
+ heightInEm: state.boxHeight / state.fontSize,
+ backgroundOpacity: styledSettings.backgroundOpacity,
+ };
+ });
+ return {
+ styles,
+ samples,
+ nextText: next.layout.editTarget.text,
+ nextActiveWord: next.layout.activeWordIndex,
+ };
+}
+void run()
+ .then((result) => {
+ document.body.dataset.result = JSON.stringify(result);
+ })
+ .catch((error: unknown) => {
+ document.body.dataset.error = String(error);
+ });
diff --git a/tests/ui/caption-word-spacing.spec.ts b/tests/ui/caption-word-spacing.spec.ts
new file mode 100644
index 00000000..d1625ff5
--- /dev/null
+++ b/tests/ui/caption-word-spacing.spec.ts
@@ -0,0 +1,124 @@
+import { expect, test } from "@playwright/test";
+
+test("word highlighting preserves every word's position and size", async ({ page }, testInfo) => {
+ test.setTimeout(120000);
+ await page.goto("/tests/ui/caption-preview.html");
+ const caption = page.getByRole("button", { name: "Edit current caption", exact: true });
+ const picker = page.getByRole("group", { name: "Caption style", exact: true });
+ for (const style of ["Classic", "TikTok", "Karaoke", "Minimal", "Neon", "Paper"]) {
+ await picker.getByRole("button", { name: style, exact: true }).click();
+ await page.evaluate(() => document.fonts.ready);
+ const samples = [];
+ for (const [time, activeWord] of [
+ [400, "Follow"],
+ [700, "every"],
+ [1200, "word"],
+ ] as const) {
+ await page.getByRole("spinbutton", { name: "Time", exact: true }).fill(String(time));
+ await expect(caption.locator('[data-caption-word-state="active"]')).toHaveText(
+ activeWord,
+ );
+ samples.push(
+ await caption.locator("[data-caption-word-state]").evaluateAll((words) =>
+ words.map((word) => {
+ const rect = word
+ .querySelector("[data-caption-word-visual]")!
+ .getBoundingClientRect();
+ return {
+ x: rect.x,
+ y: rect.y,
+ width: rect.width,
+ height: rect.height,
+ };
+ }),
+ ),
+ );
+ }
+ for (let index = 0; index < 3; index++) {
+ const before = samples[0][index];
+ for (const words of samples.slice(1)) {
+ for (const axis of ["x", "y", "width", "height"] as const) {
+ expect(words[index][axis], `${style}: word ${index} ${axis}`).toBeCloseTo(
+ before[axis],
+ 2,
+ );
+ }
+ }
+ }
+ }
+ await picker.getByRole("button", { name: "Classic", exact: true }).click();
+ await page.screenshot({ path: testInfo.outputPath("independent-caption-highlight.png") });
+});
+
+test("export highlighting preserves every word's position and size", async ({ page }) => {
+ await page.goto("/tests/ui/caption-preview.html");
+ await page.evaluate(() => document.fonts.ready);
+ const samples = await page.evaluate(async () => {
+ const rendererPath = "/src/lib/exporter/captionText.ts";
+ const presetsPath = "/src/components/video-editor/captionPresets.ts";
+ const layoutPath = "/src/components/video-editor/captionLayout.ts";
+ const typesPath = "/src/components/video-editor/types.ts";
+ const { drawCaptionLine } = await import(rendererPath);
+ const { CAPTION_PRESETS, applyCaptionPreset } = await import(presetsPath);
+ const { buildActiveCaptionLayout } = await import(layoutPath);
+ const { DEFAULT_AUTO_CAPTION_SETTINGS, getDefaultCaptionFontFamily } = await import(
+ typesPath
+ );
+ const cues = [
+ {
+ id: "spacing",
+ startMs: 0,
+ endMs: 1500,
+ text: "Follow every word",
+ words: [
+ { text: "Follow", startMs: 0, endMs: 400 },
+ { text: "every", startMs: 500, endMs: 900, leadingSpace: true },
+ { text: "word", startMs: 1000, endMs: 1400, leadingSpace: true },
+ ],
+ },
+ ];
+ return CAPTION_PRESETS.map((preset: { id: string; fontWeight: number }) => {
+ const settings = applyCaptionPreset(DEFAULT_AUTO_CAPTION_SETTINGS, preset.id);
+ const family = getDefaultCaptionFontFamily();
+ const ctx = document.createElement("canvas").getContext("2d")!;
+ ctx.font = `${preset.fontWeight} 30px ${family}`;
+ let positions: Record =
+ {};
+ ctx.fillText = (text, x, y) => {
+ const transform = ctx.getTransform();
+ const point = transform.transformPoint({ x, y });
+ positions[text] = {
+ x: point.x,
+ y: point.y,
+ width: ctx.measureText(text).width * transform.a,
+ scaleY: transform.d,
+ };
+ };
+ const frames = [400, 700, 1200].map((timeMs) => {
+ positions = {};
+ const layout = buildActiveCaptionLayout({
+ cues,
+ timeMs,
+ settings,
+ maxWidthPx: 700,
+ measureText: (text: string) => ctx.measureText(text).width,
+ });
+ drawCaptionLine(ctx, layout.visibleLines[0], settings, 30, family, 480, 200, true);
+ return positions;
+ });
+ return { style: preset.id, frames };
+ });
+ });
+ for (const { style, frames } of samples) {
+ for (const word of ["Follow", "every", "word"]) {
+ for (const frame of frames.slice(1)) {
+ for (const axis of ["x", "y", "width", "scaleY"] as const) {
+ expect(frame[word][axis], `${style}: ${word} ${axis}`).toBeCloseTo(
+ frames[0][word][axis],
+ 5,
+ );
+ }
+ }
+ }
+ }
+});
diff --git a/tests/ui/capture-picker.spec.ts b/tests/ui/capture-picker.spec.ts
index c69da82a..f4d5c332 100644
--- a/tests/ui/capture-picker.spec.ts
+++ b/tests/ui/capture-picker.spec.ts
@@ -58,7 +58,9 @@ async function pick(page: Page) {
return value ? JSON.parse(value) : undefined;
}
-test("hovering highlights the frontmost window and a click picks it", async ({ page }) => {
+test("clicking the highlighted frontmost window selects it immediately without options", async ({
+ page,
+}) => {
await openPicker(page);
await page.mouse.move(600, 300);
await expect(page.getByText("Notes — Launch checklist · 620 × 460")).toBeVisible();
@@ -76,7 +78,7 @@ test("hovering highlights the frontmost window and a click picks it", async ({ p
test("a click on the desktop picks the whole screen", async ({ page }) => {
await openPicker(page);
await page.mouse.move(1380, 60);
- await expect(page.getByText("Entire screen · 1440 × 1000")).toBeVisible();
+ await expect(page.getByText("Entire screen · 1,440 × 1,000")).toBeVisible();
await page.mouse.click(1380, 60);
expect(await pick(page)).toEqual({ kind: "screen", displayId: 1, record: false });
@@ -118,6 +120,7 @@ test("platforms without area capture keep drag selection on an existing screen",
page,
}) => {
await openPicker(page, false);
+ await expect(page.getByRole("button", { name: "Area", exact: true })).toHaveCount(0);
await page.mouse.move(1300, 100);
await page.mouse.down();
await page.mouse.move(1380, 180, { steps: 8 });
@@ -160,7 +163,7 @@ for (const { selected, trigger } of [
if (selected) {
await expect(control).toContainText("Existing screen");
} else {
- await expect(control).toHaveText("Pick screen");
+ await expect(control).toHaveText("Pick source");
}
await expect(page.locator("html")).not.toHaveAttribute("data-picker-calls");
await (trigger === "source"
@@ -303,7 +306,7 @@ test("HUD reports a failed pick and keeps cancellation silent", async ({ page })
).toHaveCount(0);
});
-test("unselected source reads Pick screen once and Record stays disabled while picking", async ({
+test("unselected source reads Pick source once and Record stays disabled while picking", async ({
page,
}) => {
await installDesktopBridge(page);
@@ -321,14 +324,14 @@ test("unselected source reads Pick screen once and Record stays disabled while p
await page.goto("/?windowType=hud-overlay");
const source = page.getByRole("button", { name: "Choose recording source" });
await expect(source).toBeEnabled();
- await expect(source).toHaveText("Pick screen");
+ await expect(source).toHaveText("Pick source");
await expect(page.locator("html")).not.toHaveAttribute("data-picker-pending");
await source.click();
await expect(page.locator("html")).toHaveAttribute("data-picker-pending", "true");
await expect(page.getByRole("button", { name: "Record", exact: true })).toBeDisabled();
await page.evaluate(() => (window as unknown as { finishPicker: () => void }).finishPicker());
await expect(page.getByRole("button", { name: "Record", exact: true })).toBeEnabled();
- await expect(source).toHaveText("Pick screen");
+ await expect(source).toHaveText("Pick source");
});
test("changing source cancels countdown and discards native warm-up before opening the picker", async ({
@@ -485,10 +488,116 @@ for (const destination of ["window", "screen"] as const) {
page.getByText(
destination === "window"
? "Notes — Launch checklist · 620 × 460"
- : "Entire screen · 1440 × 1000",
+ : "Entire screen · 1,440 × 1,000",
),
).toBeVisible();
await page.mouse.click(point.x, point.y);
expect(await pick(page)).toMatchObject({ kind: destination, displayId: 1, record: false });
});
}
+
+test("a queued selection can still be canceled with Escape", async ({ page }) => {
+ await openPicker(page);
+ await page.evaluate(() => {
+ window.electronAPI.completeCapturePick = async (value) => {
+ if (value) {
+ document.documentElement.dataset.queued = "true";
+ await new Promise(() => {
+ /* Simulate a queued completion from another overlay. */
+ });
+ } else document.documentElement.dataset.pick = "null";
+ };
+ });
+ await page.mouse.click(1380, 200);
+ await expect(page.locator("html")).toHaveAttribute("data-queued", "true");
+ await page.keyboard.press("Escape");
+ expect(await pick(page)).toBeNull();
+});
+
+test("a window expanding from another display keeps its frontmost hit-test rank", async ({
+ page,
+}) => {
+ await openPicker(page);
+ await page.addInitScript(() => {
+ window.electronAPI.getCapturePickerContext = async () => ({
+ displayBounds: { x: 0, y: 0, width: 1440, height: 1000 },
+ lastArea: null,
+ allowArea: true,
+ cursor: null,
+ windows: [
+ {
+ id: "window:101:0",
+ appName: "Notes",
+ title: "Launch checklist",
+ x: -500,
+ y: 100,
+ width: 400,
+ height: 500,
+ },
+ {
+ id: "window:102:0",
+ appName: "Safari",
+ title: "Release notes",
+ x: 0,
+ y: 100,
+ width: 700,
+ height: 500,
+ },
+ ],
+ });
+ window.electronAPI.onCapturePickerWindowResized = (callback) => {
+ const listener = (event: Event) => callback((event as CustomEvent).detail);
+ window.addEventListener("test-window-resized", listener);
+ return () => window.removeEventListener("test-window-resized", listener);
+ };
+ });
+ await page.reload();
+ await expect(page.locator("html")).toHaveAttribute("data-picker-ready", "true");
+ await page.evaluate(() =>
+ window.dispatchEvent(
+ new CustomEvent("test-window-resized", {
+ detail: {
+ id: "window:101:0",
+ appName: "Notes",
+ title: "Launch checklist",
+ x: -500,
+ y: 100,
+ width: 900,
+ height: 500,
+ },
+ }),
+ ),
+ );
+ await page.mouse.move(200, 300);
+ await expect(page.getByText("Notes — Launch checklist · 400 × 500")).toBeVisible();
+ await page.mouse.click(200, 300);
+});
+
+for (const theme of ["light", "dark"] as const) {
+ test(`window selection has no options or instruction panel in ${theme} mode`, async ({
+ page,
+ }) => {
+ await openPicker(page);
+ await page.evaluate((theme) => {
+ document.documentElement.dataset.theme = theme;
+ document.documentElement.classList.toggle("dark", theme === "dark");
+ }, theme);
+ await expect(
+ page.getByText(/Click a window|drag an area|Move or resize the area|Esc to cancel/i),
+ ).toHaveCount(0);
+ await page.mouse.click(600, 300);
+ expect(await pick(page)).toEqual({
+ kind: "window",
+ windowId: "window:101:0",
+ displayId: 1,
+ record: false,
+ });
+ await expect(page.getByRole("form", { name: "Window size" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Select window", exact: true })).toHaveCount(
+ 0,
+ );
+ await expect(
+ page.getByRole("button", { name: "Adjust recording area", exact: true }),
+ ).toHaveCount(0);
+ });
+}
diff --git a/tests/ui/cinematic-cursor.spec.ts b/tests/ui/cinematic-cursor.spec.ts
new file mode 100644
index 00000000..97e9fee8
--- /dev/null
+++ b/tests/ui/cinematic-cursor.spec.ts
@@ -0,0 +1,173 @@
+import { expect, test } from "@playwright/test";
+
+test("Cinematic's heavier glide matches preview and canvas export", async ({ page }) => {
+ await page.route("**/cinematic-weight-audit.html", (route) =>
+ route.fulfill({ contentType: "text/html", body: "" }),
+ );
+ await page.goto("/cinematic-weight-audit.html");
+ const trajectories = await page.evaluate(async () => {
+ const renderer = await import(
+ "/src/components/video-editor/videoPlayback/cursorRenderer.ts"
+ );
+ const { CURSOR_MOTION_PRESETS } = await import(
+ "/src/components/video-editor/cursorMotionPresets.ts"
+ );
+ await renderer.preloadCursorAssets();
+ const canvas = document.createElement("canvas");
+ canvas.width = 800;
+ canvas.height = 400;
+ const viewport = { x: 0, y: 0, width: 800, height: 400 };
+ const samples = [
+ { timeMs: 0, cx: 0.2, cy: 0.5 },
+ { timeMs: 100, cx: 0.8, cy: 0.5 },
+ { timeMs: 2000, cx: 0.8, cy: 0.5 },
+ ];
+ const results = [];
+ for (const preset of [CURSOR_MOTION_PRESETS.focused, CURSOR_MOTION_PRESETS.smooth]) {
+ const config = {
+ ...renderer.DEFAULT_CURSOR_CONFIG,
+ style: "dot",
+ smoothingFactor: preset.cursorSmoothing,
+ springTuning: {
+ stiffnessMultiplier: preset.cursorSpringStiffnessMultiplier,
+ dampingMultiplier: preset.cursorSpringDampingMultiplier,
+ massMultiplier: preset.cursorSpringMassMultiplier,
+ },
+ };
+ const overlay = new renderer.PixiCursorOverlay(config);
+ const exported = new renderer.SmoothedCursorState(config);
+ const frames = [];
+ for (let frame = 0; frame <= 120; frame++) {
+ const time = (frame * 1000) / 60;
+ overlay.update(samples, time, viewport, true);
+ renderer.drawCursorOnCanvas(
+ canvas.getContext("2d")!,
+ samples,
+ time,
+ viewport,
+ exported,
+ config,
+ );
+ frames.push({
+ time,
+ preview: overlay.getSmoothedCursorSnapshot()!.cx,
+ exported: exported.x,
+ });
+ }
+ results.push(frames);
+ overlay.destroy();
+ }
+ return results;
+ });
+ for (const frames of trajectories)
+ for (const frame of frames) expect(frame.preview).toBeCloseTo(frame.exported, 10);
+ expect(trajectories[1][6].preview).toBeLessThan(trajectories[0][6].preview);
+ expect(trajectories[1][120].preview).toBeCloseTo(0.8, 3);
+});
+
+test("Cinematic removes brief hover shapes in preview and canvas export without delaying sustained hovers", async ({
+ page,
+}) => {
+ await page.route("**/cinematic-cursor-audit.html", (route) =>
+ route.fulfill({ contentType: "text/html", body: "" }),
+ );
+ await page.goto("/cinematic-cursor-audit.html");
+ const frames = await page.evaluate(async () => {
+ const renderer = await import(
+ "/src/components/video-editor/videoPlayback/cursorRenderer.ts"
+ );
+ const { CURSOR_MOTION_PRESETS } = await import(
+ "/src/components/video-editor/cursorMotionPresets.ts"
+ );
+ const { Application } = await import("/tests/ui/pixiAudit.ts");
+ await renderer.preloadCursorAssets();
+ const app = new Application();
+ await app.init({ width: 400, height: 200, backgroundAlpha: 0, resolution: 1 });
+ const preset = CURSOR_MOTION_PRESETS.smooth;
+ const config = {
+ ...renderer.DEFAULT_CURSOR_CONFIG,
+ style: "macos",
+ dotRadius: 120,
+ idleHide: { ...renderer.DEFAULT_CURSOR_CONFIG.idleHide, enabled: false },
+ smoothingFactor: preset.cursorSmoothing,
+ springTuning: {
+ stiffnessMultiplier: preset.cursorSpringStiffnessMultiplier,
+ dampingMultiplier: preset.cursorSpringDampingMultiplier,
+ massMultiplier: preset.cursorSpringMassMultiplier,
+ },
+ };
+ const overlay = new renderer.PixiCursorOverlay(config);
+ app.stage.addChild(overlay.container);
+ const canvas = document.createElement("canvas");
+ canvas.width = 400;
+ canvas.height = 200;
+ const ctx = canvas.getContext("2d")!;
+ const viewport = { x: 0, y: 0, width: 400, height: 200 };
+ const fingerprint = (source: HTMLCanvasElement) => {
+ ctx.clearRect(0, 0, 400, 200);
+ ctx.drawImage(source, 0, 0);
+ let hash = 2166136261;
+ for (const byte of ctx.getImageData(0, 0, 400, 200).data)
+ hash = Math.imul(hash ^ byte, 16777619);
+ return hash;
+ };
+ const draw = (samples, time) => {
+ overlay.update(samples, time, viewport, true, true);
+ app.render();
+ const preview = fingerprint(app.canvas);
+ ctx.clearRect(0, 0, 400, 200);
+ renderer.drawCursorOnCanvas(
+ ctx,
+ samples,
+ time,
+ viewport,
+ new renderer.SmoothedCursorState(config),
+ config,
+ );
+ let hash = 2166136261;
+ for (const byte of ctx.getImageData(0, 0, 400, 200).data)
+ hash = Math.imul(hash ^ byte, 16777619);
+ return { preview, exported: hash };
+ };
+ const point = (timeMs, cursorType) => ({
+ timeMs,
+ cursorType,
+ cx: 0.5,
+ cy: 0.5,
+ interactionType: "move",
+ });
+ const results = [];
+ for (const [baseline, excursion] of [
+ ["arrow", "pointer"],
+ ["pointer", "arrow"],
+ ]) {
+ const samples = [
+ point(0, baseline),
+ point(1000, excursion),
+ point(1300, baseline),
+ point(2000, excursion),
+ point(3000, baseline),
+ point(4000, baseline),
+ ];
+ const reference = {
+ [baseline]: draw([point(0, baseline), point(5000, baseline)], 0),
+ [excursion]: draw([point(0, excursion), point(5000, excursion)], 0),
+ };
+ for (const time of [1000, 1150, 1299, 2000, 2250, 3000, 1100]) {
+ const expected = time >= 2000 && time < 3000 ? excursion : baseline;
+ results.push({
+ baseline,
+ time,
+ actual: draw(samples, time),
+ expected: reference[expected],
+ });
+ }
+ }
+ overlay.destroy();
+ app.destroy(true);
+ return results;
+ });
+ for (const backend of ["preview", "exported"] as const)
+ expect(frames[0].expected[backend]).not.toBe(frames[7].expected[backend]);
+ for (const frame of frames) expect(frame.actual, JSON.stringify(frame)).toEqual(frame.expected);
+});
diff --git a/tests/ui/clip-captions-and-background.spec.ts b/tests/ui/clip-captions-and-background.spec.ts
index 3c97e7a0..77c3dc81 100644
--- a/tests/ui/clip-captions-and-background.spec.ts
+++ b/tests/ui/clip-captions-and-background.spec.ts
@@ -16,7 +16,7 @@ test("split clips share one grip and captions live inside the filmstrip", async
await expect
.poll(() => page.getByTestId("playhead-cap").getAttribute("aria-label"))
.toBe("Playhead 3.0s");
- await page.getByRole("button", { name: "Split Clip (C)", exact: true }).click();
+ await page.getByRole("button", { name: /^Split Clip \(/ }).click();
await expect(clips).toHaveCount(2);
const first = clips.nth(0),
second = clips.nth(1);
@@ -73,10 +73,30 @@ test("split clips share one grip and captions live inside the filmstrip", async
const caption = page.locator('[data-variant="caption"]');
await expect(caption).toHaveCount(1);
const text = page.getByRole("textbox", { name: "Text", exact: true });
- await text.fill("And this is what the caption looks like inside a clip");
+ const captionText = "And this is what the caption looks like inside a clip";
+ await text.fill(captionText);
+ await text.press("Enter");
+ await expect(caption).toHaveText(captionText);
+ const label = caption.locator("span.truncate");
+ await expect(label).toHaveCSS("text-overflow", "ellipsis");
+ // A wide block must display the entire cue, including text beyond character 24.
+ await expect
+ .poll(() => label.evaluate((node) => node.scrollWidth - node.clientWidth))
+ .toBeLessThanOrEqual(1);
+ // A longer cue uses the available block width, with only CSS adding an ellipsis.
+ await text.fill(`${captionText} — ${captionText}`);
+ await text.press("Enter");
+ await expect(caption).toHaveText(`${captionText} — ${captionText}`);
+ await expect
+ .poll(() => label.evaluate((node) => node.scrollWidth - node.clientWidth))
+ .toBeGreaterThan(1);
+ const availableTextWidth = await label.evaluate((node) => ({
+ text: node.clientWidth,
+ block: node.closest(".timeline-block")!.clientWidth,
+ }));
+ expect(availableTextWidth.block - availableTextWidth.text).toBeLessThanOrEqual(20);
+ await text.fill(captionText);
await text.press("Enter");
- await expect(caption).toContainText("And this is what");
- expect((await caption.textContent())!.length).toBeLessThan(35);
const captionBox = (await caption.boundingBox())!;
const clipBox = (await first.locator(".timeline-block").boundingBox())!;
expect(captionBox.y).toBeGreaterThan(clipBox.y);
@@ -112,7 +132,7 @@ test("background swaps use the section fade without sliding or overlapping panel
await installDesktopBridge(page);
await page.goto("/?windowType=editor");
await expect(page.locator('[data-background-panel="image"]')).toBeVisible({ timeout: 20000 });
- for (const name of ["Color", "Gradient", "Video", "Image"]) {
+ for (const name of ["Colors", "Video", "Image"]) {
await page.getByRole("row", { name, exact: true }).click();
const panel = page.locator("[data-background-panel]");
await expect(panel).toHaveCount(1);
diff --git a/tests/ui/clip-duration.spec.ts b/tests/ui/clip-duration.spec.ts
new file mode 100644
index 00000000..9f7e8092
--- /dev/null
+++ b/tests/ui/clip-duration.spec.ts
@@ -0,0 +1,110 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test("clip duration appears on hover or selection and follows speed, splits and trimming", async ({
+ page,
+}) => {
+ test.setTimeout(90000);
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/?windowType=editor");
+ const clips = page.locator('[data-timeline-item][data-variant="clip"]');
+ await expect(clips).toHaveAttribute("data-end-ms", "6000", { timeout: 30000 });
+ const duration = clips.locator("[data-clip-duration]");
+ await expect(duration).toHaveText("6.0s");
+ await expect(duration).toBeHidden();
+ await expect(clips.locator(".speed-icon")).toHaveCount(0);
+ await clips.hover();
+ await expect(duration).toBeVisible();
+ const blockBounds = (await clips.locator(".timeline-block").boundingBox())!;
+ const badgeBounds = (await duration.boundingBox())!;
+ expect(badgeBounds.x - blockBounds.x).toBeCloseTo(7, 0);
+ expect(badgeBounds.y - blockBounds.y).toBeCloseTo(5, 0);
+ await clips.click();
+ await page.getByRole("button", { name: "Home", exact: true }).hover();
+ await expect(duration).toBeVisible();
+
+ const speed = page.getByRole("slider", { name: "Speed", exact: true });
+ await speed.focus();
+ await page.keyboard.press("ArrowRight");
+ await expect(speed).toHaveValue("1.25");
+ await expect(duration).toHaveText("4.8s");
+ await expect(clips).toHaveAttribute("data-end-ms", "4800");
+ const speedIcon = clips.locator(".speed-icon");
+ const speedValue = speedIcon.locator("..").locator("span");
+ const checkCompactSpeed = async () => {
+ const block = clips.locator(".timeline-block");
+ for (const width of [56, 24]) {
+ await block.evaluate((node: HTMLElement, width) => {
+ node.style.width = `${width}px`;
+ }, width);
+ await expect(speedValue).toBeHidden();
+ await expect(speedIcon).toBeVisible();
+ const iconBounds = (await speedIcon.boundingBox())!;
+ const blockBounds = (await block.boundingBox())!;
+ expect(iconBounds.x).toBeGreaterThanOrEqual(blockBounds.x);
+ expect(iconBounds.x + iconBounds.width).toBeLessThanOrEqual(
+ blockBounds.x + blockBounds.width,
+ );
+ }
+ await page.screenshot({
+ path: test.info().outputPath(`compact-speed-${await speed.inputValue()}.png`),
+ });
+ await block.evaluate((node: HTMLElement) => node.style.removeProperty("width"));
+ await expect(speedValue).toBeVisible();
+ };
+ await expect(speedIcon).toBeVisible();
+ await expect(speedIcon).toHaveAttribute("fill", "currentColor");
+ await checkCompactSpeed();
+ const rabbitPath = await speedIcon.locator("path").getAttribute("d");
+ await page.screenshot({ path: test.info().outputPath("clip-speed-rabbit.png") });
+ await page.keyboard.press("ArrowLeft");
+ await expect(speed).toHaveValue("1");
+ await expect(speedIcon).toHaveCount(0);
+ await page.keyboard.press("ArrowLeft");
+ await expect(speed).toHaveValue("0.75");
+ await expect(speedIcon).toBeVisible();
+ await expect(speedIcon).toHaveAttribute("fill", "currentColor");
+ await checkCompactSpeed();
+ await expect(clips).toHaveAttribute("data-end-ms", "8000");
+ await expect(duration).toHaveText("8.0s");
+ expect(await speedIcon.locator("path").getAttribute("d")).not.toBe(rabbitPath);
+ await page.screenshot({ path: test.info().outputPath("clip-speed-tortoise.png") });
+ await page.keyboard.press("ArrowRight");
+ await page.keyboard.press("ArrowRight");
+ await expect(speed).toHaveValue("1.25");
+
+ const body = (await clips.locator(".timeline-block").boundingBox())!;
+ const row = (await page.locator('[data-timeline-row="row-clip"]').boundingBox())!;
+ await page.mouse.click(body.x + body.width / 2, row.y - 8);
+ await page.getByRole("button", { name: /^Split Clip \(/ }).click();
+ await expect(clips).toHaveCount(2);
+ await expect(clips.locator("[data-clip-duration]")).toHaveText(["2.4s", "2.4s"]);
+ await clips.nth(1).click();
+ await page.getByRole("button", { name: "Home", exact: true }).hover();
+ await expect(clips.nth(0).locator("[data-clip-duration]")).toBeHidden();
+ await expect(clips.nth(1).locator("[data-clip-duration]")).toBeVisible();
+
+ const last = clips.nth(1);
+ const edge = (await last.boundingBox())!;
+ await page.mouse.move(edge.x + edge.width - 2, edge.y + 20);
+ await page.mouse.down();
+ await page.mouse.move(edge.x + edge.width - 42, edge.y + 20, { steps: 10 });
+ await page.mouse.up();
+ await expect
+ .poll(async () => Number(await last.getAttribute("data-end-ms")))
+ .toBeLessThan(4800);
+ const length =
+ (Number(await last.getAttribute("data-end-ms")) -
+ Number(await last.getAttribute("data-start-ms"))) /
+ 1000;
+ await expect(last.locator("[data-clip-duration]")).toHaveText(
+ new Intl.NumberFormat("en", {
+ style: "unit",
+ unit: "second",
+ unitDisplay: "narrow",
+ minimumFractionDigits: 1,
+ maximumFractionDigits: 2,
+ }).format(length),
+ );
+ await page.screenshot({ path: test.info().outputPath("clip-duration-selected.png") });
+});
diff --git a/tests/ui/clip-edge-scroll.spec.ts b/tests/ui/clip-edge-scroll.spec.ts
new file mode 100644
index 00000000..35692dc6
--- /dev/null
+++ b/tests/ui/clip-edge-scroll.spec.ts
@@ -0,0 +1,98 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+for (const { name, sourceStartMs, speed, sourceMaxMs, trimmedEnd, fullEnd } of [
+ {
+ name: "normal speed",
+ sourceStartMs: 0,
+ speed: 1,
+ sourceMaxMs: 6000,
+ trimmedEnd: 750,
+ fullEnd: 6000,
+ },
+ {
+ name: "double speed with a source cut boundary",
+ sourceStartMs: 1000,
+ speed: 2,
+ sourceMaxMs: 5000,
+ trimmedEnd: 250,
+ fullEnd: 2000,
+ },
+]) {
+ test(`a trimmed clip extends in one held edge drag: ${name}`, async ({ page }) => {
+ test.setTimeout(60000);
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await installDesktopBridgeOverrides(
+ page,
+ ({ sourceStartMs, speed, sourceMaxMs, trimmedEnd }) => {
+ window.electronAPI.loadCurrentProjectFile = async () => ({
+ success: true,
+ path: "/projects/edge-scroll.recordly",
+ project: {
+ version: 2,
+ videoPath: `${location.origin}/tests/ui/fixtures/filmstrip.mp4`,
+ editor: {
+ clipRegions: [
+ {
+ id: "clip-1",
+ startMs: 0,
+ endMs: trimmedEnd,
+ sourceStartMs,
+ sourceMaxMs,
+ speed,
+ },
+ ],
+ zoomRegions: [],
+ annotationRegions: [],
+ },
+ },
+ });
+ },
+ { sourceStartMs, speed, sourceMaxMs, trimmedEnd },
+ );
+ await page.goto("/?windowType=editor");
+ const clip = page.locator('[data-timeline-item][data-variant="clip"]');
+ await expect(clip).toHaveAttribute("data-end-ms", String(trimmedEnd), { timeout: 30000 });
+ const box = (await clip.boundingBox())!;
+ const x = box.x + box.width - 2;
+ const y = box.y + 20;
+ await page.mouse.move(x, y);
+ await page.mouse.down();
+ await page.mouse.move(x + 8, y, { steps: 4 });
+ // No more pointer moves: the timeline must continue revealing source time.
+ await expect
+ .poll(
+ async () => {
+ const tooltip = page.locator("[data-clip-resize-tooltip]");
+ return tooltip.textContent();
+ },
+ { timeout: 15000 },
+ )
+ .toBe(`0.0s – ${(fullEnd / 1000).toFixed(1)}s`);
+ // The edit is committed once, on release.
+ await expect(clip).toHaveAttribute("data-end-ms", String(trimmedEnd));
+ await page.mouse.up();
+ await expect(clip).toHaveAttribute("data-end-ms", String(fullEnd));
+ await page.keyboard.press("Meta+z");
+ await expect(clip).toHaveAttribute("data-end-ms", String(trimmedEnd));
+ await page.keyboard.press("Meta+Shift+z");
+ await expect(clip).toHaveAttribute("data-end-ms", String(fullEnd));
+ });
+}
+
+test("Escape cancels a held right-edge resize without changing the clip", async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/?windowType=editor");
+ const clip = page.locator('[data-timeline-item][data-variant="clip"]');
+ await expect(clip).toHaveAttribute("data-end-ms", "6000", { timeout: 30000 });
+ const box = (await clip.boundingBox())!;
+ const x = box.x + box.width - 2;
+ const y = box.y + 20;
+ await page.mouse.move(x, y);
+ await page.mouse.down();
+ await page.mouse.move(x - 100, y, { steps: 8 });
+ await page.keyboard.press("Escape");
+ await page.mouse.up();
+ await expect(clip).toHaveAttribute("data-end-ms", "6000");
+ await expect.poll(async () => (await clip.boundingBox())!.width).toBeCloseTo(box.width, 0);
+});
diff --git a/tests/ui/clip-logic-regressions.spec.ts b/tests/ui/clip-logic-regressions.spec.ts
new file mode 100644
index 00000000..20977751
--- /dev/null
+++ b/tests/ui/clip-logic-regressions.spec.ts
@@ -0,0 +1,159 @@
+import { expect, type Page, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test.setTimeout(60000);
+
+async function loadClipProject(page: Page, threeClips: boolean) {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await installDesktopBridgeOverrides(
+ page,
+ ({ threeClips }) => {
+ const editor = {
+ clipRegions: threeClips
+ ? [0, 1, 2].map((index) => ({
+ id: `clip-${index + 1}`,
+ startMs: index * 2000,
+ endMs: (index + 1) * 2000,
+ speed: 1,
+ }))
+ : [{ id: "clip-1", startMs: 0, endMs: 6000, speed: 1 }],
+ zoomRegions: threeClips
+ ? [
+ {
+ id: "zoom-1",
+ startMs: 1000,
+ endMs: 3000,
+ depth: 2,
+ focus: { cx: 0.5, cy: 0.5 },
+ },
+ ]
+ : [],
+ annotationRegions: threeClips
+ ? [
+ {
+ id: "annotation-1",
+ startMs: 1000,
+ endMs: 3000,
+ type: "text",
+ content: "Attached",
+ },
+ ]
+ : [],
+ sourceAudioTrackSettingsByClip: {
+ "clip-1": { mixed: { volume: 0, normalize: true } },
+ },
+ defaultSourceAudioTrackSettings: { mixed: { volume: 0.8, normalize: false } },
+ };
+ window.electronAPI.loadCurrentProjectFile = async () => ({
+ success: true,
+ path: "/projects/clip-regression.recordly",
+ project: {
+ version: 2,
+ videoPath: `${location.origin}/tests/ui/fixtures/filmstrip.mp4`,
+ editor,
+ },
+ });
+ window.electronAPI.onMenuSaveProject = (callback) => {
+ const handler = () => {
+ void callback();
+ };
+ window.addEventListener("test-save-clips", handler);
+ return () => window.removeEventListener("test-save-clips", handler);
+ };
+ window.electronAPI.saveProjectFile = async (project) => {
+ sessionStorage.setItem("test-clip-editor", JSON.stringify(project.editor));
+ return { success: true, path: "/projects/clip-regression.recordly" };
+ };
+ },
+ { threeClips },
+ );
+ await page.goto("/?windowType=editor");
+ await expect(page.locator('[data-variant="clip"]')).toHaveCount(threeClips ? 3 : 1, {
+ timeout: 30000,
+ });
+ await expect(page.locator('[data-variant="clip"]').last()).toHaveAttribute(
+ "data-end-ms",
+ "6000",
+ );
+}
+
+async function savedEditor(page: Page) {
+ await page.evaluate(() => {
+ sessionStorage.removeItem("test-clip-editor");
+ window.dispatchEvent(new Event("test-save-clips"));
+ });
+ await expect
+ .poll(() => page.evaluate(() => sessionStorage.getItem("test-clip-editor")))
+ .not.toBeNull();
+ return page.evaluate(() => JSON.parse(sessionStorage.getItem("test-clip-editor")!));
+}
+
+test("split, undo and redo preserve silenced source tracks on both halves", async ({ page }) => {
+ await loadClipProject(page, false);
+ const clips = page.locator('[data-variant="clip"]');
+ await clips.first().click();
+ const body = (await clips.first().locator(".timeline-block").boundingBox())!;
+ const row = (await page.locator('[data-timeline-row="row-clip"]').boundingBox())!;
+ await page.mouse.click(body.x + body.width / 2, row.y - 8);
+ await page.getByRole("button", { name: /^Split Clip \(/ }).click();
+ await expect(clips).toHaveCount(2);
+ const split = await savedEditor(page);
+ for (const clip of split.clipRegions) {
+ expect(split.sourceAudioTrackSettingsByClip[clip.id].mixed).toEqual({
+ volume: 0,
+ normalize: true,
+ });
+ }
+ await page.keyboard.press("Meta+z");
+ await expect(clips).toHaveCount(1);
+ const undone = await savedEditor(page);
+ expect(undone.sourceAudioTrackSettingsByClip).toEqual({
+ "clip-1": { mixed: { volume: 0, normalize: true } },
+ });
+ await page.keyboard.press("Meta+Shift+z");
+ await expect(clips).toHaveCount(2);
+ const redone = await savedEditor(page);
+ expect(redone.sourceAudioTrackSettingsByClip).toEqual(split.sourceAudioTrackSettingsByClip);
+});
+
+test("reordering preserves disconnected zoom and annotation pieces through undo and save", async ({
+ page,
+}) => {
+ await loadClipProject(page, true);
+ const clips = page.locator('[data-variant="clip"]');
+ const third = (await clips.nth(2).boundingBox())!;
+ const second = (await clips.nth(1).boundingBox())!;
+ await page.mouse.move(third.x + third.width / 2, third.y + 20);
+ await page.mouse.down();
+ await page.mouse.move(second.x + second.width / 2 - 15, second.y + 20, { steps: 16 });
+ await page.mouse.up();
+ const zooms = page.locator('[data-variant="zoom"]');
+ const annotations = page.locator('[data-variant="annotation"]');
+ await expect(zooms).toHaveCount(2);
+ await expect(annotations).toHaveCount(2);
+ const edited = await savedEditor(page);
+ expect(edited.clipRegions.map((clip: { id: string }) => clip.id)).toEqual([
+ "clip-1",
+ "clip-3",
+ "clip-2",
+ ]);
+ for (const regions of [edited.zoomRegions, edited.annotationRegions]) {
+ expect(
+ regions.map(({ startMs, endMs }: { startMs: number; endMs: number }) => [
+ startMs,
+ endMs,
+ ]),
+ ).toEqual([
+ [1000, 2000],
+ [4000, 5000],
+ ]);
+ expect(new Set(regions.map((region: { id: string }) => region.id)).size).toBe(2);
+ }
+ await page.keyboard.press("Meta+z");
+ await expect(zooms).toHaveCount(1);
+ await expect(annotations).toHaveCount(1);
+ await page.keyboard.press("Meta+Shift+z");
+ await expect(zooms).toHaveCount(2);
+ await expect(annotations).toHaveCount(2);
+ expect((await savedEditor(page)).zoomRegions).toEqual(edited.zoomRegions);
+});
diff --git a/tests/ui/clip-merge.spec.ts b/tests/ui/clip-merge.spec.ts
new file mode 100644
index 00000000..9d58d8c0
--- /dev/null
+++ b/tests/ui/clip-merge.spec.ts
@@ -0,0 +1,117 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test.beforeEach(async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.loadCurrentProjectFile = async () => ({
+ success: true,
+ path: "/projects/merge.recordly",
+ project: {
+ version: 2,
+ videoPath: `${location.origin}/tests/ui/fixtures/filmstrip.mp4`,
+ editor: {
+ clipRegions: [0, 1, 2].map((i) => ({
+ id: `clip-${i + 1}`,
+ startMs: i * 2000,
+ endMs: (i + 1) * 2000,
+ sourceStartMs:
+ i * 2000 +
+ (location.search.includes("incompatible") && i === 1 ? 500 : 0),
+ speed: 1,
+ })),
+ zoomRegions: [
+ {
+ id: "zoom-1",
+ startMs: 500,
+ endMs: 1500,
+ depth: 2,
+ focus: { cx: 0.5, cy: 0.5 },
+ },
+ ],
+ },
+ },
+ });
+ });
+ await page.goto("/?windowType=editor");
+ await expect(page.locator('[data-variant="clip"]')).toHaveCount(3, { timeout: 30000 });
+ await expect(page.getByLabel("Loading preview", { exact: true })).toHaveCount(0, {
+ timeout: 30000,
+ });
+});
+
+test("Shift-click selects a clip range, merges it, and supports undo and redo", async ({
+ page,
+}, testInfo) => {
+ const clips = page.locator('[data-variant="clip"]');
+ const selected = page.locator('[data-variant="clip"][data-selected="true"]');
+ await clips.first().locator(".timeline-block").click();
+ await expect(selected).toHaveCount(1);
+ await clips
+ .last()
+ .locator(".timeline-block")
+ .click({ modifiers: ["Shift"] });
+ await expect(selected).toHaveCount(3);
+ await page.screenshot({ path: testInfo.outputPath("shift-selection.png") });
+ const merge = page.getByText("Merge clips", { exact: true });
+ await expect(merge).toBeEnabled();
+ await merge.click();
+ await expect(clips).toHaveCount(1);
+ await expect(clips).toHaveAttribute("data-start-ms", "0");
+ await expect(clips).toHaveAttribute("data-end-ms", "6000");
+ const zoom = page.locator('[data-variant="zoom"]');
+ await expect(zoom).toHaveAttribute("data-start-ms", "500");
+ await expect(zoom).toHaveAttribute("data-stored-end-ms", "1500");
+ await page.getByTestId("timeline-scroll").focus();
+ await page.keyboard.press("Meta+z");
+ await expect(clips).toHaveCount(3);
+ await page.keyboard.press("Meta+Shift+z");
+ await expect(clips).toHaveCount(1);
+});
+
+test("a plain click clears the range and reverse Shift selection includes intermediate clips", async ({
+ page,
+}) => {
+ const clips = page.locator('[data-variant="clip"]');
+ const selected = page.locator('[data-variant="clip"][data-selected="true"]');
+ await clips.last().locator(".timeline-block").click();
+ await clips
+ .first()
+ .locator(".timeline-block")
+ .click({ modifiers: ["Shift"] });
+ await expect(selected).toHaveCount(3);
+ await clips.nth(1).locator(".timeline-block").click();
+ await expect(selected).toHaveCount(1);
+ await expect(page.getByText("Merge clips", { exact: true })).toHaveCount(0);
+ await clips
+ .last()
+ .locator(".timeline-block")
+ .click({ modifiers: ["Shift"] });
+ await expect(selected).toHaveCount(2);
+ await page.getByText("Merge clips", { exact: true }).click();
+ await expect(clips).toHaveCount(2);
+ await expect(clips.first()).toHaveAttribute("data-end-ms", "2000");
+ await expect(clips.last()).toHaveAttribute("data-end-ms", "6000");
+});
+
+test("merge is disabled when selected footage is discontinuous", async ({ page }) => {
+ await page.goto("/?windowType=editor&incompatible=1");
+ const clips = page.locator('[data-variant="clip"]');
+ await expect(clips).toHaveCount(3);
+ await clips.first().locator(".timeline-block").click();
+ await clips
+ .last()
+ .locator(".timeline-block")
+ .click({ modifiers: ["Shift"] });
+ await expect(page.locator('[data-variant="clip"][data-selected="true"]')).toHaveCount(3);
+ await expect(page.getByText("Merge clips", { exact: true })).toBeDisabled();
+ await expect(page.getByText("Merge clips", { exact: true }).locator("..")).toHaveAttribute(
+ "title",
+ /continuous footage/,
+ );
+ // Selecting a different track clears the range; it must not return on reselect.
+ await page.locator('[data-variant="zoom"] .timeline-block').click();
+ await expect(page.getByText("Merge clips", { exact: true })).toHaveCount(0);
+ await clips.last().locator(".timeline-block").click();
+ await expect(page.locator('[data-variant="clip"][data-selected="true"]')).toHaveCount(1);
+});
diff --git a/tests/ui/clip-origin.spec.ts b/tests/ui/clip-origin.spec.ts
index c404153a..2d785431 100644
--- a/tests/ui/clip-origin.spec.ts
+++ b/tests/ui/clip-origin.spec.ts
@@ -68,11 +68,11 @@ test("selecting an untouched clip does not introduce a leading gap", async ({ pa
await expect(clip).toHaveAttribute("data-start-ms", "0");
await expect(clip).toHaveAttribute("data-end-ms", "6000");
- await page.getByRole("button", { name: "Export", exact: true }).click();
- const dialog = page.getByRole("dialog", { name: "Export", exact: true });
+ await page.getByRole("button", { name: "Publish", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "Publish", exact: true });
const dialogBox = (await dialog.boundingBox())!;
const titleBox = (await dialog
- .getByRole("heading", { name: "Export", exact: true })
+ .getByRole("heading", { name: "Publish", exact: true })
.boundingBox())!;
expect(titleBox.x - dialogBox.x).toBeGreaterThanOrEqual(16);
expect(titleBox.y - dialogBox.y).toBeGreaterThanOrEqual(16);
diff --git a/tests/ui/clip-sequence.spec.ts b/tests/ui/clip-sequence.spec.ts
index 997f8441..a84497ac 100644
--- a/tests/ui/clip-sequence.spec.ts
+++ b/tests/ui/clip-sequence.spec.ts
@@ -20,7 +20,7 @@ async function seekInsideClip(page: Page, clip: Locator, fraction: number) {
async function splitInsideClip(page: Page, clip: Locator, fraction: number) {
const count = await clipsFor(page).count();
await seekInsideClip(page, clip, fraction);
- await page.getByRole("button", { name: "Split Clip (C)", exact: true }).click();
+ await page.getByRole("button", { name: /^Split Clip \(/ }).click();
await expect(clipsFor(page)).toHaveCount(count + 1);
}
diff --git a/tests/ui/clipboard-share.html b/tests/ui/clipboard-share.html
new file mode 100644
index 00000000..66270c97
--- /dev/null
+++ b/tests/ui/clipboard-share.html
@@ -0,0 +1,5 @@
+
+
+ Share clipboard fixture
+
+
diff --git a/tests/ui/clipboard-share.spec.ts b/tests/ui/clipboard-share.spec.ts
new file mode 100644
index 00000000..5e3de690
--- /dev/null
+++ b/tests/ui/clipboard-share.spec.ts
@@ -0,0 +1,114 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+for (const title of ["My walkthrough", "", " "]) {
+ test(`shares with empty notes and ${title.trim() ? "an edited" : JSON.stringify(title)} title`, async ({
+ page,
+ }) => {
+ await page.goto("/tests/ui/clipboard-share.html");
+ await expect(page.getByLabel("Title", { exact: true })).toHaveValue("Fixture");
+ await page.getByLabel("Title", { exact: true }).fill(title);
+ await expect(page.getByLabel("Notes", { exact: true })).toHaveValue("");
+ await expect(page.getByRole("button", { name: "Share", exact: true })).toBeEnabled();
+ await page.getByRole("button", { name: "Share", exact: true }).click();
+ await expect(page.getByLabel("Share link", { exact: true })).toHaveValue(
+ "https://example.test/s/preview",
+ );
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-upload-metadata",
+ JSON.stringify({ title: title.trim() || "Fixture", notes: null }),
+ );
+ });
+}
+
+test("copies a share link through the desktop bridge when the browser denies access", async ({
+ page,
+}) => {
+ await page.goto("/tests/ui/clipboard-share.html");
+ await expect(page.getByText(/Free cloud recordings expire/)).toHaveCount(0);
+ await page.getByRole("button", { name: "Share", exact: true }).click();
+ await expect(page.getByLabel("Share link", { exact: true })).toHaveValue(
+ "https://example.test/s/preview",
+ );
+ await page.getByRole("button", { name: "Copy link", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-copied-text",
+ "https://example.test/s/preview",
+ );
+ await expect(page.getByRole("button", { name: "Copied", exact: true })).toBeVisible();
+ await expect(page.getByText("Link copied", { exact: true })).toBeVisible();
+});
+
+test("selects the actual link for manual copying without a recursive Copy error action", async ({
+ page,
+}) => {
+ await page.goto("/tests/ui/clipboard-share.html?fail");
+ await page.getByRole("button", { name: "Share", exact: true }).click();
+ await page.getByRole("button", { name: "Copy link", exact: true }).click();
+ const link = page.getByLabel("Share link", { exact: true });
+ await expect(link).toBeFocused();
+ expect(
+ await link.evaluate(
+ (input: HTMLInputElement) => input.selectionEnd! - input.selectionStart!,
+ ),
+ ).toBe("https://example.test/s/preview".length);
+ await expect(
+ page.getByText("Could not copy link. Select and copy it manually.", { exact: true }),
+ ).toHaveCount(1);
+ await expect(page.getByRole("button", { name: "Copy", exact: true })).toHaveCount(0);
+});
+
+test("localizes the sharing and clipboard flow", async ({ page }) => {
+ await page.addInitScript(() => localStorage.setItem("recordly.locale", "es"));
+ await page.goto("/tests/ui/clipboard-share.html");
+ await page.getByRole("button", { name: "Compartir", exact: true }).click();
+ await page.getByRole("button", { name: "Copiar enlace", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-copied-text",
+ "https://example.test/s/preview",
+ );
+ await expect(page.getByRole("button", { name: "Copy link", exact: true })).toHaveCount(0);
+});
+
+test("rejected free-account sessions offer sign-in instead of a plan refresh", async ({ page }) => {
+ await page.goto("/tests/ui/clipboard-share.html?session-rejected");
+ await expect(
+ page.getByText("Your cloud session could not be verified. Sign in again to share."),
+ ).toBeVisible();
+ await expect(page.getByText("Refresh plan", { exact: true })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Share", exact: true })).toBeDisabled();
+ await page.getByRole("button", { name: "Check sign-in", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-sign-in-requested", "true");
+});
+
+for (const rejected of [false, true]) {
+ test(`automatically refreshes a rejected session once (${rejected ? "still rejected" : "recovered"})`, async ({
+ page,
+ }) => {
+ await installDesktopBridge(page);
+ let refreshes = 0;
+ page.on("request", (request) => {
+ if (new URL(request.url()).pathname === "/auth/v1/token") refreshes++;
+ });
+ await page.goto(
+ `/tests/ui/clipboard-share.html?recover-session${rejected ? "&session-rejected" : ""}`,
+ );
+ if (rejected) {
+ await expect(
+ page.getByRole("button", { name: "Check sign-in", exact: true }),
+ ).toBeVisible();
+ await expect(page.getByRole("button", { name: "Share", exact: true })).toBeDisabled();
+ await page.evaluate(() => window.dispatchEvent(new Event("focus")));
+ await expect(
+ page.getByRole("button", { name: "Check sign-in", exact: true }),
+ ).toBeVisible();
+ } else {
+ await expect(page.getByRole("button", { name: "Share", exact: true })).toBeEnabled();
+ await page.getByRole("button", { name: "Share", exact: true }).click();
+ await expect(page.getByLabel("Share link", { exact: true })).toHaveValue(
+ "https://example.test/s/preview",
+ );
+ }
+ expect(refreshes).toBe(1);
+ });
+}
diff --git a/tests/ui/clipboard-share.tsx b/tests/ui/clipboard-share.tsx
new file mode 100644
index 00000000..61b84929
--- /dev/null
+++ b/tests/ui/clipboard-share.tsx
@@ -0,0 +1,88 @@
+import { useRecordlyAuth } from "@/components/auth/useRecordlyAuth";
+import { createRoot } from "react-dom/client";
+import { CloudShareButton } from "@/components/video-editor/cloud/CloudShareButton";
+import { Toaster } from "@/components/ui/toast";
+import { I18nProvider } from "@/contexts/I18nContext";
+import "@/index.css";
+
+const recoverSession = new URLSearchParams(location.search).has("recover-session");
+let statusCalls = 0;
+const rejectedSession = new URLSearchParams(location.search).has("session-rejected");
+const fail = new URLSearchParams(location.search).has("fail");
+window.electronAPI = {
+ ...window.electronAPI,
+ cloudBilling: async () => {
+ statusCalls++;
+ document.documentElement.dataset.statusCalls = String(statusCalls);
+ return {
+ success: false,
+ httpStatus: recoverSession && (statusCalls === 1 || rejectedSession) ? 401 : 404,
+ };
+ },
+ onAuthCallbackUrl: () => () => undefined,
+ getPendingAuthCallbackUrl: async () => undefined,
+ cloudShareManage: async () =>
+ rejectedSession
+ ? { success: false, error: "Verified sign-in required" }
+ : { success: true, videos: [] },
+ onCloudShareProgress: () => () => undefined,
+ cloudShareUpload: async (input: { title?: string; notes?: string }) => {
+ document.documentElement.dataset.uploadMetadata = JSON.stringify({
+ title: input.title,
+ notes: input.notes ?? null,
+ });
+ return { success: true, shareUrl: "https://example.test/s/preview" };
+ },
+ cloudShareCancel: async () => ({ success: true }),
+ writeClipboardText: async (text: string) => {
+ if (fail) return { success: false };
+ document.documentElement.dataset.copiedText = text;
+ return { success: true };
+ },
+} as unknown as typeof window.electronAPI;
+Object.defineProperty(navigator, "clipboard", {
+ value: {
+ writeText: async () => {
+ throw new DOMException("Document is not focused", "NotAllowedError");
+ },
+ },
+});
+document.execCommand = () => false;
+
+function SessionShare() {
+ const auth = useRecordlyAuth();
+ return (
+ {
+ document.documentElement.dataset.signInRequested = "true";
+ }}
+ />
+ );
+}
+
+createRoot(document.getElementById("root")!).render(
+
+
+ {recoverSession ? (
+
+ ) : (
+ {
+ document.documentElement.dataset.signInRequested = "true";
+ }}
+ />
+ )}
+
+
+ ,
+);
diff --git a/tests/ui/clips-polish.spec.ts b/tests/ui/clips-polish.spec.ts
index 16d7a8a3..e8f25176 100644
--- a/tests/ui/clips-polish.spec.ts
+++ b/tests/ui/clips-polish.spec.ts
@@ -51,21 +51,41 @@ test("annotations can be dragged into the canvas background and survive undo", a
.toBeGreaterThan(overlay.x + 8);
});
-test("cloud sharing is reachable from Export and the account control", async ({ page }) => {
+test("rejected cloud sessions can request reauthentication", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
- await page.getByRole("button", { name: "Export", exact: true }).click();
- await page.getByRole("button", { name: "Create share link", exact: true }).click();
- await expect(page.getByRole("heading", { name: "Sign into Recordly" })).toBeVisible();
- await expect(
- page.getByText("Sign in to publish this video and manage its shared link."),
- ).toBeVisible();
+ // The desktop session remains signed in; only the cloud service rejects it.
+ await page.evaluate(() => {
+ window.electronAPI.cloudBilling = async () => ({
+ success: false,
+ httpStatus: 401,
+ error: "Verified sign-in required",
+ });
+ window.electronAPI.cloudShareManage = async () => ({
+ success: false,
+ error: "Verified sign-in required",
+ });
+ });
+ await page.getByRole("button", { name: "Publish", exact: true }).click();
+ await page.getByRole("row", { name: "Create share link", exact: true }).click();
+ const publish = page.getByRole("dialog", { name: "Publish", exact: true });
+ await expect(publish.getByRole("button", { name: "Share", exact: true })).toBeDisabled();
+ await publish.getByRole("button", { name: "Check sign-in", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "Sign in to Recordly", exact: true });
+ await expect(dialog).toBeVisible();
+ const email = dialog.getByRole("textbox", { name: /^Email\*?$/ });
+ await expect(email).toBeVisible();
+ await email.fill("test@email.com");
+ await expect(dialog.getByRole("button", { name: "Email me a code", exact: true })).toBeEnabled();
+ await expect(dialog.locator("form")).toBeVisible();
await page.screenshot({ path: "test-results/cloud-sign-in.png", animations: "disabled" });
- const dialog = page.getByRole("dialog");
- await expect(dialog.locator(".modal__body")).toBeVisible();
expect(await dialog.evaluate((node) => node.scrollWidth <= node.clientWidth + 1)).toBe(true);
- await page.getByRole("button", { name: "Close", exact: true }).click();
+ await dialog.getByRole("button", { name: "Close", exact: true }).click();
+ await expect(dialog).not.toBeVisible();
await page.getByRole("button", { name: "Recordly account", exact: true }).click();
- await expect(page.getByRole("heading", { name: "Sign into Recordly" })).toBeVisible();
+ const account = page.getByRole("dialog", { name: "Account menu", exact: true });
+ await expect(account).toBeVisible();
+ await expect(account.getByRole("button", { name: "Manage account", exact: true })).toBeVisible();
+ await expect(account.getByText("Test User", { exact: true })).toBeVisible();
expect(errors).toEqual([]);
});
diff --git a/tests/ui/crop-zoom.html b/tests/ui/crop-zoom.html
new file mode 100644
index 00000000..7b1161f4
--- /dev/null
+++ b/tests/ui/crop-zoom.html
@@ -0,0 +1 @@
+Cropped zoom regression
diff --git a/tests/ui/crop-zoom.spec.ts b/tests/ui/crop-zoom.spec.ts
new file mode 100644
index 00000000..bb58cb52
--- /dev/null
+++ b/tests/ui/crop-zoom.spec.ts
@@ -0,0 +1,26 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+for (const mode of ["auto", "classic", "manual"]) {
+ test(`cropped ${mode} zoom centers the intended content`, async ({ page }) => {
+ const errors: string[] = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto(`/tests/ui/crop-zoom.html${mode === "auto" ? "" : `?${mode}`}`);
+ const main = page.locator("main");
+ const video = page.locator("video").first();
+ await expect(main).toHaveAttribute("data-ready", "true");
+ await expect
+ .poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime))
+ .toBeCloseTo(2, 2);
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.seeking)).toBe(false);
+ await expect
+ .poll(async () => {
+ await page.getByRole("button", { name: "Read click position" }).click();
+ return Math.abs(Number(await main.getAttribute("data-point-x")) - 0.5);
+ })
+ .toBeLessThan(0.005);
+ expect(Math.abs(Number(await main.getAttribute("data-point-y")) - 0.5)).toBeLessThan(0.005);
+ expect(errors).toEqual([]);
+ });
+}
diff --git a/tests/ui/crop-zoom.tsx b/tests/ui/crop-zoom.tsx
new file mode 100644
index 00000000..035022e3
--- /dev/null
+++ b/tests/ui/crop-zoom.tsx
@@ -0,0 +1,95 @@
+import { useRef, useState } from "react";
+import { createRoot } from "react-dom/client";
+import { Point } from "pixi.js";
+import VideoPlayback, { type VideoPlaybackRef } from "@/components/video-editor/VideoPlayback";
+import type { ZoomRegion } from "@/components/video-editor/types";
+import { I18nProvider } from "@/contexts/I18nContext";
+import "@/index.css";
+
+const params = new URLSearchParams(location.search);
+const classic = params.has("classic");
+const manual = params.has("manual");
+const sourceCrop = { x: 0.5, y: 0.2, width: 0.5, height: 0.6 };
+const sourceClick = { cx: 0.6, cy: 0.4 };
+const focus = manual ? { cx: 0.35, cy: 0.65 } : sourceClick;
+const zooms: ZoomRegion[] = [
+ { id: "zoom", startMs: 0, endMs: 4000, depth: 6, focus, mode: manual ? "manual" : "auto" },
+];
+const telemetry = [
+ { timeMs: 0, ...sourceClick },
+ { timeMs: 6000, ...sourceClick },
+];
+const clips = [{ id: "clip", startMs: 0, endMs: 6000, speed: 1 }];
+const noop = () => undefined;
+
+function Preview() {
+ const playback = useRef(null);
+ const [ready, setReady] = useState(false);
+ const [time, setTime] = useState(2);
+ const [point, setPoint] = useState<{ x: number; y: number } | null>(null);
+ return (
+
+ {
+ const api = playback.current;
+ const video = api?.video;
+ const sprite = api?.videoSprite;
+ const screen = api?.app?.screen;
+ if (!video || !sprite || !screen) return;
+ const sourcePoint = manual
+ ? {
+ cx: sourceCrop.x + focus.cx * sourceCrop.width,
+ cy: sourceCrop.y + focus.cy * sourceCrop.height,
+ }
+ : sourceClick;
+ const global = sprite.toGlobal(
+ new Point(
+ sourcePoint.cx * video.videoWidth,
+ sourcePoint.cy * video.videoHeight,
+ ),
+ );
+ setPoint({ x: global.x / screen.width, y: global.y / screen.height });
+ }}
+ >
+ Read click position
+
+ {
+ throw new Error(error);
+ }}
+ onPreviewReadyChange={setReady}
+ zoomRegions={zooms}
+ selectedZoomId={manual ? "zoom" : null}
+ onSelectZoom={noop}
+ onZoomFocusChange={noop}
+ isPlaying={false}
+ aspectRatio="native"
+ wallpaper="#111111"
+ showShadow={false}
+ padding={0}
+ cropRegion={sourceCrop}
+ showCursor={false}
+ cursorTelemetry={telemetry}
+ zoomClassicMode={classic}
+ zoomMotionBlur={0}
+ />
+
+ );
+}
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/tests/ui/cursor-burst.spec.ts b/tests/ui/cursor-burst.spec.ts
new file mode 100644
index 00000000..af213a33
--- /dev/null
+++ b/tests/ui/cursor-burst.spec.ts
@@ -0,0 +1,140 @@
+import { expect, test } from "@playwright/test";
+
+test("burst shoots grey rays from the click, then separates and fades in playback and export", async ({
+ page,
+}, testInfo) => {
+ await page.route("**/burst-audit.html", (route) =>
+ route.fulfill({ contentType: "text/html", body: "" }),
+ );
+ await page.goto("/burst-audit.html");
+ const frames = await page.evaluate(async () => {
+ const { Application, Graphics } = await import("/tests/ui/pixiAudit.ts");
+ const { drawClickEffectGraphics, drawClickEffectOnCanvas } = await import(
+ "/src/components/video-editor/videoPlayback/cursorClickEffects.ts"
+ );
+ const { getCursorVisualState } = await import(
+ "/src/components/video-editor/videoPlayback/cursorVisualState.ts"
+ );
+ const { normalizeCursorClickEffectStyle } = await import(
+ "/src/components/video-editor/types.ts"
+ );
+ if (normalizeCursorClickEffectStyle("burst") !== "burst")
+ throw new Error("Burst must survive project loading");
+ const app = new Application();
+ await app.init({
+ width: 200,
+ height: 200,
+ backgroundAlpha: 0,
+ resolution: 1,
+ antialias: true,
+ });
+ const graphics = new Graphics();
+ app.stage.addChild(graphics);
+ const samples = [{ timeMs: 0, cx: 0.5, cy: 0.5, interactionType: "click", mouseButton: 1 }];
+ function measure(canvas: HTMLCanvasElement) {
+ const pixels = canvas.getContext("2d")!.getImageData(0, 0, 200, 200).data;
+ const visited = new Set();
+ let components = 0,
+ minY = 200,
+ innerRadius = 200,
+ maxAlpha = 0,
+ colorOffset = 0;
+ for (let i = 0; i < 40000; i++) {
+ if (pixels[i * 4 + 3] < 10) continue;
+ minY = Math.min(minY, Math.floor(i / 200));
+ innerRadius = Math.min(
+ innerRadius,
+ Math.hypot((i % 200) - 100, Math.floor(i / 200) - 100),
+ );
+ if (pixels[i * 4 + 3] > maxAlpha) {
+ maxAlpha = pixels[i * 4 + 3];
+ colorOffset = i * 4;
+ }
+ if (visited.has(i)) continue;
+ components++;
+ const queue = [i];
+ visited.add(i);
+ while (queue.length) {
+ const point = queue.pop()!;
+ for (const offset of [-201, -200, -199, -1, 1, 199, 200, 201]) {
+ const next = point + offset;
+ if (
+ next >= 0 &&
+ next < 40000 &&
+ !visited.has(next) &&
+ pixels[next * 4 + 3] >= 10
+ ) {
+ visited.add(next);
+ queue.push(next);
+ }
+ }
+ }
+ }
+ const top = Array.from(pixels.slice(colorOffset, colorOffset + 3));
+ return {
+ components,
+ innerRadius,
+ minY,
+ maxAlpha,
+ top,
+ centerAlpha: pixels[(100 * 200 + 100) * 4 + 3],
+ };
+ }
+ document.body.style.cssText =
+ "display:grid;grid-template-columns:200px 200px;background:#222;color:white;font:16px system-ui";
+ const frames = [];
+ for (const time of [30, 90, 180, 350, 90]) {
+ const { clickProgress } = getCursorVisualState(
+ samples,
+ time,
+ 180,
+ 600,
+ "dot",
+ 1,
+ "burst",
+ );
+ const args = ["burst", 100, 100, 32, clickProgress, 1, 1] as const;
+ drawClickEffectGraphics(graphics, ...args);
+ app.render();
+ const preview = document.createElement("canvas");
+ preview.width = preview.height = 200;
+ preview.getContext("2d")!.drawImage(app.canvas, 0, 0);
+ const exported = document.createElement("canvas");
+ exported.width = exported.height = 200;
+ drawClickEffectOnCanvas(exported.getContext("2d")!, ...args);
+ frames.push({ preview: measure(preview), exported: measure(exported) });
+ const label = document.createElement("div");
+ label.style.gridColumn = "span 2";
+ label.textContent = `${time} ms — preview / export`;
+ document.body.append(label, preview, exported);
+ }
+ app.destroy(true);
+ return frames;
+ });
+ await page.screenshot({ path: testInfo.outputPath("burst-frames.png"), fullPage: true });
+ for (const mode of ["preview", "exported"] as const) {
+ for (const index of [0, 1, 2]) {
+ const frame = frames[index][mode];
+ if (index === 0) expect(frame.components).toBeGreaterThan(0);
+ else expect(frame.components).toBe(10);
+ expect(frame.centerAlpha).toBe(0);
+ for (const [channel, expected] of [156, 163, 175].entries()) {
+ // Transparent edge pixels can differ slightly after unpremultiplication.
+ expect(Math.abs(frame.top[channel] - expected)).toBeLessThanOrEqual(3);
+ }
+ }
+ expect(frames[0][mode].innerRadius).toBeLessThan(8);
+ expect(frames[1][mode].innerRadius).toBeGreaterThan(20);
+ expect(100 - frames[2][mode].minY).toBeGreaterThan((100 - frames[0][mode].minY) * 2);
+ expect(frames[2][mode].maxAlpha).toBeLessThan(frames[0][mode].maxAlpha);
+ expect(frames[3][mode].components).toBe(0);
+ expect(frames[4][mode]).toEqual(frames[1][mode]);
+ }
+ for (const frame of frames) {
+ expect(Math.abs(frame.preview.minY - frame.exported.minY)).toBeLessThanOrEqual(1);
+ expect(
+ Math.abs(frame.preview.maxAlpha - frame.exported.maxAlpha),
+ JSON.stringify(frames),
+ ).toBeLessThanOrEqual(1);
+ }
+});
diff --git a/tests/ui/cursor-height.spec.ts b/tests/ui/cursor-height.spec.ts
new file mode 100644
index 00000000..67ea7d91
--- /dev/null
+++ b/tests/ui/cursor-height.spec.ts
@@ -0,0 +1,171 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+// Use actual loaded/rasterized artwork: checking the SVG canvas dimensions misses
+// transparent margins and the shadow padding that caused the size mismatch.
+test("cursor bodies match across styles and preview/export renderers", async ({ page }) => {
+ await page.route("**/cursor-height-audit.html", (route) =>
+ route.fulfill({
+ contentType: "text/html",
+ body: "",
+ }),
+ );
+ await page.goto("/cursor-height-audit.html");
+ const measurements = await page.evaluate(async () => {
+ const renderer = await import(
+ "/src/components/video-editor/videoPlayback/cursorRenderer.ts"
+ );
+ const sizing = await import(
+ "/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts"
+ );
+ const { Application } = await import("/tests/ui/pixiAudit.ts");
+ function bodyHeight(canvas: HTMLCanvasElement) {
+ const pixels = canvas
+ .getContext("2d")!
+ .getImageData(0, 0, canvas.width, canvas.height).data;
+ let top = canvas.height;
+ let bottom = -1;
+ for (let y = 0; y < canvas.height; y++)
+ for (let x = 0; x < canvas.width; x++) {
+ if (pixels[(y * canvas.width + x) * 4 + 3] >= 128) {
+ top = Math.min(top, y);
+ bottom = Math.max(bottom, y);
+ }
+ }
+ return bottom - top + 1;
+ }
+ const app = new Application();
+ await app.init({ width: 400, height: 400, backgroundAlpha: 0, resolution: 1 });
+ const results: {
+ style: string;
+ target: number;
+ pixi: number;
+ canvas: number;
+ atlas: number;
+ }[] = [];
+ for (const style of [
+ "macos",
+ "tahoe",
+ "windows11",
+ "dot",
+ "figma",
+ "bold-outline",
+ "pixel-hand",
+ "bee",
+ "breezex",
+ "tahoe-inverted",
+ "catppuccin-pink",
+ ]) {
+ const atlas = await renderer.buildNativeCursorAtlas(style);
+ const image = new Image();
+ image.src = atlas!.dataUrl;
+ await image.decode();
+ const entry = atlas!.entries[0];
+ const tile = document.createElement("canvas");
+ tile.width = entry.width;
+ tile.height = entry.height;
+ tile.getContext("2d")!.drawImage(
+ image,
+ entry.x,
+ entry.y,
+ entry.width,
+ entry.height,
+ 0,
+ 0,
+ tile.width,
+ tile.height,
+ );
+ for (const [width, size] of [
+ [1920, 28],
+ [1920, 84],
+ [960, 84],
+ ]) {
+ const target = (size * width) / 1920;
+ const config = {
+ ...renderer.DEFAULT_CURSOR_CONFIG,
+ style,
+ dotRadius: size,
+ clickBounce: 0,
+ clickEffect: "none" as const,
+ };
+ const viewport = { x: 0, y: 0, width, height: 1080 };
+ const samples = [{ timeMs: 0, cx: 0.5, cy: 0.5, cursorType: "arrow" as const }];
+ const overlay = new renderer.PixiCursorOverlay(config);
+ app.stage.addChild(overlay.container);
+ overlay.update(samples, 0, viewport, true, true);
+ overlay.container.position.set(200 - width / 2, 200 - 540);
+ app.render();
+ const pixi = app.renderer.extract.canvas(app.stage) as HTMLCanvasElement;
+ const canvas = document.createElement("canvas");
+ canvas.width = 400;
+ canvas.height = 400;
+ const ctx = canvas.getContext("2d")!;
+ ctx.translate(200 - width / 2, 200 - 540);
+ renderer.drawCursorOnCanvas(
+ ctx,
+ samples,
+ 0,
+ viewport,
+ new renderer.SmoothedCursorState(config),
+ config,
+ );
+ results.push({
+ style,
+ target,
+ pixi: bodyHeight(pixi),
+ canvas: bodyHeight(canvas),
+ atlas:
+ (bodyHeight(tile) / entry.height) *
+ target *
+ sizing.getCursorStyleSizeMultiplier(style),
+ });
+ overlay.destroy();
+ }
+ }
+ app.destroy(true);
+ return results;
+ });
+ for (const row of measurements) {
+ expect(Math.abs(row.pixi - row.target), JSON.stringify(row)).toBeLessThanOrEqual(2);
+ expect(Math.abs(row.canvas - row.target), JSON.stringify(row)).toBeLessThanOrEqual(2);
+ expect(Math.abs(row.atlas - row.target), JSON.stringify(row)).toBeLessThanOrEqual(1);
+ expect(Math.abs(row.pixi - row.canvas), JSON.stringify(row)).toBeLessThanOrEqual(1);
+ }
+});
+
+test("picker thumbnails have the same visible height using renderer assets", async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/?windowType=editor");
+ await page.getByRole("radio", { name: "Cursor", exact: true }).click();
+ const picker = page.getByRole("grid", { name: "Cursor Style", exact: true });
+ await expect(picker.locator("img")).toHaveCount(8);
+ await expect(picker.getByRole("row", { name: "Bibata Bee", exact: true })).toHaveCount(0);
+ const boldOutline = picker.getByRole("row", { name: "Bold Outline", exact: true });
+ await boldOutline.click();
+ await expect(boldOutline).toHaveAttribute("aria-selected", "true");
+ const heights = await picker.locator("img").evaluateAll(async (images) =>
+ Promise.all(
+ images.map(async (element) => {
+ const image = element as HTMLImageElement;
+ await image.decode();
+ const canvas = document.createElement("canvas");
+ canvas.width = image.naturalWidth;
+ canvas.height = image.naturalHeight;
+ const ctx = canvas.getContext("2d")!;
+ ctx.drawImage(image, 0, 0);
+ const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
+ let top = canvas.height;
+ let bottom = -1;
+ for (let y = 0; y < canvas.height; y++)
+ for (let x = 0; x < canvas.width; x++) {
+ if (data[(y * canvas.width + x) * 4 + 3] >= 128) {
+ top = Math.min(top, y);
+ bottom = Math.max(bottom, y);
+ }
+ }
+ return ((bottom - top + 1) / canvas.height) * image.getBoundingClientRect().height;
+ }),
+ ),
+ );
+ for (const height of heights) expect(height).toBeCloseTo(28, 0);
+});
diff --git a/tests/ui/cursor-hold.spec.ts b/tests/ui/cursor-hold.spec.ts
new file mode 100644
index 00000000..0b32c6bb
--- /dev/null
+++ b/tests/ui/cursor-hold.spec.ts
@@ -0,0 +1,127 @@
+import { expect, test } from "@playwright/test";
+
+for (const effect of ["echo", "spotlight", "burst"] as const)
+ test(`preview and canvas export ${effect === "burst" ? "burst from the original click point" : `keep held ${effect} on the moving cursor until release`}`, async ({
+ page,
+ }) => {
+ await page.route("**/cursor-hold-audit.html", (route) =>
+ route.fulfill({ contentType: "text/html", body: "" }),
+ );
+ await page.goto("/cursor-hold-audit.html");
+ const frames = await page.evaluate(async (effect) => {
+ const renderer = await import(
+ "/src/components/video-editor/videoPlayback/cursorRenderer.ts"
+ );
+ await renderer.preloadCursorAssets();
+ const { Application } = await import("/tests/ui/pixiAudit.ts");
+ const app = new Application();
+ await app.init({ width: 800, height: 400, backgroundAlpha: 0, resolution: 1 });
+ const config = {
+ ...renderer.DEFAULT_CURSOR_CONFIG,
+ style: "dot",
+ dotRadius: 80,
+ clickEffect: effect,
+ clickEffectColor: "#ff0000",
+ clickEffectOpacity: 1,
+ };
+ const samples =
+ effect === "burst"
+ ? [
+ {
+ timeMs: 0,
+ cx: 0.2,
+ cy: 0.5,
+ interactionType: "click",
+ mouseButton: 1,
+ },
+ { timeMs: 60, cx: 0.5, cy: 0.5, interactionType: "move" },
+ {
+ timeMs: 100,
+ cx: 0.8,
+ cy: 0.5,
+ interactionType: "mouseup",
+ mouseButton: 1,
+ },
+ { timeMs: 400, cx: 0.8, cy: 0.5, interactionType: "move" },
+ ]
+ : [
+ {
+ timeMs: 0,
+ cx: 0.2,
+ cy: 0.5,
+ interactionType: "click",
+ mouseButton: 1,
+ },
+ { timeMs: 2000, cx: 0.5, cy: 0.5, interactionType: "move" },
+ {
+ timeMs: 4000,
+ cx: 0.8,
+ cy: 0.5,
+ interactionType: "mouseup",
+ mouseButton: 1,
+ },
+ { timeMs: 6000, cx: 0.8, cy: 0.5, interactionType: "move" },
+ ];
+ const viewport = { x: 0, y: 0, width: 800, height: 400 };
+ const overlay = new renderer.PixiCursorOverlay(config);
+ app.stage.addChild(overlay.container);
+ function redBounds(canvas: HTMLCanvasElement) {
+ const ctx = canvas.getContext("2d")!;
+ const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
+ let count = 0,
+ xSum = 0;
+ for (let y = 0; y < canvas.height; y++)
+ for (let x = 0; x < canvas.width; x++) {
+ const i = (y * canvas.width + x) * 4;
+ if (
+ pixels[i] > 180 &&
+ pixels[i + 1] < 90 &&
+ pixels[i + 2] < 90 &&
+ pixels[i + 3] > 20
+ ) {
+ count++;
+ xSum += x;
+ }
+ }
+ return { count, x: count ? xSum / count : null };
+ }
+ const results = [];
+ for (const time of effect === "burst"
+ ? [30, 90, 180, 350, 90]
+ : [2000, 3000, 5000, 2000]) {
+ overlay.update(samples, time, viewport, true, true);
+ app.render();
+ const preview = document.createElement("canvas");
+ preview.width = 800;
+ preview.height = 400;
+ preview.getContext("2d")!.drawImage(app.canvas, 0, 0);
+ const canvas = document.createElement("canvas");
+ canvas.width = 800;
+ canvas.height = 400;
+ renderer.drawCursorOnCanvas(
+ canvas.getContext("2d")!,
+ samples,
+ time,
+ viewport,
+ new renderer.SmoothedCursorState(config),
+ config,
+ );
+ results.push({ time, preview: redBounds(preview), exported: redBounds(canvas) });
+ }
+ overlay.destroy();
+ app.destroy(true);
+ return results;
+ }, effect);
+ for (const frame of frames)
+ for (const result of [frame.preview, frame.exported]) {
+ if (frame.time === 5000 || (effect === "burst" && frame.time === 350))
+ expect(result.count).toBe(0);
+ else {
+ expect(result.count, JSON.stringify(frames)).toBeGreaterThan(10);
+ expect(result.x, JSON.stringify(frames)).toBeCloseTo(
+ effect === "burst" ? 160 : frame.time === 2000 ? 400 : 520,
+ -1,
+ );
+ }
+ }
+ });
diff --git a/tests/ui/cursor-idle-settings.spec.ts b/tests/ui/cursor-idle-settings.spec.ts
new file mode 100644
index 00000000..5153db6f
--- /dev/null
+++ b/tests/ui/cursor-idle-settings.spec.ts
@@ -0,0 +1,43 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test("cursor idle hiding defaults on and advanced timings survive toggling the feature", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ await page.getByRole("radio", { name: "Cursor", exact: true }).click();
+ const hide = page.getByRole("switch", { name: "Hide cursor when idle", exact: true });
+ await expect(hide).toBeChecked();
+ const detection = page.getByRole("slider", { name: "Idle detection", exact: true });
+ const delay = page.getByRole("slider", { name: "Fade delay", exact: true });
+ const fade = page.getByRole("slider", { name: "Fade duration", exact: true });
+ const tolerance = page.getByRole("slider", { name: "Movement tolerance", exact: true });
+ await expect(detection).toHaveCount(0);
+ await page.locator("aside").getByText("Advanced", { exact: true }).click();
+ await expect(detection).toHaveValue("2000");
+ await expect(delay).toHaveValue("500");
+ await expect(fade).toHaveValue("300");
+ await expect(tolerance).toHaveValue("2");
+ await detection.focus();
+ await page.keyboard.press("ArrowRight");
+ await expect(detection).toHaveValue("2100");
+ await delay.focus();
+ await page.keyboard.press("ArrowRight");
+ await expect(delay).toHaveValue("550");
+ await fade.focus();
+ await page.keyboard.press("ArrowRight");
+ await expect(delay).toHaveValue("550");
+ await expect(fade).toHaveValue("325");
+ await tolerance.focus();
+ await page.keyboard.press("ArrowRight");
+ await expect(tolerance).toHaveValue("2.5");
+ await hide.press("Space");
+ await expect(hide).not.toBeChecked();
+ await expect(detection).toHaveCount(0);
+ await hide.press("Space");
+ await expect(detection).toHaveValue("2100");
+ await expect(delay).toHaveValue("550");
+ await expect(fade).toHaveValue("325");
+ await expect(tolerance).toHaveValue("2.5");
+});
diff --git a/tests/ui/cursor-idle.spec.ts b/tests/ui/cursor-idle.spec.ts
new file mode 100644
index 00000000..e92ac7b8
--- /dev/null
+++ b/tests/ui/cursor-idle.spec.ts
@@ -0,0 +1,81 @@
+import { expect, test } from "@playwright/test";
+
+test("idle fades match preview and canvas rendering, including seeking and disabling", async ({
+ page,
+}) => {
+ await page.route("**/cursor-idle-audit.html", (route) =>
+ route.fulfill({ contentType: "text/html", body: "" }),
+ );
+ await page.goto("/cursor-idle-audit.html");
+ const frames = await page.evaluate(async () => {
+ const renderer = await import(
+ "/src/components/video-editor/videoPlayback/cursorRenderer.ts"
+ );
+ await renderer.preloadCursorAssets();
+ const { Application } = await import("/tests/ui/pixiAudit.ts");
+ const app = new Application();
+ await app.init({ width: 400, height: 200, backgroundAlpha: 0, resolution: 1 });
+ const config = { ...renderer.DEFAULT_CURSOR_CONFIG, style: "dot", dotRadius: 100 };
+ const samples = [
+ { timeMs: 0, cx: 0.5, cy: 0.5 },
+ { timeMs: 3000, cx: 0.5, cy: 0.5 },
+ { timeMs: 3100, cx: 0.8, cy: 0.5 },
+ ];
+ const viewport = { x: 0, y: 0, width: 400, height: 200 };
+ const overlay = new renderer.PixiCursorOverlay(config);
+ app.stage.addChild(overlay.container);
+ const alphaSum = (canvas: HTMLCanvasElement) => {
+ const copy = document.createElement("canvas");
+ copy.width = 400;
+ copy.height = 200;
+ const ctx = copy.getContext("2d")!;
+ ctx.drawImage(canvas, 0, 0);
+ const data = ctx.getImageData(0, 0, 400, 200).data;
+ let sum = 0;
+ for (let i = 3; i < data.length; i += 4) sum += data[i];
+ return sum;
+ };
+ const results = [];
+ for (const time of [
+ 0, 250, 500, 650, 800, 2000, 2999, 3000, 3037.5, 3075, 3112.5, 3150, 3275, 3075, 650,
+ ]) {
+ overlay.update(samples, time, viewport, true, true);
+ app.render();
+ const canvas = document.createElement("canvas");
+ canvas.width = 400;
+ canvas.height = 200;
+ renderer.drawCursorOnCanvas(
+ canvas.getContext("2d")!,
+ samples,
+ time,
+ viewport,
+ new renderer.SmoothedCursorState(config),
+ config,
+ );
+ results.push({ time, preview: alphaSum(app.canvas), canvas: alphaSum(canvas) });
+ }
+ overlay.setIdleHideSettings({ ...config.idleHide, enabled: false });
+ overlay.update(samples, 2000, viewport, true, true);
+ app.render();
+ const disabled = alphaSum(app.canvas);
+ overlay.destroy();
+ app.destroy(true);
+ return { results, disabled };
+ });
+ for (const backend of ["preview", "canvas"] as const) {
+ const full = frames.results[0][backend];
+ expect(full).toBeGreaterThan(1000);
+ for (const frame of frames.results) {
+ if ([800, 2000, 2999, 3000].includes(frame.time)) expect(frame[backend]).toBe(0);
+ else if (frame.time === 650) expect(frame[backend] / full).toBeCloseTo(0.5, 1);
+ else if (frame.time > 3000 && frame.time < 3150) {
+ const progress = (frame.time - 3000) / 150;
+ expect(frame[backend] / full).toBeCloseTo(
+ progress * progress * (3 - 2 * progress),
+ 1,
+ );
+ } else expect(frame[backend] / full).toBeCloseTo(1, 1);
+ }
+ }
+ expect(frames.disabled / frames.results[0].preview).toBeCloseTo(1, 1);
+});
diff --git a/tests/ui/cursor-ripple.spec.ts b/tests/ui/cursor-ripple.spec.ts
new file mode 100644
index 00000000..819f9f51
--- /dev/null
+++ b/tests/ui/cursor-ripple.spec.ts
@@ -0,0 +1,158 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test("all click effects select grey for the recording and previews", async ({ page }, testInfo) => {
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ await page.getByRole("radio", { name: "Cursor", exact: true }).click();
+ const choices = page.getByRole("grid", { name: "Click Effects", exact: true });
+ await expect(choices.getByRole("row")).toHaveCount(4);
+ await expect(choices.getByRole("row", { name: "Off", exact: true })).toHaveCount(0);
+ for (const name of ["Spotlight", "Echo", "Burst"]) {
+ const effect = choices.getByRole("row", { name, exact: true });
+ await effect.click();
+ await expect(effect).toHaveAttribute("aria-selected", "true");
+ await expect(effect.locator("svg")).toHaveCSS("color", "rgb(156, 163, 175)");
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ JSON.parse(localStorage.getItem("recordly.editor.preferences") ?? "{}")
+ .cursorClickEffectColor,
+ ),
+ )
+ .toBe("#9CA3AF");
+ }
+ const ripple = page.getByRole("row", { name: "Ripple", exact: true });
+ await ripple.click();
+ await expect(ripple).toHaveAttribute("aria-selected", "true");
+ await expect(ripple.locator("svg")).toHaveCSS("color", "rgb(156, 163, 175)");
+ await expect(ripple.locator("circle")).toHaveAttribute("fill", "currentColor");
+ await ripple.click();
+ await expect(ripple).toHaveAttribute("aria-selected", "false");
+ await ripple.press("Enter");
+ await expect(ripple).toHaveAttribute("aria-selected", "true");
+ await ripple.press("Enter");
+ await expect(ripple).toHaveAttribute("aria-selected", "false");
+ await ripple.click();
+ await page.reload();
+ await page.getByRole("radio", { name: "Cursor", exact: true }).click();
+ await expect(ripple).toHaveAttribute("aria-selected", "true");
+ await expect(ripple.locator("svg")).toHaveCSS("color", "rgb(156, 163, 175)");
+ const burst = page.getByRole("row", { name: "Burst", exact: true });
+ await burst.click();
+ await expect(burst).toHaveAttribute("aria-selected", "true");
+ await expect(ripple).toHaveAttribute("aria-selected", "false");
+ await expect(burst.locator("svg path")).toHaveCount(10);
+ await expect(burst.locator("svg")).toHaveCSS("color", "rgb(156, 163, 175)");
+ await page.reload();
+ await page.getByRole("radio", { name: "Cursor", exact: true }).click();
+ await expect(burst).toHaveAttribute("aria-selected", "true");
+ await choices.screenshot({ path: testInfo.outputPath("click-effects.png") });
+ await burst.click();
+ await expect(burst).toHaveAttribute("aria-selected", "false");
+});
+
+test("gray ripple expands and fades completely while held in preview and export", async ({
+ page,
+}, testInfo) => {
+ await page.route("**/ripple-audit.html", (route) =>
+ route.fulfill({ contentType: "text/html", body: "" }),
+ );
+ await page.goto("/ripple-audit.html");
+ const frames = await page.evaluate(async () => {
+ const { Application, Graphics } = await import("/tests/ui/pixiAudit.ts");
+ const { drawClickEffectGraphics, drawClickEffectOnCanvas } = await import(
+ "/src/components/video-editor/videoPlayback/cursorClickEffects.ts"
+ );
+ const { getCursorVisualState } = await import(
+ "/src/components/video-editor/videoPlayback/cursorVisualState.ts"
+ );
+ const { DEFAULT_CURSOR_RIPPLE_COLOR } = await import(
+ "/src/components/video-editor/types.ts"
+ );
+ const app = new Application();
+ await app.init({ width: 200, height: 200, backgroundAlpha: 0, resolution: 1 });
+ const graphics = new Graphics();
+ app.stage.addChild(graphics);
+ const samples = [
+ { timeMs: 0, cx: 0.5, cy: 0.5, interactionType: "click", mouseButton: 1 },
+ { timeMs: 400, cx: 0.5, cy: 0.5, interactionType: "mouseup", mouseButton: 1 },
+ ];
+ function measure(canvas: HTMLCanvasElement) {
+ const pixels = canvas.getContext("2d")!.getImageData(0, 0, 200, 200).data;
+ let left = 200;
+ let right = -1;
+ for (let x = 0; x < 200; x++) {
+ if (pixels[(100 * 200 + x) * 4 + 3] > 3) {
+ left = Math.min(left, x);
+ right = Math.max(right, x);
+ }
+ }
+ return {
+ width: Math.max(0, right - left + 1),
+ center: Array.from(pixels.slice((100 * 200 + 100) * 4, (100 * 200 + 100) * 4 + 4)),
+ };
+ }
+ document.body.style.cssText =
+ "display:grid;grid-template-columns:200px 200px;background:white;color:#333;font:16px system-ui";
+ const results = [];
+ for (const time of [30, 90, 180, 240, 350, 90, 550]) {
+ const { clickProgress } = getCursorVisualState(
+ samples,
+ time,
+ 180,
+ 600,
+ "dot",
+ 1,
+ "ripple",
+ );
+ const args = [
+ "ripple",
+ 100,
+ 100,
+ 32,
+ clickProgress,
+ 1,
+ 1,
+ DEFAULT_CURSOR_RIPPLE_COLOR,
+ ] as const;
+ drawClickEffectGraphics(graphics, ...args);
+ app.render();
+ const preview = document.createElement("canvas");
+ preview.width = preview.height = 200;
+ preview.getContext("2d")!.drawImage(app.canvas, 0, 0);
+ const exported = document.createElement("canvas");
+ exported.width = exported.height = 200;
+ drawClickEffectOnCanvas(exported.getContext("2d")!, ...args);
+ results.push({ time, preview: measure(preview), exported: measure(exported) });
+ const label = document.createElement("div");
+ label.style.gridColumn = "span 2";
+ label.textContent = `${time} ms — preview / export`;
+ document.body.append(label, preview, exported);
+ }
+ app.destroy(true);
+ return results;
+ });
+ await page.screenshot({ path: testInfo.outputPath("ripple-frames.png"), fullPage: true });
+ for (const mode of ["preview", "exported"] as const) {
+ expect(frames[1][mode].width).toBeGreaterThan(frames[0][mode].width);
+ expect(frames[2][mode].width).toBeGreaterThan(frames[1][mode].width);
+ for (const index of [1, 2, 3]) {
+ expect(frames[index][mode].center[3]).toBeLessThan(frames[index - 1][mode].center[3]);
+ }
+ expect(frames[4][mode].width).toBe(0);
+ expect(frames[5][mode]).toEqual(frames[1][mode]);
+ expect(frames[6][mode].width).toBe(0);
+ }
+ for (const frame of frames.filter((frame) => frame.time < 300)) {
+ for (const result of [frame.preview, frame.exported]) {
+ expect(result.center[3]).toBeGreaterThan(0);
+ for (const [i, channel] of [156, 163, 175].entries()) {
+ expect(Math.abs(result.center[i] - channel)).toBeLessThanOrEqual(8);
+ }
+ }
+ expect(Math.abs(frame.preview.width - frame.exported.width)).toBeLessThanOrEqual(2);
+ expect(Math.abs(frame.preview.center[3] - frame.exported.center[3])).toBeLessThanOrEqual(1);
+ }
+});
diff --git a/tests/ui/custom-color.spec.ts b/tests/ui/custom-color.spec.ts
new file mode 100644
index 00000000..8c28a022
--- /dev/null
+++ b/tests/ui/custom-color.spec.ts
@@ -0,0 +1,56 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test("background colors use a tile grid and save colors through the plus tile", async ({
+ page,
+}, testInfo) => {
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ await expect(page.locator('[data-background-panel="image"]')).toBeVisible({ timeout: 30000 });
+ const imageSelections = await page
+ .locator('[data-background-panel="image"] button[aria-pressed]')
+ .count();
+ await page.getByRole("row", { name: "Colors", exact: true }).click();
+ const grid = page.locator('[aria-label="Background colors"]');
+ await expect(grid).toHaveCSS("display", "grid");
+ await expect(grid.getByRole("button").first()).toHaveAccessibleName("Add custom color");
+ await expect(page.getByRole("row", { name: "Color", exact: true })).toHaveCount(0);
+ await expect(page.getByRole("row", { name: "Gradient", exact: true })).toHaveCount(0);
+ await expect(grid.locator("button[aria-pressed]")).toHaveCount(imageSelections);
+ await expect(grid.getByRole("button")).toHaveCount(25);
+ await page.screenshot({ path: testInfo.outputPath("colors-palette.png") });
+ const solid = grid.getByRole("button", { name: "Background color #FFFFFF", exact: true });
+ const gradient = grid.getByRole("button", { name: "Gradient 1", exact: true });
+ await solid.click();
+ await expect(solid).toHaveAttribute("aria-pressed", "true");
+ await gradient.click();
+ await expect(gradient).toHaveAttribute("aria-pressed", "true");
+ await expect(solid).toHaveAttribute("aria-pressed", "false");
+ await expect(grid.locator('button[aria-pressed="true"]')).toHaveCount(1);
+ await grid.getByRole("button", { name: "Add custom color" }).click();
+ const dialog = page.getByRole("dialog", { name: "Custom color", exact: true });
+ const hex = page.getByRole("textbox", { name: "Hex color", exact: true });
+ await hex.fill("#123456");
+ await hex.press("Tab");
+ await page.screenshot({ path: testInfo.outputPath("add-color-picker.png") });
+ await dialog.getByRole("button", { name: "Add", exact: true }).click();
+ await expect(
+ grid.getByRole("button", { name: "Background color #123456", exact: true }),
+ ).toHaveAttribute("aria-pressed", "true");
+ await page.screenshot({ path: testInfo.outputPath("color-grid.png") });
+ await page.reload();
+ await page.getByRole("row", { name: "Colors", exact: true }).click();
+ await expect(
+ grid.getByRole("button", { name: "Background color #123456", exact: true }),
+ ).toBeVisible();
+ await grid.getByRole("button", { name: "Add custom color" }).click();
+ await dialog.getByRole("button", { name: "Close color picker" }).click();
+ await expect(dialog).toBeHidden();
+ const swatch = grid.getByRole("button", { name: "Background color #123456", exact: true });
+ await swatch.hover();
+ await grid.getByRole("button", { name: "Remove custom color #123456", exact: true }).click();
+ await expect(swatch).toHaveCount(0);
+ await page.reload();
+ await page.getByRole("row", { name: "Colors", exact: true }).click();
+ await expect(swatch).toHaveCount(0);
+});
diff --git a/tests/ui/custom-gradient.spec.ts b/tests/ui/custom-gradient.spec.ts
new file mode 100644
index 00000000..9cdb264d
--- /dev/null
+++ b/tests/ui/custom-gradient.spec.ts
@@ -0,0 +1,62 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test("gradient gallery starts with a HeroUI creator and saves custom gradients", async ({
+ page,
+}, testInfo) => {
+ const errors: string[] = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ await page.getByRole("row", { name: "Colors", exact: true }).click();
+ const gallery = page.locator('[aria-label="Background colors"]');
+ await expect(gallery.getByRole("button").first()).toHaveAccessibleName("Add custom color");
+ await gallery.getByRole("button", { name: "Add custom color", exact: true }).click();
+ await page.getByRole("button", { name: "Create custom gradient", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "Custom gradient", exact: true });
+ await expect(dialog.getByRole("img", { name: "Gradient preview" })).toBeVisible();
+ await dialog.getByRole("button", { name: "Add color", exact: true }).click();
+ await expect(dialog.getByRole("button", { name: "Color 3", exact: true })).toBeVisible();
+ await dialog.getByRole("button", { name: "Color 1", exact: true }).click();
+ const hex = page.getByRole("textbox", { name: "Hex color", exact: true });
+ await hex.fill("#FF0000");
+ await hex.press("Tab");
+ await hex.press("Escape");
+ await dialog.getByRole("slider", { name: "Angle", exact: true }).focus();
+ await page.keyboard.press("Home");
+ await page.keyboard.press("ArrowRight");
+ await expect(dialog.getByRole("slider", { name: "Angle", exact: true })).toHaveValue("1");
+ await expect(dialog.getByRole("img", { name: "Gradient preview" })).toHaveCSS(
+ "background-image",
+ /linear-gradient\(1deg, rgb\(255, 0, 0\)/,
+ );
+ await page.screenshot({ path: testInfo.outputPath("custom-gradient-editor.png") });
+ await dialog.getByRole("button", { name: "Save gradient", exact: true }).click();
+ await expect(dialog).toBeHidden();
+ await expect(
+ page.getByRole("button", { name: "Custom gradient 1", exact: true }),
+ ).toHaveAttribute("aria-pressed", "true");
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ JSON.parse(localStorage.getItem("recordly.editor.preferences") ?? "{}")
+ .customGradients?.[0],
+ ),
+ )
+ .toBe("linear-gradient(1deg, #ff0000 0%, #22d3ee 50%, #fb7185 100%)");
+ await page.reload();
+ await page.getByRole("row", { name: "Colors", exact: true }).click();
+ await expect(
+ page.getByRole("button", { name: "Custom gradient 1", exact: true }),
+ ).toBeVisible();
+ await gallery.getByRole("button", { name: "Add custom color", exact: true }).click();
+ await page.getByRole("button", { name: "Create custom gradient", exact: true }).click();
+ await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
+ await expect(page.getByRole("button", { name: /^Custom gradient \d+$/ })).toHaveCount(1);
+ await page.getByRole("button", { name: "Remove Custom gradient 1", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Custom gradient 1", exact: true })).toHaveCount(
+ 0,
+ );
+ expect(errors).toEqual([]);
+});
diff --git a/tests/ui/dashboard-settings.spec.ts b/tests/ui/dashboard-settings.spec.ts
new file mode 100644
index 00000000..f1352940
--- /dev/null
+++ b/tests/ui/dashboard-settings.spec.ts
@@ -0,0 +1,492 @@
+import { expect, type Page, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+async function installSettingsBridge(page: Page) {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ let preferences = {
+ success: true,
+ showInDock: true,
+ hideWhileRecording: false,
+ launchAtLogin: false,
+ launchAtLoginSupported: true,
+ platform: "darwin",
+ };
+ window.electronAPI.getApplicationPreferences = async () => preferences;
+ window.electronAPI.setApplicationPreferences = async (patch) => {
+ preferences = { ...preferences, ...patch };
+ return preferences;
+ };
+ });
+}
+
+async function openDashboard(page: Page) {
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ return page.getByRole("dialog", { name: "Projects dashboard", exact: true });
+}
+
+for (const fails of [false, true]) {
+ test(`Hide HUD untoggle ${fails ? "keeps the previous state on failure" : "saves HUD inclusion in recordings"}`, async ({
+ page,
+ }) => {
+ await installSettingsBridge(page);
+ await page.addInitScript(
+ ({ fails }) => {
+ window.electronAPI.getHudOverlayCaptureProtection = async () => ({
+ success: true,
+ enabled: localStorage.getItem("test-hide-hud") !== "false",
+ });
+ window.electronAPI.setHudOverlayCaptureProtection = async (enabled) => {
+ document.documentElement.dataset.hideHudRequested = String(enabled);
+ if (fails) return { success: false, enabled: true };
+ localStorage.setItem("test-hide-hud", String(enabled));
+ return { success: true, enabled };
+ };
+ },
+ { fails },
+ );
+ const openRecordingSettings = async () => {
+ const home = await openDashboard(page);
+ await home.getByRole("button", { name: "Recordly account" }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true })
+ .click();
+ await home.getByRole("tab", { name: "Recording", exact: true }).click();
+ return home.getByRole("switch", {
+ name: "Hide recording controls from captured video",
+ exact: true,
+ });
+ };
+ const toggle = await openRecordingSettings();
+ await expect(toggle).toBeChecked();
+ await toggle.focus();
+ await toggle.press("Space");
+ await expect(page.locator("html")).toHaveAttribute("data-hide-hud-requested", "false");
+ if (fails) {
+ const failure = page
+ .getByRole("tabpanel", { name: /^Recording settings/ })
+ .getByRole("alert");
+ await expect(failure).toContainText("Could not complete action");
+ await expect(failure.getByText("Could not update capture protection")).not.toBeVisible();
+ await failure.getByRole("button", { name: "Technical details", exact: true }).click();
+ await expect(failure.getByText("Could not update capture protection")).toBeVisible();
+ await expect(toggle).toBeChecked();
+ } else {
+ await expect(toggle).not.toBeChecked();
+ await page.reload();
+ await expect(await openRecordingSettings()).not.toBeChecked();
+ }
+ });
+}
+
+test("Settings uses compact themes and keyboard tabs, and persists appearance", async ({
+ page,
+}) => {
+ await installSettingsBridge(page);
+ const home = await openDashboard(page);
+ await home.getByRole("button", { name: "Recordly account" }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true })
+ .click();
+ const sections = home.getByRole("tablist", { name: "Settings sections" });
+ await expect(sections).toBeVisible();
+ await sections.getByRole("tab", { name: "General", exact: true }).click();
+ const appearance = home.getByRole("radiogroup", { name: "Appearance" });
+ await expect(appearance.getByRole("radio")).toHaveCount(3);
+ await appearance.getByText("Light", { exact: true }).click();
+ await expect(appearance.getByRole("radio", { name: "Light", exact: true })).toBeChecked();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
+ await sections.getByRole("tab", { name: "General", exact: true }).focus();
+ await page.keyboard.press("ArrowDown");
+ await expect(sections.getByRole("tab", { name: "Recording", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await expect(home.getByRole("tabpanel", { name: /^Recording settings/ })).toBeVisible();
+ await sections.getByRole("tab", { name: "General", exact: true }).click();
+ await page.screenshot({
+ path: "test-results/settings-theme-previews.png",
+ animations: "disabled",
+ });
+ await page.reload();
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ await home.getByRole("button", { name: "Recordly account" }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true })
+ .click();
+ await home.getByRole("tab", { name: "General", exact: true }).click();
+ await expect(home.getByRole("radio", { name: "Light", exact: true })).toBeChecked();
+});
+
+test("library header account panel edits profile and opens Settings", async ({ page }) => {
+ await installSettingsBridge(page);
+ const home = await openDashboard(page);
+ const trigger = home.getByRole("button", { name: "Recordly account" });
+ await trigger.click();
+ const menu = page.getByRole("dialog", { name: "Account menu", exact: true });
+ await expect(menu.getByText("Test User", { exact: true })).toBeVisible();
+ await expect(menu.getByText("Your account", { exact: true })).toBeVisible();
+ const menuBounds = (await menu.boundingBox())!;
+ const triggerBounds = (await trigger.boundingBox())!;
+ expect(menuBounds.y).toBeGreaterThanOrEqual(triggerBounds.y + triggerBounds.height);
+ await expect(menu).toBeInViewport();
+ await page.screenshot({
+ path: "test-results/sidebar-account-menu.png",
+ animations: "disabled",
+ });
+ await page.keyboard.press("Escape");
+ await expect(menu).not.toBeVisible();
+ await expect(trigger).toBeFocused();
+ await trigger.click();
+ await menu.getByRole("button", { name: "Manage account" }).click();
+ const account = page.getByRole("dialog", { name: "Account settings", exact: true });
+ await expect(account.getByRole("textbox", { name: /^Display name\*?$/ })).toHaveValue(
+ "Test User",
+ );
+ await account.getByRole("textbox", { name: /^Display name\*?$/ }).fill("Jordan Chen");
+ await account.getByRole("button", { name: "Save profile" }).click();
+ await expect(
+ account.getByRole("status").filter({ hasText: /^Profile saved\.$/ }),
+ ).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(account).not.toBeVisible();
+ await expect(trigger.getByLabel("Jordan Chen", { exact: true })).toBeVisible();
+ await trigger.click();
+ await expect(menu.getByText("Jordan Chen", { exact: true })).toBeVisible();
+ await menu.getByRole("button", { name: "Settings", exact: true }).click();
+ await expect(home.getByRole("tablist", { name: "Settings sections" })).toBeVisible();
+});
+
+test("account logout clears the session", async ({ page }) => {
+ await installSettingsBridge(page);
+ const home = await openDashboard(page);
+ await home.getByRole("button", { name: "Recordly account" }).click();
+ const menu = page.getByRole("dialog", { name: "Account menu", exact: true });
+ const logout = page.waitForRequest((request) => request.url().includes("/auth/v1/logout"));
+ await menu.getByRole("button", { name: "Log out" }).click();
+ await logout;
+ await expect(
+ page.getByRole("dialog", { name: "Sign in to Recordly", exact: true }),
+ ).toBeVisible();
+ await expect(menu).not.toBeVisible();
+});
+
+test("editor profile opens shared Settings in a popup and preserves the editor", async ({
+ page,
+}) => {
+ await installSettingsBridge(page);
+ await page.goto("/?windowType=editor");
+ const tools = page.getByRole("navigation", { name: "Editor tools", exact: true });
+ await expect(tools.getByRole("radio", { name: "Settings", exact: true })).toHaveCount(0);
+ await tools.getByRole("radio", { name: "Cursor", exact: true }).click();
+ const video = page.locator('video[aria-hidden="true"]');
+ await expect(video).toHaveCount(1);
+ const originalVideo = (await video.elementHandle())!;
+ const trigger = page.getByRole("button", { name: "Recordly account", exact: true });
+ await trigger.hover();
+ const triggerBounds = (await trigger.boundingBox())!;
+ expect(triggerBounds.width).toBe(triggerBounds.height);
+ await trigger.screenshot({ path: "test-results/editor-account-highlight.png" });
+ await trigger.click();
+ const menu = page.getByRole("dialog", { name: "Account menu", exact: true });
+ await expect(menu.getByText("Test User", { exact: true })).toBeVisible();
+ await expect(
+ page.getByRole("dialog", { name: "Sign in to Recordly", exact: true }),
+ ).toHaveCount(0);
+ await page.screenshot({ path: "test-results/editor-account-menu.png", animations: "disabled" });
+ await page.keyboard.press("Escape");
+ await expect(trigger).toBeFocused();
+ await trigger.click();
+ await menu.getByRole("button", { name: "Manage account" }).click();
+ const profile = page.getByRole("dialog", { name: "Account settings", exact: true });
+ await expect(profile.getByRole("textbox", { name: /^Display name\*?$/ })).toHaveValue(
+ "Test User",
+ );
+ await page.keyboard.press("Escape");
+ await trigger.click();
+ await menu.getByRole("button", { name: "Settings", exact: true }).click();
+ const settings = page.getByRole("dialog", { name: "Settings", exact: true });
+ const home = page.getByRole("dialog", { name: "Projects dashboard", exact: true });
+ await expect(home).not.toBeVisible();
+ await expect(settings.getByRole("tablist", { name: "Settings sections" })).toBeVisible();
+ await expect(settings.getByRole("tab", { name: "General", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ const bounds = (await settings.boundingBox())!;
+ const viewport = page.viewportSize()!;
+ expect(bounds.x + bounds.width / 2).toBeCloseTo(viewport.width / 2, 0);
+ expect(bounds.y + bounds.height / 2).toBeCloseTo(viewport.height / 2, 0);
+ await page.screenshot({
+ path: "test-results/editor-settings-popup.png",
+ animations: "disabled",
+ });
+ await page.setViewportSize({ width: 420, height: 800 });
+ const narrowBounds = (await settings.boundingBox())!;
+ expect(narrowBounds.x).toBeGreaterThanOrEqual(0);
+ expect(narrowBounds.x + narrowBounds.width).toBeLessThanOrEqual(420);
+ await page.screenshot({
+ path: "test-results/editor-settings-popup-narrow.png",
+ animations: "disabled",
+ });
+ await page.setViewportSize(viewport);
+ await page.keyboard.press("Escape");
+ await expect(settings).not.toBeVisible();
+ await expect(trigger).toBeFocused();
+ await expect(tools.getByRole("radio", { name: "Cursor", exact: true })).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+ expect(await originalVideo.evaluate((node) => node.isConnected)).toBe(true);
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ await expect(home.getByRole("textbox", { name: "Search projects" })).toBeVisible();
+});
+
+test("account popup contains feedback and settings, and editor controls follow the theme", async ({
+ page,
+}) => {
+ await installSettingsBridge(page);
+ await page.goto("/?windowType=editor");
+ const header = page.locator("header.editor-header");
+ const upgrade = header.getByRole("button", { name: "Upgrade to Cloud", exact: true });
+ await expect(upgrade).toBeVisible();
+ const headerFeedback = header.getByRole("button", { name: "Feedback", exact: true });
+ await expect(headerFeedback).toBeVisible();
+ const undoBounds = await header
+ .getByRole("button", { name: "Undo", exact: true })
+ .boundingBox();
+ const feedbackBounds = await headerFeedback.boundingBox();
+ const upgradeBounds = await upgrade.boundingBox();
+ const redoBounds = await header
+ .getByRole("button", { name: "Redo", exact: true })
+ .boundingBox();
+ expect(upgradeBounds!.x + upgradeBounds!.width).toBeLessThan(undoBounds!.x);
+ expect(feedbackBounds!.x).toBeGreaterThan(redoBounds!.x + redoBounds!.width);
+ const feedbackFontSize = await headerFeedback.evaluate(
+ (element) => getComputedStyle(element).fontSize,
+ );
+ expect(await upgrade.evaluate((element) => getComputedStyle(element).fontSize)).toBe(
+ feedbackFontSize,
+ );
+ await expect(upgrade.locator(".plus-badge")).toHaveCount(0);
+ await headerFeedback.click();
+ await expect(page.getByRole("dialog", { name: "Send feedback", exact: true })).toBeVisible();
+ await page.keyboard.press("Escape");
+ const clips = header.getByRole("button", { name: "Clips", exact: true });
+ await clips.click();
+ await expect(clips).toHaveAttribute("aria-expanded", "true");
+ await expect(page.getByRole("region", { name: "Clips library" })).toBeVisible();
+ await clips.click();
+ await expect(clips).toHaveAttribute("aria-expanded", "false");
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ const menu = page.getByRole("dialog", { name: "Account menu", exact: true });
+ await expect(menu.getByRole("button", { name: "Settings", exact: true })).toBeVisible();
+ await menu.getByRole("button", { name: "Feedback", exact: true }).click();
+ await expect(menu).not.toBeVisible();
+ const feedback = page.getByRole("dialog", { name: "Send feedback", exact: true });
+ await expect(feedback).toBeVisible();
+ await expect(
+ feedback.getByText("Relevant diagnostic logs will be attached.", { exact: true }),
+ ).toBeVisible();
+ await page.keyboard.press("Escape");
+ for (const theme of ["light", "dark"]) {
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await menu.getByRole("button", { name: "Settings", exact: true }).click();
+ const settings = page.getByRole("dialog", { name: "Settings", exact: true });
+ await settings.getByRole("tab", { name: "General", exact: true }).click();
+ await settings
+ .getByRole("radiogroup", { name: "Appearance" })
+ .getByText(theme === "light" ? "Light" : "Dark", { exact: true })
+ .click();
+ await page.keyboard.press("Escape");
+ const colors = await upgrade.evaluate((element) => {
+ const style = getComputedStyle(element);
+ return { background: style.backgroundColor, color: style.color };
+ });
+ expect(colors.background).not.toBe(colors.color);
+ await page.screenshot({
+ path: `test-results/editor-controls-${theme}.png`,
+ animations: "disabled",
+ });
+ }
+});
+
+for (const viewport of [
+ { width: 1440, height: 1000 },
+ { width: 420, height: 800 },
+]) {
+ test(`settings keeps its frame stable at ${viewport.width}px while switching sections and loading permissions`, async ({
+ page,
+ }) => {
+ await page.setViewportSize(viewport);
+ await installSettingsBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getScreenRecordingPermissionStatus = async () => {
+ await new Promise((resolve) => setTimeout(resolve, 600));
+ return { success: true, status: "granted" };
+ };
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true })
+ .click();
+ const settings = page.getByRole("dialog", { name: "Settings", exact: true });
+ await expect(settings.getByRole("tab", { name: "General", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ // Let the opening animation settle; measure every frame during subsequent swaps.
+ await expect(settings).not.toHaveAttribute("data-entering");
+ await settings.screenshot({
+ path: "test-results/settings-stable-frame.png",
+ animations: "disabled",
+ });
+ const initial = (await settings.boundingBox())!;
+ for (const name of ["Recording", "Account", "Motion", "General"]) {
+ const frames = settings.evaluate(
+ (element) =>
+ new Promise<{ x: number; y: number; width: number; height: number }[]>(
+ (resolve) => {
+ const samples: {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ }[] = [];
+ const start = performance.now();
+ const sample = () => {
+ const { x, y, width, height } = element.getBoundingClientRect();
+ samples.push({ x, y, width, height });
+ if (performance.now() - start < 850) requestAnimationFrame(sample);
+ else resolve(samples);
+ };
+ requestAnimationFrame(sample);
+ },
+ ),
+ );
+ await settings.getByRole("tab", { name, exact: true }).click();
+ for (const frame of await frames) {
+ for (const axis of ["x", "y", "width", "height"] as const)
+ expect(Math.abs(frame[axis] - initial[axis]), `${name}: ${axis}`).toBeLessThan(
+ 1,
+ );
+ }
+ }
+ });
+}
+
+test("settings retains account drafts, groups preferences, and limits developer tools", async ({
+ page,
+}) => {
+ await installSettingsBridge(page);
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true })
+ .click();
+ const settings = page.getByRole("dialog", { name: "Settings", exact: true });
+ await expect(settings.getByRole("button", { name: "Show onboarding" })).toHaveCount(0);
+ await settings.getByRole("tab", { name: "General", exact: true }).click();
+ await settings
+ .getByRole("radiogroup", { name: "Appearance" })
+ .getByText("Dark", { exact: true })
+ .click();
+ await settings.getByRole("tab", { name: "Account", exact: true }).click();
+ const name = settings.getByRole("textbox", { name: /^Display name\*?$/ });
+ await name.fill("Unsaved draft");
+ const originalInput = (await name.elementHandle())!;
+ await settings.getByRole("tab", { name: "Recording", exact: true }).click();
+ await expect(settings.getByText("Allowed", { exact: true })).toHaveCount(2);
+ await settings.screenshot({
+ path: "test-results/settings-permissions.png",
+ animations: "disabled",
+ });
+ await settings.getByRole("tab", { name: "Account", exact: true }).click();
+ await expect(name).toHaveValue("Unsaved draft");
+ expect(await originalInput.evaluate((element) => element.isConnected)).toBe(true);
+ await settings.getByRole("tab", { name: "Recording", exact: true }).click();
+ await expect(
+ settings.getByRole("switch", { name: "Automatically add zooms", exact: true }),
+ ).toHaveCount(0);
+ await expect(settings.getByText("Recordings folder", { exact: true })).not.toBeVisible();
+ await settings.getByRole("tab", { name: "General", exact: true }).click();
+ await expect(settings.getByText("Recordings folder", { exact: true })).toBeVisible();
+ await expect(settings.getByText("Projects folder", { exact: true })).toBeVisible();
+ await expect(settings.getByRole("button", { name: "Open file", exact: true })).toHaveCount(0);
+ await settings.getByRole("tab", { name: "General", exact: true }).click();
+ await expect(settings.getByRole("switch", { name: "Experimental updates" })).toBeVisible();
+ await expect(settings.getByRole("button", { name: "Preview", exact: true })).toHaveCount(0);
+ if (process.env.RECORDLY_TEST_PRODUCTION === "1") {
+ await expect(settings.getByRole("tab", { name: "Dev", exact: true })).toHaveCount(0);
+ await expect(settings.getByRole("button", { name: "Show onboarding" })).toHaveCount(0);
+ await expect(settings.getByText("Camera Debug Tuning", { exact: true })).toHaveCount(0);
+ } else {
+ await settings.getByRole("tab", { name: "Dev", exact: true }).click();
+ await expect(settings.getByRole("button", { name: "Show onboarding" })).toBeVisible();
+ await expect(settings.getByText("Camera Debug Tuning", { exact: true })).toBeVisible();
+ await expect(settings.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
+ }
+ await page.setViewportSize({ width: 420, height: 800 });
+ await settings.getByRole("tab", { name: "Recording", exact: true }).click();
+ await settings.screenshot({
+ path: "test-results/settings-permissions-narrow.png",
+ animations: "disabled",
+ });
+ const panel = settings.getByRole("tabpanel");
+ expect(await panel.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(
+ true,
+ );
+});
+
+test("editor rail labels appear promptly above the inspector on hover and keyboard focus", async ({
+ page,
+}) => {
+ await installSettingsBridge(page);
+ await page.goto("/?windowType=editor");
+ const rail = page.getByRole("navigation", { name: "Editor tools", exact: true });
+ for (const label of ["Scene", "Cursor", "Webcam", "Captions"]) {
+ const control = rail.getByRole("radio", { name: label, exact: true });
+ await control.hover();
+ const tooltip = page.getByRole("tooltip", { name: label, exact: true });
+ await expect(tooltip).toBeVisible({ timeout: 1000 });
+ await expect
+ .poll(() =>
+ tooltip.evaluate((node) => {
+ const rect = node.getBoundingClientRect();
+ const top = document.elementFromPoint(
+ rect.x + rect.width / 2,
+ rect.y + rect.height / 2,
+ );
+ return top === node || node.contains(top);
+ }),
+ )
+ .toBe(true);
+ if (label === "Scene")
+ await page.screenshot({ path: "test-results/editor-rail-tooltip.png" });
+ await page.mouse.move(700, 50);
+ await expect(tooltip).toBeHidden();
+ }
+ const scene = rail.getByRole("radio", { name: "Scene", exact: true });
+ await page.keyboard.press("Tab");
+ await scene.focus();
+ await expect(page.getByRole("tooltip", { name: "Scene", exact: true })).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("tooltip")).toBeHidden();
+ await page.keyboard.press("ArrowDown");
+ await expect(rail.getByRole("radio", { name: "Cursor", exact: true })).toBeFocused();
+ await page.keyboard.press("Enter");
+ await expect(rail.getByRole("radio", { name: "Cursor", exact: true })).toBeChecked();
+ const account = page
+ .locator(".app-frame-header")
+ .getByRole("button", { name: "Recordly account", exact: true });
+ await account.click();
+ await expect(page.getByRole("dialog", { name: "Account menu", exact: true })).toBeVisible();
+ await expect(page.getByRole("tooltip")).toBeHidden();
+});
diff --git a/tests/ui/desktop-windows.spec.ts b/tests/ui/desktop-windows.spec.ts
index cfcd6e48..7306e111 100644
--- a/tests/ui/desktop-windows.spec.ts
+++ b/tests/ui/desktop-windows.spec.ts
@@ -16,7 +16,7 @@ test("update window fits the desktop toast and can be dismissed", async ({ page
await expect(
page.getByRole("heading", { name: "Update available", exact: true }),
).toBeVisible();
- await expect(page.getByRole("button", { name: "Update now", exact: true })).toBeInViewport();
+ await expect(page.getByRole("button", { name: "Download update", exact: true })).toBeInViewport();
await page.screenshot({ path: "test-results/update-light.png", animations: "disabled" });
await page.getByRole("button", { name: "Not now", exact: true }).click();
await expect(page.locator("html")).toHaveAttribute("data-update-dismissed", "true");
@@ -27,15 +27,20 @@ test("editor inspector and playback controls fit a smaller desktop", async ({ pa
await installDesktopBridge(page);
await page.goto("/?windowType=editor");
await expect(page.getByRole("slider", { name: "Blur", exact: true })).toBeVisible();
- await expect(page.getByRole("button", { name: "Export", exact: true })).toBeInViewport();
+ await expect(page.getByRole("button", { name: "Publish", exact: true })).toBeInViewport();
await expect(page.getByRole("button", { name: "Play", exact: true })).toBeInViewport();
- await page.getByRole("radio", { name: "Settings", exact: true }).click();
- await page.getByRole("row", { name: "Motion", exact: true }).click();
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true })
+ .click();
+ const settings = page.getByRole("dialog", { name: "Settings", exact: true });
+ await settings.getByRole("tab", { name: "Motion", exact: true }).click();
await expect(
- page.getByRole("radiogroup", { name: "Motion Presets", exact: true }),
+ settings.getByRole("radiogroup", { name: "Presets", exact: true }),
).toBeVisible();
- await page.getByText("Smooth", { exact: true }).click();
- await expect(page.getByRole("radio", { name: "Smooth", exact: true })).toBeChecked();
+ await settings.getByText("Cinematic", { exact: true }).click();
+ await expect(settings.getByRole("radio", { name: /^Cinematic/ })).toBeChecked();
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth))
.toBe(true);
diff --git a/tests/ui/dev-account-gating.spec.ts b/tests/ui/dev-account-gating.spec.ts
new file mode 100644
index 00000000..73e9c28d
--- /dev/null
+++ b/tests/ui/dev-account-gating.spec.ts
@@ -0,0 +1,87 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+import { productionTest } from "./production";
+
+test("dev skip survives editor reloads and stale production permission requests", async ({ page }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await installDesktopBridgeOverrides(page, () => {
+ const getSetting = window.electronAPI.getAppSetting;
+ window.electronAPI.getAppSetting = (key) => {
+ if (key === "recordly.dev.onboardingDismissed")
+ return localStorage.getItem(key) === "true";
+ if (key === "recordly.onboarding.permissionsRequested") return true;
+ return getSetting(key);
+ };
+ window.electronAPI.setAppSetting = (key, value) => {
+ if (key === "recordly.dev.onboardingDismissed") localStorage.setItem(key, String(value));
+ else if (key.startsWith("recordly.onboarding.")) throw new Error("Production settings changed");
+ return true;
+ };
+ window.electronAPI.showOnboardingWindow = async () => false;
+ });
+ await page.goto("/?windowType=editor");
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await tour.getByRole("button", { name: "Skip onboarding", exact: true }).click();
+ await expect(tour).not.toBeVisible();
+ await expect.poll(() => page.evaluate(() => localStorage.getItem("recordly.dev.onboardingDismissed"))).toBe("true");
+ await page.reload();
+ await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible();
+ await expect(tour).not.toBeVisible();
+ await expect(page.getByRole("dialog", { name: "Sign in to Recordly", exact: true })).not.toBeVisible();
+});
+
+for (const dev of [true, false]) {
+ const modeTest = dev ? test : productionTest;
+ modeTest(`${dev ? "dev records without an account" : "production requires an account"} with recording permissions granted`, async ({ page }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: true });
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getCountdownDelay = async () => ({ success: true, delay: 0 });
+ window.electronAPI.startNativeScreenRecording = async () => {
+ document.documentElement.dataset.captureStarted = "true";
+ return { success: true };
+ };
+ });
+ await page.goto("/?windowType=hud-overlay");
+ await page.getByRole("button", { name: "Record", exact: true }).click();
+ if (dev) {
+ await expect(page.locator("html")).toHaveAttribute("data-capture-started", "true");
+ await expect(page.locator("html")).not.toHaveAttribute("data-permissions-opened");
+ } else {
+ await expect(page.locator("html")).toHaveAttribute("data-permissions-opened", "true");
+ await expect(page.locator("html")).not.toHaveAttribute("data-capture-started");
+ }
+ });
+}
+
+for (const dev of [true, false]) {
+ const modeTest = dev ? test : productionTest;
+ modeTest(`${dev ? "dev does not refresh" : "production still refreshes"} the account on a background timer`, async ({ page }) => {
+ await page.clock.install();
+ await installDesktopBridge(page, "preview.mp4", { signedIn: true, onboarded: true });
+ const tokenRequests: string[] = [];
+ page.on("request", (request) => {
+ if (request.url().includes("/auth/v1/token")) tokenRequests.push(request.url());
+ });
+ await page.goto("/?windowType=editor");
+ await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible();
+ await page.clock.fastForward(3_660_000);
+ if (dev) {
+ await page.waitForTimeout(100);
+ expect(tokenRequests).toEqual([]);
+ } else await expect.poll(() => tokenRequests.length).toBeGreaterThan(0);
+ });
+}
+
+productionTest("production ignores a saved dev dismissal", async ({ page }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await installDesktopBridgeOverrides(page, () => {
+ const getSetting = window.electronAPI.getAppSetting;
+ window.electronAPI.getAppSetting = (key) =>
+ key === "recordly.dev.onboardingDismissed" ? true : getSetting(key);
+ });
+ await page.goto("/?windowType=editor");
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await expect(tour).toBeVisible();
+ await expect(tour.getByRole("button", { name: "Skip onboarding", exact: true })).toHaveCount(0);
+ await expect(tour.getByRole("button", { name: "Permissions", exact: true })).toBeDisabled();
+});
diff --git a/tests/ui/device-preference.spec.ts b/tests/ui/device-preference.spec.ts
new file mode 100644
index 00000000..1c23222f
--- /dev/null
+++ b/tests/ui/device-preference.spec.ts
@@ -0,0 +1,74 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+for (const webcamEnabled of [true, false]) {
+ test(`HUD restores the saved camera when webcam starts ${webcamEnabled ? "enabled" : "disabled"}`, async ({
+ page,
+ }) => {
+ await installDesktopBridge(page);
+ await page.addInitScript((enabled) => {
+ Object.assign(window, { __testWebcamEnabled: enabled });
+ }, webcamEnabled);
+ await installDesktopBridgeOverrides(page, () => {
+ const writes: string[] = [];
+ Object.assign(window, { __testCameraWrites: writes });
+ window.electronAPI.getEditorMode = async () => true;
+ window.electronAPI.getRecordingPreferences = async () => ({
+ success: true,
+ microphoneEnabled: false,
+ systemAudioEnabled: false,
+ webcamEnabled: (window as unknown as { __testWebcamEnabled: boolean })
+ .__testWebcamEnabled,
+ webcamDeviceId: "camera-saved",
+ });
+ window.electronAPI.setRecordingPreferences = async (preferences) => {
+ if (preferences.webcamDeviceId) writes.push(preferences.webcamDeviceId);
+ return { success: true };
+ };
+ Object.defineProperty(navigator.mediaDevices, "enumerateDevices", {
+ value: async () => [
+ {
+ deviceId: "camera-first",
+ kind: "videoinput",
+ label: "Built-in camera",
+ groupId: "first",
+ },
+ {
+ deviceId: "camera-saved",
+ kind: "videoinput",
+ label: "Saved external camera",
+ groupId: "saved",
+ },
+ ],
+ });
+ });
+ await page.goto("/?windowType=hud-overlay");
+ await page
+ .getByRole("button", {
+ name: webcamEnabled ? "Disable webcam overlay" : "Enable webcam overlay",
+ exact: true,
+ })
+ .click();
+ const camera = page.getByRole("button", { name: "Saved external camera", exact: true });
+ await expect(camera).toBeVisible();
+ if (webcamEnabled) await expect(camera).toHaveAttribute("aria-pressed", "true");
+ else await camera.click();
+ await expect(camera).toHaveAttribute("aria-pressed", "true");
+ await expect(
+ page.getByRole("button", { name: "Built-in camera", exact: true }),
+ ).toHaveAttribute("aria-pressed", "false");
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ (window as unknown as { __testCameraWrites: string[] }).__testCameraWrites,
+ ),
+ )
+ .toEqual(expect.arrayContaining(["camera-saved"]));
+ expect(
+ await page.evaluate(
+ () => (window as unknown as { __testCameraWrites: string[] }).__testCameraWrites,
+ ),
+ ).not.toContain("camera-first");
+ });
+}
diff --git a/tests/ui/editor-layout.spec.ts b/tests/ui/editor-layout.spec.ts
index f88c48c6..563aa808 100644
--- a/tests/ui/editor-layout.spec.ts
+++ b/tests/ui/editor-layout.spec.ts
@@ -8,6 +8,8 @@ test("advanced controls preserve values and remember each section's view", async
await expect(advanced).toBeVisible({ timeout: 20000 });
await expect(page.getByRole("switch", { name: "Link padding sides" })).toHaveCount(0);
const blur = page.getByRole("slider", { name: "Blur", exact: true });
+ await expect(blur).not.toBeVisible();
+ await page.getByRole("button", { name: "Background details", exact: true }).click();
await blur.focus();
await page.keyboard.press("ArrowRight");
const value = await blur.inputValue();
@@ -15,6 +17,10 @@ test("advanced controls preserve values and remember each section's view", async
await expect(page.getByRole("switch", { name: "Link padding sides" })).toBeVisible();
await page.getByRole("radio", { name: "Webcam", exact: true }).click();
await expect(advanced).not.toBeChecked();
+ await page
+ .getByRole("grid", { name: "Shape", exact: true })
+ .getByRole("row", { name: "Squircle", exact: true })
+ .click();
await expect(page.getByRole("slider", { name: "Webcam Height" })).toHaveCount(0);
await page.locator("aside").getByText("Advanced", { exact: true }).click();
await expect(page.getByRole("slider", { name: "Webcam Height" })).toBeAttached();
@@ -23,19 +29,23 @@ test("advanced controls preserve values and remember each section's view", async
animations: "disabled",
});
await page.getByRole("radio", { name: "Scene", exact: true }).click();
+ await page.getByRole("button", { name: "Background details", exact: true }).click();
await expect(advanced).toBeChecked();
await expect(blur).toHaveValue(value);
await page.locator("aside").getByText("Advanced", { exact: true }).click();
await expect(blur).toHaveValue(value);
await page.getByRole("row", { name: "Color", exact: true }).click();
- await page.getByRole("button", { name: "Custom color", exact: true }).click();
+ await page.getByRole("button", { name: "Add custom color", exact: true }).click();
await page.getByRole("textbox", { name: "Hex color", exact: true }).fill("#27AE60");
await page.keyboard.press("Tab");
await expect(page.getByRole("textbox", { name: "Hex color", exact: true })).toHaveValue(
"#27AE60",
);
- await page.keyboard.press("Escape");
- await page.getByRole("button", { name: "Custom color", exact: true }).click();
+ await page.getByRole("dialog", { name: "Custom color", exact: true })
+ .getByRole("button", { name: "Add", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Background color #27AE60", exact: true }))
+ .toHaveAttribute("aria-pressed", "true");
+ await page.getByRole("button", { name: "Add custom color", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Hex color", exact: true })).toHaveValue(
"#27AE60",
);
@@ -71,7 +81,10 @@ test("header breadcrumb fits long names, native chrome and compact windows", asy
);
const header = await page.locator(".editor-header").boundingBox();
const dividers = await page.locator(".editor-header").getByRole("separator").all();
- expect(dividers).toHaveLength(2);
+ expect(dividers).toHaveLength(1);
+ await expect(
+ page.locator(".editor-header-start > span[aria-hidden=\"true\"]"),
+ ).toHaveText("/");
for (const divider of dividers) {
const box = await divider.boundingBox();
expect(box!.height).toBe(20);
@@ -113,7 +126,7 @@ test("header breadcrumb fits long names, native chrome and compact windows", asy
).toBe(true);
}
await expect(
- page.getByRole("button", { name: "Export", exact: true }),
+ page.getByRole("button", { name: "Publish", exact: true }),
).toBeInViewport();
await expect(page.getByRole("button", { name: "Play", exact: true })).toBeInViewport();
}
@@ -126,7 +139,7 @@ test("header breadcrumb fits long names, native chrome and compact windows", asy
await expect(page.getByRole("button", { name: "Rename project" })).toBeVisible();
});
-test("header name edits in place and saves on blur without a boxed input", async ({ page }) => {
+test("header name edits directly without a selection box and saves on blur", async ({ page }) => {
await installDesktopBridge(page);
await page.addInitScript(() => {
window.electronAPI.saveProjectFileNamed = async (_data, name) => ({
@@ -137,11 +150,21 @@ test("header name edits in place and saves on blur without a boxed input", async
await page.goto("/?windowType=editor");
await expect(page.getByRole("button", { name: "Rename project" })).toContainText(
"Untitled Project",
+ { timeout: 30000 },
);
- await page.getByRole("button", { name: "Rename project" }).click();
+ await expect(page.locator('[data-variant="clip"]')).toHaveCount(1);
+ await expect(page.getByLabel("Loading preview")).toHaveCount(0);
+ const title = page.getByRole("button", { name: "Rename project" });
+ const originalTextBox = (await title.locator("span").boundingBox())!;
+ await title.click();
const input = page.getByRole("textbox", { name: "Project name" });
- await expect(input).toHaveCSS("box-shadow", "none");
+ await expect(input).toBeFocused();
+ await expect(input).toHaveCSS("outline-style", "none");
await expect(input).toHaveCSS("border-top-width", "0px");
+ await expect(input).toHaveCSS("box-shadow", "none");
+ await expect(input).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
+ await expect.poll(() => input.evaluate((node: HTMLInputElement) => node.selectionStart === node.selectionEnd)).toBe(true);
+ expect((await input.boundingBox())!.x).toBeCloseTo(originalTextBox.x, 0);
await input.fill("Launch demo");
await input.press("Tab");
await expect(page.getByRole("button", { name: "Rename project" })).toContainText("Launch demo");
diff --git a/tests/ui/editor-recording-loading.spec.ts b/tests/ui/editor-recording-loading.spec.ts
new file mode 100644
index 00000000..5728077d
--- /dev/null
+++ b/tests/ui/editor-recording-loading.spec.ts
@@ -0,0 +1,92 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+for (const reducedMotion of ["no-preference", "reduce"] as const) {
+ for (const failure of [false, true]) {
+ test(`recording opens the editor skeleton before media finishes${failure ? " and surfaces failures" : ""} (${reducedMotion})`, async ({
+ page,
+ }) => {
+ test.setTimeout(120000);
+ await page.emulateMedia({ reducedMotion });
+ await installDesktopBridge(page);
+ await page.addInitScript(() => {
+ let complete!: (result: { error?: string }) => void;
+ const ready = new Promise<{ error?: string }>((resolve) => {
+ complete = resolve;
+ });
+ Object.assign(window, { finishTestRecording: complete });
+ Object.assign(window.electronAPI, {
+ getEditorStartupView: () => "editor",
+ waitForRecordingEditor: () => {
+ document.documentElement.dataset.recordingAwait = "true";
+ return ready;
+ },
+ });
+ });
+ await page.goto("/?windowType=editor");
+ await expect(page.locator("html")).toHaveAttribute("data-recording-await", "true", {
+ timeout: 60000,
+ });
+ await expect(page.getByRole("status", { name: "Loading editor" })).toBeVisible();
+ const mark = page.locator(".loading-recordly-mark");
+ await expect(mark).toBeVisible();
+ const markBounds = (await mark.boundingBox())!;
+ const previewBounds = (await page.locator(".loading-preview-frame").boundingBox())!;
+ expect(
+ Math.abs(
+ markBounds.x + markBounds.width / 2 - previewBounds.x - previewBounds.width / 2,
+ ),
+ ).toBeLessThan(1);
+ expect(
+ Math.abs(
+ markBounds.y +
+ markBounds.height / 2 -
+ previewBounds.y -
+ previewBounds.height / 2,
+ ),
+ ).toBeLessThan(1);
+ expect(await mark.evaluate((node) => node.getAnimations().length)).toBe(
+ reducedMotion === "reduce" ? 0 : 1,
+ );
+ await expect(page.getByText("Preparing recording", { exact: true })).toHaveCount(0);
+ await page.evaluate((failure) => {
+ (
+ window as unknown as {
+ finishTestRecording: (result: { error?: string }) => void;
+ }
+ ).finishTestRecording(failure ? { error: "Recording interrupted" } : {});
+ }, failure);
+ if (failure) {
+ const errorDialog = page.getByRole("dialog", {
+ name: "We couldn’t open this file",
+ exact: true,
+ });
+ await expect(errorDialog).toBeVisible();
+ const details = page.locator("details").filter({
+ has: page.getByText("Technical details", { exact: true }),
+ });
+ await expect(details.getByText(/Recording interrupted/)).not.toBeVisible();
+ await details.locator("summary").click();
+ await expect(details.getByText(/Recording interrupted/)).toBeVisible();
+ await errorDialog.getByRole("button", { name: "Close", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible();
+ } else {
+ await expect(page.getByRole("navigation", { name: "Editor tools" })).toBeVisible({
+ timeout: 30000,
+ });
+ await expect
+ .poll(
+ () =>
+ page
+ .locator("video")
+ .evaluateAll((videos) =>
+ videos.some((video) => video.readyState >= 2),
+ ),
+ { timeout: 30000 },
+ )
+ .toBe(true);
+ }
+ await expect(page.getByRole("status", { name: "Loading editor" })).toHaveCount(0);
+ });
+ }
+}
diff --git a/tests/ui/editor-refinements.spec.ts b/tests/ui/editor-refinements.spec.ts
index 36e202aa..1fac6b95 100644
--- a/tests/ui/editor-refinements.spec.ts
+++ b/tests/ui/editor-refinements.spec.ts
@@ -1,4 +1,4 @@
-import { expect, test, type Locator, type Page } from "@playwright/test";
+import { expect, type Locator, type Page, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
async function expectButtonsFit(container: Locator) {
@@ -61,7 +61,6 @@ test("advanced controls, webcam defaults and captions have consistent layouts",
await page.getByRole("radio", { name: "Webcam", exact: true }).click();
await expect(page.getByRole("switch", { name: "Mirror webcam" })).toHaveCount(0);
await expect(page.getByRole("switch", { name: "Webcam Reacts To Zoom" })).toHaveCount(0);
- await expect(page.getByRole("slider", { name: "Webcam Roundness" })).toHaveValue("100");
await page.evaluate(() => {
window.electronAPI.openVideoFilePicker = async () => ({
success: true,
@@ -69,12 +68,20 @@ test("advanced controls, webcam defaults and captions have consistent layouts",
});
});
await page.getByRole("button", { name: "Upload footage", exact: true }).click();
+ await expect(page.getByRole("row", { name: "Circle", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await expect(page.getByRole("slider", { name: "Webcam Roundness" })).toHaveCount(0);
+ await page.getByRole("row", { name: "Squircle", exact: true }).click();
+ await expect(page.getByRole("slider", { name: "Webcam Roundness" })).toHaveValue("50");
const replace = page.getByRole("button", { name: "Replace footage", exact: true });
const remove = page.getByRole("button", { name: "Remove footage", exact: true });
await remove.scrollIntoViewIfNeeded();
const first = await replace.boundingBox();
const second = await remove.boundingBox();
- expect(first!.y + first!.height + 4).toBeLessThanOrEqual(second!.y);
+ expect(first!.y).toBeCloseTo(second!.y, 0);
+ expect(first!.x + first!.width).toBeLessThanOrEqual(second!.x);
await expectButtonsFit(page.locator("aside"));
await page.screenshot({
path: "test-results/editor-webcam-footage.png",
@@ -84,25 +91,18 @@ test("advanced controls, webcam defaults and captions have consistent layouts",
await expect(page.getByRole("button", { name: "Select Model", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Use custom", exact: true })).toHaveCount(0);
await expect(page.getByRole("switch", { name: "Hover to add on timeline" })).toHaveCount(0);
- const language = await page
- .locator("aside")
- .getByText("Language", { exact: true })
- .boundingBox();
- const animation = await page
- .locator("aside")
- .getByText("Animation", { exact: true })
- .boundingBox();
- expect(animation!.y).toBeGreaterThan(language!.y);
+ await expect(page.getByRole("group", { name: "Caption style", exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: /Animation/ })).toHaveCount(0);
await expectButtonsFit(page.locator("aside"));
await page.screenshot({
path: "test-results/editor-captions-basic.png",
animations: "disabled",
});
await page.locator("aside").getByText("Advanced", { exact: true }).click();
- await expect(page.getByRole("button", { name: "Use custom", exact: true })).toBeVisible();
+ await expect(page.getByRole("slider", { name: "Rows", exact: true })).toBeVisible();
});
-test("populated project and preset popovers fit long names and dismiss outside", async ({
+test("project dashboard fits long names and editor overlays dismiss with current controls", async ({
page,
}) => {
test.setTimeout(60000);
@@ -124,35 +124,32 @@ test("populated project and preset popovers fit long names and dismiss outside",
});
});
await page.getByRole("button", { name: "Home", exact: true }).click();
- const projects = page.getByRole("dialog", { name: "Projects", exact: true });
- await expect(projects.getByRole("button", { name: /A very long project name/ })).toHaveCount(5);
+ const projects = page.getByRole("dialog", { name: "Projects dashboard", exact: true });
+ await expect(
+ projects.getByRole("button", { name: /^A very long project name with many words \d$/ }),
+ ).toHaveCount(5);
await expectButtonsFit(projects);
await page.screenshot({
path: "test-results/editor-projects-populated.png",
animations: "disabled",
});
- await clickOutside(page);
+ // Home fills the window; clicking inside the dashboard must not dismiss it.
+ await projects.getByRole("heading", { name: "Your projects", exact: true }).click();
+ await expect(projects).toBeVisible();
+ await projects.getByRole("button", { name: "Resume Untitled Project", exact: true }).click();
await expect(projects).toHaveCount(0);
- await page.getByRole("button", { name: "Open presets", exact: true }).click();
- const presets = page.getByRole("dialog", { name: "Presets", exact: true });
- await presets
- .getByRole("textbox", { name: "Preset name" })
- .fill("A very long saved preset name that must not push its delete button out");
- await presets.getByRole("button", { name: "Save", exact: true }).click();
- await expect(presets.getByRole("button", { name: /Delete preset/ })).toBeVisible();
- await expectButtonsFit(presets);
- await page.screenshot({
- path: "test-results/editor-presets-populated.png",
- animations: "disabled",
- });
- await clickOutside(page);
- await expect(presets).toHaveCount(0);
- await page.getByRole("button", { name: "Export", exact: true }).click();
+ // Preset creation is intentionally unavailable in the current editor header.
+ await expect(page.getByRole("button", { name: "Open presets", exact: true })).toHaveCount(0);
+ await page.getByRole("button", { name: "Publish", exact: true }).click();
+ await page.getByRole("row", { name: "Save video", exact: true }).click();
await expect(page.getByRole("grid", { name: "Format", exact: true })).toBeVisible();
await clickOutside(page);
await expect(page.getByRole("grid", { name: "Format", exact: true })).toHaveCount(0);
- await page.getByRole("button", { name: "Crop Video", exact: true }).click();
- await expect(page.getByRole("dialog", { name: "Crop Video" })).toBeVisible();
+ await page.getByRole("button", { name: "Crop", exact: true }).click();
+ const crop = page.locator("[data-inline-crop]");
+ await expect(crop).toBeVisible();
await page.mouse.click(20, 300);
- await expect(page.getByRole("dialog", { name: "Crop Video" })).toHaveCount(0);
+ await expect(crop).toBeVisible();
+ await crop.getByRole("button", { name: "Cancel", exact: true }).click();
+ await expect(crop).toHaveCount(0);
});
diff --git a/tests/ui/editor-reset-defaults.spec.ts b/tests/ui/editor-reset-defaults.spec.ts
new file mode 100644
index 00000000..3e150e52
--- /dev/null
+++ b/tests/ui/editor-reset-defaults.spec.ts
@@ -0,0 +1,28 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test("editor controls keep saved values without redundant section headers", async ({ page }) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ const originalGet = window.electronAPI.getAppSetting;
+ window.electronAPI.getAppSetting = (key) =>
+ key === "recordly.editor.preferences"
+ ? {
+ padding: { top: 30, bottom: 30, left: 30, right: 30, linked: true },
+ showCursor: false,
+ }
+ : originalGet(key);
+ });
+ await page.goto("/?windowType=editor");
+ const sidebar = page.locator("aside");
+ await expect(page.getByRole("slider", { name: "Padding", exact: true })).toHaveValue("30");
+ for (const label of ["Background", "Frame", "Appearance", "Shape"]) {
+ await expect(sidebar.getByText(label, { exact: true })).toHaveCount(0);
+ }
+ await expect(sidebar.getByRole("button", { name: "Reset", exact: true })).toHaveCount(0);
+ await page.getByRole("radio", { name: "Cursor", exact: true }).click();
+ await page.getByRole("switch", { name: "Advanced settings" }).press("Space");
+ await expect(page.getByRole("switch", { name: "Show Cursor", exact: true })).not.toBeChecked();
+ await expect(sidebar.getByText("Appearance", { exact: true })).toHaveCount(0);
+ await expect(sidebar.getByRole("button", { name: "Reset", exact: true })).toHaveCount(0);
+});
diff --git a/tests/ui/editor.spec.ts b/tests/ui/editor.spec.ts
index fe4127a0..9029cd8d 100644
--- a/tests/ui/editor.spec.ts
+++ b/tests/ui/editor.spec.ts
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test";
-import { installDesktopBridge } from "./bridge";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
test("editor loads video, switches tools and edits export options", async ({ page }) => {
test.setTimeout(120000);
const errors: string[] = [];
@@ -11,6 +11,7 @@ test("editor loads video, switches tools and edits export options", async ({ pag
await expect(page.getByRole("navigation", { name: "Editor tools" })).toBeVisible({
timeout: 20000,
});
+ await page.getByRole("button", { name: "Background details", exact: true }).click();
await expect(page.getByRole("slider", { name: "Blur", exact: true })).toBeVisible();
await expect
.poll(() =>
@@ -28,26 +29,38 @@ test("editor loads video, switches tools and edits export options", async ({ pag
});
const inspectorWidth = (await page.locator("aside").boundingBox())?.width;
await page.getByRole("row", { name: "Color", exact: true }).click();
- await expect(page.getByRole("button", { name: "Custom color", exact: true })).toBeVisible();
- await page.getByRole("button", { name: "Custom color", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Add custom color", exact: true })).toBeVisible();
+ await page.getByRole("button", { name: "Add custom color", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Custom color", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await page.getByRole("radio", { name: "Cursor", exact: true }).click();
await page.getByRole("radio", { name: "Webcam", exact: true }).click();
await page.getByRole("radio", { name: "Captions", exact: true }).click();
- await page.getByRole("radio", { name: "Settings", exact: true }).click();
- await expect(page.getByText("Appearance", { exact: true })).toBeVisible();
- await page.getByRole("row", { name: "Dark", exact: true }).click();
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Appearance", exact: true })
+ .click();
+ const settings = page.getByRole("dialog", { name: "Settings", exact: true });
+ await expect(settings.getByRole("radiogroup", { name: "Appearance", exact: true })).toBeVisible();
+ await settings.getByRole("radiogroup", { name: "Appearance", exact: true })
+ .getByText("Dark", { exact: true }).click();
+ await expect(settings.getByRole("radio", { name: "Dark", exact: true })).toBeChecked();
await expect(page.locator("html")).toHaveClass(/dark/);
await page.screenshot({
path: "test-results/editor-dark.png",
fullPage: true,
animations: "disabled",
});
- await page.getByRole("row", { name: "Light", exact: true }).click();
+ await settings.getByRole("radiogroup", { name: "Appearance", exact: true })
+ .getByText("Light", { exact: true }).click();
+ await expect(settings.getByRole("radio", { name: "Light", exact: true })).toBeChecked();
await expect(page.locator("html")).not.toHaveClass(/dark/);
+ await page.keyboard.press("Escape");
+ await expect(settings).toHaveCount(0);
await page.getByRole("radio", { name: "Scene", exact: true }).click();
- await page.getByRole("button", { name: "Export", exact: true }).click();
+ await page.getByRole("button", { name: "Publish", exact: true }).click();
+ await page.getByRole("row", { name: "Save video", exact: true }).click();
await expect(page.getByRole("grid", { name: "Format", exact: true })).toBeVisible();
await page.getByRole("row", { name: "GIF", exact: true }).click();
await expect(page.getByRole("switch", { name: "Loop", exact: false })).toBeVisible();
@@ -68,10 +81,12 @@ test("editor loads video, switches tools and edits export options", async ({ pag
page.getByRole("dialog", { name: "Projects dashboard", exact: true }),
).toBeVisible();
await page.keyboard.press("Escape");
- await page.getByRole("button", { name: "Crop Video", exact: true }).click();
- await expect(page.getByRole("dialog", { name: "Crop Video", exact: true })).toBeVisible();
- await page.getByRole("button", { name: "Done", exact: true }).click();
- await page.getByRole("button", { name: "16:9", exact: true }).click();
+ await page.getByRole("button", { name: "Crop", exact: true }).click();
+ const crop = page.locator("[data-inline-crop]");
+ await expect(crop).toBeVisible();
+ await crop.getByRole("button", { name: "Done", exact: true }).click();
+ await expect(crop).toHaveCount(0);
+ await page.getByRole("button", { name: "Original aspect ratio", exact: true }).click();
await page.getByRole("menuitem", { name: "1:1", exact: true }).click();
await expect(page.getByRole("button", { name: "1:1", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Open presets", exact: true })).toHaveCount(0);
@@ -97,20 +112,39 @@ test("editor loads video, switches tools and edits export options", async ({ pag
});
expect(errors).toEqual([]);
});
-test("recorder opens device and source popovers", async ({ page }) => {
+test("recorder countdown and native source selection keep the HUD responsive", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => {
errors.push(e.message);
});
await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.pickCaptureTarget = async () => {
+ document.documentElement.dataset.pickerCalls = "1";
+ return {
+ success: true,
+ source: {
+ id: "window:101:0",
+ name: "Selected window",
+ display_id: "1",
+ thumbnail: null,
+ appIcon: null,
+ },
+ };
+ };
+ });
await page.goto("/?windowType=hud-overlay");
await expect(page.getByRole("button", { name: "Record", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Countdown delay", exact: false }).click();
- await page.getByRole("button", { name: "5s", exact: true }).click();
+ await page.getByRole("button", { name: "5 sec", exact: true }).click();
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Countdown delay: 5s", exact: true })).toBeVisible();
+ const source = page.getByRole("button", { name: "Choose recording source", exact: true });
+ await expect(source).toContainText("Built-in Display");
+ await source.click();
+ await expect(page.locator("html")).toHaveAttribute("data-picker-calls", "1");
+ await expect(source).toContainText("Selected window");
await expect(page.getByRole("dialog")).toHaveCount(0);
- await page.getByRole("button", { name: "Built-in Display", exact: true }).click();
- await expect(page.getByRole("dialog")).toBeVisible();
- await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "More", exact: true })).toHaveCount(0);
await page.getByRole("button", { name: "Home", exact: true }).click();
await expect(page.locator("html")).toHaveAttribute("data-dashboard-opened", "true");
diff --git a/tests/ui/email-code.spec.ts b/tests/ui/email-code.spec.ts
new file mode 100644
index 00000000..d05d438a
--- /dev/null
+++ b/tests/ui/email-code.spec.ts
@@ -0,0 +1,75 @@
+import { expect } from "@playwright/test";
+import { productionTest as test } from "./production";
+import { installDesktopBridge } from "./bridge";
+
+test("email code verification recovers from expiry without changing the target address", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await page.goto("/?windowType=onboarding");
+ const dialog = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await dialog.getByLabel("Email", { exact: true }).fill("test@email.com");
+ await dialog.getByRole("button", { name: "Email me a code", exact: true }).click();
+ await expect(
+ dialog.getByText("Enter code sent to test@email.com", { exact: true }),
+ ).toBeVisible();
+ await expect(dialog.getByLabel("Email", { exact: true })).toHaveCount(0);
+ await expect(dialog.getByRole("button", { name: /Resend in/ })).toBeDisabled();
+ const slots = dialog.locator('[data-slot="input-otp-slot"]');
+ await expect(slots).toHaveCount(6);
+ await dialog.getByLabel("Enter code sent to test@email.com", { exact: true }).fill("12345");
+ await expect(slots).toHaveText(["1", "2", "3", "4", "5", ""]);
+ await dialog.getByLabel("Enter code sent to test@email.com", { exact: true }).press("Backspace");
+ await expect(slots).toHaveText(["1", "2", "3", "4", "", ""]);
+ await expect(
+ dialog.getByRole("button", { name: "Verify and continue", exact: true }),
+ ).toBeDisabled();
+ await page.route("https://auth.recordly.test/auth/v1/verify", (route) =>
+ route.fulfill({
+ status: 403,
+ json: { message: "Token has expired or is invalid", error_code: "otp_expired" },
+ }),
+ );
+ await dialog.getByLabel("Enter code sent to test@email.com", { exact: true }).fill("000000");
+ await dialog.getByRole("button", { name: "Verify and continue", exact: true }).click();
+ await expect(dialog.getByRole("alert")).toContainText("That code is invalid or has expired.");
+ await expect(dialog).toHaveAttribute("data-onboarding-state", "login");
+ await page.unroute("https://auth.recordly.test/auth/v1/verify");
+ const codeInput = dialog.getByLabel("Enter code sent to test@email.com", { exact: true });
+ await codeInput.press("ControlOrMeta+a");
+ await codeInput.evaluate((input) => {
+ const clipboard = new DataTransfer();
+ clipboard.setData("text/plain", "123 456");
+ input.dispatchEvent(
+ new ClipboardEvent("paste", {
+ clipboardData: clipboard,
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+ });
+ await expect(codeInput).toHaveValue("123456");
+ await expect(slots).toHaveText(["1", "2", "3", "4", "5", "6"]);
+ const verify = page.waitForRequest((request) => request.url().includes("/auth/v1/verify"));
+ await dialog.getByRole("button", { name: "Verify and continue", exact: true }).click();
+ expect((await verify).postDataJSON()).toMatchObject({
+ email: "test@email.com",
+ token: "123456",
+ type: "email",
+ });
+ await expect(dialog).toHaveAttribute("data-onboarding-state", "permissions");
+});
+
+test("password sign-in remains optional alongside email codes", async ({ page }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await page.goto("/?windowType=onboarding");
+ const dialog = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await dialog.getByRole("button", { name: "Or use password to sign in", exact: true }).click();
+ await dialog.getByLabel("Email", { exact: true }).fill("test@email.com");
+ await dialog.getByLabel("Password", { exact: true }).fill("test-only-password");
+ await expect(dialog.getByText(/Forgot your password/)).toBeVisible();
+ const request = page.waitForRequest((req) => req.url().includes("/auth/v1/token"));
+ await dialog.getByRole("button", { name: "Sign in with password", exact: true }).click();
+ expect(new URL((await request).url()).searchParams.get("grant_type")).toBe("password");
+ await expect(dialog).toHaveAttribute("data-onboarding-state", "permissions");
+});
diff --git a/tests/ui/empty-zoom-seeking.spec.ts b/tests/ui/empty-zoom-seeking.spec.ts
new file mode 100644
index 00000000..da7f108a
--- /dev/null
+++ b/tests/ui/empty-zoom-seeking.spec.ts
@@ -0,0 +1,42 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test("empty zoom row seeks and scrubs without adding zooms", async ({ page }) => {
+ test.setTimeout(90000);
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/?windowType=editor");
+ const apply = page.getByRole("button", { name: "Apply auto zooms", exact: true });
+ await expect(apply).toBeVisible({ timeout: 20000 });
+ const row = page.locator('[data-timeline-row="row-zoom"]');
+ const bounds = (await row.boundingBox())!;
+ const playhead = page.getByTestId("timeline-playhead").locator(":scope > div");
+ const firstX = bounds.x + bounds.width * 0.2;
+ const lastX = bounds.x + bounds.width * 0.8;
+ const y = bounds.y + bounds.height / 2;
+ const expectPlayheadAt = async (x: number) => {
+ await expect
+ .poll(async () => Math.abs((await playhead.boundingBox())!.x - x))
+ .toBeLessThan(2);
+ };
+
+ await page.mouse.click(firstX, y);
+ await expectPlayheadAt(firstX);
+ await expect(row.getByTestId("timeline-add-preview")).toHaveCount(0);
+ await page.mouse.down();
+ await page.mouse.move(lastX, y, { steps: 3 });
+ await expectPlayheadAt(lastX);
+ // A scrub keeps tracking after leaving the row and ends on mouseup.
+ await page.mouse.move(firstX, bounds.y - 10, { steps: 3 });
+ await page.mouse.up();
+ await expectPlayheadAt(firstX);
+ await page.mouse.move(lastX, y);
+ await expectPlayheadAt(firstX);
+ await expect(page.locator('[data-variant="zoom"]')).toHaveCount(0);
+
+ // The button handles its own click without moving the playhead.
+ await apply.click();
+ await expect(page.getByText("No cursor telemetry available", { exact: true })).toBeVisible();
+ await expectPlayheadAt(firstX);
+ await expect(page.locator('[data-variant="zoom"]')).toHaveCount(0);
+ await expect(apply).toBeVisible();
+});
diff --git a/tests/ui/export-error-report.spec.ts b/tests/ui/export-error-report.spec.ts
index c673e0d1..67b3dfa4 100644
--- a/tests/ui/export-error-report.spec.ts
+++ b/tests/ui/export-error-report.spec.ts
@@ -1,9 +1,9 @@
import { expect, test } from "@playwright/test";
-test("export reports preview only technical data and submit explicitly without account identity", async ({
+test("one click sends actual redacted logs, handles failure and retries without confirmation", async ({
page,
}) => {
- const reports: unknown[] = [];
+ const reports: Record[] = [];
await page.route("**/functions/v1/submit-export-error", async (route) => {
const req = route.request();
expect(req.headers().authorization).toBeUndefined();
@@ -19,21 +19,157 @@ test("export reports preview only technical data and submit explicitly without a
});
});
await page.goto("/tests/ui/export-error-report.html");
- await page.getByRole("button", { name: "Report error", exact: true }).click();
- const dialog = page.getByRole("dialog", { name: "Report export error", exact: true });
- await expect(dialog).toBeVisible();
expect(reports).toHaveLength(0);
- await dialog.getByText("View data to be sent", { exact: true }).click();
- await expect(dialog.locator("pre")).toContainText("VIDEO_DECODE_ENCODING_ERROR");
- await expect(dialog.locator("pre")).not.toContainText("Alice");
- await dialog.getByLabel("Cloud plan", { exact: false }).selectOption("paid");
- await expect(dialog.locator("pre")).toContainText('"cloudPlan": "paid"');
- await page.screenshot({ path: "test-results/export-error-report.png" });
- await dialog.getByRole("button", { name: "Send report", exact: true }).click();
- await expect(dialog.getByRole("alert")).toContainText("Could not send");
+ await page.getByRole("button", { name: "Report error", exact: true }).click();
+ await expect(page.getByRole("status")).toContainText("Could not send");
expect(reports).toHaveLength(1);
- await dialog.getByRole("button", { name: "Send report", exact: true }).click();
- await expect(page.getByRole("dialog", { name: "Report sent", exact: true })).toBeVisible();
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await page.getByRole("button", { name: "Retry report", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Report sent", exact: true })).toBeDisabled();
expect(reports).toHaveLength(2);
- expect(reports[1]).toMatchObject({ cloudPlan: "paid", format: "mp4" });
+ expect(reports[1]).toMatchObject({ schemaVersion: 2, context: "export", format: "mp4" });
+ expect(JSON.stringify(reports[1])).not.toContain("Alice");
+ expect(JSON.stringify(reports[1])).not.toContain("alice@example.com");
+ const logs = JSON.parse(reports[1].logs as string).logs;
+ expect(logs.join("\n")).toContain("Report test: decoder log");
+ await page.screenshot({ path: "test-results/error-report-sent.png" });
+});
+
+test("a crashed view can still send its error logs", async ({ page }) => {
+ let report: { context?: string; logs?: string } = {};
+ await page.route("**/functions/v1/submit-export-error", async (route) => {
+ report = route.request().postDataJSON();
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: '{"success":true}',
+ });
+ });
+ await page.goto("/tests/ui/export-error-report.html?crash");
+ await expect(page.getByRole("heading", { name: "Recordly ran into a problem" })).toBeVisible();
+ await page.getByRole("button", { name: "Report error", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Report sent", exact: true })).toBeDisabled();
+ expect(report.context).toBe("application-crash");
+ expect(report.logs).toContain("Test view crashed");
+});
+
+for (const engine of ["lightning", "native"]) {
+ test(`${engine} failure keeps report in view above full scrollable export diagnostics`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 760, height: 480 });
+ const reports: Record[] = [];
+ await page.route("**/functions/v1/submit-export-error", async (route) => {
+ reports.push(route.request().postDataJSON());
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: '{"success":true}',
+ });
+ });
+ let copied = "";
+ await page.exposeFunction("captureErrorCopy", (text: string) => {
+ copied = text;
+ });
+ await page.addInitScript(() => {
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: {
+ writeText: async (text: string) =>
+ (
+ window as unknown as { captureErrorCopy(text: string): Promise }
+ ).captureErrorCopy(text),
+ },
+ });
+ });
+ await page.goto(`/tests/ui/export-error-report.html?menu&pending&engine=${engine}`);
+ await page.getByRole("button", { name: "Fail export", exact: true }).click();
+ const menu = page.getByRole("dialog", { name: "Publish", exact: true });
+ const report = menu.getByRole("button", { name: "Report error", exact: true });
+ await expect(report).toBeInViewport({ ratio: 1 });
+ const copy = menu.getByRole("button", { name: "Copy error", exact: true });
+ await expect(copy).toBeInViewport({ ratio: 1 });
+ await expect(menu.getByRole("button", { name: "Save Again", exact: true })).toBeInViewport({
+ ratio: 1,
+ });
+ const details = menu.locator("[data-export-error-details]");
+ await expect(details).toContainText("Diagnostic tail: full failure retained");
+ expect(await details.evaluate((node) => node.scrollHeight > node.clientHeight)).toBe(true);
+ expect(reports).toHaveLength(0);
+ await copy.click();
+ expect(copied).toBe(await details.textContent());
+ expect(copied).toContain("Diagnostic tail: full failure retained");
+ await report.click();
+ await expect(menu.getByRole("button", { name: "Report sent", exact: true })).toBeDisabled();
+ expect(reports).toHaveLength(1);
+ expect(reports[0]).toMatchObject({ context: "export", format: "mp4" });
+ expect(reports[0].error).toContain("Diagnostic tail: full failure retained");
+ expect(JSON.stringify(reports[0])).not.toContain("Alice");
+ });
+ test(`${engine} export toast keeps reporting in a short viewport and retries a failed send`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 760, height: 480 });
+ const reports: Record[] = [];
+ let releaseFirst: (() => void) | undefined;
+ await page.route("**/functions/v1/submit-export-error", async (route) => {
+ expect(route.request().headers().authorization).toBeUndefined();
+ reports.push(route.request().postDataJSON());
+ if (reports.length === 1)
+ await new Promise((resolve) => {
+ releaseFirst = resolve;
+ });
+ await route.fulfill({
+ status: reports.length === 1 ? 500 : 200,
+ contentType: "application/json",
+ body: reports.length === 1 ? "{}" : '{"success":true}',
+ });
+ });
+ await page.goto(`/tests/ui/export-error-report.html?toast&engine=${engine}`);
+ await page.getByRole("button", { name: "Fail export", exact: true }).click();
+ const report = page.getByRole("button", { name: "Report error", exact: true });
+ await expect(report).toBeInViewport({ ratio: 1 });
+ const copy = page.getByRole("button", { name: "Copy", exact: true });
+ await expect(copy).toBeInViewport({ ratio: 1 });
+ const reportBox = (await report.boundingBox())!;
+ const copyBox = (await copy.boundingBox())!;
+ expect(Math.abs(reportBox.y - copyBox.y)).toBeLessThan(1);
+ expect(reportBox.x + reportBox.width).toBeLessThan(copyBox.x);
+ const details = page.locator("[data-error-diagnostics]");
+ await expect(details).toContainText("Diagnostic tail: full failure retained");
+ expect(await details.evaluate((node) => node.scrollHeight > node.clientHeight)).toBe(true);
+ expect(reports).toHaveLength(0);
+ await report.dblclick();
+ await expect(
+ page.getByRole("button", { name: "Sending logs…", exact: true }),
+ ).toBeDisabled();
+ await expect.poll(() => reports.length).toBe(1);
+ releaseFirst?.();
+ const retry = page.getByRole("button", { name: "Retry report", exact: true });
+ await expect(retry).toBeInViewport({ ratio: 1 });
+ const retryBox = (await retry.boundingBox())!;
+ const retryCopyBox = (await copy.boundingBox())!;
+ expect(Math.abs(retryBox.y - retryCopyBox.y)).toBeLessThan(1);
+ expect(retryBox.x + retryBox.width).toBeLessThan(retryCopyBox.x);
+ await retry.click();
+ await expect(page.getByRole("button", { name: "Report sent", exact: true })).toBeDisabled();
+ expect(reports).toHaveLength(2);
+ expect(reports[1]).toMatchObject({ context: "notification" });
+ expect(reports[1].error).toContain("Diagnostic tail: full failure retained");
+ expect(JSON.stringify(reports[1])).not.toContain("alice@example.com");
+ });
+}
+
+test("a multiline generic error title cannot push Report outside a short viewport", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 760, height: 480 });
+ await page.goto("/tests/ui/export-error-report.html?toast&title");
+ await page.getByRole("button", { name: "Fail export", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Report error", exact: true })).toBeInViewport({
+ ratio: 1,
+ });
+ const title = page.locator("[data-error-title]");
+ expect(await title.evaluate((node) => node.scrollHeight > node.clientHeight)).toBe(true);
+ await expect(title).toContainText("Diagnostic tail: full failure retained");
});
diff --git a/tests/ui/export-error-report.tsx b/tests/ui/export-error-report.tsx
index d680e194..377ae9d6 100644
--- a/tests/ui/export-error-report.tsx
+++ b/tests/ui/export-error-report.tsx
@@ -1,17 +1,127 @@
+import { type ComponentProps } from "react";
import { createRoot } from "react-dom/client";
-import { I18nProvider } from "@/contexts/I18nContext";
-import { ThemeProvider } from "@/contexts/ThemeContext";
+import { AppErrorBoundary } from "@/components/feedback/AppErrorBoundary";
import { ExportErrorReportButton } from "@/components/feedback/ExportErrorReportButton";
+import { Button } from "@/components/ui/button";
+import { Toaster, toast } from "@/components/ui/toast";
+import { DEFAULT_EDITOR_PREFERENCES } from "@/components/video-editor/editorPreferences";
+import { showExportErrorToast } from "@/components/video-editor/export/exportRunnerSupport";
+import { useExportSession } from "@/components/video-editor/export/useExportSession";
+import { useExportSettings } from "@/components/video-editor/export/useExportSettings";
+import { useExportStatusViewModel } from "@/components/video-editor/export/useExportStatusViewModel";
+import { EditorExportMenu } from "@/components/video-editor/layout/EditorExportMenu";
+import { I18nProvider, useI18n } from "@/contexts/I18nContext";
+import { ThemeProvider } from "@/contexts/ThemeContext";
+import { installFeedbackDiagnostics } from "@/lib/feedback/diagnostics";
import "@/index.css";
+installFeedbackDiagnostics();
+console.error("Report test: decoder log /Users/Alice/private.mp4 alice@example.com");
+function BrokenView(): never {
+ throw new Error("Test view crashed");
+}
+const scenario = new URLSearchParams(location.search);
+function exportFailure() {
+ const engine = scenario.get("engine") === "native" ? "Native FFmpeg" : "Lightning MP4";
+ return `${engine} export failed: encoder unavailable\nOutput: 1920x1080 @ 60 FPS\n${Array.from({ length: 40 }, (_, index) => `Diagnostic ${index}: /Users/Alice/private.mp4 alice@example.com`).join("\n")}\nDiagnostic tail: full failure retained`;
+}
+function ToastFailure() {
+ return (
+
+ {
+ if (scenario.has("title")) toast.error(exportFailure());
+ else showExportErrorToast(exportFailure());
+ }}
+ >
+ Fail export
+
+
+
+ );
+}
+function FailedExportMenu() {
+ const { t } = useI18n();
+ const exportSession = useExportSession();
+ const exportSettings = useExportSettings(
+ { ...DEFAULT_EDITOR_PREFERENCES, publishDestination: "local" },
+ [],
+ [],
+ );
+ const exportStatus = useExportStatusViewModel({
+ t,
+ session: exportSession,
+ settings: exportSettings,
+ });
+ const dimensions = { width: 1920, height: 1080 };
+ const supported = { ...dimensions, capped: false, encoderPath: null };
+ const exportDimensions: ComponentProps["exportDimensions"] = {
+ gifOutputDimensions: dimensions,
+ mp4OutputDimensions: {
+ hd: dimensions,
+ medium: dimensions,
+ good: dimensions,
+ high: dimensions,
+ source: dimensions,
+ custom: dimensions,
+ },
+ supportedMp4SourceDimensions: supported,
+ ensureSupportedMp4SourceDimensions: async () => supported,
+ };
+ return (
+
+ {
+ exportSession.setExportError(exportFailure());
+ exportSession.setShowExportDropdown(true);
+ exportSession.setHasPendingExportSave(scenario.has("pending"));
+ }}
+ >
+ Fail export
+
+ undefined}
+ handleOpenExportDropdown={() => exportSession.setShowExportDropdown(true)}
+ handleExportDropdownClose={() => exportSession.setShowExportDropdown(false)}
+ handleCancelExport={() => undefined}
+ handleRetrySaveExport={() => undefined}
+ handleStartExportFromDropdown={() => undefined}
+ revealExportedFile={() => undefined}
+ exportMessage={null}
+ projectTitle="Failed export fixture"
+ prepareExportForShare={async () => undefined}
+ onRequestShareSignIn={() => undefined}
+ shareRequestNonce={0}
+ />
+
+ );
+}
createRoot(document.getElementById("root")!).render(
-
-
-
+
+ {scenario.has("menu") ? (
+
+ ) : scenario.has("toast") ? (
+
+ ) : scenario.has("crash") ? (
+
+ ) : (
+
+
+
+ )}
+
,
);
diff --git a/tests/ui/feedback-placeholder.spec.ts b/tests/ui/feedback-placeholder.spec.ts
new file mode 100644
index 00000000..4a7374e5
--- /dev/null
+++ b/tests/ui/feedback-placeholder.spec.ts
@@ -0,0 +1,73 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+for (const theme of ["light", "dark"]) {
+ test(`feedback placeholder is readable in ${theme} mode`, async ({ page }) => {
+ await installDesktopBridge(page);
+ await page.addInitScript((theme) => localStorage.setItem("recordly.theme", theme), theme);
+ await page.addInitScript(() => {
+ localStorage.setItem(
+ "recordly.feedback.draft.v1.00000000-0000-4000-8000-000000000001",
+ JSON.stringify({ sentiment: "loving", message: " \n\t " }),
+ );
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Feedback", exact: true })
+ .click();
+ const field = page
+ .getByRole("dialog", { name: "Send feedback", exact: true })
+ .getByRole("textbox", { name: "Description" });
+ await expect(field).toHaveAttribute("placeholder", "We read all feedback...");
+ for (const focused of [false, true]) {
+ if (focused) await field.focus();
+ const appearance = await field.evaluate((element) => {
+ const placeholder = getComputedStyle(element, "::placeholder");
+ const canvas = document.createElement("canvas");
+ canvas.width = canvas.height = 1;
+ const context = canvas.getContext("2d")!;
+ const luminance = (color: string) => {
+ context.clearRect(0, 0, 1, 1);
+ context.fillStyle = color;
+ context.fillRect(0, 0, 1, 1);
+ const channels = [...context.getImageData(0, 0, 1, 1).data]
+ .slice(0, 3)
+ .map((channel) => {
+ const value = channel / 255;
+ return value <= 0.04045
+ ? value / 12.92
+ : ((value + 0.055) / 1.055) ** 2.4;
+ });
+ return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
+ };
+ const foreground = luminance(placeholder.color);
+ const background = luminance(getComputedStyle(element).backgroundColor);
+ return {
+ shown: element.matches(":placeholder-shown"),
+ opacity: placeholder.opacity,
+ contrast:
+ (Math.max(foreground, background) + 0.05) /
+ (Math.min(foreground, background) + 0.05),
+ };
+ });
+ await expect(field).toBeVisible();
+ expect(appearance.shown).toBe(true);
+ expect(appearance.opacity).toBe("1");
+ expect(appearance.contrast).toBeGreaterThanOrEqual(4.5);
+ }
+ await field.fill(" \n\t ");
+ await page
+ .getByRole("dialog", { name: "Send feedback", exact: true })
+ .getByRole("button", { name: "Close", exact: true })
+ .click();
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Feedback", exact: true })
+ .click();
+ await expect(field).toHaveValue("");
+ expect(await field.evaluate((element) => element.matches(":placeholder-shown"))).toBe(true);
+ });
+}
diff --git a/tests/ui/feedback.spec.ts b/tests/ui/feedback.spec.ts
index 727117ec..7d5515f5 100644
--- a/tests/ui/feedback.spec.ts
+++ b/tests/ui/feedback.spec.ts
@@ -6,9 +6,20 @@ test("feedback preserves a draft on failure, supports attachments, and includes
}) => {
await installDesktopBridge(page);
await page.goto("/?windowType=editor");
- await page.getByRole("button", { name: "Feedback", exact: true }).click();
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Feedback", exact: true })
+ .click();
const modal = page.getByRole("dialog", { name: "Send feedback", exact: true });
- await modal.getByLabel("Subject", { exact: true }).fill("Export stops early");
+ await expect(modal.getByLabel("Subject", { exact: true })).toHaveCount(0);
+ await expect(modal.locator("label")).toHaveCount(0);
+ await modal.getByRole("button", { name: "Loving", exact: true }).click();
+ await modal.getByRole("button", { name: "Angry", exact: true }).click();
+ await expect(modal.getByRole("button", { name: "Loving", exact: true })).toHaveAttribute(
+ "aria-pressed",
+ "false",
+ );
await modal
.getByLabel("Description", { exact: true })
.fill("Export stopped before the last frame.");
@@ -20,15 +31,21 @@ test("feedback preserves a draft on failure, supports attachments, and includes
await expect(modal.getByText("notes.txt", { exact: true })).toBeVisible();
await modal.getByRole("button", { name: "Remove notes.txt" }).click();
await expect(modal.getByText("notes.txt", { exact: true })).toHaveCount(0);
- await expect(
- modal.getByText("Diagnostics will be sent along with your feedback."),
- ).toBeVisible();
+ await expect(modal.getByText("Relevant diagnostic logs will be attached.")).toBeVisible();
await expect(modal.getByRole("checkbox")).toHaveCount(0);
await expect(modal.getByLabel("Title", { exact: true })).toHaveCount(0);
await modal.getByRole("button", { name: "Send feedback", exact: true }).click();
- await expect(modal.getByRole("alert")).toHaveText("Could not send feedback. Please try again.");
- await expect(modal.getByLabel("Subject", { exact: true })).toHaveValue("Export stops early");
- await modal.getByLabel("Subject", { exact: true }).fill("Export stops early (updated)");
+ await expect(modal.getByRole("alert")).toHaveText(
+ "Could not send feedback. Please try again. Report error",
+ );
+ await expect(
+ modal.getByRole("alert").getByRole("button", { name: "Report error", exact: true }),
+ ).toBeVisible();
+ await expect(modal.getByRole("button", { name: "Angry", exact: true })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ await modal.getByLabel("Description", { exact: true }).fill("Export stops early (updated)");
await expect(modal.getByRole("alert")).toHaveCount(0);
await modal.getByRole("button", { name: "Send feedback", exact: true }).click();
await expect(modal.getByRole("alert")).toBeVisible();
@@ -39,7 +56,59 @@ test("feedback preserves a draft on failure, supports attachments, and includes
await page.getByRole("button", { name: "Home", exact: true }).click();
await page
.getByRole("dialog", { name: "Projects dashboard", exact: true })
+ .getByRole("button", { name: "Recordly account" })
+ .click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
.getByRole("button", { name: "Feedback", exact: true })
.click();
await expect(page.getByRole("dialog", { name: "Send feedback", exact: true })).toBeVisible();
+ await expect(modal.getByRole("button", { name: "Angry", exact: true })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ await expect(modal.getByLabel("Description", { exact: true })).toHaveValue(
+ "Updated reproduction steps.",
+ );
+});
+
+test("feedback attaches saved export logs", async ({ page }) => {
+ await installDesktopBridge(page);
+ await page.addInitScript(() => {
+ window.electronAPI.getExportDiagnostics = async () => ({
+ success: true,
+ logs: [
+ '2026-10-18T08:00:00.000Z info: [export] failed {"encoderName":"Breeze","error":"Encoder stopped"}',
+ ],
+ });
+ });
+ let requestBody = "";
+ await page.route("**/functions/v1/submit-feedback", async (route) => {
+ requestBody = route.request().postData() ?? "";
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ success: true }),
+ });
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Feedback", exact: true })
+ .click();
+ const modal = page.getByRole("dialog", { name: "Send feedback", exact: true });
+ await modal.getByRole("button", { name: "Neutral", exact: true }).click();
+ await modal
+ .getByLabel("Description", { exact: true })
+ .fill("The exporter stopped before completion.");
+ await modal.getByRole("button", { name: "Send feedback", exact: true }).click();
+ await expect(
+ page.getByRole("dialog", { name: "Thanks for your feedback", exact: true }),
+ ).toBeVisible();
+ expect(requestBody).toContain("😐 The exporter stopped before completion.");
+ expect(requestBody).toContain('name="logs"');
+ expect(requestBody).toContain("[export] failed");
+ expect(requestBody).toContain("Breeze");
+ expect(requestBody).toContain("Encoder stopped");
});
diff --git a/tests/ui/fixtures/background.webm b/tests/ui/fixtures/background.webm
new file mode 100644
index 00000000..74717b1d
Binary files /dev/null and b/tests/ui/fixtures/background.webm differ
diff --git a/tests/ui/fixtures/wallpaper.heic b/tests/ui/fixtures/wallpaper.heic
new file mode 100644
index 00000000..03f4f319
Binary files /dev/null and b/tests/ui/fixtures/wallpaper.heic differ
diff --git a/tests/ui/frontend-clarity.spec.ts b/tests/ui/frontend-clarity.spec.ts
new file mode 100644
index 00000000..25587ef0
--- /dev/null
+++ b/tests/ui/frontend-clarity.spec.ts
@@ -0,0 +1,132 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test("upgrade stays secondary and project titles open or select the recording", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.cloudShareManage = async () => ({ success: true, videos: [] });
+ // Discounts are shown only when returned by a verified public catalog.
+ window.electronAPI.cloudBilling = async ({ action }) => {
+ if (action !== "catalog") return { success: false, httpStatus: 404 };
+ return {
+ success: true,
+ prices: [
+ {
+ interval: "monthly",
+ currency: "usd",
+ unitAmount: 900,
+ regularUnitAmount: 1500,
+ promotion: "early_bird",
+ },
+ ],
+ limits: {
+ freeRecordingLimit: 5,
+ freeMaxRecordingBytes: 1_000_000_000,
+ plusMaxRecordingBytes: 10_000_000_000,
+ storageLimitBytes: null,
+ },
+ };
+ };
+ window.electronAPI.listProjectFiles = async () => ({
+ success: true,
+ entries: [
+ {
+ path: "/projects/walkthrough.recordly",
+ name: "A detailed product walkthrough with a title that needs more than one line",
+ updatedAt: Date.now(),
+ thumbnailPath: `${location.origin}/tests/ui/fixtures/recording-thumbnail.jpg`,
+ isCurrent: false,
+ isInProjectsDirectory: true,
+ },
+ ],
+ });
+ window.electronAPI.openProjectFileAtPath = async (path) => {
+ document.documentElement.dataset.titleOpenedProject = path;
+ return { success: false, canceled: true };
+ };
+ });
+ await page.goto("/?windowType=editor");
+ await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible({
+ timeout: 30000,
+ });
+ const upgrade = page
+ .locator(".editor-header")
+ .getByRole("button", { name: "Upgrade to Cloud, 40% off", exact: true });
+ await expect(upgrade).toHaveClass(/button--secondary/);
+ await expect(upgrade).not.toHaveClass(/plus-badge/);
+ await expect(upgrade.getByText("40% off", { exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Publish", exact: true })).toHaveClass(
+ /button--primary/,
+ );
+ await page.screenshot({
+ path: test.info().outputPath("editor-secondary-upgrade.png"),
+ animations: "disabled",
+ });
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ const home = page.getByRole("dialog", { name: "Projects dashboard", exact: true });
+ await expect(home.getByRole("heading", { name: "Your projects", exact: true })).toBeVisible();
+ await expect(home.getByText("1 project", { exact: true })).toBeVisible();
+ await expect(home.getByText("Local only", { exact: true })).toBeVisible();
+ await expect(home.getByRole("button", { name: "Shared links", exact: true })).toBeVisible();
+ await expect(home.getByRole("button", { name: "Open raw folder", exact: true })).toHaveCount(0);
+ await home.getByRole("button", { name: "Recordly account", exact: true }).click();
+ const menu = page.getByRole("dialog", { name: "Account menu", exact: true });
+ await expect(menu.getByRole("button", { name: "Open raw folder", exact: true })).toBeVisible();
+ await page.keyboard.press("Escape");
+ const title = "A detailed product walkthrough with a title that needs more than one line";
+ const thumbnail = home.getByRole("button", { name: title, exact: true });
+ const beforeHover = await thumbnail.boundingBox();
+ await thumbnail.hover();
+ await expect.poll(async () => (await thumbnail.boundingBox())?.width).toBe(beforeHover?.width);
+ await page.screenshot({
+ path: test.info().outputPath("library-navigation.png"),
+ animations: "disabled",
+ });
+ await home.getByRole("button", { name: `Open ${title}`, exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-title-opened-project",
+ "/projects/walkthrough.recordly",
+ );
+ await expect(
+ home.getByRole("list", { name: "Your projects" }).getByLabel("Test User"),
+ ).toHaveCount(0);
+ await home.getByRole("button", { name: "Select projects to delete", exact: true }).click();
+ const selectTitle = home.getByRole("button", { name: `Select ${title}`, exact: true });
+ await selectTitle.click();
+ await expect(selectTitle).toHaveAttribute("aria-pressed", "true");
+ await expect(home.getByText("1 selected", { exact: true })).toBeVisible();
+ await page.screenshot({
+ path: test.info().outputPath("project-card-content.png"),
+ animations: "disabled",
+ });
+});
+
+test("scene shows frame controls in Padding, Blur, Shadow, Radius order", async ({ page }) => {
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ const padding = page.getByRole("slider", { name: "Padding", exact: true });
+ await expect(padding).toBeVisible({ timeout: 30000 });
+ await expect(page.getByRole("button", { name: "Background details", exact: true })).toHaveCount(
+ 0,
+ );
+ let previousY = -1;
+ for (const name of ["Padding", "Blur", "Shadow", "Radius"]) {
+ const slider = page.getByRole("slider", { name, exact: true });
+ await expect(slider).toBeVisible();
+ const bounds = await slider.boundingBox();
+ expect(bounds).not.toBeNull();
+ expect(bounds!.y).toBeGreaterThan(previousY);
+ previousY = bounds!.y;
+ }
+ const blur = page.getByRole("slider", { name: "Blur", exact: true });
+ const before = Number(await blur.inputValue());
+ await blur.focus();
+ await page.keyboard.press("ArrowRight");
+ await expect(blur).toHaveValue(String(before + 0.25));
+ await page.screenshot({
+ path: test.info().outputPath("scene-controls.png"),
+ animations: "disabled",
+ });
+});
diff --git a/tests/ui/gif-optimization.html b/tests/ui/gif-optimization.html
new file mode 100644
index 00000000..b84779dc
--- /dev/null
+++ b/tests/ui/gif-optimization.html
@@ -0,0 +1 @@
+GIF optimization
diff --git a/tests/ui/gif-optimization.spec.ts b/tests/ui/gif-optimization.spec.ts
new file mode 100644
index 00000000..6defad56
--- /dev/null
+++ b/tests/ui/gif-optimization.spec.ts
@@ -0,0 +1,215 @@
+import { createRequire } from "node:module";
+import { compressGifBuffer } from "../../electron/ipc/export/gifOptimization";
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+const ffmpegPath = createRequire(import.meta.url)("ffmpeg-static") as string;
+
+test.beforeEach(async ({ page }) => {
+ const jobs = new Map();
+ await page.exposeFunction("__compressGif", async (requestId: string, data: number[]) => {
+ const controller = new AbortController();
+ jobs.set(requestId, controller);
+ try {
+ return {
+ success: true,
+ data: Array.from(
+ await compressGifBuffer(new Uint8Array(data), {
+ ffmpegPath,
+ signal: controller.signal,
+ }),
+ ),
+ };
+ } catch (error) {
+ return { success: false, error: String(error) };
+ } finally {
+ jobs.delete(requestId);
+ }
+ });
+ await page.exposeFunction("__cancelGif", (requestId: string) => {
+ jobs.get(requestId)?.abort();
+ return { success: true };
+ });
+ page.on("close", () => {
+ for (const job of jobs.values()) job.abort();
+ });
+ await installDesktopBridgeOverrides(page, () => {
+ const native = window as unknown as {
+ __compressGif: (
+ id: string,
+ data: number[],
+ ) => Promise<{ success: boolean; data?: number[]; error?: string }>;
+ __cancelGif: (id: string) => Promise<{ success: boolean }>;
+ };
+ window.electronAPI.optimizeGif = async (id, data) => {
+ const response = await native.__compressGif(id, Array.from(new Uint8Array(data)));
+ return { ...response, data: response.data ? new Uint8Array(response.data) : undefined };
+ };
+ window.electronAPI.cancelGifOptimization = (id) => native.__cancelGif(id);
+ });
+});
+
+test("free users can tick optimization without an upgrade prompt", async ({ page }) => {
+ await page.goto("/tests/ui/gif-optimization.html");
+ const checkbox = page.getByRole("checkbox", { name: "Optimize size" });
+ await page.locator('[data-slot="checkbox-content"]').click();
+ await expect(checkbox).toBeChecked();
+ await expect(page.getByText("Upgrade requested")).toHaveCount(0);
+});
+test("users can toggle optimization and see real FFmpeg savings", async ({ page }) => {
+ await page.goto("/tests/ui/gif-optimization.html");
+ const checkbox = page.getByRole("checkbox", { name: "Optimize size" });
+ await page.locator('[data-slot="checkbox-content"]').click();
+ await expect(checkbox).toBeChecked();
+ await page.locator('[data-slot="checkbox-content"]').click();
+ await expect(checkbox).not.toBeChecked();
+ await page.getByRole("button", { name: "Optimize sample" }).click();
+ await expect(page.getByText("Frames, timing and loop preserved")).toBeVisible({
+ timeout: 60000,
+ });
+ await expect(page.locator('div[role="status"]')).toContainText(/Saved .+\([\d.]+% smaller\)/);
+ await page.screenshot({ path: "test-results/gif-optimization.png" });
+});
+
+for (const target of ["met", "missed", "failed"] as const) {
+ test(`shows honest GIF optimization result when target is ${target}`, async ({ page }) => {
+ await page.goto(`/tests/ui/gif-optimization.html?summary=${target}`);
+ const result = page.locator('div[role="status"]');
+ if (target === "failed") {
+ await expect(result).toContainText("Optimization unavailable. Original GIF kept.");
+ await expect(result).not.toContainText("Target not reached");
+ await expect(result).not.toContainText("320 × 240");
+ } else {
+ await expect(result).toContainText("800 × 600 → 320 × 240");
+ await expect(result).toContainText(
+ target === "met" ? "90.0% smaller" : "80.0% smaller",
+ );
+ if (target === "missed") await expect(result).toContainText("Target not reached");
+ else await expect(result).not.toContainText("Target not reached");
+ }
+ });
+}
+
+for (const plan of ["free", "pro", "loading", "unavailable"] as const) {
+ test(`optimized local GIF exports work when billing is ${plan}`, async ({ page }) => {
+ test.setTimeout(90000);
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.onCloudShareProgress = () => () => undefined;
+ const native = window as unknown as {
+ __compressGif: (
+ id: string,
+ data: number[],
+ ) => Promise<{ success: boolean; data?: number[]; error?: string }>;
+ __cancelGif: (id: string) => Promise<{ success: boolean }>;
+ };
+ window.electronAPI.optimizeGif = async (id, data) => {
+ const response = await native.__compressGif(id, Array.from(new Uint8Array(data)));
+ return {
+ ...response,
+ data: response.data ? new Uint8Array(response.data) : undefined,
+ };
+ };
+ window.electronAPI.cancelGifOptimization = (id) => native.__cancelGif(id);
+ window.electronAPI.saveExportedVideo = async (data, name) => {
+ const bytes = new Uint8Array(data);
+ document.documentElement.dataset.savedGifHeader = String.fromCharCode(
+ ...bytes.subarray(0, 6),
+ );
+ document.documentElement.dataset.savedGifBytes = String(bytes.length);
+ return { success: true, path: `/exports/${name}` };
+ };
+ window.electronAPI.cloudBilling = async ({ action }) => {
+ const prices = [
+ { interval: "monthly" as const, unitAmount: 900, currency: "usd" },
+ ];
+ if (action === "catalog")
+ return {
+ success: true,
+ prices,
+ limits: {
+ freeRecordingLimit: 5,
+ freeMaxRecordingBytes: 1e9,
+ plusMaxRecordingBytes: 1e10,
+ storageLimitBytes: 1e11,
+ },
+ };
+ const plan = new URLSearchParams(location.search).get("gifPlan");
+ if (plan === "loading") return new Promise(() => undefined);
+ if (plan === "unavailable") return { success: false, error: "Billing unavailable" };
+ return {
+ success: true,
+ billing: {
+ plan:
+ new URLSearchParams(location.search).get("gifPlan") === "pro"
+ ? "pro"
+ : "free",
+ subscriptionStatus: "active",
+ currentPeriodEnd: null,
+ cancelAtPeriodEnd: false,
+ checkoutAvailable: true,
+ checkoutIntervals: ["monthly"],
+ portalAvailable: true,
+ prices,
+ },
+ usage: {
+ recordings: 0,
+ recordingLimit:
+ new URLSearchParams(location.search).get("gifPlan") === "pro"
+ ? null
+ : 5,
+ maxRecordingBytes: 1e9,
+ storageBytes: 0,
+ storageLimitBytes: 1e11,
+ },
+ };
+ };
+ });
+
+ await page.goto(`/?windowType=editor&gifPlan=${plan}`);
+ await expect(page.getByRole("row", { name: "Color", exact: true })).toBeVisible();
+ await expect
+ .poll(() =>
+ page
+ .locator("video")
+ .evaluateAll((videos) =>
+ videos.some((video) => (video as HTMLVideoElement).readyState >= 2),
+ ),
+ )
+ .toBe(true);
+ await page.getByRole("button", { name: "Publish", exact: true }).click();
+ const fileSize = page.getByRole("status", { name: "Estimated file size", exact: true });
+ await expect(fileSize).toHaveCount(0);
+ await page.getByRole("row", { name: "Save video", exact: true }).click();
+ await expect(fileSize).toHaveCount(0);
+ await page.getByRole("row", { name: "GIF", exact: true }).click();
+ const checkbox = page.getByRole("checkbox", { name: "Optimize size" });
+ await expect(checkbox).not.toBeChecked();
+ await page.locator('[data-slot="checkbox-content"]').filter({ has: checkbox }).click();
+ await expect(checkbox).toBeChecked();
+ await expect(fileSize).toHaveCount(0);
+ await expect(page.getByText(/^Estimated: /)).toHaveCount(0);
+ await expect(
+ page.getByText(
+ "Targets 90% smaller by reducing colors and resolution. Actual size may vary.",
+ ),
+ ).toBeVisible();
+ await expect(page.getByText("Export complete", { exact: true })).toHaveCount(0);
+ if (plan === "free")
+ await page.screenshot({ path: "test-results/gif-estimate-editor.png" });
+ await page.getByRole("button", { name: "Export GIF", exact: true }).click();
+ await expect(page.getByRole("dialog", { name: "Recordly Cloud", exact: true })).toHaveCount(
+ 0,
+ );
+ await expect(page.getByText("Export complete", { exact: true })).toBeVisible({
+ timeout: 60000,
+ });
+ await expect(page.locator("html")).toHaveAttribute("data-saved-gif-header", /^GIF8[79]a$/);
+ await expect(page.locator("html")).toHaveAttribute("data-saved-gif-bytes", /^[1-9]\d*$/);
+ await expect(page.getByText(/Saved .+\([\d.]+% smaller\)/)).toBeVisible();
+ await expect(page.getByText("Optimization unavailable. Original GIF kept.")).toHaveCount(0);
+ await expect(page.getByRole("dialog", { name: "Recordly Cloud", exact: true })).toHaveCount(
+ 0,
+ );
+ });
+}
diff --git a/tests/ui/gif-optimization.tsx b/tests/ui/gif-optimization.tsx
new file mode 100644
index 00000000..846dcb3a
--- /dev/null
+++ b/tests/ui/gif-optimization.tsx
@@ -0,0 +1,110 @@
+import { useState } from "react";
+import { createRoot } from "react-dom/client";
+import GIF from "gif.js";
+import { GifOptimizeSizeControl } from "@/components/video-editor/GifOptimizeSizeCheckbox";
+import { GifOptimizationResult } from "@/components/video-editor/GifOptimizationResult";
+import { I18nProvider } from "@/contexts/I18nContext";
+import { optimizeGif, type GifOptimizationSummary } from "@/lib/exporter/gifOptimization";
+import "@/index.css";
+
+const workerScript = new URL("gif.js/dist/gif.worker.js", import.meta.url).toString();
+function Fixture() {
+ const [selected, setSelected] = useState(false);
+ const [summary, setSummary] = useState(() => {
+ const target = new URLSearchParams(location.search).get("summary");
+ if (target !== "met" && target !== "missed" && target !== "failed") return null;
+ const optimizedBytes = target === "met" ? 100 : target === "failed" ? 1000 : 200;
+ return {
+ originalBytes: 1000,
+ optimizedBytes,
+ savedBytes: 1000 - optimizedBytes,
+ reductionPercent: (1000 - optimizedBytes) / 10,
+ status: target === "failed" ? "failed" : "optimized",
+ originalWidth: 800,
+ originalHeight: 600,
+ outputWidth: target === "failed" ? 800 : 320,
+ outputHeight: target === "failed" ? 600 : 240,
+ targetBytes: 100,
+ targetReached: target === "met",
+ };
+ });
+ const [verified, setVerified] = useState("");
+ async function run() {
+ const gif = new GIF({ workers: 1, width: 100, height: 100, repeat: 0, workerScript });
+ const canvas = document.createElement("canvas");
+ canvas.width = canvas.height = 100;
+ const ctx = canvas.getContext("2d")!;
+ for (let i = 0; i < 8; i++) {
+ ctx.fillStyle = "#112233";
+ ctx.fillRect(0, 0, 100, 100);
+ ctx.fillStyle = "#ffaa00";
+ ctx.fillRect(i * 5, 20, 10, 10);
+ gif.addFrame(canvas, { delay: 100, copy: true });
+ }
+ const original = await new Promise((resolve) => {
+ gif.on("finished", resolve);
+ gif.render();
+ });
+ const result = await optimizeGif(original);
+ setSummary(result);
+ // Compression may reduce colors and dimensions; all frames and playback must match.
+ const Decoder = (
+ window as unknown as {
+ ImageDecoder: new (
+ config: object,
+ ) => {
+ tracks: {
+ ready: Promise;
+ selectedTrack: { frameCount: number; repetitionCount: number };
+ };
+ decode: (options: { frameIndex: number }) => Promise<{ image: VideoFrame }>;
+ close: () => void;
+ };
+ }
+ ).ImageDecoder;
+ const before = new Decoder({
+ data: new Uint8Array(await original.arrayBuffer()),
+ type: "image/gif",
+ });
+ const after = new Decoder({
+ data: new Uint8Array(await result.blob.arrayBuffer()),
+ type: "image/gif",
+ });
+ await Promise.all([before.tracks.ready, after.tracks.ready]);
+ if (
+ before.tracks.selectedTrack.frameCount !== after.tracks.selectedTrack.frameCount ||
+ before.tracks.selectedTrack.repetitionCount !==
+ after.tracks.selectedTrack.repetitionCount
+ )
+ throw new Error("GIF track changed");
+ for (let i = 0; i < 8; i++) {
+ const a = (await before.decode({ frameIndex: i })).image;
+ const b = (await after.decode({ frameIndex: i })).image;
+ if (a.duration !== b.duration) throw new Error("Timing changed");
+ if (b.displayWidth > a.displayWidth || b.displayHeight > a.displayHeight)
+ throw new Error("Dimensions increased");
+ if (b.displayWidth !== result.outputWidth || b.displayHeight !== result.outputHeight)
+ throw new Error("Reported dimensions differ from the GIF");
+ a.close();
+ b.close();
+ }
+ before.close();
+ after.close();
+ setVerified("Frames, timing and loop preserved");
+ }
+ return (
+
+
+ void run().catch((error) => setVerified(String(error)))}>
+ Optimize sample
+
+ {summary && }
+ {verified}
+
+ );
+}
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/tests/ui/history-persistence.spec.ts b/tests/ui/history-persistence.spec.ts
new file mode 100644
index 00000000..d703ba36
--- /dev/null
+++ b/tests/ui/history-persistence.spec.ts
@@ -0,0 +1,140 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test("undo and redo survive project switching and a renderer restart", async ({ page }) => {
+ test.setTimeout(180_000);
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await installDesktopBridgeOverrides(page, () => {
+ const storageKey = "test-history-project";
+ const path = "/projects/History.recordly";
+ let activePath = path;
+ const read = () => JSON.parse(localStorage.getItem(storageKey) || "null");
+ const save = async (data: unknown) => {
+ localStorage.setItem(
+ activePath === path ? storageKey : "test-other-project",
+ JSON.stringify(data),
+ );
+ return {
+ success: true,
+ path: activePath,
+ projectId: activePath === path ? "history-project" : "other",
+ };
+ };
+ window.electronAPI.createProjectFile = save;
+ window.electronAPI.saveProjectFile = save;
+ window.electronAPI.loadCurrentProjectFile = async () => ({
+ success: !!read(),
+ path,
+ project: read(),
+ });
+ window.electronAPI.listProjectFiles = async () => ({
+ success: true,
+ entries: [
+ {
+ path,
+ name: "History",
+ updatedAt: Date.now(),
+ thumbnailPath: null,
+ isCurrent: false,
+ isInProjectsDirectory: true,
+ },
+ {
+ path: "/projects/Other.recordly",
+ name: "Other",
+ updatedAt: Date.now(),
+ thumbnailPath: null,
+ isCurrent: false,
+ isInProjectsDirectory: true,
+ },
+ ],
+ });
+ window.electronAPI.openProjectFileAtPath = async (selected) => {
+ activePath = selected;
+ return {
+ success: true,
+ path: selected,
+ project:
+ selected === path
+ ? read()
+ : {
+ version: 2,
+ projectId: "other",
+ videoPath: `${location.origin}/tests/ui/fixtures/filmstrip.mp4`,
+ editor: {
+ clipRegions: [
+ {
+ id: "clip-1",
+ startMs: 0,
+ endMs: 6000,
+ sourceStartMs: 0,
+ speed: 1,
+ },
+ ],
+ },
+ },
+ };
+ };
+ });
+ await page.goto("/?windowType=editor");
+ const clips = page.locator('[data-variant="clip"]');
+ const zooms = page.locator('[data-variant="zoom"]');
+ await expect(clips).toHaveCount(1, { timeout: 30_000 });
+ await page.getByRole("button", { name: "Add Zoom (Z)", exact: true }).click();
+ await expect(zooms).toHaveCount(1);
+ await zooms.click();
+ await page.keyboard.press("Delete");
+ await expect(zooms).toHaveCount(0);
+ await page.keyboard.press("Meta+z");
+ await expect(zooms).toHaveCount(1);
+ await expect
+ .poll(() =>
+ page.evaluate(() => {
+ const saved = JSON.parse(localStorage.getItem("test-history-project") || "null");
+ return [saved?.editor.zoomRegions.length, saved?.history?.future.length];
+ }),
+ )
+ .toEqual([1, 1]);
+
+ await page.reload();
+ await expect(zooms).toHaveCount(1, { timeout: 30_000 });
+ await page.getByTestId("timeline-scroll").focus();
+ await page.keyboard.press("Meta+Shift+z");
+ await expect(zooms).toHaveCount(0);
+ await page.keyboard.press("Meta+z");
+ await expect(zooms).toHaveCount(1);
+ await page.keyboard.press("Meta+z");
+ await expect(zooms).toHaveCount(0);
+ await page.keyboard.press("Meta+Shift+z");
+ await expect(zooms).toHaveCount(1);
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ JSON.parse(localStorage.getItem("test-history-project") || "null")?.editor
+ .zoomRegions.length,
+ ),
+ )
+ .toBe(1);
+
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ const dashboard = page.getByRole("dialog", { name: "Projects dashboard" });
+ await dashboard.getByRole("button", { name: "Other", exact: true }).click();
+ await expect(dashboard).not.toBeVisible();
+ await expect(page.getByRole("button", { name: "Undo", exact: true })).toBeDisabled();
+ await expect(page.getByRole("button", { name: "Redo", exact: true })).toBeDisabled();
+ await page.getByTestId("timeline-scroll").focus();
+ await page.keyboard.press("Meta+z");
+ await expect(zooms).toHaveCount(0);
+ await expect(clips).toHaveCount(1);
+ await page.getByRole("button", { name: "Add Zoom (Z)", exact: true }).click();
+ await expect(zooms).toHaveCount(1);
+ await page.keyboard.press("Meta+z");
+ await expect(zooms).toHaveCount(0);
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ await dashboard.getByRole("button", { name: "History", exact: true }).click();
+ await expect(dashboard).not.toBeVisible();
+ await expect(zooms).toHaveCount(1);
+ await page.getByTestId("timeline-scroll").focus();
+ await page.keyboard.press("Meta+Shift+z");
+ await expect(zooms).toHaveCount(0);
+});
diff --git a/tests/ui/home-transition.spec.ts b/tests/ui/home-transition.spec.ts
new file mode 100644
index 00000000..143036f7
--- /dev/null
+++ b/tests/ui/home-transition.spec.ts
@@ -0,0 +1,61 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test("Home stays opaque throughout its loading transition over the editor", async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: "no-preference" });
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ const save = window.electronAPI.saveProjectFile;
+ window.electronAPI.saveProjectFile = async (...args) => {
+ if (args[3])
+ await new Promise((resolve) => {
+ window.addEventListener("test-release-home-save", () => resolve(), {
+ once: true,
+ });
+ });
+ return save(...args);
+ };
+ });
+ await page.goto("/?windowType=editor");
+ await expect(page.locator(".editor-inspector")).toBeVisible({ timeout: 30000 });
+ await expect(page.locator("html")).toHaveAttribute("data-project-creates", "1");
+ await page.evaluate(() => {
+ const samples: number[] = [];
+ Object.assign(window, { homeOpacitySamples: samples });
+ const sample = () => {
+ const home = document.querySelector(".dashboard-shell");
+ if (home) {
+ const backdrop = home.closest(".modal__backdrop")!;
+ const container = home.closest(".modal__container")!;
+ samples.push(
+ Number(getComputedStyle(backdrop).opacity) *
+ Number(getComputedStyle(container).opacity),
+ );
+ }
+ if (!(window as unknown as { homeSamplingDone?: boolean }).homeSamplingDone)
+ requestAnimationFrame(sample);
+ };
+ requestAnimationFrame(sample);
+ });
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ const home = page.getByRole("dialog", { name: "Projects dashboard" });
+ await expect(home.getByLabel("Loading projects")).toBeVisible();
+ const samples = await page.evaluate(async () => {
+ const start = performance.now();
+ const state = window as unknown as {
+ homeOpacitySamples: number[];
+ homeSamplingDone: boolean;
+ };
+ while (performance.now() - start < 350 || state.homeOpacitySamples.length < 3)
+ await new Promise(requestAnimationFrame);
+ state.homeSamplingDone = true;
+ return state.homeOpacitySamples;
+ });
+ expect(samples.length).toBeGreaterThan(2);
+ expect(Math.min(...samples), "Home must never fade through to the editor").toBe(1);
+ await page.evaluate(() => window.dispatchEvent(new Event("test-release-home-save")));
+ await expect(home.getByLabel("Loading projects")).toHaveCount(0);
+ await home.getByRole("button", { name: "Resume Untitled Project", exact: true }).click();
+ await expect(home).not.toBeVisible();
+ await expect(page.locator(".editor-inspector")).toBeVisible();
+});
diff --git a/tests/ui/hud-centering.spec.ts b/tests/ui/hud-centering.spec.ts
new file mode 100644
index 00000000..b1fa8c8c
--- /dev/null
+++ b/tests/ui/hud-centering.spec.ts
@@ -0,0 +1,123 @@
+import { expect, type Page, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+async function observeTransition(page: Page, recording: boolean) {
+ return page.locator("[data-recording-state]").evaluate(
+ (node, recording) =>
+ new Promise<{ center: number; cameraX?: number; cameraY?: number }[]>((resolve) => {
+ const samples: { center: number; cameraX?: number; cameraY?: number }[] = [];
+ const observer = new ResizeObserver(() => {
+ const bounds = node.getBoundingClientRect();
+ const camera = document
+ .querySelector("[data-floating-webcam-preview]")
+ ?.getBoundingClientRect();
+ samples.push({
+ center: bounds.left + bounds.width / 2,
+ cameraX: camera?.left,
+ cameraY: camera?.top,
+ });
+ });
+ observer.observe(node);
+ window.dispatchEvent(new CustomEvent("test-recording", { detail: recording }));
+ setTimeout(() => {
+ observer.disconnect();
+ resolve(samples);
+ }, 400);
+ }),
+ recording,
+ );
+}
+
+for (const scenario of [
+ { webcam: false, viewport: 1440 },
+ { webcam: true, viewport: 1440 },
+ { webcam: true, viewport: 1280 },
+ { webcam: true, viewport: 600 },
+]) {
+ test(`HUD alone stays centered from setup through recording (${JSON.stringify(scenario)})`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: scenario.viewport, height: 1000 });
+ await installDesktopBridge(page);
+ await page.addInitScript(({ webcam }) => {
+ window.electronAPI.getRecordingPreferences = async () => ({
+ success: true,
+ webcamEnabled: webcam,
+ microphoneEnabled: false,
+ systemAudioEnabled: false,
+ });
+ window.electronAPI.onRecordingStateChanged = (callback) => {
+ const listener = (event: Event) =>
+ callback({
+ recording: (event as CustomEvent).detail,
+ sourceName: "Built-in Display",
+ });
+ window.addEventListener("test-recording", listener);
+ return () => window.removeEventListener("test-recording", listener);
+ };
+ }, scenario);
+ await page.goto("/?windowType=hud-overlay");
+ const bar = page.locator("[data-recording-state]");
+ await expect(
+ page.getByRole("group", { name: "Recording setup", exact: true }),
+ ).toBeVisible();
+ if (scenario.webcam)
+ await expect(page.locator("[data-floating-webcam-preview]")).toBeVisible();
+ const ready = (await bar.boundingBox())!;
+ const cameraBefore = scenario.webcam
+ ? await page.locator("[data-floating-webcam-preview]").boundingBox()
+ : null;
+ expect(ready.x + ready.width / 2).toBeCloseTo(scenario.viewport / 2);
+ expect(ready.y + ready.height).toBeCloseTo(968);
+ const radius = await bar.evaluate((node) => getComputedStyle(node).borderRadius);
+ expect(radius).toBe("999px");
+ const samples = await observeTransition(page, true);
+ for (const sample of samples) {
+ expect(sample.center).toBeCloseTo(scenario.viewport / 2, 0);
+ if (cameraBefore) {
+ expect(sample.cameraX).toBeCloseTo(cameraBefore.x);
+ expect(sample.cameraY).toBeCloseTo(cameraBefore.y);
+ }
+ }
+ const recording = (await bar.boundingBox())!;
+ expect(recording.x + recording.width / 2).toBeCloseTo(scenario.viewport / 2);
+ expect(recording.y + recording.height).toBeCloseTo(ready.y + ready.height);
+ await expect(bar).toHaveCSS("border-radius", radius);
+ const cameraBeforeReturn = scenario.webcam
+ ? await page.locator("[data-floating-webcam-preview]").boundingBox()
+ : null;
+ const returnSamples = await observeTransition(page, false);
+ for (const sample of returnSamples) {
+ expect(sample.center).toBeCloseTo(scenario.viewport / 2, 0);
+ if (cameraBeforeReturn) {
+ expect(sample.cameraX).toBeCloseTo(cameraBeforeReturn.x);
+ expect(sample.cameraY).toBeCloseTo(cameraBeforeReturn.y);
+ }
+ }
+ await expect
+ .poll(async () => {
+ const bounds = (await bar.boundingBox())!;
+ return Math.abs(bounds.x + bounds.width / 2 - scenario.viewport / 2);
+ })
+ .toBeLessThan(0.5);
+ if (scenario.webcam) {
+ await observeTransition(page, true);
+ const camera = page.locator("[data-floating-webcam-preview]");
+ const origin = (await camera.boundingBox())!;
+ await page.mouse.move(origin.x + origin.width / 2, origin.y + origin.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(scenario.viewport - 1, origin.y + origin.height / 2, {
+ steps: 5,
+ });
+ await page.mouse.up();
+ const atEdge = (await camera.boundingBox())!;
+ for (const recording of [false, true]) {
+ const edgeSamples = await observeTransition(page, recording);
+ for (const sample of edgeSamples) {
+ expect(sample.cameraX).toBeCloseTo(atEdge.x);
+ expect(sample.cameraY).toBeCloseTo(atEdge.y);
+ }
+ }
+ }
+ });
+}
diff --git a/tests/ui/hud-drag.spec.ts b/tests/ui/hud-drag.spec.ts
new file mode 100644
index 00000000..b4dae1b5
--- /dev/null
+++ b/tests/ui/hud-drag.spec.ts
@@ -0,0 +1,107 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+for (const webcam of [false, true]) {
+ test(`dragging the ${webcam ? "docked" : "standalone"} pill moves smoothly without stealing button clicks`, async ({
+ page,
+ }) => {
+ await installDesktopBridge(page);
+ await page.addInitScript((webcam) => {
+ window.electronAPI.getRecordingPreferences = async () => ({
+ success: true,
+ webcamEnabled: webcam,
+ microphoneEnabled: false,
+ systemAudioEnabled: false,
+ });
+ window.electronAPI.onRecordingStateChanged = (callback) => {
+ const listener = () =>
+ callback({ recording: true, sourceName: "Built-in Display" });
+ window.addEventListener("test-recording", listener);
+ return () => window.removeEventListener("test-recording", listener);
+ };
+ window.electronAPI.hudOverlayHide = async () => {
+ document.documentElement.dataset.hudHidden = "true";
+ return { success: true };
+ };
+ }, webcam);
+ await page.goto("/?windowType=hud-overlay");
+ await expect(
+ page.getByRole("group", { name: "Recording setup", exact: true }),
+ ).toBeVisible();
+ if (webcam) await expect(page.locator("[data-floating-webcam-preview]")).toBeVisible();
+ await page.evaluate(() => window.dispatchEvent(new Event("test-recording")));
+ const controls = page.getByRole("group", { name: "Recording controls", exact: true });
+ const bar = page.locator("[data-recording-state]");
+ await expect(controls).toBeVisible();
+ await expect
+ .poll(() =>
+ bar.evaluate(
+ (node) =>
+ node
+ .getAnimations({ subtree: true })
+ .filter((animation) => animation.playState === "running").length,
+ ),
+ )
+ .toBe(0);
+ const before = (await bar.boundingBox())!;
+ const cameraBefore = webcam
+ ? (await page.locator("[data-floating-webcam-preview]").boundingBox())!
+ : null;
+ const timer = (await page.getByRole("timer").boundingBox())!;
+ const start = { x: timer.x + 12, y: timer.y + timer.height / 2 };
+ await page.mouse.move(start.x, start.y);
+ await page.mouse.down();
+ await page.mouse.move(start.x + 2, start.y + 2);
+ await page.mouse.up();
+ expect(await bar.boundingBox()).toEqual(before);
+ await page.mouse.move(start.x, start.y);
+ await page.mouse.down();
+ await page.mouse.move(start.x + 100, start.y - 80, { steps: 6 });
+ await page.mouse.up();
+ const moved = (await bar.boundingBox())!;
+ expect(moved.x).toBeCloseTo(before.x + 100);
+ expect(moved.y).toBeCloseTo(before.y - 80);
+ if (cameraBefore) {
+ const camera = (await page.locator("[data-floating-webcam-preview]").boundingBox())!;
+ expect(camera.x).toBeCloseTo(cameraBefore.x + 100);
+ expect(camera.y).toBeCloseTo(cameraBefore.y - 80);
+ }
+ await page.getByRole("button", { name: /Hide/ }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-hud-hidden", "true");
+ expect(await bar.boundingBox()).toEqual(moved);
+ const updatedTimer = (await page.getByRole("timer").boundingBox())!;
+ await page.mouse.move(updatedTimer.x + 10, updatedTimer.y + 5);
+ await page.mouse.down();
+ await page.mouse.move(updatedTimer.x + 50, updatedTimer.y - 35, { steps: 4 });
+ await page.evaluate(() => window.dispatchEvent(new Event("blur")));
+ const interrupted = (await bar.boundingBox())!;
+ await page.mouse.move(0, 0);
+ await page.mouse.up();
+ expect(await bar.boundingBox()).toEqual(interrupted);
+ await expect(page.locator("html")).not.toHaveAttribute(
+ "data-webcam-preview-cursor",
+ "grabbing",
+ );
+ const lastTimer = (await page.getByRole("timer").boundingBox())!;
+ const beforeCancel = (await bar.boundingBox())!;
+ await page.mouse.move(lastTimer.x + 10, lastTimer.y + 5);
+ await page.mouse.down();
+ await page.mouse.move(lastTimer.x + 70, lastTimer.y - 45, { steps: 4 });
+ await bar.evaluate((node) =>
+ node.dispatchEvent(
+ new PointerEvent("pointercancel", {
+ pointerId: 1,
+ bubbles: true,
+ clientX: 0,
+ clientY: 0,
+ }),
+ ),
+ );
+ const cancelled = (await bar.boundingBox())!;
+ expect(cancelled.x).toBeCloseTo(beforeCancel.x + 60);
+ expect(cancelled.y).toBeCloseTo(beforeCancel.y - 50);
+ await page.mouse.move(0, 0);
+ await page.mouse.up();
+ expect(await bar.boundingBox()).toEqual(cancelled);
+ });
+}
diff --git a/tests/ui/hud-layout.spec.ts b/tests/ui/hud-layout.spec.ts
index 20a6609e..4b7948f1 100644
--- a/tests/ui/hud-layout.spec.ts
+++ b/tests/ui/hud-layout.spec.ts
@@ -36,11 +36,13 @@ for (const reducedMotion of ["reduce", "no-preference"] as const) {
});
}
-test("HUD dividers are vertically centered", async ({ page }) => {
+test("HUD ready state uses a fixed grid with visible Home and minimise controls", async ({
+ page,
+}) => {
await installDesktopBridge(page);
await page.goto("/?windowType=hud-overlay");
const dividers = page.locator(".separator--vertical");
- await expect(dividers.first()).toBeVisible();
+ await expect(dividers).toHaveCount(0);
const offsets = await dividers.evaluateAll((elements) =>
elements.map((element) => {
const bounds = element.getBoundingClientRect();
@@ -61,6 +63,9 @@ test("HUD dividers are vertically centered", async ({ page }) => {
)
.toEqual({ width: 20, height: 20 });
await page.screenshot({ path: "test-results/hud-idle.png", animations: "disabled" });
+ await page
+ .locator('[data-recording-state="idle"]')
+ .screenshot({ path: "test-results/hud-ready-detail.png" });
await home.click();
await expect(page.locator("html")).toHaveAttribute("data-dashboard-opened", "true");
await page.goto("/?windowType=editor");
@@ -77,19 +82,44 @@ test("recording HUD uses uniform controls and a readable timer", async ({ page }
};
});
await page.goto("/?windowType=hud-overlay");
- await expect(page.locator(".separator--vertical").first()).toBeVisible();
+ const setup = page.getByRole("group", { name: "Recording setup", exact: true });
+ await expect(setup).toBeVisible();
+ const bar = page.locator('[data-recording-state="idle"]');
+ const readyBounds = await bar.boundingBox();
await page.evaluate(() => window.dispatchEvent(new Event("test-recording-started")));
const controls = page.getByRole("group", { name: "Recording controls" });
await expect(controls).toBeVisible();
- await expect(controls.getByRole("status")).toContainText("00:00");
- await expect(controls.getByRole("button", { name: "Home", exact: true })).toBeVisible();
- const sizes = await controls.getByRole("button").evaluateAll((buttons) =>
- buttons.map((button) => {
- return [(button as HTMLElement).offsetWidth, (button as HTMLElement).offsetHeight];
- }),
- );
+ await expect
+ .poll(() =>
+ page
+ .locator('[data-recording-state="recording"]')
+ .evaluate(
+ (node) =>
+ node
+ .getAnimations({ subtree: true })
+ .filter((animation) => animation.playState === "running").length,
+ ),
+ )
+ .toBe(0);
+ await expect(controls.getByRole("timer")).toContainText("0:00");
+ await expect(controls.getByRole("button", { name: "Home", exact: true })).toHaveCount(0);
+ await expect(controls.getByText("REC", { exact: true })).toHaveCount(0);
+ expect(
+ (await page.locator('[data-recording-state="recording"]').boundingBox())!.width,
+ ).toBeLessThan(readyBounds!.width);
+ const sizes = await controls
+ .getByRole("button")
+ .filter({ hasNot: page.getByRole("timer") })
+ .evaluateAll((buttons) =>
+ buttons.map((button) => {
+ return [(button as HTMLElement).offsetWidth, (button as HTMLElement).offsetHeight];
+ }),
+ );
for (const size of sizes) expect(size).toEqual([36, 36]);
await page.screenshot({ path: "test-results/hud-recording.png" });
+ await page
+ .locator('[data-recording-state="recording"]')
+ .screenshot({ path: "test-results/hud-recording-detail.png" });
});
test("New recording mode starts the webcam with an editor still open and releases it on Home", async ({
@@ -147,82 +177,25 @@ test("New recording mode starts the webcam with an editor still open and release
.toBe(true);
});
-for (const storageBlocked of [false, true]) {
- test(`floating webcam preview resizes from its corners ${storageBlocked ? "when storage is unavailable" : "and remembers the size"}`, async ({
- page,
- }) => {
- const errors: string[] = [];
- page.on("pageerror", (error) => errors.push(error.message));
- await installDesktopBridge(page);
- if (storageBlocked) {
- await page.addInitScript(() => {
- const getItem = Storage.prototype.getItem;
- const setItem = Storage.prototype.setItem;
- Storage.prototype.getItem = function (key) {
- if (key === "recordly.webcam-preview-size")
- throw new Error("Storage unavailable");
- return getItem.call(this, key);
- };
- Storage.prototype.setItem = function (key, value) {
- if (key === "recordly.webcam-preview-size")
- throw new Error("Storage unavailable");
- return setItem.call(this, key, value);
- };
- });
- }
- await page.addInitScript(() => {
- window.electronAPI.hudOverlaySetIgnoreMouse = async (ignore) => {
- document.documentElement.dataset.webcamPassthrough = String(ignore);
- return { success: true };
- };
- window.electronAPI.getRecordingPreferences = async () => ({
- success: true,
- microphoneEnabled: false,
- webcamEnabled: true,
- systemAudioEnabled: false,
- });
- navigator.mediaDevices.getUserMedia = async () => {
- const canvas = document.createElement("canvas");
- canvas.getContext("2d")!.fillRect(0, 0, 320, 320);
- return canvas.captureStream(24);
- };
- navigator.mediaDevices.enumerateDevices = async () => [];
+test("floating webcam preview ignores a legacy saved resize preference", async ({ page }) => {
+ await installDesktopBridge(page);
+ await page.addInitScript(() => {
+ localStorage.setItem(
+ "recordly.webcam-preview-size",
+ JSON.stringify({ width: 480, height: 320 }),
+ );
+ window.electronAPI.getRecordingPreferences = async () => ({
+ success: true,
+ microphoneEnabled: false,
+ webcamEnabled: true,
+ systemAudioEnabled: false,
});
- await page.goto("/?windowType=hud-overlay");
- const preview = page.locator("[data-resize-handle]").first().locator("..");
- await expect(preview).toBeVisible();
- const before = (await preview.boundingBox())!;
- expect([before.width, before.height]).toEqual([288, 288]);
-
- const dragHandle = async (handle: string, dx: number, dy: number) => {
- const box = (await preview.locator(`[data-resize-handle="${handle}"]`).boundingBox())!;
- await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
- await page.mouse.down();
- await page.mouse.move(box.x + box.width / 2 + dx, box.y + box.height / 2 + dy, {
- steps: 5,
- });
- await page.mouse.up();
- };
-
- // Top-left grows the preview while the bottom-right corner stays anchored.
- await dragHandle("nw", -60, -40);
- const grown = (await preview.boundingBox())!;
- expect([grown.width, grown.height]).toEqual([348, 328]);
- expect(grown.x + grown.width).toBeCloseTo(before.x + before.width);
- expect(grown.y + grown.height).toBeCloseTo(before.y + before.height);
-
- // Bottom-right shrinks the preview while the top-left corner stays put, down to the minimum.
- await dragHandle("se", -500, -500);
- const shrunk = (await preview.boundingBox())!;
- expect([shrunk.width, shrunk.height]).toEqual([120, 120]);
- expect(shrunk.x).toBeCloseTo(grown.x);
- expect(shrunk.y).toBeCloseTo(grown.y);
- await expect(page.locator("html")).toHaveAttribute("data-webcam-passthrough", "true");
-
- await page.reload();
- await expect(preview).toBeVisible();
- const restored = (await preview.boundingBox())!;
- expect([restored.width, restored.height]).toEqual(storageBlocked ? [288, 288] : [120, 120]);
- expect(errors).toEqual([]);
});
-}
+ await page.goto("/?windowType=hud-overlay");
+ const preview = page.locator("[data-floating-webcam-preview]");
+ await expect(preview).toBeVisible();
+ const bounds = (await preview.boundingBox())!;
+ expect([bounds.width, bounds.height]).toEqual([230, 230]);
+ await expect(preview.locator("[data-resize-handle]")).toHaveCount(0);
+ await expect(preview.locator("video")).toHaveCSS("clip-path", "circle(50%)");
+});
diff --git a/tests/ui/hud-restart.spec.ts b/tests/ui/hud-restart.spec.ts
new file mode 100644
index 00000000..1b7e0ce0
--- /dev/null
+++ b/tests/ui/hud-restart.spec.ts
@@ -0,0 +1,107 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+for (const failure of ["none", "stop", "delete", "delete-result"] as const) {
+ test(`HUD discard returns to setup only after capture cleanup (${failure})`, async ({
+ page,
+ }) => {
+ test.setTimeout(60000);
+ await installDesktopBridge(page);
+ await page.addInitScript((failure) => {
+ let starts = 0;
+ const events: string[] = [];
+ const event = (name: string) => {
+ events.push(name);
+ document.documentElement.dataset.captureEvents = events.join(",");
+ };
+ Object.assign(window.electronAPI, {
+ setRecordingState: async () => ({ success: true }),
+ pauseNativeScreenRecording: async () => ({ success: true }),
+ resumeNativeScreenRecording: async () => ({ success: true }),
+ getPlatform: async () => "darwin",
+ getSelectedSource: async () => ({ id: "screen:1:0", name: "Built-in Display" }),
+ getCountdownDelay: async () => ({ success: true, delay: 0 }),
+ getRecordingPreferences: async () => ({
+ success: true,
+ microphoneEnabled: false,
+ webcamEnabled: false,
+ systemAudioEnabled: false,
+ }),
+ startNativeScreenRecording: async () => {
+ starts += 1;
+ event(`start-${starts}`);
+ return { success: true };
+ },
+ stopNativeScreenRecording: () => {
+ event("stop");
+ return new Promise((resolve) =>
+ window.addEventListener(
+ "release-native-stop",
+ () =>
+ resolve(
+ failure === "stop"
+ ? { success: false, error: "Capture is busy" }
+ : { success: true, path: "/discard.mp4" },
+ ),
+ { once: true },
+ ),
+ );
+ },
+ deleteRecordingFile: async () => {
+ event("delete");
+ if (failure === "delete") throw new Error("Could not delete the take");
+ if (failure === "delete-result") return { success: false, error: "File is locked" };
+ return { success: true };
+ },
+ });
+ }, failure);
+ await page.goto("/?windowType=hud-overlay");
+ const record = page.getByRole("button", { name: "Record", exact: true });
+ await expect(record).toBeEnabled({ timeout: 30000 });
+ await record.click();
+ const controls = page.getByRole("group", { name: "Recording controls", exact: true });
+ await expect(controls).toBeVisible();
+ await expect(controls.getByRole("timer")).not.toHaveText("0:00");
+ if (failure === "stop") {
+ await controls.getByRole("button", { name: "Pause", exact: true }).click();
+ await expect(
+ controls.getByRole("button", { name: "Resume", exact: true }),
+ ).toBeVisible();
+ }
+ const bounds = await controls.boundingBox();
+ const discard = controls.getByRole("button", { name: "Discard recording", exact: true });
+ await discard.click();
+ await expect(page.locator("html")).toHaveAttribute("data-capture-events", "start-1,stop");
+ await expect(discard).toBeDisabled();
+ expect(await controls.boundingBox()).toEqual(bounds);
+ await page.evaluate(() => window.dispatchEvent(new Event("release-native-stop")));
+ if (failure === "stop") {
+ await expect(discard).toBeEnabled();
+ await expect(
+ controls.getByRole("button", { name: "Resume", exact: true }),
+ ).toBeVisible();
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-capture-events",
+ "start-1,stop",
+ );
+ } else {
+ await expect(record).toBeVisible();
+ await expect(
+ page.getByRole("group", { name: "Recording setup", exact: true }),
+ ).toBeVisible();
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-capture-events",
+ "start-1,stop,delete",
+ );
+ if (failure === "none") {
+ await record.click();
+ await expect(controls).toBeVisible();
+ await expect(page.locator("html")).toHaveAttribute("data-capture-events", "start-1,stop,delete,start-2");
+ } else {
+ await record.click();
+ await expect(page.locator("html")).toHaveAttribute("data-capture-events", "start-1,stop,delete,delete");
+ await expect(record).toBeEnabled();
+ }
+ }
+ });
+}
diff --git a/tests/ui/hud-transition.spec.ts b/tests/ui/hud-transition.spec.ts
new file mode 100644
index 00000000..0fa4f6e3
--- /dev/null
+++ b/tests/ui/hud-transition.spec.ts
@@ -0,0 +1,82 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+for (const reducedMotion of ["reduce", "no-preference"] as const) {
+ test(`HUD morphs between ready and recording with ${reducedMotion} motion`, async ({
+ page,
+ }) => {
+ await page.emulateMedia({ reducedMotion });
+ await installDesktopBridge(page);
+ await page.addInitScript(() => {
+ window.electronAPI.onRecordingStateChanged = (callback) => {
+ const listener = (event: Event) =>
+ callback({
+ recording: (event as CustomEvent).detail,
+ sourceName: "Built-in Display",
+ });
+ window.addEventListener("test-recording", listener);
+ return () => window.removeEventListener("test-recording", listener);
+ };
+ });
+ await page.goto("/?windowType=hud-overlay");
+ await expect(
+ page.getByRole("group", { name: "Recording setup", exact: true }),
+ ).toBeVisible();
+ const result = await page.evaluate(() => {
+ window.dispatchEvent(new CustomEvent("test-recording", { detail: true }));
+ return new Promise<{
+ animations: number;
+ from: string | number | null | undefined;
+ to: string | number | null | undefined;
+ }>((resolve) =>
+ requestAnimationFrame(() => {
+ const bar = document.querySelector(
+ "[data-recording-state=recording]",
+ )!;
+ const animations = bar.getAnimations();
+ const frames = (
+ animations[0]?.effect as KeyframeEffect | undefined
+ )?.getKeyframes();
+ resolve({
+ animations: animations.length,
+ from: frames?.[0]?.width,
+ to: frames?.[1]?.width,
+ });
+ }),
+ );
+ });
+ if (reducedMotion === "reduce") expect(result.animations).toBe(0);
+ else {
+ expect(result.animations).toBe(1);
+ expect(Number.parseFloat(String(result.from))).toBeGreaterThan(
+ Number.parseFloat(String(result.to)),
+ );
+ }
+ const bar = page.locator("[data-recording-state]");
+ await expect
+ .poll(() =>
+ bar.evaluate(
+ (node) =>
+ node
+ .getAnimations({ subtree: true })
+ .filter((animation) => animation.playState === "running").length,
+ ),
+ )
+ .toBe(0);
+ const stop = page.getByRole("button", { name: "Stop", exact: true });
+ const red = await stop.locator("span").evaluate((node) => {
+ const context = document.createElement("canvas").getContext("2d")!;
+ context.fillStyle = getComputedStyle(node).backgroundColor;
+ context.fillRect(0, 0, 1, 1);
+ return [...context.getImageData(0, 0, 1, 1).data];
+ });
+ expect(red[0]).toBeGreaterThan(red[1] + 50);
+ expect(red[0]).toBeGreaterThan(red[2] + 50);
+ await page.evaluate(() =>
+ window.dispatchEvent(new CustomEvent("test-recording", { detail: false })),
+ );
+ await expect(
+ page.getByRole("group", { name: "Recording setup", exact: true }),
+ ).toBeVisible();
+ });
+}
diff --git a/tests/ui/hud-webcam-dock.spec.ts b/tests/ui/hud-webcam-dock.spec.ts
new file mode 100644
index 00000000..855d6aa0
--- /dev/null
+++ b/tests/ui/hud-webcam-dock.spec.ts
@@ -0,0 +1,107 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test("webcam and HUD stay docked across recording states and drag together within the screen", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await page.addInitScript(() => {
+ window.electronAPI.getEditorMode = async () => false;
+ window.electronAPI.getRecordingPreferences = async () => ({
+ success: true,
+ microphoneEnabled: false,
+ webcamEnabled: true,
+ systemAudioEnabled: false,
+ });
+ window.electronAPI.onRecordingStateChanged = (callback) => {
+ const listener = () => callback({ recording: true, sourceName: "Built-in Display" });
+ window.addEventListener("test-recording-started", listener);
+ return () => window.removeEventListener("test-recording-started", listener);
+ };
+ window.electronAPI.hudOverlayHide = async () => {
+ document.documentElement.dataset.hudHidden = "true";
+ return { success: true };
+ };
+ navigator.mediaDevices.getUserMedia = async () => {
+ const canvas = document.createElement("canvas");
+ canvas.width = 320;
+ canvas.height = 320;
+ const context = canvas.getContext("2d")!;
+ context.fillStyle = "#d1d5db";
+ context.fillRect(0, 0, 320, 320);
+ return canvas.captureStream(24);
+ };
+ navigator.mediaDevices.enumerateDevices = async () => [];
+ });
+ await page.goto("/?windowType=hud-overlay");
+ const preview = page.locator("[data-floating-webcam-preview]");
+ const bar = page.locator("[data-recording-state]");
+ await expect(preview).toBeVisible();
+ const readyBar = (await bar.boundingBox())!;
+ const readyCamera = (await preview.boundingBox())!;
+ expect(readyBar.x - readyCamera.x - readyCamera.width).toBeCloseTo(10);
+ expect(readyCamera.y + readyCamera.height).toBeCloseTo(readyBar.y + readyBar.height);
+ await page.evaluate(() => window.dispatchEvent(new Event("test-recording-started")));
+ await expect(page.getByRole("group", { name: "Recording controls" })).toBeVisible();
+ await expect
+ .poll(() =>
+ bar.evaluate(
+ (node) =>
+ node
+ .getAnimations({ subtree: true })
+ .filter((animation) => animation.playState === "running").length,
+ ),
+ )
+ .toBe(0);
+ const recordingCamera = (await preview.boundingBox())!;
+ const captureBounds = (await bar.boundingBox())!;
+ expect(captureBounds.x + captureBounds.width / 2).toBeCloseTo(720);
+ expect(readyBar.x + readyBar.width / 2).toBeCloseTo(720);
+ const recordingBar = (await bar.boundingBox())!;
+ await page.mouse.move(
+ recordingCamera.x + recordingCamera.width / 2,
+ recordingCamera.y + recordingCamera.height / 2,
+ );
+ await page.mouse.down();
+ await page.mouse.move(
+ recordingCamera.x + recordingCamera.width / 2 + 100,
+ recordingCamera.y + recordingCamera.height / 2 - 100,
+ { steps: 8 },
+ );
+ await page.mouse.up();
+ const movedBar = (await bar.boundingBox())!;
+ const movedCamera = (await preview.boundingBox())!;
+ expect(movedCamera.x).toBeCloseTo(recordingCamera.x + 100);
+ expect(movedBar.x).toBeCloseTo(recordingBar.x + 100);
+ expect(movedBar.y).toBeCloseTo(recordingBar.y - 100);
+ await page.screenshot({
+ path: "test-results/hud-webcam-dock.png",
+ clip: {
+ x: movedCamera.x - 12,
+ y: movedCamera.y - 12,
+ width: movedBar.x + movedBar.width - movedCamera.x + 24,
+ height: movedCamera.height + 24,
+ },
+ });
+ await page.mouse.move(movedCamera.x + 50, movedCamera.y + 50);
+ await page.mouse.down();
+ await page.mouse.move(0, 0, { steps: 8 });
+ await page.mouse.up();
+ expect((await bar.boundingBox())!.x).toBeGreaterThanOrEqual(0);
+ expect((await preview.boundingBox())!.y).toBeGreaterThanOrEqual(0);
+ await page.getByRole("button", { name: /Hide/ }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-hud-hidden", "true");
+ await page.setViewportSize({ width: 600, height: 700 });
+ await expect
+ .poll(async () => {
+ const camera = (await preview.boundingBox())!;
+ const controls = (await bar.boundingBox())!;
+ return Math.round(controls.y - camera.y - camera.height);
+ })
+ .toBe(10);
+ await expect.poll(async () => (await bar.boundingBox())!.x).toBeGreaterThanOrEqual(0);
+ const narrowBar = (await bar.boundingBox())!;
+ expect(narrowBar.x).toBeGreaterThanOrEqual(0);
+ expect(narrowBar.x + narrowBar.width).toBeLessThanOrEqual(600);
+ expect(narrowBar.y + narrowBar.height).toBeLessThanOrEqual(700);
+});
diff --git a/tests/ui/inline-crop.spec.ts b/tests/ui/inline-crop.spec.ts
new file mode 100644
index 00000000..feb6e2f4
--- /dev/null
+++ b/tests/ui/inline-crop.spec.ts
@@ -0,0 +1,144 @@
+import { expect, test, type Page } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test.setTimeout(90000);
+
+async function openCrop(page: Page) {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Crop", exact: true }).click({ timeout: 60000 });
+ await expect(page.getByTestId("crop-surface")).toBeVisible();
+}
+async function selection(page: Page) {
+ const box = await page.getByTestId("crop-selection").boundingBox();
+ if (!box) throw new Error("Missing crop selection");
+ return box;
+}
+
+test("crops directly in the preview, Shift snaps to square, cancel restores and done keeps it", async ({
+ page,
+}) => {
+ await openCrop(page);
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await page.keyboard.press("Space");
+ await expect
+ .poll(() =>
+ page
+ .locator("video")
+ .first()
+ .evaluate((video: HTMLVideoElement) => video.paused),
+ )
+ .toBe(true);
+ const original = await selection(page);
+ const corner = await page
+ .getByLabel("Resize crop: bottom right", { exact: true })
+ .boundingBox();
+ await page.mouse.move(corner!.x + corner!.width / 2, corner!.y + corner!.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(original.x + original.width * 0.7, original.y + original.height * 0.7, {
+ steps: 5,
+ });
+ await page.keyboard.down("Shift");
+ await expect
+ .poll(async () => {
+ const box = await selection(page);
+ return Math.abs(box.width - box.height);
+ })
+ .toBeLessThan(1);
+ await page.keyboard.up("Shift");
+ await expect
+ .poll(async () => {
+ const box = await selection(page);
+ return box.width / box.height;
+ })
+ .toBeGreaterThan(1.2);
+ await page.keyboard.down("Shift");
+ await page.mouse.up();
+ await page.keyboard.up("Shift");
+ await page.getByRole("button", { name: "Done", exact: true }).click();
+ await expect(page.getByTestId("crop-surface")).toHaveCount(0);
+ await page.getByRole("button", { name: "Crop", exact: true }).click();
+ const saved = await selection(page);
+ expect(Math.abs(saved.width - saved.height)).toBeLessThan(1);
+ await page
+ .locator("[data-inline-crop]")
+ .getByRole("button", { name: "Reset", exact: true })
+ .click();
+ await page.keyboard.press("Escape");
+ await expect(page.getByTestId("crop-surface")).toHaveCount(0);
+ await page.getByRole("button", { name: "Crop", exact: true }).click();
+ const restored = await selection(page);
+ expect(restored.width).toBeCloseTo(saved.width, 0);
+ expect(restored.height).toBeCloseTo(saved.height, 0);
+});
+
+test("custom aspect ratio uses a compact dialog, locks resizing and supports moving", async ({
+ page,
+}) => {
+ await openCrop(page);
+ await page.getByRole("button", { name: "Aspect ratio", exact: true }).click();
+ await expect(page.getByRole("dialog")).toBeVisible();
+ await page.screenshot({ path: "/tmp/recordly-crop-ratio.png" });
+ await page.getByRole("spinbutton", { name: "Width", exact: true }).fill("0");
+ await expect(page.getByRole("button", { name: "Apply", exact: true })).toBeDisabled();
+ await page.getByRole("spinbutton", { name: "Width", exact: true }).fill("3");
+ await page.getByRole("spinbutton", { name: "Height", exact: true }).fill("2");
+ await page.getByRole("button", { name: "Apply", exact: true }).click();
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ let box = await selection(page);
+ expect(box.width / box.height).toBeCloseTo(1.5, 2);
+ const handle = await page
+ .getByLabel("Resize crop: bottom right", { exact: true })
+ .boundingBox();
+ await page.mouse.move(handle!.x + handle!.width / 2, handle!.y + handle!.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(box.x + box.width * 0.7, box.y + box.height * 0.7, { steps: 5 });
+ await page.mouse.up();
+ box = await selection(page);
+ expect(box.width / box.height).toBeCloseTo(1.5, 2);
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(box.x + box.width / 2 + 20, box.y + box.height / 2 + 15, { steps: 3 });
+ await page.mouse.up();
+ const moved = await selection(page);
+ expect(moved.width).toBeCloseTo(box.width, 0);
+ expect(moved.height).toBeCloseTo(box.height, 0);
+ expect(moved.x).toBeGreaterThan(box.x + 10);
+ await page.screenshot({ path: "/tmp/recordly-inline-crop.png" });
+});
+
+for (const ratio of [1, 3]) {
+ test(`Auto fits the entire ${ratio}:1 crop inside the preview after Done`, async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: /^Aspect ratio:/ }).click({ timeout: 60000 });
+ await page
+ .getByRole("dialog", { name: "Aspect ratio", exact: true })
+ .getByRole("radio", { name: "Auto", exact: true })
+ .check();
+ await page.keyboard.press("Escape");
+ await page.getByRole("button", { name: "Crop", exact: true }).click();
+ await page.getByRole("button", { name: "Aspect ratio", exact: true }).click();
+ await page.getByRole("spinbutton", { name: "Width", exact: true }).fill(String(ratio));
+ await page.getByRole("spinbutton", { name: "Height", exact: true }).fill("1");
+ await page.getByRole("button", { name: "Apply", exact: true }).click();
+ await page.getByRole("button", { name: "Done", exact: true }).click();
+ const frame = page.locator(".editor-preview-frame");
+ const canvas = frame.locator("canvas").first();
+ await expect(canvas).toBeVisible();
+ await expect
+ .poll(async () => {
+ const box = await frame.boundingBox();
+ return box!.width / box!.height;
+ })
+ .toBeCloseTo(ratio, 2);
+ const outer = await frame.boundingBox();
+ const content = await canvas.boundingBox();
+ const stage = await page.locator(".editor-preview-stage").boundingBox();
+ expect(Math.abs(content!.width - outer!.width)).toBeLessThan(2);
+ expect(Math.abs(content!.height - outer!.height)).toBeLessThan(2);
+ expect(content!.y + content!.height).toBeLessThanOrEqual(stage!.y + stage!.height + 1);
+ expect(content!.x + content!.width).toBeLessThanOrEqual(stage!.x + stage!.width + 1);
+ await page.screenshot({ path: `/tmp/recordly-auto-crop-${ratio}.png` });
+ });
+}
diff --git a/tests/ui/library-error-states.spec.ts b/tests/ui/library-error-states.spec.ts
new file mode 100644
index 00000000..67d0cafa
--- /dev/null
+++ b/tests/ui/library-error-states.spec.ts
@@ -0,0 +1,149 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+async function prepare(page: Parameters[0]) {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getCurrentVideoPath = async () => ({ success: false });
+ });
+}
+
+test("project empty states distinguish first use, folder and combined filters", async ({
+ page,
+}) => {
+ await prepare(page);
+ await page.goto("/?windowType=editor");
+ const home = page.getByRole("dialog", { name: "Projects dashboard" });
+ await expect(home.getByRole("heading", { name: "No projects yet" })).toBeVisible();
+ await expect(home.getByRole("button", { name: "Import video", exact: true })).toBeVisible();
+ await home.getByRole("button", { name: "New folder", exact: true }).click();
+ await home.getByRole("button", { name: "Untitled folder", exact: true }).click();
+ await expect(home.getByRole("heading", { name: "This folder is empty" })).toBeVisible();
+ await home.getByRole("button", { name: "Last 7 days", exact: true }).click();
+ await home.getByRole("textbox", { name: "Search projects" }).fill("missing");
+ await expect(home.getByRole("heading", { name: "No matching projects" })).toBeVisible();
+ await home.getByRole("button", { name: "Clear filters", exact: true }).click();
+ await expect(home.getByRole("textbox", { name: "Search projects" })).toHaveValue("");
+ await expect(home.getByRole("button", { name: "All", exact: true })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ await expect(home.getByRole("heading", { name: "No projects yet" })).toBeVisible();
+});
+
+test("Open raw folder reports a folder-opening failure and allows retry", async ({ page }) => {
+ test.setTimeout(60000);
+ await prepare(page);
+ await installDesktopBridgeOverrides(page, () => {
+ let count = 0;
+ window.electronAPI.openRecordingsFolder = async () => {
+ count += 1;
+ document.documentElement.dataset.rawFolderRequests = String(count);
+ return count === 1 ? { success: false, error: "Unavailable" } : { success: true };
+ };
+ });
+ await page.goto("/?windowType=editor");
+ const home = page.getByRole("dialog", { name: "Projects dashboard" });
+ await home.getByRole("button", { name: "Recordly account", exact: true }).click();
+ const menu = page.getByRole("dialog", { name: "Account menu", exact: true });
+ const raw = menu.getByRole("button", { name: "Open raw folder", exact: true });
+ await raw.click();
+ await expect(menu.getByRole("alert")).toContainText("Could not open this folder.");
+ await expect(raw).toBeEnabled();
+ await raw.click();
+ await expect(page.locator("html")).toHaveAttribute("data-raw-folder-requests", "2");
+ await expect(menu).not.toBeVisible();
+ await expect(home.getByRole("heading", { name: "Your projects", exact: true })).toBeVisible();
+});
+
+test("shared list failures have retry without first-use success messaging", async ({ page }) => {
+ await prepare(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.cloudShareManage = async () => {
+ if (document.documentElement.dataset.testCloudReady !== "true")
+ return { success: true, videos: [] };
+ return document.documentElement.dataset.testCloudRetry !== "true"
+ ? { success: false, error: "Cloud library unavailable" }
+ : { success: true, videos: [] };
+ };
+ });
+ await page.goto("/?windowType=editor");
+ const home = page.getByRole("dialog", { name: "Projects dashboard" });
+ await page.evaluate(() => {
+ document.documentElement.dataset.testCloudReady = "true";
+ });
+ await home.getByRole("button", { name: "Shared links", exact: true }).click();
+ const library = home.getByRole("main");
+ const failure = library.getByRole("alert");
+ await expect(failure).toContainText("Could not load shared recordings.");
+ await expect(failure.getByText("Cloud library unavailable", { exact: true })).not.toBeVisible();
+ await failure.getByRole("button", { name: "Technical details", exact: true }).click();
+ await expect(failure.getByText("Cloud library unavailable", { exact: true })).toBeVisible();
+ await expect(home.getByRole("heading", { name: "No shared recordings yet" })).toHaveCount(0);
+ await page.evaluate(() => {
+ document.documentElement.dataset.testCloudRetry = "true";
+ });
+ await home.getByRole("button", { name: "Retry loading recordings", exact: true }).click();
+ await expect(library.getByRole("alert")).toHaveCount(0);
+ await expect(home.getByRole("heading", { name: "No shared recordings yet" })).toBeVisible();
+ await home.getByRole("button", { name: "Last 7 days", exact: true }).click();
+ await home.getByRole("textbox", { name: "Search projects" }).fill("missing");
+ await expect(
+ home.getByRole("heading", { name: "No matching shared recordings" }),
+ ).toBeVisible();
+ await home.getByRole("button", { name: "Clear filters" }).click();
+ await expect(home.getByRole("button", { name: "All", exact: true })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ await expect(home.getByRole("heading", { name: "No shared recordings yet" })).toBeVisible();
+});
+
+test("shared page retry preserves loaded recordings and requests the failed cursor", async ({
+ page,
+}) => {
+ await prepare(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.cloudShareManage = async ({ cursor }) => {
+ if (document.documentElement.dataset.testCloudReady !== "true")
+ return { success: true, videos: [] };
+ const video = (code: string) => ({
+ code,
+ title: `Shared ${code}`,
+ size: 1234,
+ ready: true,
+ url: `https://example.test/s/${code}`,
+ createdAt: new Date().toISOString(),
+ });
+ if (!cursor) return { success: true, videos: [video("first")], nextCursor: "page-two" };
+ if (document.documentElement.dataset.testCloudRetry !== "true") {
+ return { success: false, error: "Next page unavailable" };
+ }
+ document.documentElement.dataset.retriedCursor = cursor;
+ return { success: true, videos: [video("second")] };
+ };
+ });
+ await page.goto("/?windowType=editor");
+ const home = page.getByRole("dialog", { name: "Projects dashboard" });
+ await page.evaluate(() => {
+ document.documentElement.dataset.testCloudReady = "true";
+ });
+ await home.getByRole("button", { name: "Shared links", exact: true }).click();
+ await expect(home.getByRole("button", { name: "Shared first", exact: true })).toBeVisible();
+ await home.getByRole("button", { name: "Load more recordings", exact: true }).click();
+ const library = home.getByRole("main");
+ const failure = library.getByRole("alert");
+ await expect(failure).toContainText("Could not load more recordings.");
+ await expect(failure.getByText("Next page unavailable", { exact: true })).not.toBeVisible();
+ await failure.getByRole("button", { name: "Technical details", exact: true }).click();
+ await expect(failure.getByText("Next page unavailable", { exact: true })).toBeVisible();
+ await expect(home.getByRole("button", { name: "Shared first", exact: true })).toBeVisible();
+ await page.evaluate(() => {
+ document.documentElement.dataset.testCloudRetry = "true";
+ });
+ await home.getByRole("button", { name: "Retry loading more recordings", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-retried-cursor", "page-two");
+ await expect(home.getByRole("button", { name: "Shared first", exact: true })).toBeVisible();
+ await expect(home.getByRole("button", { name: "Shared second", exact: true })).toBeVisible();
+ await expect(library.getByRole("alert")).toHaveCount(0);
+});
diff --git a/tests/ui/library-project-load-recovery.spec.ts b/tests/ui/library-project-load-recovery.spec.ts
new file mode 100644
index 00000000..a81dde47
--- /dev/null
+++ b/tests/ui/library-project-load-recovery.spec.ts
@@ -0,0 +1,93 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+for (const failure of ["result", "exception"] as const) {
+ test(`project library ${failure} failure shows retry instead of an empty library`, async ({
+ page,
+ }, testInfo) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(
+ page,
+ (mode) => {
+ window.electronAPI.getCurrentVideoPath = async () => ({ success: false });
+ window.electronAPI.listProjectFiles = async () => {
+ if (document.documentElement.dataset.libraryRecovered === "true")
+ return { success: true, entries: [] };
+ if (mode === "exception")
+ throw new Error("EACCES: projects folder inaccessible");
+ return {
+ success: false,
+ entries: [],
+ error: "EACCES: projects folder inaccessible",
+ };
+ };
+ },
+ failure,
+ );
+ await page.goto("/?windowType=editor");
+ const home = page.getByRole("dialog", { name: "Projects dashboard" });
+ const alert = home.getByRole("main").getByRole("alert");
+ await expect(alert).toContainText("Could not load projects.");
+ await expect(home.getByRole("heading", { name: "No projects yet" })).toHaveCount(0);
+ if (failure === "result")
+ await page.screenshot({
+ path: testInfo.outputPath("project-library-error.png"),
+ animations: "disabled",
+ });
+ await expect(
+ alert.getByText("EACCES: projects folder inaccessible", { exact: true }),
+ ).not.toBeVisible();
+ await alert.getByRole("button", { name: "Technical details", exact: true }).click();
+ await expect(
+ alert.getByText("EACCES: projects folder inaccessible", { exact: true }),
+ ).toBeVisible();
+ await page.evaluate(() => {
+ document.documentElement.dataset.libraryRecovered = "true";
+ });
+ await home.getByRole("button", { name: "Retry loading projects", exact: true }).click();
+ await expect(alert).toHaveCount(0);
+ await expect(home.getByRole("heading", { name: "No projects yet" })).toBeVisible();
+ });
+}
+
+test("project library refresh failure preserves loaded projects until retry succeeds", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.listProjectFiles = async () => {
+ if (document.documentElement.dataset.libraryUnavailable === "true")
+ return { success: false, entries: [], error: "Temporary disk failure" };
+ return {
+ success: true,
+ entries: [
+ {
+ path: "/projects/demo.recordly",
+ name: "Demo project",
+ updatedAt: Date.now(),
+ thumbnailPath: null,
+ isCurrent: false,
+ isInProjectsDirectory: true,
+ },
+ ],
+ };
+ };
+ });
+ await page.goto("/?windowType=editor");
+ await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible();
+ await page.evaluate(() => {
+ document.documentElement.dataset.libraryUnavailable = "true";
+ });
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ const home = page.getByRole("dialog", { name: "Projects dashboard" });
+ await expect(home.getByRole("main").getByRole("alert")).toContainText(
+ "Could not load projects.",
+ );
+ await expect(home.getByRole("button", { name: "Demo project", exact: true })).toBeVisible();
+ await page.evaluate(() => {
+ delete document.documentElement.dataset.libraryUnavailable;
+ });
+ await home.getByRole("button", { name: "Retry loading projects", exact: true }).click();
+ await expect(home.getByRole("main").getByRole("alert")).toHaveCount(0);
+ await expect(home.getByRole("button", { name: "Demo project", exact: true })).toBeVisible();
+});
diff --git a/tests/ui/linux-capture.spec.ts b/tests/ui/linux-capture.spec.ts
index 0e685e70..c05c233c 100644
--- a/tests/ui/linux-capture.spec.ts
+++ b/tests/ui/linux-capture.spec.ts
@@ -65,7 +65,9 @@ test("Linux lists screens and windows and selects the requested window", async (
test("a closed Linux window leaves the picker open and allows refresh", async ({ page }) => {
await openLinuxList(page, true);
await page.getByRole("button", { name: "Document", exact: true }).click();
- await expect(page.getByRole("alert")).toContainText("no longer available");
+ await expect(page.getByRole("alert")).toContainText(
+ "Could not select that source. Try again after recording stops.",
+ );
await expect(page.locator("html")).not.toHaveAttribute("data-closed");
await expect(page.locator("html")).not.toHaveAttribute("data-selected");
const before = Number(await page.locator("html").getAttribute("data-refreshes"));
diff --git a/tests/ui/loading-reveal.spec.ts b/tests/ui/loading-reveal.spec.ts
new file mode 100644
index 00000000..1a418e3a
--- /dev/null
+++ b/tests/ui/loading-reveal.spec.ts
@@ -0,0 +1,128 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+for (const reducedMotion of ["no-preference", "reduce"] as const) {
+ test(`editor reveals loaded content with ${reducedMotion} motion`, async ({ page }) => {
+ await page.emulateMedia({ reducedMotion });
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getCurrentVideoPath = async () => {
+ await new Promise((resolve) => {
+ window.addEventListener("test-release-loading", () => resolve(), {
+ once: true,
+ });
+ document.documentElement.dataset.loadingHeld = "true";
+ });
+ return { success: true, path: `${location.origin}/tests/ui/fixtures/preview.mp4` };
+ };
+ });
+ await page.goto("/?windowType=editor");
+ await expect(page.locator("html")).toHaveAttribute("data-loading-held", "true");
+ await expect(
+ page.getByRole("status", { name: "Loading editor", exact: true }),
+ ).toBeVisible();
+ // Pause the reveal at its midpoint to verify both layers, without relying on wall-clock timing.
+ await page.evaluate(() => {
+ const observer = new MutationObserver(() => {
+ const reveal = document.querySelector(
+ ".loading-transition > .loading-content-enter",
+ );
+ if (!reveal) return;
+ const animation = reveal.getAnimations()[0];
+ if (animation) {
+ animation.pause();
+ animation.currentTime = 90;
+ document.documentElement.dataset.revealOpacity =
+ getComputedStyle(reveal).opacity;
+ } else {
+ document.documentElement.dataset.revealOpacity =
+ getComputedStyle(reveal).opacity;
+ }
+ const placeholder = document.querySelector(
+ ".loading-placeholder[data-retiring=true]",
+ );
+ document.documentElement.dataset.retiringHidden = String(
+ placeholder?.getAttribute("aria-hidden") === "true" &&
+ placeholder.hasAttribute("inert"),
+ );
+ observer.disconnect();
+ });
+ observer.observe(document.getElementById("root")!, { subtree: true, childList: true });
+ window.dispatchEvent(new Event("test-release-loading"));
+ });
+ await expect(page.locator("html")).toHaveAttribute("data-retiring-hidden", "true");
+ const opacity = Number(await page.locator("html").getAttribute("data-reveal-opacity"));
+ if (reducedMotion === "reduce") expect(opacity).toBe(1);
+ else {
+ expect(opacity).toBeGreaterThan(0);
+ expect(opacity).toBeLessThan(1);
+ }
+ await expect(page.locator(".loading-placeholder")).toHaveCount(0);
+ await expect(page.locator(".editor-inspector")).toBeVisible();
+ await expect(page.getByRole("status", { name: "Loading editor", exact: true })).toHaveCount(
+ 0,
+ );
+ });
+}
+
+for (const width of [1440, 800]) {
+ test(`Home bootstrap, React skeleton and loaded frame align at ${width}px`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width, height: 900 });
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: true });
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getEditorStartupView = () => "home";
+ window.electronAPI.getCurrentUpdateToastPayload = async () => null;
+ window.electronAPI.loadCurrentProjectFile = async () => ({ success: false });
+ window.electronAPI.getCurrentRecordingSession = async () => ({
+ success: false,
+ session: null,
+ });
+ window.electronAPI.getCurrentVideoPath = async () => {
+ await new Promise((resolve) => {
+ window.addEventListener("test-release-home-frame", () => resolve(), {
+ once: true,
+ });
+ document.documentElement.dataset.homeFrameHeld = "true";
+ });
+ return { success: false, path: null };
+ };
+ });
+ await page.route("**/src/main.tsx*", (route) => route.abort());
+ await page.goto("/?windowType=editor&startupView=home");
+ const bootstrap = {
+ header: await page.locator(".boot-home .boot-header").boundingBox(),
+ sidebar: await page.locator(".boot-home-sidebar").boundingBox(),
+ panel: await page.locator(".boot-home-panel").boundingBox(),
+ };
+ await page.unroute("**/src/main.tsx*");
+ await page.reload();
+ await expect(page.locator("html")).toHaveAttribute("data-home-frame-held", "true");
+ const skeleton = {
+ header: await page.locator(".app-frame-header").boundingBox(),
+ sidebar: await page.locator("[data-loading-home-sidebar]").boundingBox(),
+ panel: await page.locator("[data-loading-home-panel]").boundingBox(),
+ };
+ await page.evaluate(() => window.dispatchEvent(new Event("test-release-home-frame")));
+ const dashboard = page.getByRole("dialog", { name: "Projects dashboard", exact: true });
+ await expect(dashboard).toBeVisible();
+ const live = {
+ header: await dashboard.locator(".app-frame-header").boundingBox(),
+ sidebar: await dashboard
+ .getByRole("complementary", { name: "Library navigation" })
+ .boundingBox(),
+ panel: await dashboard.locator(".app-frame-surface").boundingBox(),
+ };
+ for (const region of ["header", "sidebar", "panel"] as const) {
+ for (const dimension of ["x", "y", "width", "height"] as const) {
+ for (const placeholder of [bootstrap, skeleton]) {
+ expect(
+ Math.abs(placeholder[region]![dimension] - live[region]![dimension]),
+ `${region} ${dimension}`,
+ ).toBeLessThan(2);
+ }
+ }
+ }
+ });
+}
diff --git a/tests/ui/localization.spec.ts b/tests/ui/localization.spec.ts
new file mode 100644
index 00000000..564fa018
--- /dev/null
+++ b/tests/ui/localization.spec.ts
@@ -0,0 +1,197 @@
+import { readFileSync } from "node:fs";
+import { expect, test } from "@playwright/test";
+import { SUPPORTED_LOCALES } from "../../src/i18n/config";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+function catalog(locale: string, namespace: string) {
+ return JSON.parse(
+ readFileSync(
+ new URL(`../../src/i18n/locales/${locale}/${namespace}.json`, import.meta.url),
+ "utf8",
+ ),
+ );
+}
+const languageNames = [
+ "English",
+ "Español",
+ "Français",
+ "Deutsch",
+ "Italiano",
+ "Nederlands",
+ "한국어",
+ "日本語",
+ "Português (Brasil)",
+ "简体中文",
+ "繁體中文",
+];
+
+for (const [index, locale] of SUPPORTED_LOCALES.entries()) {
+ test(`language selection updates editor, settings, library and pricing in ${locale}`, async ({
+ page,
+ }, info) => {
+ test.setTimeout(90000);
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ const settings = catalog(locale, "settings");
+ const ui = catalog(locale, "ui");
+ const billing = catalog(locale, "billing");
+ const common = catalog(locale, "common");
+ const editor = catalog(locale, "editor");
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ let preferences = {
+ success: true,
+ showInDock: true,
+ hideWhileRecording: false,
+ launchAtLogin: false,
+ launchAtLoginSupported: true,
+ platform: "darwin",
+ };
+ window.electronAPI.getApplicationPreferences = async () => preferences;
+ window.electronAPI.setApplicationPreferences = async (patch) => {
+ preferences = { ...preferences, ...patch };
+ return preferences;
+ };
+ window.electronAPI.setAppLocale = async (locale) => {
+ document.documentElement.dataset.nativeLocale = locale;
+ return true;
+ };
+ });
+ await page.addInitScript(() => localStorage.setItem("recordly.locale", "en"));
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true })
+ .click();
+ // The selector itself remains stable while all surrounding text changes.
+ await page.locator(".settings-language-trigger").click();
+ const languageMenu = page.getByRole("listbox");
+ await expect(languageMenu.getByRole("option")).toHaveCount(SUPPORTED_LOCALES.length);
+ for (const name of languageNames) {
+ await expect(
+ languageMenu
+ .getByRole("option", { name, exact: true })
+ .locator("svg[data-country]"),
+ ).toHaveAttribute("aria-hidden", "true");
+ }
+ if (locale === "en" || locale === "ja") {
+ await languageMenu.screenshot({ path: info.outputPath(`languages-${locale}.png`) });
+ }
+ await page.getByRole("option", { name: languageNames[index], exact: true }).click();
+ await expect(page.locator(".settings-language-trigger svg[data-country]")).toBeVisible();
+ await expect(page.locator(".settings-language-trigger")).toContainText(
+ languageNames[index],
+ );
+ await expect(page.locator("html")).toHaveAttribute("lang", locale);
+ await expect(page.locator("html")).toHaveAttribute("data-native-locale", locale);
+ expect(
+ await page.evaluate(async () => {
+ const { translate } = await import("/src/contexts/I18nContext.tsx");
+ return translate("common.actions.cancel");
+ }),
+ ).toBe(common.actions.cancel);
+ expect(await page.evaluate(() => localStorage.getItem("recordly.locale"))).toBe(locale);
+ await expect(page.getByText(common.app.language, { exact: true }).first()).toBeVisible();
+ await expect(
+ page.getByText(settings.appPreferences.languageDescription, { exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("tab", { name: settings.categories.general, exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("switch", { name: settings.application.showDock, exact: true }),
+ ).toBeVisible();
+ const settingsDialog = page.getByRole("dialog", {
+ name: settings.sections.settings,
+ exact: true,
+ });
+ await settingsDialog.screenshot({ path: info.outputPath(`settings-${locale}.png`) });
+ const selectedLanguage = page.locator(".settings-language-trigger .truncate");
+ expect(
+ await selectedLanguage.evaluate(
+ (element) => element.scrollWidth <= element.clientWidth,
+ ),
+ ).toBe(true);
+ const closeSettings = settingsDialog.locator('[data-slot="modal-close-trigger"]');
+ await expect(closeSettings).toHaveAccessibleName(common.actions.close);
+ await closeSettings.click();
+ await expect(
+ page.getByRole("button", { name: editor.project.renameTitle, exact: true }),
+ ).toHaveText("Untitled Project");
+ await page.getByRole("button", { name: ui.home, exact: true }).click();
+ const library = page.getByRole("dialog", { name: ui.projectsDashboard, exact: true });
+ await expect(library).toBeVisible();
+ await expect(
+ library.getByRole("complementary", { name: ui.libraryNavigation }),
+ ).toBeVisible();
+ await expect(
+ library.getByRole("button", { name: billing.upgrade, exact: true }),
+ ).toBeVisible();
+ await library.getByRole("button", { name: billing.upgrade, exact: true }).click();
+ const pricing = page.getByRole("dialog", { name: "Recordly Cloud", exact: true });
+ await expect(pricing).toBeVisible();
+ await expect(
+ pricing.getByRole("heading", { name: billing.heading, exact: true }),
+ ).toBeVisible();
+ await expect(pricing.getByText(billing.intro, { exact: true })).toBeVisible();
+ await expect(pricing).not.toContainText(/\b(?:ui|billing|settings)\.[a-zA-Z]/);
+ await page.setViewportSize({ width: 440, height: 820 });
+ await pricing.screenshot({
+ path: info.outputPath(`pricing-${locale}.png`),
+ animations: "disabled",
+ });
+ const overflow = await pricing.evaluate((element) => ({
+ width: element.clientWidth,
+ scroll: element.scrollWidth,
+ nodes: [...element.querySelectorAll("*")]
+ .filter((node) => {
+ const rect = node.getBoundingClientRect();
+ return (
+ rect.right > element.getBoundingClientRect().right + 2 &&
+ getComputedStyle(node).position !== "absolute"
+ );
+ })
+ .slice(0, 12)
+ .map((node) => ({
+ tag: node.tagName,
+ class: node.className,
+ text: node.textContent?.slice(0, 70),
+ width: node.clientWidth,
+ scroll: node.scrollWidth,
+ })),
+ }));
+ expect(overflow.scroll, JSON.stringify(overflow)).toBeLessThanOrEqual(overflow.width);
+ });
+}
+
+test("language changes synchronize between windows and survive reload", async ({
+ page,
+ context,
+}) => {
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ const other = await context.newPage();
+ await installDesktopBridge(other);
+ await other.goto("/?windowType=editor");
+ await expect(page.locator("html")).toHaveAttribute("lang", "en");
+ await other.evaluate(() => localStorage.setItem("recordly.locale", "zh-TW"));
+ await expect(page.locator("html")).toHaveAttribute("lang", "zh-TW");
+ await page.reload();
+ await expect(page.locator("html")).toHaveAttribute("lang", "zh-TW");
+ await other.evaluate(() => localStorage.removeItem("recordly.locale"));
+ await expect(page.locator("html")).toHaveAttribute("lang", "en");
+});
+
+test("a fresh renderer origin restores the native saved app language", async ({ page }) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ const getSetting = window.electronAPI.getAppSetting;
+ window.electronAPI.getAppSetting = (key) =>
+ key === "recordly.locale" ? "zh-TW" : getSetting(key);
+ });
+ await page.goto("/?windowType=editor");
+ await expect(page.locator("html")).toHaveAttribute("lang", "zh-TW");
+ await expect(
+ page.getByRole("button", { name: catalog("zh-TW", "ui").home, exact: true }),
+ ).toBeVisible();
+});
diff --git a/tests/ui/login.spec.ts b/tests/ui/login.spec.ts
index b8a40865..f3519737 100644
--- a/tests/ui/login.spec.ts
+++ b/tests/ui/login.spec.ts
@@ -1,15 +1,29 @@
-import { expect, test } from "@playwright/test";
-import { installDesktopBridge } from "./bridge";
+import { expect, type Page } from "@playwright/test";
+import { productionTest as test } from "./production";
import { completeSupabaseSignIn } from "./auth";
+import { installDesktopBridge } from "./bridge";
-test("sign-in offers only providers enabled in Supabase", async ({ page }) => {
- await installDesktopBridge(page, "preview.mp4", { signedIn: false });
+async function captureSignInReports(page: Page) {
+ const reports: { error: string; logs: string }[] = [];
+ await page.route("https://auth.recordly.test/functions/v1/submit-export-error", async (route) => {
+ reports.push(route.request().postDataJSON());
+ await route.fulfill({ json: { success: true } });
+ });
+ return reports;
+}
+
+test("sign-in keeps all providers visible and marks unavailable providers disabled", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
await page.route("https://auth.recordly.test/auth/v1/settings", (route) =>
route.fulfill({
- json: { external: { google: true, azure: false, github: false, email: true } },
+ json: { external: { google: true, azure: false, apple: false, email: true } },
}),
);
- await page.goto("/?windowType=editor");
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await page.setViewportSize({ width: 600, height: 600 });
+ await page.goto("/?windowType=onboarding");
const login = page.getByRole("dialog", { name: "Welcome to Recordly" });
await expect(login).toBeVisible({ timeout: 20000 });
await expect(
@@ -17,80 +31,161 @@ test("sign-in offers only providers enabled in Supabase", async ({ page }) => {
).toBeVisible();
await expect(
login.getByRole("button", { name: "Continue with Microsoft", exact: true }),
- ).toHaveCount(0);
+ ).toBeDisabled();
await expect(
- login.getByRole("button", { name: "Continue with GitHub", exact: true }),
- ).toHaveCount(0);
+ login.getByRole("button", { name: "Continue with Apple", exact: true }),
+ ).toBeDisabled();
+ await expect(
+ login.getByRole("button", { name: "Continue with Apple", exact: true }),
+ ).toHaveAccessibleDescription("Apple sign-in is not enabled yet");
+ await expect(login.getByRole("button", { name: "Continue with GitHub", exact: true })).toHaveCount(0);
const email = login.getByRole("textbox", { name: /^Email\*?$/ });
- const magicLink = login.getByRole("button", { name: "Email me a magic link" });
- await expect(magicLink).toHaveCount(0);
+ const emailCode = login.getByRole("button", { name: "Email me a code" });
+ await expect(login.locator("[data-signin-divider]")).toHaveText("or");
+ const passwordSwitch = login.getByRole("button", {
+ name: "Or use password to sign in",
+ exact: true,
+ });
+ await expect(passwordSwitch).toHaveCount(1);
+ const emailLabel = login.locator('[data-signin-form] label').filter({ hasText: /^Email/ });
+ const labelBounds = (await emailLabel.boundingBox())!;
+ const linkBounds = (await passwordSwitch.boundingBox())!;
+ expect(
+ Math.abs(linkBounds.y + linkBounds.height / 2 - (labelBounds.y + labelBounds.height / 2)),
+ ).toBeLessThan(3);
+ expect(linkBounds.x).toBeGreaterThan(labelBounds.x + labelBounds.width);
+ await expect(passwordSwitch).toHaveCSS("text-decoration-line", "none");
+ await passwordSwitch.hover();
+ await expect(passwordSwitch).toHaveCSS("text-decoration-line", "underline");
+ let codeRequests = 0;
+ page.on("request", (request) => {
+ if (request.url().includes("/auth/v1/otp")) codeRequests++;
+ });
+ await expect(emailCode).toBeVisible();
+ await expect(emailCode).toBeEnabled();
+ await emailCode.click();
+ await expect(email).toHaveAttribute("aria-invalid", "true");
+ expect(codeRequests).toBe(0);
await email.fill("owner@example");
- await expect(magicLink).toHaveCount(0);
+ await expect(emailCode).toBeEnabled();
+ await emailCode.click();
+ await expect(email).toHaveAttribute("aria-invalid", "true");
+ expect(codeRequests).toBe(0);
await email.fill("owner@example.test");
- await expect(magicLink).toBeEnabled();
+ await expect(emailCode).toBeEnabled();
await email.clear();
- await expect(magicLink).toHaveCount(0);
+ await expect(emailCode).toBeVisible();
+ await expect(emailCode).toBeEnabled();
await page.screenshot({
path: "test-results/onboarding-enabled-providers.png",
animations: "disabled",
});
});
-test("email sign-in reports Supabase errors and allows retry without granting access", async ({
+test("email sign-in reports localized failures and allows retry without granting access", async ({
page,
}) => {
- await installDesktopBridge(page, "preview.mp4", { signedIn: false });
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ const reports = await captureSignInReports(page);
let attempts = 0;
await page.route("https://auth.recordly.test/auth/v1/otp**", async (route) => {
attempts++;
if (attempts === 1)
return route.fulfill({
status: 429,
- json: { msg: "Please wait before requesting another email." },
+ json: {
+ msg: "Please wait before requesting another email.",
+ error_code: "over_email_send_rate_limit",
+ },
});
return route.fulfill({ json: {} });
});
await page.goto("/?windowType=editor");
const login = page.getByRole("dialog", { name: "Welcome to Recordly" });
await login.getByRole("textbox", { name: /^Email\*?$/ }).fill("owner@example.test");
- await login.getByRole("button", { name: "Email me a magic link" }).click();
- await expect(login.getByText("Please wait before requesting another email.")).toBeVisible();
+ const rejectedCode = page.waitForResponse(
+ (response) => response.url().includes("/auth/v1/otp") && response.status() === 429,
+ );
+ await login.getByRole("button", { name: "Email me a code" }).click();
+ await rejectedCode;
+ await expect(login.getByRole("alert")).toContainText(
+ "Sign-in failed. Check your connection and try again.",
+ );
+ await expect(login).not.toContainText("Please wait before requesting another email.");
await expect(login).toHaveAttribute("data-onboarding-state", "login");
- await login.getByRole("button", { name: "Email me a magic link" }).click();
- await expect(login.getByText(/Check your inbox/)).toBeVisible();
+ await login.getByRole("button", { name: "Report error", exact: true }).click();
+ await expect(login.getByRole("button", { name: "Report sent", exact: true })).toBeVisible();
+ expect(reports).toHaveLength(1);
+ expect(reports[0].error).toContain("[auth] sign-in-code: failed");
+ expect(reports[0].error).toContain("Please wait before requesting another email.");
+ expect(reports[0].error).toContain("over_email_send_rate_limit");
+ expect(reports[0].error).toContain("status: 429");
+ expect(reports[0].logs).toContain("[auth] sign-in-code: failed");
+ expect(JSON.stringify(reports)).not.toContain("owner@example.test");
+ await login.getByRole("button", { name: "Email me a code" }).click();
+ await expect(login.getByText(/^Enter code sent to /)).toBeVisible();
await expect(login).toHaveAttribute("data-onboarding-state", "login");
expect(attempts).toBe(2);
});
test("a rejected Supabase callback keeps sign-in mandatory", async ({ page }) => {
- await installDesktopBridge(page, "preview.mp4", { signedIn: false });
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ const reports = await captureSignInReports(page);
await page.route("https://auth.recordly.test/auth/v1/token**", (route) =>
route.fulfill({
status: 400,
- json: { error: "invalid_grant", error_description: "Sign-in link has expired" },
+ json: {
+ error: "invalid_grant",
+ error_code: "flow_state_expired",
+ error_description:
+ "Sign-in link has expired for person@example.test code_verifier=private-verifier recordly://auth/callback?code=private-callback",
+ },
}),
);
await page.goto("/?windowType=editor");
const login = page.getByRole("dialog", { name: "Welcome to Recordly" });
await login.getByRole("textbox", { name: /^Email\*?$/ }).fill("owner@example.test");
- await login.getByRole("button", { name: "Email me a magic link" }).click();
- await expect(login.getByText(/Check your inbox/)).toBeVisible();
+ await login.getByRole("button", { name: "Email me a code" }).click();
+ await expect(login.getByText(/^Enter code sent to /)).toBeVisible();
await completeSupabaseSignIn(page);
- await expect(login.getByText("Sign-in link has expired")).toBeVisible();
+ const failure = "Sign-in failed. Check your connection and try again.";
+ await expect(login.getByRole("alert")).toContainText(failure);
+ await expect(login).not.toContainText("Sign-in link has expired");
+ await login.getByRole("button", { name: "Report error", exact: true }).click();
+ await expect(login.getByRole("button", { name: "Report sent", exact: true })).toBeVisible();
+ expect(reports).toHaveLength(1);
+ expect(reports[0].error).toContain("callback-delivery");
+ expect(reports[0].error).toContain("AuthApiError");
+ expect(reports[0].error).toContain("flow_state_expired");
+ expect(reports[0].error).toContain("status: 400");
+ expect(reports[0].logs).toContain("[auth] callback-token-exchange: failed");
+ for (const secret of ["person@example.test", "private-verifier", "private-callback"])
+ expect(JSON.stringify(reports)).not.toContain(secret);
await expect(login).toHaveAttribute("data-onboarding-state", "login");
await expect(login.getByRole("button", { name: "Continue", exact: true })).toHaveCount(0);
- await login.getByRole("button", { name: "Email me a magic link" }).click();
- await expect(login.getByText(/Check your inbox/)).toBeVisible();
- await expect(login.getByText("Sign-in link has expired")).toHaveCount(0);
+ await expect(
+ login.getByRole("navigation", { name: "Onboarding pages" })
+ .getByRole("button", { name: "Permissions", exact: true }),
+ ).toBeDisabled();
+ await page.keyboard.press("Escape");
+ await expect(login).toBeVisible();
+ await login.getByRole("button", { name: "Use a different email", exact: true }).click();
+ await expect(login.getByRole("textbox", { name: /^Email\*?$/ })).toHaveValue("owner@example.test");
+ await login.getByRole("button", { name: "Email me a code" }).click();
+ await expect(
+ login.getByRole("textbox", { name: "Enter code sent to owner@example.test", exact: true }),
+ ).toBeVisible();
+ await expect(login.getByText(failure, { exact: true })).toHaveCount(0);
+ await expect(login).toHaveAttribute("data-onboarding-state", "login");
});
for (const [label, provider] of [
["Google", "google"],
["Microsoft", "azure"],
- ["GitHub", "github"],
+ ["Apple", "apple"],
]) {
test(`${label} sign-in opens Supabase OAuth in the system browser`, async ({ page }) => {
- await installDesktopBridge(page, "preview.mp4", { signedIn: false });
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
await page.addInitScript(() => {
window.electronAPI.openExternalUrl = async (url) => {
document.documentElement.dataset.authUrl = url;
@@ -104,7 +199,7 @@ for (const [label, provider] of [
const url = new URL((await page.locator("html").getAttribute("data-auth-url"))!);
expect(url.origin).toBe("https://auth.recordly.test");
expect(url.searchParams.get("provider")).toBe(provider);
- expect(url.searchParams.get("redirect_to")).toBe("http://127.0.0.1:43821/auth/callback");
+ expect(url.searchParams.get("redirect_to")).toBe("recordly://auth/callback");
expect(url.searchParams.get("code_challenge")).toBeTruthy();
await expect(login).toHaveAttribute("data-onboarding-state", "login");
});
@@ -116,36 +211,88 @@ test("restored Supabase sessions show the account and sign-out requires authenti
await installDesktopBridge(page);
await page.goto("/?windowType=editor");
await page.getByRole("button", { name: "Recordly account", exact: true }).click();
- const account = page.getByRole("dialog", { name: "Your account", exact: true });
- await expect(account.getByText("test@email.com")).toBeVisible();
- await expect(account.locator('img[src$="login-banner.png"]')).toBeVisible();
+ const account = page.getByRole("dialog", { name: "Account menu", exact: true });
+ await expect(account.getByText("Test User", { exact: true })).toBeVisible();
+ await expect(account.getByRole("button", { name: "Manage account", exact: true })).toBeVisible();
+ await expect(account.locator('img[src$="login-banner.png"]')).toHaveCount(0);
const logout = page.waitForRequest((request) => request.url().includes("/auth/v1/logout"));
- await account.getByRole("button", { name: "Sign out", exact: true }).click();
+ await account.getByRole("button", { name: "Log out", exact: true }).click();
await logout;
- const login = page.locator('[data-onboarding-state="login"]');
+ const login = page.getByRole("dialog", { name: "Sign in to Recordly", exact: true });
await expect(login).toBeVisible();
await page.reload();
await expect(login).toBeVisible();
await expect(login.getByRole("button", { name: "Continue", exact: true })).toHaveCount(0);
});
-test("sign-in banner fills spare space and keeps the email action usable in a short window", async ({
+test("returning users sign in through a compact popup with the requested copy", async ({
page,
}) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: true });
+ await page.goto("/?windowType=editor");
+ const login = page.getByRole("dialog", { name: "Sign in to Recordly", exact: true });
+ await expect(login).toBeVisible({ timeout: 20000 });
+ await expect(
+ login.getByRole("heading", {
+ name: "Sign into Recordly",
+ exact: true,
+ }),
+ ).toBeVisible();
+ await page.screenshot({ path: "test-results/signin-polished.png", animations: "disabled" });
+ await expect(login.getByRole("heading")).toHaveCount(1);
+ await expect(login.getByText("Sign in to get started", { exact: true })).toHaveCount(0);
+ await expect(login.getByRole("navigation", { name: "Onboarding pages" })).toHaveCount(0);
+ await page.keyboard.press("Escape");
+ await expect(login).toBeVisible();
+ await login.getByRole("textbox", { name: /^Email\*?$/ }).fill("test@email.com");
+ await login.getByRole("button", { name: "Email me a code" }).click();
+ await expect(login.getByText(/^Enter code sent to /)).toBeVisible();
+ await completeSupabaseSignIn(page);
+ await expect(login).not.toBeVisible();
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await expect(page.getByRole("dialog", { name: "Account menu", exact: true })).toBeVisible();
+});
+
+test("editor and onboarding share one exchange for a broadcast sign-in callback", async ({
+ page,
+ context,
+}) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await page.goto("/?windowType=editor");
+ const onboarding = await context.newPage();
+ await installDesktopBridge(onboarding, "preview.mp4", { signedIn: false, onboarded: false });
+ await onboarding.goto("/?windowType=onboarding");
+ let exchanges = 0;
+ context.on("request", (request) => {
+ if (request.url().includes("/auth/v1/token") && request.method() === "POST") exchanges++;
+ });
+ const login = onboarding.getByRole("dialog", { name: "Welcome to Recordly" });
+ await login.getByRole("textbox", { name: /^Email\*?$/ }).fill("test@email.com");
+ await login.getByRole("button", { name: "Email me a code" }).click();
+ await expect(login.getByText(/^Enter code sent to /)).toBeVisible();
+ await Promise.all([completeSupabaseSignIn(page), completeSupabaseSignIn(onboarding)]);
+ for (const window of [page, onboarding]) {
+ const tour = window.getByRole("dialog", { name: "Welcome to Recordly" });
+ await expect(tour.getByRole("heading", { name: "Allow Recordly to record" })).toBeVisible();
+ await expect(tour.getByRole("alert")).toHaveCount(0);
+ }
+ expect(exchanges).toBe(1);
+ await onboarding.close();
+});
+
+test("sign-in keeps its banner and email action usable in a short window", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
- await installDesktopBridge(page, "preview.mp4", { signedIn: false });
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
await page.goto("/?windowType=editor");
const login = page.getByRole("dialog", { name: "Welcome to Recordly" });
const banner = login.locator("[data-onboarding-artwork]");
- const submit = login.getByRole("button", { name: "Email me a magic link" });
+ const submit = login.getByRole("button", { name: "Email me a code" });
const pages = login.getByRole("navigation", { name: "Onboarding pages" });
await login.getByRole("textbox", { name: /^Email\*?$/ }).fill("owner@example.test");
await expect(submit).toBeVisible();
const tallBanner = (await banner.boundingBox())!;
expect(tallBanner.height).toBeGreaterThan(128);
- const submitBounds = (await submit.boundingBox())!;
- const navigationBounds = (await pages.boundingBox())!;
- expect(navigationBounds.y - submitBounds.y - submitBounds.height).toBeLessThanOrEqual(48);
+ await expect(pages).toBeInViewport();
await page.screenshot({
path: "test-results/onboarding-responsive-banner.png",
animations: "disabled",
@@ -159,6 +306,121 @@ test("sign-in banner fills spare space and keeps the email action usable in a sh
await submit.scrollIntoViewIfNeeded();
await expect(submit).toBeInViewport();
await submit.click();
- await expect(login.getByText(/Check your inbox/)).toBeVisible();
+ await expect(login.getByText(/^Enter code sent to /)).toBeVisible();
await page.screenshot({ path: "test-results/required-sign-in.png", animations: "disabled" });
});
+
+test("signup keeps the legal notice without promotional controls", async ({ page }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await page.goto("/?windowType=onboarding");
+ const login = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await expect(login).toBeVisible({ timeout: 20000 });
+ await expect(login.getByText(/By continuing, you agree/)).toBeVisible();
+ await expect(login.getByText(/By continuing, you agree/)).toHaveCount(1);
+ await expect(login.getByRole("navigation", { name: "Legal and support" })).toHaveCount(0);
+ await expect(login.getByRole("link", { name: "Terms", exact: true })).toHaveCount(1);
+ await expect(login.getByRole("link", { name: "Privacy Policy", exact: true })).toHaveCount(1);
+ expect(
+ await login.evaluate((element) => {
+ const email = element.querySelector('input[type="email"]');
+ const notice = element.querySelector(".pricing-legal-consent");
+ return Boolean(
+ email &&
+ notice &&
+ email.compareDocumentPosition(notice) & Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+ }),
+ ).toBe(true);
+ await expect(login.getByRole("checkbox")).toHaveCount(0);
+ await login.getByRole("textbox", { name: /^Email\*?$/ }).fill("new@example.test");
+ const request = page.waitForRequest(
+ (request) => request.url().includes("/auth/v1/otp") && request.method() === "POST",
+ );
+ await login.getByRole("button", { name: "Email me a code" }).click();
+ const consent = (await request).postDataJSON().data.signup_consent;
+ expect(consent).toMatchObject({
+ legal_accepted: true,
+ acceptance_method: "continue-with-notice",
+ version: "2026-10-29.1",
+ });
+ expect(consent).not.toHaveProperty("marketing_emails");
+ expect(consent).not.toHaveProperty("marketing_wording");
+ await expect(login.getByText(/^Enter code sent to /)).toBeVisible();
+ await page.screenshot({ path: "test-results/signup-legal-only.png", animations: "disabled" });
+});
+
+for (const fails of [false, true]) {
+ test(`account email preference ${fails ? "reports a save error without changing the choice" : "saves opt-in and opt-out"}`, async ({
+ page,
+ }) => {
+ await installDesktopBridge(page);
+ if (fails)
+ await page.route("https://auth.recordly.test/auth/v1/user", (route) => {
+ if (route.request().method() === "PUT")
+ return route.fulfill({
+ status: 400,
+ json: { msg: "Could not save email preference." },
+ });
+ return route.fallback();
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Manage account" })
+ .click();
+ const settings = page.getByRole("dialog", { name: "Account settings", exact: true });
+ const checkbox = settings.getByRole("checkbox", { name: /Email me the latest updates/ });
+ await expect(checkbox).not.toBeChecked();
+ const request = page.waitForRequest(
+ (request) => request.url().endsWith("/auth/v1/user") && request.method() === "PUT",
+ );
+ await checkbox.press("Space");
+ expect((await request).postDataJSON().data.email_preferences).toMatchObject({
+ marketing_emails: true,
+ source: "account-settings",
+ wording: "Email me the latest updates, deals and offers from Recordly.",
+ });
+ if (fails) {
+ const feedback = settings
+ .getByRole("region", { name: "Email preferences", exact: true })
+ .getByRole("alert");
+ await expect(feedback).toContainText(
+ "Could not save this account change. Check your details and connection, then try again.",
+ );
+ await expect(feedback.getByRole("button", { name: "Report error", exact: true })).toBeVisible();
+ await feedback.locator("summary").click();
+ await expect(feedback.locator("details")).toHaveAttribute("open", "");
+ await expect(feedback.locator("details")).toContainText("Could not save email preference.");
+ await expect(checkbox).not.toBeChecked();
+ } else {
+ await expect(
+ settings.getByText("Email preferences saved.", { exact: true }),
+ ).toBeVisible();
+ await expect(checkbox).toBeChecked();
+ await page.screenshot({
+ path: "test-results/account-email-preference.png",
+ animations: "disabled",
+ });
+ await settings.getByRole("button", { name: "Close", exact: true }).click();
+ await expect(settings).not.toBeVisible();
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Manage account" })
+ .click();
+ await expect(checkbox).toBeChecked();
+ const optOut = page.waitForRequest(
+ (request) => request.url().endsWith("/auth/v1/user") && request.method() === "PUT",
+ );
+ await checkbox.press("Space");
+ expect((await optOut).postDataJSON().data.email_preferences.marketing_emails).toBe(
+ false,
+ );
+ await expect(checkbox).not.toBeChecked();
+ await expect(
+ settings.getByText("Email preferences saved.", { exact: true }),
+ ).toBeVisible();
+ }
+ });
+}
diff --git a/tests/ui/onboarding-dev.spec.ts b/tests/ui/onboarding-dev.spec.ts
new file mode 100644
index 00000000..631729da
--- /dev/null
+++ b/tests/ui/onboarding-dev.spec.ts
@@ -0,0 +1,170 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+import { productionTest } from "./production";
+
+for (const signedIn of [false, true]) {
+ test(`skipping dev onboarding keeps the HUD open (${signedIn ? "signed in" : "signed out"})`, async ({ page }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn, onboarded: false });
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getScreenRecordingPermissionStatus = async () => ({ success: true, status: "denied" });
+ window.electronAPI.getAccessibilityPermissionStatus = async () => ({ success: true, trusted: false });
+ window.electronAPI.showRecordingHud = async () => {
+ window.location.assign("/?windowType=hud-overlay");
+ };
+ });
+ await page.goto("/?windowType=onboarding");
+ await page.getByRole("button", { name: "Skip onboarding", exact: true }).click();
+ await expect(page.getByRole("group", { name: "Recording setup", exact: true })).toBeVisible();
+ // Allow startup auth and permission checks to settle before asserting no redirect.
+ await page.waitForTimeout(500);
+ await expect(page.locator("html")).not.toHaveAttribute("data-permissions-opened");
+ // Skipping the tour does not allow an actual recording without the required access.
+ await page.getByRole("button", { name: "Record", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-permissions-opened", "true");
+ });
+
+ productionTest(`production HUD still checks access on startup (${signedIn ? "signed in" : "signed out"})`, async ({ page }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn, onboarded: true });
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getScreenRecordingPermissionStatus = async () => ({ success: true, status: "denied" });
+ });
+ await page.goto("/?windowType=hud-overlay");
+ await expect(page.locator("html")).toHaveAttribute("data-permissions-opened", "true");
+ });
+}
+
+for (const signedIn of [false, true]) {
+ for (const query of ["", "&onboardingPreview=0"]) {
+ test(`dev onboarding advances without an explicit preview flag (${signedIn ? "signed in" : "signed out"}, ${query || "no query"})`, async ({
+ page,
+ }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn, onboarded: false });
+ await page.goto(`/?windowType=onboarding${query}`);
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await expect(tour.getByPlaceholder("you@example.com")).toBeVisible({ timeout: 30000 });
+ await tour.getByRole("button", { name: "Continue", exact: true }).click();
+ await expect(
+ tour.getByRole("heading", { name: "Allow Recordly to record" }),
+ ).toBeVisible();
+ await tour.getByRole("button", { name: "Back to account" }).click();
+ await expect(tour.getByPlaceholder("you@example.com")).toBeVisible();
+ await tour.getByRole("button", { name: "Permissions", exact: true }).click();
+ await tour.getByRole("button", { name: "Start recording", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-hud-opened", "true");
+ await expect(page.locator("html")).toHaveAttribute("data-onboarding-closed", "true");
+ });
+ }
+}
+
+test("automatic first-run onboarding uses preview without writing completion", async ({ page }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.showOnboardingWindow = async (options) => {
+ document.documentElement.dataset.previewRequested = String(options?.preview);
+ return false;
+ };
+ const setSetting = window.electronAPI.setAppSetting;
+ window.electronAPI.setAppSetting = (key, value) => {
+ if (key.startsWith("recordly.onboarding."))
+ document.documentElement.dataset.onboardingWritten = "true";
+ return setSetting(key, value);
+ };
+ });
+ await page.goto("/?windowType=editor");
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await expect(tour).toBeVisible({ timeout: 30000 });
+ await expect(page.locator("html")).toHaveAttribute("data-preview-requested", "true");
+ await tour.getByRole("button", { name: "Continue", exact: true }).click();
+ await tour.getByRole("button", { name: "Start recording", exact: true }).click();
+ await expect(tour).not.toBeVisible();
+ await expect(page.locator("html")).not.toHaveAttribute("data-onboarding-written");
+});
+
+for (const query of ["", "&onboardingPreview=1"]) {
+ productionTest(
+ `production onboarding requires sign-in (${query || "no query"})`,
+ async ({ page }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await page.goto(`/?windowType=onboarding${query}`);
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await expect(tour.getByPlaceholder("you@example.com")).toBeVisible({ timeout: 30000 });
+ await expect(tour.getByRole("button", { name: "Skip onboarding" })).toHaveCount(0);
+ await expect(tour.getByRole("button", { name: "Continue", exact: true })).toHaveCount(
+ 0,
+ );
+ await expect(
+ tour.getByRole("button", { name: "Permissions", exact: true }),
+ ).toBeDisabled();
+ await expect(
+ tour.getByRole("button", { name: "Start recording", exact: true }),
+ ).toHaveCount(0);
+ },
+ );
+}
+
+for (const windowType of ["onboarding", "editor"]) {
+ for (const stage of ["account", "permissions"]) {
+ test(`dev can skip ${stage} onboarding from ${windowType} without permission grants or completion writes`, async ({ page }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.showOnboardingWindow = async () => false;
+ window.electronAPI.getScreenRecordingPermissionStatus = async () => ({ success: true, status: "denied" });
+ window.electronAPI.getAccessibilityPermissionStatus = async () => ({ success: true, trusted: false });
+ const setSetting = window.electronAPI.setAppSetting;
+ window.electronAPI.setAppSetting = (key, value) => {
+ if (key.startsWith("recordly.onboarding."))
+ document.documentElement.dataset.onboardingWritten = "true";
+ return setSetting(key, value);
+ };
+ });
+ await page.goto(`/?windowType=${windowType}`);
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ if (stage === "permissions") {
+ await tour.getByRole("button", { name: "Continue", exact: true }).click();
+ await expect(tour.getByRole("button", { name: "Start recording", exact: true })).toBeDisabled();
+ }
+ await tour.getByRole("button", { name: "Skip onboarding", exact: true }).click();
+ await expect(tour).not.toBeVisible();
+ await expect(page.locator("html")).toHaveAttribute("data-hud-opened", "true");
+ await expect(page.locator("html")).toHaveAttribute("data-onboarding-closed", "true");
+ await expect(page.locator("html")).not.toHaveAttribute("data-onboarding-written");
+ });
+ }
+}
+
+test("a dev editor with completed onboarding does not reopen mandatory sign-in", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: true });
+ await page.goto("/?windowType=editor");
+ await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible({
+ timeout: 30000,
+ });
+ await expect(
+ page.getByRole("dialog", { name: "Sign in to Recordly", exact: true }),
+ ).not.toBeVisible();
+});
+
+test("signed-out dev onboarding still waits for real recording permissions", async ({ page }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await installDesktopBridgeOverrides(page, () => {
+ let allowed = false;
+ window.electronAPI.getScreenRecordingPermissionStatus = async () => ({
+ success: true,
+ status: allowed ? "granted" : "denied",
+ });
+ window.electronAPI.openScreenRecordingPreferences = async () => {
+ allowed = true;
+ return { success: true };
+ };
+ });
+ await page.goto("/?windowType=onboarding");
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await tour.getByRole("button", { name: "Continue", exact: true }).click();
+ const start = tour.getByRole("button", { name: "Start recording", exact: true });
+ await expect(start).toBeDisabled();
+ await tour.getByRole("button", { name: "Allow Screen Recording", exact: true }).click();
+ await expect(start).toBeEnabled();
+ await start.click();
+ await expect(page.locator("html")).toHaveAttribute("data-hud-opened", "true");
+});
diff --git a/tests/ui/onboarding-locales.spec.ts b/tests/ui/onboarding-locales.spec.ts
new file mode 100644
index 00000000..0f40b183
--- /dev/null
+++ b/tests/ui/onboarding-locales.spec.ts
@@ -0,0 +1,90 @@
+import { readFileSync } from "node:fs";
+import { expect, test } from "@playwright/test";
+import { SUPPORTED_LOCALES } from "../../src/i18n/config";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+for (const locale of SUPPORTED_LOCALES) {
+ const messages = JSON.parse(
+ readFileSync(
+ new URL(`../../src/i18n/locales/${locale}/onboarding.json`, import.meta.url),
+ "utf8",
+ ),
+ ) as Record;
+
+ test(`onboarding and permission recovery use ${locale}`, async ({ page }, testInfo) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false });
+ await page.addInitScript(
+ (language) => localStorage.setItem("recordly.locale", language),
+ locale,
+ );
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getScreenRecordingPermissionStatus = async () => ({
+ success: true,
+ status: "denied",
+ });
+ window.electronAPI.openScreenRecordingPreferences = async () => ({
+ success: false,
+ error: "Untranslated native error",
+ });
+ });
+ await page.goto("/?windowType=onboarding&onboardingPreview=1");
+ const dialog = page.getByRole("dialog", { name: messages.welcomeLabel });
+ await expect(dialog).toBeVisible();
+ await expect(page.locator("html")).toHaveAttribute("lang", locale);
+ await expect(dialog.getByRole("heading")).toHaveText(
+ messages.welcomeTitle.replace("{{brand}}", "Recordly"),
+ );
+ await expect(dialog.locator(".onboarding-header .description")).toHaveCount(0);
+ await expect(
+ dialog
+ .getByRole("button", { name: messages.muteMusic, exact: true })
+ .or(dialog.getByRole("button", { name: messages.unmuteMusic, exact: true })),
+ ).toBeVisible();
+ await expect(dialog.getByLabel(messages.email, { exact: true })).toBeVisible();
+ await expect(
+ dialog.getByRole("button", {
+ name: messages.continueWith.replace("{{provider}}", "Google"),
+ }),
+ ).toBeVisible();
+ await dialog.getByLabel(messages.email, { exact: true }).fill("locale@example.com");
+ await dialog.getByRole("button", { name: messages.sendLink, exact: true }).click();
+ await expect(
+ dialog.getByText(messages.checkInbox.replace("{{email}}", "locale@example.com"), {
+ exact: true,
+ }),
+ ).toBeVisible();
+ await dialog.getByRole("button", { name: messages.continue, exact: true }).click();
+ await expect(dialog.getByRole("heading", { level: 2 })).toHaveText(
+ messages.allowTitle.replace("{{brand}}", "Recordly"),
+ );
+ await expect(dialog.getByText(messages.screenDescription, { exact: true })).toBeVisible();
+ await expect(
+ dialog.getByRole("button", { name: messages.simulatePermissions, exact: true }),
+ ).toBeVisible();
+ await expect(
+ dialog.getByRole("button", { name: messages.skipOnboarding, exact: true }),
+ ).toBeVisible();
+ await dialog
+ .getByRole("button", {
+ name: messages.allowPermission.replace("{{permission}}", messages.screen),
+ exact: true,
+ })
+ .click();
+ await expect(dialog.getByRole("alert")).toContainText(messages.permissionFailed);
+ await expect(
+ dialog.getByRole("button", { name: messages.startRecording, exact: true }),
+ ).toBeDisabled();
+ await expect(
+ dialog.getByRole("button", { name: messages.backToAccount, exact: true }),
+ ).toBeVisible();
+ await expect(dialog.getByRole("navigation", { name: messages.pages })).toBeVisible();
+ await expect(dialog).not.toContainText("Untranslated native error");
+ await expect(dialog).not.toContainText("onboarding.");
+ expect(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(
+ true,
+ );
+ await page.screenshot({ path: testInfo.outputPath(`onboarding-${locale}.png`) });
+ });
+}
diff --git a/tests/ui/onboarding-music.spec.ts b/tests/ui/onboarding-music.spec.ts
new file mode 100644
index 00000000..ea0ec2c1
--- /dev/null
+++ b/tests/ui/onboarding-music.spec.ts
@@ -0,0 +1,157 @@
+import { expect } from "@playwright/test";
+import { productionTest as test } from "./production";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+for (const standalone of [false, true]) {
+ test(`actual onboarding plays music despite previous playback (${standalone ? "window" : "inline fallback"})`, async ({
+ page,
+ }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: standalone });
+ await installDesktopBridgeOverrides(page, () => {
+ const getSetting = window.electronAPI.getAppSetting;
+ localStorage.setItem("recordly.first-boot-music.v1.played", "true");
+ window.electronAPI.getAppSetting = (key) =>
+ key === "recordly.first-boot-music.v1.played" ? true : getSetting(key);
+ HTMLMediaElement.prototype.play = async function () {
+ if (this instanceof HTMLAudioElement) {
+ document.documentElement.dataset.welcomeMusic = this.src;
+ document.documentElement.dataset.musicAttempts = String(
+ Number(document.documentElement.dataset.musicAttempts || 0) + 1,
+ );
+ }
+ };
+ });
+ await page.goto(`/?windowType=${standalone ? "onboarding" : "editor"}`);
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await expect(tour.getByRole("button", { name: "Mute welcome music" })).toBeVisible({
+ timeout: 30000,
+ });
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-welcome-music",
+ /audio\/first-boot.m4a$/,
+ );
+ await expect(page.locator("html")).toHaveAttribute("data-music-attempts", "1");
+ await expect(tour.locator("[data-recordly-demo]")).toBeVisible();
+ });
+}
+
+test("actual onboarding restarts music when reopened in the same window", async ({ page }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: true });
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.onRecordingPermissionsRequested = (callback) => {
+ window.addEventListener("test-open-onboarding", callback);
+ return () => window.removeEventListener("test-open-onboarding", callback);
+ };
+ HTMLMediaElement.prototype.play = async function () {
+ if (this instanceof HTMLAudioElement) {
+ document.documentElement.dataset.musicStartTime = String(this.currentTime);
+ this.currentTime = 12;
+ document.documentElement.dataset.musicAttempts = String(
+ Number(document.documentElement.dataset.musicAttempts || 0) + 1,
+ );
+ }
+ };
+ });
+ await page.goto("/?windowType=onboarding");
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await expect(tour.getByRole("button", { name: "Mute welcome music" })).toBeVisible();
+ await tour.getByRole("button", { name: "Close onboarding window" }).click();
+ await expect(tour).not.toBeVisible();
+ await page.evaluate(() => window.dispatchEvent(new Event("test-open-onboarding")));
+ await expect(tour.getByRole("button", { name: "Mute welcome music" })).toBeVisible();
+ await expect(page.locator("html")).toHaveAttribute("data-music-attempts", "2");
+ await expect(page.locator("html")).toHaveAttribute("data-music-start-time", "0");
+});
+
+test("blocked welcome music retries on interaction and replays on the next onboarding", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await installDesktopBridgeOverrides(page, () => {
+ let attempts = 0;
+ HTMLMediaElement.prototype.play = async function () {
+ if (!(this instanceof HTMLAudioElement)) return;
+ attempts += 1;
+ document.documentElement.dataset.musicAttempts = String(attempts);
+ if (attempts === 1) throw new DOMException("Interaction required", "NotAllowedError");
+ };
+ });
+ await page.goto("/?windowType=onboarding");
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await expect(tour.getByRole("button", { name: "Unmute welcome music" })).toBeEnabled();
+ await tour.getByPlaceholder("you@example.com").click();
+ await expect(tour.getByRole("button", { name: "Mute welcome music" })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ await expect(page.locator("html")).toHaveAttribute("data-music-attempts", "2");
+ await tour.getByRole("button", { name: "Mute welcome music" }).click();
+ await tour.getByPlaceholder("you@example.com").click();
+ await expect(tour.getByRole("button", { name: "Unmute welcome music" })).toBeVisible();
+ await expect(page.locator("html")).toHaveAttribute("data-music-attempts", "2");
+ await page.reload();
+ await expect(tour).toBeVisible();
+ await expect(tour.getByRole("button", { name: "Unmute welcome music" })).toBeVisible();
+ await tour.getByPlaceholder("you@example.com").click();
+ await expect(tour.getByRole("button", { name: "Mute welcome music" })).toBeVisible();
+ await expect(page.locator("html")).toHaveAttribute("data-music-attempts", "2");
+});
+
+test("a failed welcome audio load shows no dead toggle or persisted playback flag", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.setAppSetting = (key, value) => {
+ localStorage.setItem(`test-write:${key}`, JSON.stringify(value));
+ return true;
+ };
+ HTMLMediaElement.prototype.play = async function () {
+ if (this instanceof HTMLAudioElement)
+ throw new DOMException("Missing audio", "NotSupportedError");
+ };
+ });
+ await page.goto("/?windowType=onboarding");
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await expect(tour).toBeVisible();
+ await expect(tour.locator("[data-welcome-music]")).toHaveCount(0);
+ expect(
+ await page.evaluate(() =>
+ localStorage.getItem("test-write:recordly.first-boot-music.v1.played"),
+ ),
+ ).toBeNull();
+});
+
+test("closing during pending welcome playback does not persist a playback flag", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.setAppSetting = (key, value) => {
+ localStorage.setItem(`test-write:${key}`, JSON.stringify(value));
+ return true;
+ };
+ HTMLMediaElement.prototype.play = async function () {
+ if (!(this instanceof HTMLAudioElement)) return;
+ document.documentElement.dataset.musicPending = "true";
+ await new Promise((resolve) => {
+ window.addEventListener("test-release-music", () => resolve(), { once: true });
+ });
+ };
+ });
+ await page.goto("/?windowType=onboarding");
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await expect(page.locator("html")).toHaveAttribute("data-music-pending", "true");
+ await expect(tour.locator("[data-welcome-music]")).toHaveCount(0);
+ await tour.getByRole("button", { name: "Close onboarding window" }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-onboarding-closed", "true");
+ await page.evaluate(async () => {
+ window.dispatchEvent(new Event("test-release-music"));
+ await new Promise(requestAnimationFrame);
+ });
+ expect(
+ await page.evaluate(() =>
+ localStorage.getItem("test-write:recordly.first-boot-music.v1.played"),
+ ),
+ ).toBeNull();
+});
diff --git a/tests/ui/onboarding-primary-action.spec.ts b/tests/ui/onboarding-primary-action.spec.ts
new file mode 100644
index 00000000..33e5400a
--- /dev/null
+++ b/tests/ui/onboarding-primary-action.spec.ts
@@ -0,0 +1,69 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+for (const reducedMotion of ["no-preference", "reduce"] as const) {
+ test(`the same onboarding button widens into Start recording (${reducedMotion})`, async ({ page }) => {
+ await page.setViewportSize({ width: 760, height: 760 });
+ await page.emulateMedia({ reducedMotion });
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=onboarding&onboardingPreview=1");
+ const dialog = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ const primary = dialog.locator("[data-onboarding-primary-action]");
+ await expect(primary).toHaveAccessibleName("Continue");
+ // Measure after the modal itself finishes opening.
+ await expect.poll(() => primary.evaluate((element) => {
+ let opacity = 1;
+ for (let node: Element | null = element; node; node = node.parentElement) {
+ opacity *= Number(getComputedStyle(node).opacity);
+ }
+ return opacity;
+ })).toBe(1);
+ const before = (await primary.boundingBox())!;
+ await primary.evaluate((element) => {
+ element.setAttribute("data-persistent-button", "true");
+ const samples: { width: number; right: number; bottom: number; opacity: number; connected: boolean }[] = [];
+ let frame = 0;
+ const sample = () => {
+ const rect = element.getBoundingClientRect();
+ let opacity = 1;
+ for (let node: Element | null = element; node; node = node.parentElement) {
+ opacity *= Number(getComputedStyle(node).opacity);
+ }
+ samples.push({ width: rect.width, right: rect.right, bottom: rect.bottom, opacity, connected: element.isConnected });
+ frame = requestAnimationFrame(sample);
+ };
+ sample();
+ Object.assign(window, { finishButtonSamples: () => { cancelAnimationFrame(frame); return samples; } });
+ });
+ await primary.click();
+ await expect(primary).toHaveAccessibleName("Start recording");
+ await expect(primary).toHaveAttribute("data-persistent-button", "true");
+ await expect(primary).toBeEnabled();
+ const samples = await primary.evaluate(async () => {
+ const start = performance.now();
+ while (performance.now() - start < 650) await new Promise(requestAnimationFrame);
+ return (window as unknown as { finishButtonSamples: () => { width: number; right: number; bottom: number; opacity: number; connected: boolean }[] }).finishButtonSamples();
+ });
+ const after = (await primary.boundingBox())!;
+ await test.info().attach("button-animation", {
+ body: JSON.stringify({ before, after, samples }),
+ contentType: "application/json",
+ });
+ expect(after.width).toBeGreaterThan(before.width + 20);
+ expect(samples.every((sample) => sample.connected && sample.opacity > 0.25)).toBe(true);
+ expect(Math.max(...samples.map((sample) => Math.abs(sample.bottom - before.y - before.height)))).toBeLessThanOrEqual(1);
+ expect(Math.max(...samples.map((sample) => Math.abs(sample.right - before.x - before.width)))).toBeLessThanOrEqual(1);
+ if (reducedMotion === "no-preference") {
+ expect(samples.some((sample) => sample.width > before.width + 3 && sample.width < after.width - 3)).toBe(true);
+ }
+ const heading = dialog.getByRole("heading", { name: "Allow Recordly to record", exact: true });
+ await expect(heading).toHaveCSS("text-align", "center");
+ const headingBox = (await heading.boundingBox())!;
+ const body = (await dialog.locator(".onboarding-body-content").boundingBox())!;
+ expect(Math.abs(headingBox.x + headingBox.width / 2 - body.x - body.width / 2)).toBeLessThanOrEqual(1);
+ await dialog.screenshot({ path: test.info().outputPath("permissions.png"), animations: "disabled" });
+ await dialog.getByRole("button", { name: "Back to account" }).click();
+ await expect(primary).toHaveAccessibleName("Continue");
+ await expect(primary).toHaveAttribute("data-persistent-button", "true");
+ });
+}
diff --git a/tests/ui/onboarding-startup.spec.ts b/tests/ui/onboarding-startup.spec.ts
new file mode 100644
index 00000000..90f61da7
--- /dev/null
+++ b/tests/ui/onboarding-startup.spec.ts
@@ -0,0 +1,125 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+for (const colorScheme of ["light", "dark"] as const) {
+ test(`onboarding uses its window background before JavaScript in ${colorScheme} mode`, async ({
+ page,
+ }) => {
+ await page.emulateMedia({ colorScheme });
+ await page.route("**/src/main.tsx*", (route) => route.abort());
+ await page.goto("/?windowType=onboarding");
+ await expect(page.locator("[data-onboarding-loading]")).toHaveCount(0);
+ await expect(page.locator("html")).toHaveAttribute("data-theme", colorScheme);
+ await expect(page.locator("html")).toHaveCSS(
+ "background-color",
+ colorScheme === "dark" ? "rgb(23, 23, 25)" : "rgb(246, 246, 247)",
+ );
+ });
+}
+
+for (const signedIn of [false, true]) {
+ test(`onboarding shows real controls during session recovery (${signedIn ? "saved account" : "signed out"})`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 760, height: 760 });
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await installDesktopBridge(page, "preview.mp4", { signedIn, onboarded: false });
+ await installDesktopBridgeOverrides(page, () => {
+ const getItem = window.electronAPI.authStorageGetItem;
+ const ready = new Promise((resolve) => {
+ window.addEventListener("test-release-auth", () => resolve(), { once: true });
+ });
+ window.electronAPI.authStorageGetItem = async (key) => {
+ document.documentElement.dataset.authPending = "true";
+ await ready;
+ return getItem(key);
+ };
+ });
+ let releaseProviders!: () => void;
+ const providersReady = new Promise((resolve) => {
+ releaseProviders = resolve;
+ });
+ await page.route("**/auth/v1/settings", async (route) => {
+ await providersReady;
+ await route.fulfill({
+ json: { external: { google: true, azure: true, apple: true } },
+ });
+ });
+ // Even unavailable artwork cannot gate the form.
+ await page.route("**/auth/recordly-demo.*", (route) => route.abort());
+ await page.goto("/?windowType=onboarding", { waitUntil: "domcontentloaded" });
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await expect(page.locator("html")).toHaveAttribute("data-auth-pending", "true", {
+ timeout: 15000,
+ });
+ await expect(tour).toBeVisible();
+ await expect(page.locator("[data-onboarding-loading]")).toHaveCount(0);
+ await expect(
+ tour.getByRole("heading", { name: "Welcome to Recordly", exact: true }),
+ ).toBeVisible();
+ const email = tour.getByPlaceholder("you@example.com");
+ await email.fill("person@example.com");
+ await expect(
+ tour.getByRole("button", { name: "Email me a code", exact: true }),
+ ).toBeEnabled();
+ for (const provider of ["Google", "Microsoft", "Apple"]) {
+ await expect(
+ tour.getByRole("button", { name: `Continue with ${provider}` }),
+ ).toBeVisible();
+ await expect(
+ tour.getByRole("button", { name: `Continue with ${provider}` }),
+ ).toBeDisabled();
+ }
+ const before = await email.boundingBox();
+ if (!signedIn)
+ await page.screenshot({
+ path: test.info().outputPath("pending-session.png"),
+ });
+ releaseProviders();
+ await expect(tour.getByRole("button", { name: "Continue with Google" })).toBeEnabled();
+ await expect(email).toHaveValue("person@example.com");
+ expect(await email.boundingBox()).toEqual(before);
+ await expect(email).toHaveValue("person@example.com");
+ await tour.getByRole("button", { name: "Continue", exact: true }).click();
+ await expect(
+ tour.getByRole("heading", { name: "Allow Recordly to record" }),
+ ).toBeVisible();
+ await page.evaluate(() => window.dispatchEvent(new Event("test-release-auth")));
+ await expect(tour).toHaveAttribute("data-onboarding-state", "permissions");
+ });
+}
+
+test("provider discovery failure leaves the real email form usable", async ({ page }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await page.route("**/auth/v1/settings", (route) => route.abort());
+ await page.goto("/?windowType=onboarding");
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await tour.getByPlaceholder("you@example.com").fill("person@example.com");
+ await expect(tour.getByRole("button", { name: "Email me a code", exact: true })).toBeEnabled();
+ await expect(tour.getByRole("button", { name: "Continue with Google" })).toBeVisible();
+ await expect(tour.getByRole("button", { name: "Continue with Google" })).toBeDisabled();
+ await tour.getByRole("button", { name: "Close onboarding window" }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-onboarding-closed", "true");
+});
+
+test("a signed-out editor opens onboarding before project hydration finishes", async ({ page }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.loadCurrentProjectFile = async () => {
+ document.documentElement.dataset.projectPending = "true";
+ await new Promise((resolve) => {
+ window.addEventListener("test-release-project", () => resolve(), { once: true });
+ });
+ return { success: false };
+ };
+ window.electronAPI.showOnboardingWindow = async () => {
+ document.documentElement.dataset.onboardingRequested = "true";
+ return false;
+ };
+ });
+ await page.goto("/?windowType=editor");
+ await expect(page.locator("html")).toHaveAttribute("data-project-pending", "true");
+ await expect(page.locator("html")).toHaveAttribute("data-onboarding-requested", "true");
+ await expect(page.getByRole("dialog", { name: "Welcome to Recordly" })).toBeVisible();
+ await page.evaluate(() => window.dispatchEvent(new Event("test-release-project")));
+});
diff --git a/tests/ui/onboarding-transitions.spec.ts b/tests/ui/onboarding-transitions.spec.ts
new file mode 100644
index 00000000..6c54310a
--- /dev/null
+++ b/tests/ui/onboarding-transitions.spec.ts
@@ -0,0 +1,110 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+for (const [width, height] of [[760, 760], [600, 600], [420, 760], [390, 760], [390, 540]]) {
+ for (const reducedMotion of ["no-preference", "reduce"] as const) {
+ test(`onboarding keeps its frame through both transitions and permission loading (${reducedMotion}, ${width}x${height})`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width, height });
+ await page.emulateMedia({ reducedMotion });
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ const ready = new Promise((resolve) => {
+ Object.assign(window, { finishPermissionCheck: resolve });
+ });
+ window.electronAPI.getScreenRecordingPermissionStatus = async () => {
+ document.documentElement.dataset.permissionCheckStarted = "true";
+ await ready;
+ return { success: true, status: "granted" };
+ };
+ });
+ await page.goto("/?windowType=onboarding&onboardingPreview=1");
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ const artwork = tour.locator("[data-onboarding-artwork]");
+ await expect.poll(() => artwork.evaluate((element) =>
+ new DOMMatrixReadOnly(getComputedStyle(element).transform).isIdentity,
+ )).toBe(true);
+ await expect(tour.getByPlaceholder("you@example.com")).toBeVisible();
+ const initialHeading = (await tour.getByRole("heading", { level: 2 }).boundingBox())!;
+ if (reducedMotion === "no-preference")
+ await tour.screenshot({ path: test.info().outputPath("account.png") });
+ const initialFrame = await artwork.evaluate((element) => {
+ const content = element.nextElementSibling!;
+ const layout = element.parentElement!;
+ const samples: number[][] = [];
+ let frame = 0;
+ const sample = () => {
+ const footer = layout.querySelector(".onboarding-footer")!.getBoundingClientRect();
+ samples.push([
+ content.clientHeight,
+ content.getBoundingClientRect().y,
+ element.clientHeight,
+ footer.y,
+ footer.height,
+ layout.querySelector(".onboarding-navigation")!.getBoundingClientRect().y,
+ ]);
+ frame = requestAnimationFrame(sample);
+ };
+ sample();
+ Object.assign(window, {
+ finishTransitionSamples: () => {
+ cancelAnimationFrame(frame);
+ return samples;
+ },
+ });
+ return samples[0];
+ });
+ await tour.getByRole("button", { name: "Continue", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-permission-check-started",
+ "true",
+ );
+ // Hold the IPC response beyond the page entrance to expose loading-layout shifts.
+ const loadingHeight = await artwork.evaluate(async (element) => {
+ const start = performance.now();
+ while (performance.now() - start < 650) {
+ await new Promise(requestAnimationFrame);
+ }
+ return element.clientHeight;
+ });
+ const loadingContentHeight = await artwork.evaluate(
+ (element) => element.nextElementSibling!.clientHeight,
+ );
+ expect(Math.abs(loadingContentHeight - initialFrame[0])).toBeLessThanOrEqual(1);
+ await expect(
+ tour.getByRole("button", { name: "Start recording", exact: true }),
+ ).toBeDisabled();
+ await page.evaluate(() => {
+ (
+ window as unknown as { finishPermissionCheck: () => void }
+ ).finishPermissionCheck();
+ });
+ await expect(tour.getByText("Allowed", { exact: true })).toHaveCount(2);
+ await expect(
+ tour.getByRole("button", { name: "Start recording", exact: true }),
+ ).toBeEnabled();
+ const loadedHeight = await artwork.evaluate((element) => element.clientHeight);
+ expect(Math.abs(loadedHeight - loadingHeight)).toBeLessThanOrEqual(1);
+ const permissionHeading = (await tour.getByRole("heading", { level: 2 }).boundingBox())!;
+ expect(Math.abs(permissionHeading.y - initialHeading.y)).toBeLessThanOrEqual(1);
+ if (reducedMotion === "no-preference") {
+ await tour.screenshot({ path: test.info().outputPath(`permissions-${width}.png`) });
+ }
+ await tour.getByRole("button", { name: "Back to account" }).click();
+ await expect(tour.getByPlaceholder("you@example.com")).toBeVisible();
+ const samples = await page.evaluate(async () => {
+ const start = performance.now();
+ while (performance.now() - start < 650) await new Promise(requestAnimationFrame);
+ return (window as unknown as { finishTransitionSamples: () => number[][] }).finishTransitionSamples();
+ });
+ await test.info().attach("frame-positions", {
+ body: JSON.stringify({ initialFrame, samples }),
+ contentType: "application/json",
+ });
+ for (let metric = 0; metric < initialFrame.length; metric++) {
+ expect(Math.max(...samples.map((sample) => Math.abs(sample[metric] - initialFrame[metric])))).toBeLessThanOrEqual(1);
+ }
+ });
+ }
+}
diff --git a/tests/ui/onboarding.spec.ts b/tests/ui/onboarding.spec.ts
index 18c2ed95..7fe2e0fa 100644
--- a/tests/ui/onboarding.spec.ts
+++ b/tests/ui/onboarding.spec.ts
@@ -1,16 +1,87 @@
-import { expect, test } from "@playwright/test";
-import { installDesktopBridge } from "./bridge";
+import { expect, type Page, test } from "@playwright/test";
import { completeSupabaseSignIn } from "./auth";
+import { productionTest } from "./production";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
-test("settings opens permissions and the carousel banner adapts to each page", async ({ page }) => {
+async function openDevSettings(page: Page) {
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true })
+ .click();
+ await page
+ .getByRole("dialog", { name: "Settings", exact: true })
+ .getByRole("tab", { name: "Dev", exact: true })
+ .click();
+}
+
+test("dedicated onboarding route fills its own window", async ({ page }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await page.setViewportSize({ width: 760, height: 760 });
+ await page.goto("/?windowType=onboarding");
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await expect(tour).toBeVisible();
+ await expect(page.locator("[data-startup-intro]")).toHaveCount(0);
+ await expect(page.locator("html")).toHaveAttribute("data-window-type", "onboarding");
+ await expect(tour.locator("[data-onboarding-window-drag]")).toBeVisible();
+ await expect(tour.getByRole("button", { name: "Close onboarding window" })).toBeVisible();
+ await expect
+ .poll(async () => {
+ const bounds = await tour.boundingBox();
+ return (
+ bounds &&
+ bounds.x >= -1 &&
+ bounds.y >= -1 &&
+ bounds.width >= 758 &&
+ bounds.height >= 758
+ );
+ })
+ .toBe(true);
+ await tour.getByRole("button", { name: "Close onboarding window" }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-onboarding-closed", "true");
+});
+
+test("Show onboarding opens directly after onboarding was completed", async ({ page }) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getAppSetting = (key) =>
+ key === "recordly.onboarding.v1.seen" ? true : null;
+ window.electronAPI.showOnboardingWindow = async (options) => {
+ document.documentElement.dataset.previewRequested = String(options?.preview);
+ return false;
+ };
+ });
+ await page.goto("/?windowType=editor");
+ await openDevSettings(page);
+ await page.getByRole("button", { name: "Show onboarding" }).click();
+ await expect(
+ page.getByRole("heading", { name: "Welcome to Recordly", exact: true }),
+ ).toBeVisible();
+ await page
+ .getByRole("dialog", { name: "Welcome to Recordly" })
+ .getByRole("button", { name: "Continue", exact: true })
+ .click();
+ await expect(page.locator("[data-startup-intro]")).toHaveCount(0);
+ await expect(page.locator("html")).toHaveAttribute("data-preview-requested", "true");
+ await expect(page.getByRole("heading", { name: "Allow Recordly to record" })).toBeVisible();
+});
+
+test("settings opens permissions and both pages keep the same layout", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await installDesktopBridge(page);
await page.goto("/?windowType=editor");
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible({
timeout: 15000,
});
- await page.getByRole("radio", { name: "Settings", exact: true }).click();
+ await openDevSettings(page);
await page.getByRole("button", { name: "Show onboarding" }).click();
+ await expect(
+ page.getByRole("heading", { name: "Welcome to Recordly", exact: true }),
+ ).toBeVisible();
+ await page
+ .getByRole("dialog", { name: "Welcome to Recordly" })
+ .getByRole("button", { name: "Continue", exact: true })
+ .click();
const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
await expect(tour.getByRole("heading", { name: "Allow Recordly to record" })).toBeVisible();
await expect(tour.getByText("Allowed", { exact: true })).toHaveCount(2);
@@ -33,11 +104,11 @@ test("settings opens permissions and the carousel banner adapts to each page", a
const permissionBounds = (await tour.boundingBox())!;
const banner = tour.locator("[data-onboarding-artwork]");
const bannerBounds = (await banner.boundingBox())!;
- await expect(banner.locator('img[src$="login-banner.png"]')).toBeVisible();
+ await expect(banner.locator("[data-recordly-demo]")).toBeVisible();
await expect
.poll(() =>
banner
- .locator('img[src$="login-banner.png"]')
+ .locator("[data-recordly-demo] .recordly-banner-light")
.evaluate((image: HTMLImageElement) => image.naturalWidth),
)
.toBeGreaterThan(0);
@@ -51,17 +122,12 @@ test("settings opens permissions and the carousel banner adapts to each page", a
"aria-current",
"page",
);
- await expect(tour.getByText("test@email.com", { exact: true })).toBeVisible();
+ await expect(tour.getByPlaceholder("you@example.com")).toBeVisible();
const loginHeading = (await tour.getByRole("heading", { level: 2 }).boundingBox())!;
- const brand = tour.locator("[data-onboarding-brand]");
- const brandBounds = (await brand.boundingBox())!;
+ await expect(tour.locator("[data-onboarding-brand]")).toHaveCount(0);
const accountBannerBounds = (await banner.boundingBox())!;
- expect(accountBannerBounds.height).toBeGreaterThan(bannerBounds.height);
+ expect(accountBannerBounds.height).toBeGreaterThanOrEqual(64);
expect(accountBannerBounds.width).toBe(bannerBounds.width);
- expect(brandBounds.y).toBeGreaterThanOrEqual(
- accountBannerBounds.y + accountBannerBounds.height,
- );
- expect(brandBounds.y + brandBounds.height).toBeLessThan(loginHeading.y);
await expect(banner.locator('img[src$="recordly-128.png"]')).toHaveCount(0);
expect(await tour.boundingBox()).toEqual(permissionBounds);
expect(await banner.boundingBox()).toEqual(accountBannerBounds);
@@ -73,29 +139,45 @@ test("settings opens permissions and the carousel banner adapts to each page", a
expect(await tour.boundingBox()).toEqual(permissionBounds);
expect(await banner.boundingBox()).toEqual(bannerBounds);
const permissionHeading = (await tour.getByRole("heading", { level: 2 }).boundingBox())!;
- expect(permissionHeading.x).toBe(loginHeading.x);
- const permissionBrandBounds = (await brand.boundingBox())!;
- expect(permissionBrandBounds.y + permissionBrandBounds.height).toBeLessThan(
- permissionHeading.y,
+ expect(loginHeading.x + loginHeading.width / 2).toBeCloseTo(
+ permissionBounds.x + permissionBounds.width / 2,
+ 0,
);
- expect(permissionBrandBounds.width).toBe(brandBounds.width);
- expect(permissionBrandBounds.height).toBe(brandBounds.height);
+ const titleLogo = tour
+ .getByRole("heading", { name: "Allow Recordly to record" })
+ .locator(".onboarding-title-logo");
+ await expect(titleLogo).toHaveCount(1);
+ const titleLogoBounds = (await titleLogo.boundingBox())!;
+ expect(titleLogoBounds.y).toBeGreaterThanOrEqual(permissionHeading.y - 1);
+ expect(titleLogoBounds.y + titleLogoBounds.height).toBeLessThanOrEqual(
+ permissionHeading.y + permissionHeading.height + 1,
+ );
+ await expect(tour.locator("[data-onboarding-brand]")).toHaveCount(0);
const navigationBounds = (await pages.boundingBox())!;
expect(navigationBounds.y).toBeGreaterThan(startBounds.y + startBounds.height);
- expect(
- await tour.getByRole("button", { name: "Start recording", exact: true }).boundingBox(),
+ await expect.poll(() =>
+ tour.getByRole("button", { name: "Start recording", exact: true }).boundingBox(),
).toEqual(startBounds);
await tour.getByRole("button", { name: "Start recording", exact: true }).click();
await expect(tour).not.toBeVisible();
await expect(page.locator("html")).toHaveAttribute("data-hud-opened", "true");
- await page.getByRole("radio", { name: "Settings", exact: true }).click();
+ await openDevSettings(page);
await page.getByRole("button", { name: "Show onboarding" }).click();
+ await expect(
+ page.getByRole("heading", { name: "Welcome to Recordly", exact: true }),
+ ).toBeVisible();
+ await page
+ .getByRole("dialog", { name: "Welcome to Recordly" })
+ .getByRole("button", { name: "Continue", exact: true })
+ .click();
await expect(tour.getByRole("heading", { name: "Allow Recordly to record" })).toBeVisible();
});
-test("first launch persists completion and signed-in users can continue", async ({ page }) => {
+productionTest("first launch opens permissions directly for signed-in users and persists completion", async ({
+ page,
+}) => {
await installDesktopBridge(page);
- await page.addInitScript(() => {
+ await installDesktopBridgeOverrides(page, () => {
window.electronAPI.getAppSetting = (key) => JSON.parse(localStorage.getItem(key) || "null");
window.electronAPI.setAppSetting = (key, value) => {
localStorage.setItem(key, JSON.stringify(value));
@@ -104,9 +186,9 @@ test("first launch persists completion and signed-in users can continue", async
});
await page.goto("/?windowType=editor");
const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
- await tour.getByRole("button", { name: "Continue", exact: true }).click();
+ await expect(tour).toBeVisible({ timeout: 15000 });
await expect(tour.getByRole("heading", { name: "Allow Recordly to record" })).toBeVisible();
- await expect(tour.locator("[data-permissions-confetti]")).toBeVisible();
+ await expect(page.locator("[data-permissions-confetti]")).toBeVisible();
await tour.getByRole("button", { name: "Start recording", exact: true }).click();
await expect
.poll(() => page.evaluate(() => localStorage.getItem("recordly.onboarding.v1.seen")))
@@ -126,16 +208,25 @@ test("onboarding fits a compact window with reduced motion", async ({ page }) =>
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible({
timeout: 15000,
});
- await page.getByRole("radio", { name: "Settings", exact: true }).click();
+ await openDevSettings(page);
await page.getByRole("button", { name: "Show onboarding" }).click();
+ await expect(
+ page.getByRole("heading", { name: "Welcome to Recordly", exact: true }),
+ ).toBeVisible();
+ await page
+ .getByRole("dialog", { name: "Welcome to Recordly" })
+ .getByRole("button", { name: "Continue", exact: true })
+ .click();
const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
await expect(
tour.getByRole("button", { name: "Start recording", exact: true }),
).toBeInViewport();
await expect(
- tour.locator('[data-onboarding-artwork] img[src$="login-banner.png"]'),
+ tour.locator("[data-onboarding-artwork] [data-recordly-demo]"),
).toBeInViewport();
for (const size of [
+ { width: 450, height: 450 },
+ { width: 390, height: 390 },
{ width: 720, height: 780 },
{ width: 640, height: 650 },
{ width: 390, height: 600 },
@@ -149,6 +240,10 @@ test("onboarding fits a compact window with reduced motion", async ({ page }) =>
.evaluate((element) => element.scrollHeight - element.clientHeight),
)
.toBeLessThanOrEqual(1);
+ await expect(
+ tour.getByRole("button", { name: "Start recording", exact: true }),
+ ).toBeInViewport();
+ await expect(tour.getByRole("navigation", { name: "Onboarding pages" })).toBeInViewport();
}
await page.screenshot({ path: "test-results/onboarding-compact.png" });
await tour.getByRole("button", { name: "Back to account" }).click();
@@ -161,8 +256,15 @@ test("short dark windows keep the banner and navigation visible while the accoun
await installDesktopBridge(page);
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/?windowType=editor");
- await page.getByRole("radio", { name: "Settings", exact: true }).click();
+ await openDevSettings(page);
await page.getByRole("button", { name: "Show onboarding" }).click();
+ await expect(
+ page.getByRole("heading", { name: "Welcome to Recordly", exact: true }),
+ ).toBeVisible();
+ await page
+ .getByRole("dialog", { name: "Welcome to Recordly" })
+ .getByRole("button", { name: "Continue", exact: true })
+ .click();
await page.setViewportSize({ width: 390, height: 600 });
await page.evaluate(() => document.documentElement.classList.add("dark"));
const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
@@ -171,9 +273,7 @@ test("short dark windows keep the banner and navigation visible while the accoun
tour.getByRole("button", { name: "Start recording", exact: true }),
).toBeInViewport();
const body = tour.locator("[data-onboarding-body]");
- await expect
- .poll(() => body.evaluate((element) => element.scrollHeight - element.clientHeight))
- .toBeLessThanOrEqual(1);
+ await expect(body).toHaveCSS("overflow-y", "auto");
await expect
.poll(() =>
body.evaluate(
@@ -187,13 +287,13 @@ test("short dark windows keep the banner and navigation visible while the accoun
.getByRole("button", { name: "Back to account" })
.boundingBox())!;
expect(bodyBounds.y + bodyBounds.height).toBeLessThanOrEqual(footerBounds.y);
- await expect(tour.locator("[data-permissions-confetti]")).toHaveCount(0);
+ await expect(page.locator("[data-permissions-confetti]")).toHaveCount(0);
await page.screenshot({ path: "test-results/onboarding-narrow-permissions.png" });
await tour.getByRole("button", { name: "Back to account" }).click();
const bounds = await tour.boundingBox();
await expect(tour.getByRole("button", { name: "Continue", exact: true })).toBeInViewport();
await expect(
- tour.locator('[data-onboarding-artwork] img[src$="login-banner.png"]'),
+ tour.locator("[data-onboarding-artwork] [data-recordly-demo]"),
).toBeInViewport();
expect(await tour.boundingBox()).toEqual(bounds);
await expect(tour).toHaveJSProperty("scrollWidth", bounds!.width);
@@ -202,45 +302,105 @@ test("short dark windows keep the banner and navigation visible while the accoun
await expect(tour.getByRole("heading", { name: "Allow Recordly to record" })).toBeVisible();
});
-test("sign-in is mandatory even after onboarding was completed", async ({ page }) => {
- await installDesktopBridge(page, "preview.mp4", { signedIn: false });
+productionTest("square onboarding uses consistent typography and scrolls permissions above fixed actions", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await page.setViewportSize({ width: 450, height: 450 });
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await page.goto("/?windowType=onboarding");
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ const typography = () =>
+ tour.getByRole("heading", { level: 2 }).evaluate((element) => {
+ const heading = getComputedStyle(element);
+ const logo = element.querySelector(".onboarding-title-logo")!;
+ const header = element.closest(".onboarding-header")!;
+ return {
+ fontFamily: heading.fontFamily,
+ fontSize: heading.fontSize,
+ fontWeight: heading.fontWeight,
+ lineHeight: heading.lineHeight,
+ letterSpacing: heading.letterSpacing,
+ logoWidth: logo.getBoundingClientRect().width,
+ logoHeight: logo.getBoundingClientRect().height,
+ headerGap: getComputedStyle(header).gap,
+ };
+ });
+ await expect(tour.getByRole("heading", { name: "Welcome to Recordly" })).toBeVisible();
+ const accountTypography = await typography();
+ const accountBanner = await tour.locator("[data-onboarding-artwork]").boundingBox();
+ await tour.getByPlaceholder("you@example.com").fill("test@email.com");
+ await tour.getByRole("button", { name: "Email me a code" }).click();
+ await expect(tour.getByText(/Enter code sent to/)).toBeVisible();
+ await completeSupabaseSignIn(page);
+ await expect(tour.getByRole("heading", { name: "Allow Recordly to record" })).toBeVisible();
+ expect(await typography()).toEqual(accountTypography);
+ const permissionBanner = (await tour.locator("[data-onboarding-artwork]").boundingBox())!;
+ // The banner grows into spare space, so its height can change when the page content does.
+ expect(permissionBanner.width).toBe(accountBanner!.width);
+ expect(permissionBanner.height).toBeGreaterThanOrEqual(64);
+ const body = tour.locator("[data-onboarding-body]");
+ await expect(body).toHaveCSS("overflow-y", "auto");
+ await expect
+ .poll(() => body.evaluate((element) => element.scrollHeight - element.clientHeight))
+ .toBeGreaterThan(0);
+ await body.evaluate((element) => {
+ element.scrollTop = element.scrollHeight;
+ });
+ await expect(
+ tour.getByText("Microphone and camera access are requested when you use them.", {
+ exact: false,
+ }),
+ ).toBeInViewport();
+ await expect(tour.getByRole("button", { name: "Start recording" })).toBeInViewport();
+ await expect(tour.getByRole("navigation", { name: "Onboarding pages" })).toBeInViewport();
+ const bodyBounds = (await body.boundingBox())!;
+ const footerBounds = (await tour.locator(".onboarding-footer").boundingBox())!;
+ expect(bodyBounds.y + bodyBounds.height).toBeLessThanOrEqual(footerBounds.y);
+ await page.screenshot({ path: "test-results/onboarding-square-permissions.png" });
+});
+
+productionTest("sign-in is mandatory even after onboarding was completed", async ({ page }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: true });
await page.addInitScript(() => sessionStorage.setItem("recordly.demo-session", "1"));
await page.goto("/?windowType=editor");
- const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
- await expect(tour).toHaveAttribute("data-onboarding-state", "login");
+ const tour = page.getByRole("dialog", { name: "Sign in to Recordly", exact: true });
+ await expect(tour).toBeVisible();
await expect(tour.getByRole("button", { name: "Continue without signing in" })).toHaveCount(0);
await expect(tour.getByText("Optional", { exact: true })).toHaveCount(0);
await expect(tour.getByText("Sign in to continue", { exact: true })).toHaveCount(0);
const pages = tour.getByRole("navigation", { name: "Onboarding pages" });
- await expect(pages).toBeInViewport();
- await expect(pages.getByRole("button", { name: "Account", exact: true })).toHaveAttribute(
- "aria-current",
- "page",
- );
- await expect(pages.getByRole("button", { name: "Permissions", exact: true })).toBeDisabled();
+ await expect(pages).toHaveCount(0);
await expect(tour.getByRole("button", { name: "Start recording" })).toHaveCount(0);
await expect(tour.getByRole("button", { name: "Close", exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await page.mouse.click(5, 5);
await expect(tour).toBeVisible();
await page.reload();
- await expect(tour).toHaveAttribute("data-onboarding-state", "login");
+ await expect(tour).toBeVisible();
});
-test("Supabase email callback advances to permissions only after a session is returned", async ({
+productionTest("Supabase email callback advances to permissions only after a session is returned", async ({
page,
}) => {
- await installDesktopBridge(page, "preview.mp4", { signedIn: false });
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getAppSetting = (key) => JSON.parse(localStorage.getItem(key) || "null");
+ window.electronAPI.setAppSetting = (key, value) => {
+ localStorage.setItem(key, JSON.stringify(value));
+ return true;
+ };
+ });
await page.goto("/?windowType=editor");
const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
await tour.getByPlaceholder("you@example.com").fill("test@email.com");
const otpRequest = page.waitForRequest((request) => request.url().includes("/auth/v1/otp"));
- await tour.getByRole("button", { name: "Email me a magic link" }).click();
+ await tour.getByRole("button", { name: "Email me a code" }).click();
expect((await otpRequest).postDataJSON()).toMatchObject({
email: "test@email.com",
create_user: true,
});
- await expect(tour.getByText(/Check your inbox to verify your email/)).toBeVisible();
+ await expect(tour.getByText(/Enter code sent to/)).toBeVisible();
await expect(tour).toHaveAttribute("data-onboarding-state", "login");
const exchange = page.waitForRequest((request) => request.url().includes("/auth/v1/token"));
await completeSupabaseSignIn(page);
@@ -293,8 +453,15 @@ test("missing macOS permissions gate Start recording until access is allowed", a
});
});
await page.goto("/?windowType=editor");
- await page.getByRole("radio", { name: "Settings", exact: true }).click();
+ await openDevSettings(page);
await page.getByRole("button", { name: "Show onboarding" }).click();
+ await expect(
+ page.getByRole("heading", { name: "Welcome to Recordly", exact: true }),
+ ).toBeVisible();
+ await page
+ .getByRole("dialog", { name: "Welcome to Recordly" })
+ .getByRole("button", { name: "Continue", exact: true })
+ .click();
const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
const start = tour.getByRole("button", { name: "Start recording", exact: true });
await expect(tour.getByRole("heading", { name: "Allow Recordly to record" })).toBeVisible();
@@ -307,7 +474,7 @@ test("missing macOS permissions gate Start recording until access is allowed", a
.evaluate((element) => element.scrollHeight - element.clientHeight),
)
.toBeLessThanOrEqual(1);
- await expect(tour.locator("[data-permissions-confetti]")).toHaveCount(0);
+ await expect(page.locator("[data-permissions-confetti]")).toHaveCount(0);
await expect(page.locator("html")).not.toHaveAttribute("data-screen-settings");
await tour.getByRole("button", { name: "Allow Screen Recording", exact: true }).click();
await expect(page.locator("html")).toHaveAttribute("data-screen-settings", "true");
@@ -316,17 +483,17 @@ test("missing macOS permissions gate Start recording until access is allowed", a
await expect(page.locator("html")).toHaveAttribute("data-accessibility-settings", "true");
await expect(tour.getByText("Allowed", { exact: true })).toHaveCount(2);
await expect(start).toBeEnabled();
- await expect(tour.locator("[data-permissions-confetti]")).toBeVisible();
- await expect(tour.locator("[data-permissions-confetti]")).toHaveCount(0, { timeout: 5000 });
+ await expect(page.locator("[data-permissions-confetti]")).toBeVisible();
+ await expect(page.locator("[data-permissions-confetti]")).toHaveCount(0, { timeout: 5000 });
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
- await expect(tour.locator("[data-permissions-confetti]")).toHaveCount(0);
+ await expect(page.locator("[data-permissions-confetti]")).toHaveCount(0);
await tour.getByRole("button", { name: "Back to account" }).click();
await tour.getByRole("button", { name: "Continue", exact: true }).click();
- await expect(tour.locator("[data-permissions-confetti]")).toBeVisible();
+ await expect(page.locator("[data-permissions-confetti]")).toBeVisible();
await expect
.poll(() =>
- tour
- .locator("[data-permissions-confetti] span")
+ page
+ .locator("[data-permissions-confetti] canvas")
.first()
.evaluate((element) => Number(getComputedStyle(element).opacity)),
)
@@ -343,18 +510,29 @@ test("missing macOS permissions gate Start recording until access is allowed", a
for (const platform of ["win32", "linux"]) {
test(`${platform} onboarding skips macOS permission APIs`, async ({ page }) => {
await installDesktopBridge(page);
- await page.addInitScript((platform) => {
- window.electronAPI.getPlatform = async () => platform;
- window.electronAPI.getScreenRecordingPermissionStatus = async () => {
- throw new Error("macOS-only check");
- };
- window.electronAPI.getAccessibilityPermissionStatus = async () => {
- throw new Error("macOS-only check");
- };
- }, platform);
+ await installDesktopBridgeOverrides(
+ page,
+ (platform) => {
+ window.electronAPI.getPlatform = async () => platform;
+ window.electronAPI.getScreenRecordingPermissionStatus = async () => {
+ throw new Error("macOS-only check");
+ };
+ window.electronAPI.getAccessibilityPermissionStatus = async () => {
+ throw new Error("macOS-only check");
+ };
+ },
+ platform,
+ );
await page.goto("/?windowType=editor");
- await page.getByRole("radio", { name: "Settings", exact: true }).click();
+ await openDevSettings(page);
await page.getByRole("button", { name: "Show onboarding" }).click();
+ await expect(
+ page.getByRole("heading", { name: "Welcome to Recordly", exact: true }),
+ ).toBeVisible();
+ await page
+ .getByRole("dialog", { name: "Welcome to Recordly" })
+ .getByRole("button", { name: "Continue", exact: true })
+ .click();
const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
await expect(tour.getByRole("heading", { name: "Allow Recordly to record" })).toBeVisible();
await expect(
@@ -387,11 +565,11 @@ test("HUD routes missing permissions to onboarding without opening System Settin
await expect(page.locator("html")).not.toHaveAttribute("data-settings-opened");
});
-test("a permission request opens its page even after onboarding was already completed", async ({
+productionTest("a permission request opens its page even after onboarding was already completed", async ({
page,
}) => {
await installDesktopBridge(page);
- await page.addInitScript(() => {
+ await installDesktopBridgeOverrides(page, () => {
window.electronAPI.getAppSetting = (key) =>
key === "recordly.onboarding.v1.seen" ||
key === "recordly.onboarding.permissionsRequested"
@@ -403,7 +581,10 @@ test("a permission request opens its page even after onboarding was already comp
});
});
await page.goto("/?windowType=editor");
- const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ const tour = page.getByRole("dialog", { name: "Permissions", exact: true });
+ await expect(tour).toBeVisible({ timeout: 15000 });
+ await expect(tour.getByRole("navigation")).toHaveCount(0);
+ await expect(tour.getByRole("button", { name: "Back to account" })).toHaveCount(0);
await expect(tour.getByRole("heading", { name: "Allow Recordly to record" })).toBeVisible();
await expect(
tour.getByRole("button", { name: "Continue without signing in" }),
@@ -412,7 +593,7 @@ test("a permission request opens its page even after onboarding was already comp
for (const platform of ["darwin", "win32", "linux"]) {
test(`${platform} HUD requires a Supabase session before recording`, async ({ page }) => {
- await installDesktopBridge(page, "preview.mp4", { signedIn: false });
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
await page.addInitScript((platform) => {
window.electronAPI.getPlatform = async () => platform;
}, platform);
@@ -420,3 +601,147 @@ for (const platform of ["darwin", "win32", "linux"]) {
await expect(page.locator("html")).toHaveAttribute("data-permissions-opened", "true");
});
}
+
+for (const signedIn of [true, false]) {
+ test(`dev preview replays welcome and music with signedIn=${signedIn}`, async ({ page }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn, onboarded: true });
+ await page.addInitScript(() => {
+ localStorage.setItem("recordly.first-boot-music.v1.played", "true");
+ window.electronAPI.getAppSetting = (key) =>
+ key === "recordly.onboarding.v1.seen" ||
+ key === "recordly.first-boot-music.v1.played"
+ ? true
+ : null;
+ window.electronAPI.setAppSetting = (key, value) => {
+ localStorage.setItem(`test-write:${key}`, JSON.stringify(value));
+ return true;
+ };
+ HTMLMediaElement.prototype.play = async function () {
+ if (this instanceof HTMLAudioElement)
+ document.documentElement.dataset.welcomeMusic = this.src;
+ };
+ HTMLMediaElement.prototype.pause = function () {
+ if (this instanceof HTMLAudioElement)
+ document.documentElement.dataset.welcomeMusicStopped = "true";
+ };
+ });
+ await page.goto("/?windowType=onboarding&onboardingPreview=1");
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await expect(tour).toHaveAttribute("data-onboarding-state", "login");
+ await expect(tour.getByRole("heading", { name: "Welcome to Recordly" })).toBeVisible();
+ await expect(tour.getByPlaceholder("you@example.com")).toBeVisible();
+ await expect(tour.getByText("Your account", { exact: true })).toHaveCount(0);
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-welcome-music",
+ /audio\/first-boot.m4a$/,
+ );
+ const music = tour.getByRole("button", { name: "Mute welcome music" });
+ await expect(music).toHaveAttribute("aria-pressed", "true");
+ const soundBounds = await music.boundingBox();
+ expect(soundBounds).not.toBeNull();
+ expect(soundBounds!.x).toBeLessThan(64);
+ expect(soundBounds!.y).toBeLessThan(64);
+ await page.screenshot({
+ path: `test-results/onboarding-sound-toggle-${signedIn}.png`,
+ animations: "disabled",
+ });
+ await music.click();
+ const pausedMusic = tour.getByRole("button", { name: "Unmute welcome music" });
+ await expect(pausedMusic).toBeVisible();
+ await expect(pausedMusic).toHaveAttribute("aria-pressed", "false");
+ await expect(page.locator("html")).toHaveAttribute("data-welcome-music-stopped", "true");
+ await pausedMusic.click();
+ await expect(music).toHaveAttribute("aria-pressed", "true");
+ await tour.getByRole("button", { name: "Continue", exact: true }).click();
+ await expect(tour).toHaveAttribute("data-onboarding-state", "permissions");
+ await tour.getByRole("button", { name: "Back to account" }).click();
+ await tour
+ .getByRole("navigation")
+ .getByRole("button", { name: "Permissions", exact: true })
+ .click();
+ await expect(tour).toHaveAttribute("data-onboarding-state", "permissions");
+ await tour.getByRole("button", { name: "Start recording", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-hud-opened", "true");
+ await expect(page.locator("html")).toHaveAttribute("data-onboarding-closed", "true");
+ await expect(page.locator("html")).toHaveAttribute("data-welcome-music-stopped", "true");
+ expect(
+ await page.evaluate(() =>
+ localStorage.getItem("test-write:recordly.onboarding.v1.seen"),
+ ),
+ ).toBeNull();
+ expect(
+ await page.evaluate(() =>
+ localStorage.getItem("test-write:recordly.first-boot-music.v1.played"),
+ ),
+ ).toBeNull();
+ await page.reload();
+ await expect(tour.getByRole("heading", { name: "Welcome to Recordly" })).toBeVisible();
+ await expect(tour.getByRole("button", { name: "Mute welcome music" })).toBeVisible();
+ });
+}
+
+test("Review permission setup uses onboarding preview in development", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.showOnboardingWindow = async (options) => {
+ document.documentElement.dataset.previewRequested = String(options?.preview);
+ return false;
+ };
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true })
+ .click();
+ await page
+ .getByRole("dialog", { name: "Settings", exact: true })
+ .getByRole("tab", { name: "Recording", exact: true })
+ .click();
+ await page.getByRole("button", { name: "Review permission setup", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-preview-requested", "true");
+ const tour = page.getByRole("dialog", { name: "Welcome to Recordly", exact: true });
+ await expect(tour.getByPlaceholder("you@example.com")).toBeVisible();
+ await tour.getByRole("button", { name: "Continue", exact: true }).click();
+ await expect(tour.getByRole("navigation")).toBeVisible();
+ await expect(tour.getByRole("button", { name: "Back to account" })).toBeVisible();
+ await expect(tour.getByRole("heading", { name: "Allow Recordly to record" })).toBeVisible();
+});
+
+test("welcome mark and video blend into floating blue blobs regardless of reduced motion", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await page.emulateMedia({ colorScheme: "light", reducedMotion: "reduce" });
+ await page.setViewportSize({ width: 760, height: 760 });
+ await page.goto("/?windowType=onboarding&onboardingPreview=1");
+ await expect(page.getByRole("heading", { name: "Welcome to Recordly" })).toBeVisible();
+ const styles = await page.locator(".onboarding-title-logo").evaluate((logo) => {
+ const style = getComputedStyle(logo);
+ const root = document.querySelector(".onboarding-layout")!;
+ const pulse = getComputedStyle(root.querySelector(".onboarding-blob")!);
+ const artwork = getComputedStyle(document.querySelector(".onboarding-artwork")!);
+ return {
+ color: style.backgroundColor,
+ mask: style.maskImage,
+ pulse: pulse.animationName,
+ duration: pulse.animationDuration,
+ fade: artwork.maskImage,
+ };
+ });
+ expect(styles.color).not.toBe("rgba(0, 0, 0, 0)");
+ expect(styles.mask).toContain("recordly-mark.svg");
+ expect(styles.pulse).toBe("onboarding-blob-one");
+ expect(styles.duration).toBe("12s");
+ expect(styles.fade).toContain("rgba(0, 0, 0, 0) 100%");
+ await expect(page.locator(".onboarding-blob")).toHaveCount(3);
+ const position = () =>
+ page.locator(".onboarding-blob-one").evaluate((el) => el.getBoundingClientRect().x);
+ const initialPosition = await position();
+ await expect
+ .poll(async () => Math.abs((await position()) - initialPosition), { timeout: 6000 })
+ .toBeGreaterThan(8);
+ await page.screenshot({ path: "output/logo-ideas-2026-10-23/welcome-pulse-preview.png" });
+});
diff --git a/tests/ui/paused-motion.spec.ts b/tests/ui/paused-motion.spec.ts
index ea3798f0..b7909c5c 100644
--- a/tests/ui/paused-motion.spec.ts
+++ b/tests/ui/paused-motion.spec.ts
@@ -1,6 +1,254 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
+test("the camera reaches the incoming framing when preview frames are delayed", async ({
+ page,
+}) => {
+ test.setTimeout(60000);
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/paused-motion.html?pan&slowCamera");
+ const main = page.locator("main");
+ const video = page.locator("video").first();
+ await expect(main).toHaveAttribute("data-ready", "true");
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.seeking)).toBe(false);
+ const offset = async () => {
+ await page.getByRole("button", { name: "Read camera", exact: true }).click();
+ return Number(await main.getAttribute("data-camera-offset"));
+ };
+ await page.getByRole("button", { name: "Seek incoming start", exact: true }).click();
+ await expect.poll(offset, { timeout: 10000 }).toBeLessThan(-20);
+ const incoming = await offset();
+ await page.getByRole("button", { name: "Seek outgoing end", exact: true }).click();
+ await expect.poll(offset, { timeout: 10000 }).toBeGreaterThan(20);
+ await page.getByRole("button", { name: "Resume through zoom-out", exact: true }).click();
+ await expect
+ .poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime), { timeout: 15000 })
+ .toBeGreaterThanOrEqual(3.85);
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.paused)).toBe(true);
+ const actual = await offset();
+ expect(Math.abs(actual - incoming)).toBeLessThan(5);
+ await test.info().attach("delayed-preview-arrival.json", {
+ body: JSON.stringify({
+ incoming,
+ actual,
+ frames: JSON.parse((await main.getAttribute("data-pan-samples"))!),
+ }),
+ contentType: "application/json",
+ });
+});
+
+test("a short second connected 1.8x block arrives before its full exit", async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/paused-motion.html?pan&shortPan&visualBounds");
+ const main = page.locator("main");
+ const video = page.locator("video").first();
+ await expect(main).toHaveAttribute("data-ready", "true");
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.seeking)).toBe(false);
+ const scale = async () => {
+ await page.getByRole("button", { name: "Read camera", exact: true }).click();
+ return Number(await main.getAttribute("data-camera-scale"));
+ };
+ await page.getByRole("button", { name: "Seek incoming start", exact: true }).click();
+ await expect.poll(scale).toBeCloseTo(1.8, 5);
+ const zoom = page.locator('[data-variant="zoom"]').last();
+ const visibleEnd = Number(await zoom.getAttribute("data-end-ms"));
+ const storedEnd = Number(await zoom.getAttribute("data-stored-end-ms"));
+ expect(visibleEnd).toBeGreaterThan(3400);
+ expect(storedEnd).toBe(3695);
+ await page.getByRole("button", { name: "Seek outgoing end", exact: true }).click();
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.seeking)).toBe(false);
+ await page.getByRole("button", { name: "Play across pan", exact: true }).click();
+ await expect
+ .poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime))
+ .toBeGreaterThanOrEqual(2.745);
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.paused)).toBe(true);
+ const frames = JSON.parse((await main.getAttribute("data-pan-samples"))!) as Array<{
+ time: number;
+ scale: number;
+ }>;
+ const arriving = frames.filter((frame) => frame.time >= 2.65 && frame.time <= 2.75);
+ expect(arriving.length).toBeGreaterThan(0);
+ expect(Math.max(...arriving.map((frame) => frame.scale))).toBeGreaterThan(1.77);
+ await page.getByRole("button", { name: "Resume through zoom-out", exact: true }).click();
+ await expect
+ .poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime))
+ .toBeGreaterThanOrEqual(3.85);
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.paused)).toBe(true);
+ await expect.poll(scale).toBe(1);
+ const allFrames = JSON.parse((await main.getAttribute("data-pan-samples"))!) as Array<{
+ time: number;
+ scale: number;
+ }>;
+ expect(
+ allFrames.some((frame) => frame.time > 3.695 && frame.time < 3.799 && frame.scale > 1),
+ ).toBe(true);
+});
+
+for (const preset of ["focused", "smooth"]) {
+ test(`${preset} short zoom keeps the configured entrance and exit pace`, async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ const samples: Array<{ entry: number; exit: number }> = [];
+ for (const duration of [3000, 1000]) {
+ await page.goto(
+ `/tests/ui/paused-motion.html?timing&zoomDuration=${duration}${preset === "smooth" ? "&smooth" : ""}`,
+ );
+ const main = page.locator("main");
+ await expect(main).toHaveAttribute("data-ready", "true");
+ await expect
+ .poll(() =>
+ page
+ .locator("video")
+ .first()
+ .evaluate((v: HTMLVideoElement) => v.seeking),
+ )
+ .toBe(false);
+ const scaleAt = async (button: string, time: number) => {
+ await page.getByRole("button", { name: button, exact: true }).click();
+ const video = page.locator("video").first();
+ await expect
+ .poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime))
+ .toBeCloseTo(time, 3);
+ await expect
+ .poll(() => video.evaluate((v: HTMLVideoElement) => v.seeking))
+ .toBe(false);
+ await page.getByRole("button", { name: "Read camera", exact: true }).click();
+ return Number(await main.getAttribute("data-camera-scale"));
+ };
+ samples.push({
+ entry: await scaleAt("Seek early entry", 1.1),
+ exit: await scaleAt("Seek last exit sample", (1000 + duration - 100) / 1000),
+ });
+ }
+ expect(samples[1].entry).toBeGreaterThan(1);
+ expect(samples[1].entry).toBeLessThanOrEqual(samples[0].entry + 0.00001);
+ expect(samples[1].exit).toBeGreaterThan(1);
+ expect(samples[1].exit).toBeLessThanOrEqual(samples[0].exit + 0.00001);
+ await test.info().attach("short-and-long-zoom-pace.json", {
+ body: JSON.stringify(samples),
+ contentType: "application/json",
+ });
+ });
+}
+
+test("automatic entrance aims at the cursor from its first faint playback frame", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/paused-motion.html?timing&focusEntry");
+ const main = page.locator("main");
+ const video = page.locator("video").first();
+ await expect(main).toHaveAttribute("data-ready", "true");
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.seeking)).toBe(false);
+ await page.getByRole("button", { name: "Play to full zoom", exact: true }).click();
+ await expect
+ .poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime))
+ .toBeGreaterThanOrEqual(2);
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.paused)).toBe(true);
+ const frames = JSON.parse((await main.getAttribute("data-pan-samples"))!) as Array<{
+ time: number;
+ offset: number;
+ scale: number;
+ }>;
+ const entrance = frames.filter(
+ (frame) => frame.time > 1 && frame.time < 1.4 && frame.scale > 1.000001,
+ );
+ expect(entrance.length).toBeGreaterThan(4);
+ // Normalizing by zoom depth exposes center-first framing even in faint frames.
+ const aim = entrance.map((frame) => frame.offset / (frame.scale - 1));
+ expect(Math.max(...aim)).toBeLessThan(-100);
+ expect(Math.max(...aim) - Math.min(...aim)).toBeLessThan(0.1);
+ await test.info().attach("automatic-entrance-camera-poses.json", {
+ body: JSON.stringify(entrance),
+ contentType: "application/json",
+ });
+});
+
+for (const panDepth of ["same", "deeper", "wider"]) {
+ test(`connected pan ${panDepth} starts in the gap and settles through the incoming block`, async ({
+ page,
+ }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto(`/tests/ui/paused-motion.html?pan&panDepth=${panDepth}`);
+ const main = page.locator("main");
+ const video = page.locator("video").first();
+ await expect(main).toHaveAttribute("data-ready", "true");
+ const readOffset = async () => {
+ await page.getByRole("button", { name: "Read camera", exact: true }).click();
+ return Number(await main.getAttribute("data-camera-offset"));
+ };
+ const seek = async (button: string, time: number) => {
+ await page.getByRole("button", { name: button, exact: true }).click();
+ await expect
+ .poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime))
+ .toBeCloseTo(time, 2);
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.seeking)).toBe(false);
+ };
+ await expect.poll(readOffset).toBeGreaterThan(20);
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.seeking)).toBe(false);
+ await seek("Seek outgoing hold", 1.5);
+ await expect.poll(readOffset).toBeGreaterThan(20);
+ const outgoing = await readOffset();
+ const outgoingScale = Number(await main.getAttribute("data-camera-scale"));
+ await seek("Seek outgoing end", 1.9);
+ await expect.poll(readOffset).toBeCloseTo(outgoing, 1);
+ await seek("Seek incoming start", 3);
+ await expect.poll(readOffset).toBeLessThan(-20);
+ const incoming = await readOffset();
+ const incomingScale = Number(await main.getAttribute("data-camera-scale"));
+ await seek("Seek pan midpoint", 2.5);
+ await expect.poll(readOffset).toBeGreaterThan(incoming);
+ expect(await readOffset()).toBeLessThan(outgoing);
+ await seek("Seek outgoing end", 1.9);
+ await page.getByRole("button", { name: "Play across pan", exact: true }).click();
+ await expect
+ .poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime))
+ .toBeGreaterThanOrEqual(3.05);
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.paused)).toBe(true);
+ const arriving = await readOffset();
+ // The camera keeps moving after the pan goal reaches the incoming block.
+ expect(arriving).toBeGreaterThan(incoming + 0.5);
+ await page.getByRole("button", { name: "Play through settling", exact: true }).click();
+ await expect
+ .poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime))
+ .toBeGreaterThanOrEqual(3.8);
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.paused)).toBe(true);
+ expect(await readOffset()).toBeLessThan(arriving - 0.5);
+ expect(await readOffset()).toBeCloseTo(incoming, 0);
+ const frames = JSON.parse((await main.getAttribute("data-pan-samples"))!) as Array<{
+ time: number;
+ offset: number;
+ scale: number;
+ }>;
+ const before = frames.filter((f) => f.time > 2.9 && f.time < 3);
+ const after = frames.filter((f) => f.time >= 3 && f.time < 3.1);
+ expect(before.length).toBeGreaterThan(1);
+ expect(after.length).toBeGreaterThan(1);
+ const crossing = Math.abs(after[0].offset - before.at(-1)!.offset);
+ const preceding = Math.abs(before.at(-1)!.offset - before.at(-2)!.offset);
+ expect(crossing).toBeLessThan(preceding * 2 + 0.5);
+ expect(Math.abs(after.at(-1)!.offset - after[0].offset)).toBeGreaterThan(0.1);
+ if (panDepth !== "same") {
+ const firstScale = after[0].scale;
+ const lastScale = after.at(-1)!.scale;
+ if (incomingScale > outgoingScale) {
+ expect(lastScale).toBeGreaterThan(firstScale);
+ expect(firstScale).toBeLessThan(incomingScale - 0.0001);
+ } else {
+ expect(lastScale).toBeLessThan(firstScale);
+ expect(firstScale).toBeGreaterThan(incomingScale + 0.0001);
+ }
+ const precedingScale = Math.abs(before.at(-1)!.scale - before.at(-2)!.scale);
+ expect(Math.abs(firstScale - before.at(-1)!.scale)).toBeLessThan(
+ precedingScale * 2 + 0.0005,
+ );
+ }
+ await test.info().attach("connected-pan-motion", {
+ body: JSON.stringify({ panDepth, outgoing, incoming, frames }),
+ contentType: "application/json",
+ });
+ });
+}
+
test("auto-zoom resumes from a paused seek without retaining the old full-zoom focus", async ({
page,
}) => {
@@ -26,18 +274,104 @@ test("auto-zoom resumes from a paused seek without retaining the old full-zoom f
await page.getByRole("button", { name: "Seek zoom-out" }).click();
await expect
.poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime))
- .toBeCloseTo(4.2, 2);
+ .toBeCloseTo(3.7, 2);
await expect.poll(cameraOffset).toBeLessThan(1);
await page.getByRole("button", { name: "Resume through zoom-out", exact: true }).click();
await expect
.poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime), { intervals: [50] })
- .toBeGreaterThanOrEqual(4.35);
+ .toBeGreaterThanOrEqual(3.85);
await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.paused)).toBe(true);
- expect(await video.evaluate((v: HTMLVideoElement) => v.currentTime)).toBeLessThan(4.7);
+ expect(await video.evaluate((v: HTMLVideoElement) => v.currentTime)).toBeLessThan(4);
expect(await cameraOffset()).toBeLessThan(1);
expect(errors).toEqual([]);
});
+for (const preset of ["focused", "smooth"]) {
+ test(`${preset} zoom finishes at its timeline boundary in playback and after seeks`, async ({
+ page,
+ }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto(
+ `/tests/ui/paused-motion.html?timing${preset === "smooth" ? "&smooth" : ""}`,
+ );
+ const main = page.locator("main");
+ const video = page.locator("video").first();
+ await expect(main).toHaveAttribute("data-ready", "true");
+ const scale = async () => {
+ await page.getByRole("button", { name: "Read camera", exact: true }).click();
+ return Number(await main.getAttribute("data-camera-scale"));
+ };
+ for (const [button, expected] of [
+ ["Seek zoom start", 1],
+ ["Seek full zoom", 1.5],
+ ["Seek zoom end", 1],
+ ] as const) {
+ await page.getByRole("button", { name: button, exact: true }).click();
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.seeking)).toBe(false);
+ await expect.poll(scale).toBeCloseTo(expected, 4);
+ }
+ await page.getByRole("button", { name: "Seek early entry", exact: true }).click();
+ await expect.poll(scale).toBeGreaterThan(1);
+ expect(await scale()).toBeLessThan(1.2);
+ await page.getByRole("button", { name: "Seek early exit", exact: true }).click();
+ await expect.poll(scale).toBeGreaterThan(1.35);
+ expect(await scale()).toBeLessThan(1.5);
+ await page.getByRole("button", { name: "Seek exit midpoint", exact: true }).click();
+ await expect.poll(scale).toBeGreaterThan(1);
+ expect(await scale()).toBeLessThan(1.5);
+ await page.getByRole("button", { name: "Resume through zoom-out", exact: true }).click();
+ await expect
+ .poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime))
+ .toBeGreaterThanOrEqual(4.05);
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.paused)).toBe(true);
+ expect(await scale()).toBe(1);
+ });
+}
+
+for (const preset of ["focused", "smooth"]) {
+ test(`${preset} zoom completes its full settling tail after the displayed block ends`, async ({
+ page,
+ }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto(
+ `/tests/ui/paused-motion.html?timing&visualBounds${preset === "smooth" ? "&smooth" : ""}`,
+ );
+ const main = page.locator("main");
+ const video = page.locator("video").first();
+ const zoom = page.locator('[data-timeline-item][data-variant="zoom"]');
+ await expect(main).toHaveAttribute("data-ready", "true");
+ await expect(zoom).toHaveAttribute("data-stored-end-ms", "4000");
+ const visibleEnd = Number(await zoom.getAttribute("data-end-ms"));
+ expect(visibleEnd).toBeGreaterThan(3500);
+ expect(visibleEnd).toBeLessThan(3750);
+ await page.getByRole("button", { name: "Seek zoom start", exact: true }).click();
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.seeking)).toBe(false);
+ await page.getByRole("button", { name: "Resume through zoom-out", exact: true }).click();
+ await expect
+ .poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime))
+ .toBeGreaterThanOrEqual(4.05);
+ await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.paused)).toBe(true);
+ const frames = JSON.parse((await main.getAttribute("data-pan-samples"))!) as Array<{
+ time: number;
+ scale: number;
+ }>;
+ const tail = frames.filter((frame) => frame.time > visibleEnd / 1000 && frame.time < 3.95);
+ await test.info().attach("zoom-visible-bound-and-full-settling.json", {
+ body: JSON.stringify({ visibleEndMs: visibleEnd, fullMotionEndMs: 4000, frames }),
+ contentType: "application/json",
+ });
+ expect(tail.length).toBeGreaterThan(2);
+ expect(tail[0].scale).toBeGreaterThan(1);
+ expect(tail.at(-1)!.scale).toBeLessThan(tail[0].scale);
+ await expect
+ .poll(async () => {
+ await page.getByRole("button", { name: "Read camera", exact: true }).click();
+ return Number(await main.getAttribute("data-camera-scale"));
+ })
+ .toBe(1);
+ });
+}
+
for (const cursor of [false, true]) {
test(`pause/resume preserves ${cursor ? "cursor" : "camera"} motion without a decoder seek`, async ({
page,
diff --git a/tests/ui/paused-motion.tsx b/tests/ui/paused-motion.tsx
index ec620cb6..57a0a69d 100644
--- a/tests/ui/paused-motion.tsx
+++ b/tests/ui/paused-motion.tsx
@@ -1,40 +1,81 @@
-import { useCallback, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
+import { CURSOR_MOTION_PRESETS } from "@/components/video-editor/cursorMotionPresets";
+import TimelineEditor from "@/components/video-editor/timeline/TimelineEditor";
import VideoPlayback, { type VideoPlaybackRef } from "@/components/video-editor/VideoPlayback";
+import { getZoomRegionTiming } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
import { I18nProvider } from "@/contexts/I18nContext";
+import { ShortcutsProvider } from "@/contexts/ShortcutsContext";
import "@/index.css";
const cursor = new URLSearchParams(location.search).has("cursor");
const autoZoom = new URLSearchParams(location.search).has("autoZoom");
+const timing = new URLSearchParams(location.search).has("timing");
+const visualBounds = new URLSearchParams(location.search).has("visualBounds");
+const focusEntry = new URLSearchParams(location.search).has("focusEntry");
+const zoomDuration = Number(new URLSearchParams(location.search).get("zoomDuration")) || 3000;
+const pan = new URLSearchParams(location.search).has("pan");
+const shortPan = new URLSearchParams(location.search).has("shortPan");
+const slowCamera = new URLSearchParams(location.search).has("slowCamera");
+const panDepth = new URLSearchParams(location.search).get("panDepth");
+const preset =
+ CURSOR_MOTION_PRESETS[
+ new URLSearchParams(location.search).has("smooth") ? "smooth" : "focused"
+ ];
const noop = () => undefined;
const onError = (error: string) => {
throw new Error(error);
};
const clips = [{ id: "clip", startMs: 0, endMs: 6000, speed: 1 }];
-const zooms = [
- {
- id: "zoom",
- startMs: 0,
- endMs: 4000,
- depth: 2 as const,
- focus: { cx: 0.5, cy: 0.5 },
- mode: autoZoom ? ("auto" as const) : ("manual" as const),
- },
-];
-const telemetry = [
- { timeMs: 0, cx: 0.1, cy: 0.5 },
- { timeMs: 1000, cx: 0.9, cy: 0.5 },
- { timeMs: 3400, cx: 0.9, cy: 0.5 },
- { timeMs: 3500, cx: 0.5, cy: 0.5 },
- { timeMs: 6000, cx: 0.5, cy: 0.5 },
-];
+const zooms = pan
+ ? [
+ {
+ id: "first",
+ startMs: 0,
+ endMs: 2000,
+ depth: shortPan || panDepth === "wider" ? (3 as const) : (2 as const),
+ focus: { cx: 0.35, cy: 0.5 },
+ mode: "manual" as const,
+ },
+ {
+ id: "next",
+ startMs: shortPan ? 2695 : 3000,
+ endMs: shortPan ? 3695 : 5000,
+ depth: shortPan || panDepth === "deeper" ? (3 as const) : (2 as const),
+ focus: { cx: 0.65, cy: 0.5 },
+ mode: "manual" as const,
+ },
+ ]
+ : [
+ {
+ id: "zoom",
+ startMs: timing ? 1000 : 0,
+ endMs: timing ? 1000 + zoomDuration : 4000,
+ depth: focusEntry ? (3 as const) : (2 as const),
+ focus: { cx: 0.5, cy: 0.5 },
+ mode: autoZoom || focusEntry ? ("auto" as const) : ("manual" as const),
+ },
+ ];
+const timingPhases = getZoomRegionTiming(zooms[0], preset);
+const telemetry = focusEntry
+ ? [{ timeMs: 0, cx: 0.9, cy: 0.1 }]
+ : [
+ { timeMs: 0, cx: 0.1, cy: 0.5 },
+ { timeMs: 1000, cx: 0.9, cy: 0.5 },
+ { timeMs: 3400, cx: 0.9, cy: 0.5 },
+ { timeMs: 3500, cx: 0.5, cy: 0.5 },
+ { timeMs: 6000, cx: 0.5, cy: 0.5 },
+ ];
function Preview() {
const playback = useRef(null);
const stopAt = useRef(null);
+ const main = useRef(null);
+ const panSamples = useRef>([]);
const [playing, setPlaying] = useState(false);
const [time, setTime] = useState(0.8);
const [blur, setBlur] = useState(true);
const [ready, setReady] = useState(false);
const [cameraOffset, setCameraOffset] = useState(null);
+ const [cameraScale, setCameraScale] = useState(null);
const onTimeUpdate = useCallback((nextTime: number) => {
setTime(nextTime);
if (stopAt.current !== null && nextTime >= stopAt.current) {
@@ -42,18 +83,127 @@ function Preview() {
playback.current?.pause();
}
}, []);
+ useEffect(() => {
+ if (!ready || !slowCamera) return;
+ const ticker = playback.current?.app?.ticker;
+ if (!ticker) return;
+ const previous = ticker.maxFPS;
+ ticker.maxFPS = 2;
+ return () => {
+ ticker.maxFPS = previous;
+ };
+ }, [ready]);
+ useEffect(() => {
+ if ((!pan && !visualBounds && !focusEntry) || !ready || !playing) return;
+ const app = playback.current?.app;
+ if (!app) return;
+ const sample = () => {
+ const video = main.current?.querySelector("video");
+ const camera = playback.current?.videoContainer?.parent?.parent;
+ if (!video || !camera) return;
+ const frames = panSamples.current;
+ if (frames.at(-1)?.time === video.currentTime) return;
+ frames.push({
+ time: video.currentTime,
+ scale: camera.scale.x,
+ offset: camera.x + (app.screen.width / 2) * (camera.scale.x - 1),
+ });
+ if (main.current) main.current.dataset.panSamples = JSON.stringify(frames);
+ };
+ // Observe composed camera poses after the playback ticker has integrated physics.
+ app.ticker.add(sample, undefined, -100);
+ return () => {
+ app.ticker.remove(sample);
+ };
+ }, [ready, playing]);
return (
playback.current?.play()}>Play
playback.current?.pause()}>Pause
setBlur((value) => !value)}>Toggle blur
playback.current?.seekTimeline(3)}>Seek elsewhere
playback.current?.seekTimeline(0.8)}>Seek back
- playback.current?.seekTimeline(4.2)}>Seek zoom-out
+ playback.current?.seekTimeline(3.7)}>Seek zoom-out
+ {pan && (
+ <>
+ playback.current?.seekTimeline(1.5)}>
+ Seek outgoing hold
+
+ playback.current?.seekTimeline(1.9)}>
+ Seek outgoing end
+
+ playback.current?.seekTimeline(2.5)}>
+ Seek pan midpoint
+
+ playback.current?.seekTimeline(zooms[1].startMs / 1000)}>
+ Seek incoming start
+
+ {
+ panSamples.current = [];
+ stopAt.current = zooms[1].startMs / 1000 + 0.05;
+ playback.current?.play();
+ }}
+ >
+ Play across pan
+
+ {
+ stopAt.current = 3.8;
+ playback.current?.play();
+ }}
+ >
+ Play through settling
+
+ >
+ )}
+ {timing && (
+ <>
+ playback.current?.seekTimeline(1.1)}>
+ Seek early entry
+
+
+ playback.current?.seekTimeline((zooms[0].endMs - 100) / 1000)
+ }
+ >
+ Seek last exit sample
+
+
+ playback.current?.seekTimeline((timingPhases.zoomOutStart + 100) / 1000)
+ }
+ >
+ Seek early exit
+
+ playback.current?.seekTimeline(1)}>
+ Seek zoom start
+
+
+ playback.current?.seekTimeline(timingPhases.zoomInEnd / 1000)
+ }
+ >
+ Seek full zoom
+
+
+ playback.current?.seekTimeline(
+ (4000 + timingPhases.zoomOutStart) / 2000,
+ )
+ }
+ >
+ Seek exit midpoint
+
+ playback.current?.seekTimeline(4)}>Seek zoom end
+ >
+ )}
{
stopAt.current = 2;
@@ -64,7 +214,7 @@ function Preview() {
{
- stopAt.current = 4.35;
+ stopAt.current = timing ? 4.05 : 3.85;
playback.current?.play();
}}
>
@@ -76,6 +226,7 @@ function Preview() {
const width = playback.current?.app?.screen.width;
if (camera && width) {
setCameraOffset(camera.x + (width / 2) * (camera.scale.x - 1));
+ setCameraScale(camera.scale.x);
}
}}
>
@@ -92,6 +243,13 @@ function Preview() {
onError={onError}
onPreviewReadyChange={setReady}
zoomRegions={cursor ? [] : zooms}
+ {...(timing || pan
+ ? {
+ zoomInDurationMs: preset.zoomInDurationMs,
+ zoomOutDurationMs: preset.zoomOutDurationMs,
+ connectZooms: pan,
+ }
+ : {})}
selectedZoomId={null}
onSelectZoom={noop}
onZoomFocusChange={noop}
@@ -106,6 +264,24 @@ function Preview() {
cursorMotionBlur={cursor && blur ? 1 : 0}
zoomMotionBlur={!cursor && !autoZoom && blur ? 1 : 0}
/>
+ {visualBounds && (
+
+
+
+
+
+ )}
);
}
diff --git a/tests/ui/permission-accessory.spec.ts b/tests/ui/permission-accessory.spec.ts
new file mode 100644
index 00000000..7f64659f
--- /dev/null
+++ b/tests/ui/permission-accessory.spec.ts
@@ -0,0 +1,54 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+for (const colorScheme of ["light", "dark"] as const) {
+ for (const permission of ["screen", "accessibility"] as const) {
+ test(`permission accessory uses Recordly components and the ${permission} label (${colorScheme})`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 368, height: 264 });
+ await page.emulateMedia({ colorScheme, reducedMotion: "reduce" });
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.startPermissionAccessoryDrag = async () => {
+ document.documentElement.dataset.nativeDrag = "requested";
+ return { success: true };
+ };
+ window.electronAPI.closePermissionAccessory = async () => {
+ document.documentElement.dataset.accessoryClosed = "true";
+ return { success: true };
+ };
+ });
+ await page.goto(`/?windowType=permission-accessory&permission=${permission}`);
+ const name = permission === "screen" ? "Screen Recording" : "Accessibility";
+ await expect(
+ page.getByRole("heading", { name: "Drag to System Settings", exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: `Drag Recordly ${name} to System Settings`, exact: true }),
+ ).toBeVisible();
+ await expect(page.getByText("Electron", { exact: true })).toHaveCount(0);
+ await expect(page.getByAltText("Recordly")).toBeVisible();
+ expect(
+ await page
+ .locator(".card")
+ .evaluate((element) => element.scrollHeight - element.clientHeight),
+ ).toBeLessThanOrEqual(1);
+ await page.locator("[data-permission-drag]").dispatchEvent("dragstart");
+ await expect(page.locator("html")).toHaveAttribute("data-native-drag", "requested");
+ await page.screenshot({ path: test.info().outputPath("accessory.png") });
+ await page.getByRole("button", { name: "Back", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-accessory-closed", "true");
+ });
+ }
+}
+
+test("sign-in restores the divider between social providers and email", async ({ page }) => {
+ await page.setViewportSize({ width: 760, height: 760 });
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: false });
+ await page.goto("/?windowType=onboarding&onboardingPreview=1");
+ const divider = page.locator("[data-signin-divider]");
+ await expect(divider).toBeVisible();
+ await expect(divider).toHaveText("or");
+ await expect(divider.getByRole("separator")).toHaveCount(2);
+});
diff --git a/tests/ui/permission-preview.spec.ts b/tests/ui/permission-preview.spec.ts
new file mode 100644
index 00000000..eaa7021b
--- /dev/null
+++ b/tests/ui/permission-preview.spec.ts
@@ -0,0 +1,133 @@
+import { expect, test } from "@playwright/test";
+import { productionTest } from "./production";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+for (const width of [390, 760]) {
+ test(`dev permission preview opens real settings without simulating grants (${width}px)`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width, height: 760 });
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getScreenRecordingPermissionStatus = async () => ({
+ success: true,
+ status: "denied",
+ });
+ window.electronAPI.getAccessibilityPermissionStatus = async () => ({
+ success: true,
+ trusted: false,
+ prompted: false,
+ });
+ window.electronAPI.openScreenRecordingPreferences = async (preview) => {
+ document.documentElement.dataset.screenOpened = String(preview);
+ return { success: true };
+ };
+ window.electronAPI.openAccessibilityPreferences = async (preview) => {
+ document.documentElement.dataset.accessibilityOpened = String(preview);
+ return { success: true };
+ };
+ });
+ await page.goto("/?windowType=onboarding&onboardingPreview=1");
+ const onboarding = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ const next = onboarding.getByRole("button", { name: "Continue", exact: true });
+ await expect(next).toBeVisible({ timeout: 30000 });
+ const before = (await next.boundingBox())!;
+ await next.click();
+ const start = onboarding.getByRole("button", { name: "Start recording", exact: true });
+ await expect(start).toBeDisabled();
+ const after = (await start.boundingBox())!;
+ expect(Math.abs(after.y - before.y)).toBeLessThanOrEqual(1);
+ expect(Math.abs(after.x + after.width - before.x - before.width)).toBeLessThanOrEqual(1);
+ const controls = onboarding.locator("[data-permission-preview-controls]");
+ await expect(controls.getByRole("button")).toHaveCount(1);
+ await controls.getByRole("button", { name: "Simulate permissions", exact: true }).click();
+ await onboarding.getByRole("button", { name: "Allow Screen Recording", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-screen-opened", "true");
+ await onboarding.getByRole("button", { name: "Allow Accessibility", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-accessibility-opened", "true");
+ await expect(start).toBeDisabled();
+ await expect(page.getByRole("dialog")).toHaveCount(1);
+ await expect(page.getByText("Drop Recordly here to add it.")).toHaveCount(0);
+ await expect(onboarding.getByRole("status", { name: /Allowed/ })).toHaveCount(0);
+ await onboarding.screenshot({
+ path: test.info().outputPath(`native-permission-preview-${width}.png`),
+ });
+ });
+}
+
+test("dev simulation resets granted rows and uses their normal Allow buttons", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 760, height: 760 });
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.openScreenRecordingPreferences = async (preview) => {
+ document.documentElement.dataset.screenOpened = String(preview);
+ return { success: true };
+ };
+ window.electronAPI.openAccessibilityPreferences = async (preview) => {
+ document.documentElement.dataset.accessibilityOpened = String(preview);
+ return { success: true };
+ };
+ });
+ await page.goto("/?windowType=onboarding&onboardingPreview=1");
+ const onboarding = page.getByRole("dialog", { name: "Welcome to Recordly" });
+ await onboarding.getByRole("button", { name: "Continue", exact: true }).click();
+ await expect(
+ onboarding.getByRole("status", { name: "Screen Recording: Allowed" }),
+ ).toBeVisible();
+ const start = onboarding.getByRole("button", { name: "Start recording", exact: true });
+ await expect(start).toBeEnabled();
+ const controls = onboarding.locator("[data-permission-preview-controls]");
+ await expect(controls.getByRole("button")).toHaveCount(1);
+ const simulate = controls.getByRole("button", { name: "Simulate permissions", exact: true });
+ await simulate.click();
+ await expect(onboarding.getByRole("status", { name: /Allowed/ })).toHaveCount(0);
+ await expect(start).toBeDisabled();
+ // Automatic status refresh must not undo the reset before the user clicks a row.
+ await page.evaluate(() => window.dispatchEvent(new Event("focus")));
+ await page.waitForTimeout(3100);
+ await expect(onboarding.getByRole("button", { name: "Allow Screen Recording", exact: true })).toBeVisible();
+ await expect(onboarding.getByRole("button", { name: "Allow Accessibility", exact: true })).toBeVisible();
+ await onboarding.getByRole("button", { name: "Allow Screen Recording", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-screen-opened", "true");
+ await expect(onboarding.getByRole("status", { name: "Screen Recording: Allowed", exact: true })).toBeVisible();
+ await expect(start).toBeDisabled();
+ await onboarding.getByRole("button", { name: "Allow Accessibility", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-accessibility-opened", "true");
+ await expect(start).toBeEnabled();
+ await simulate.click();
+ await expect(onboarding.getByRole("status", { name: /Allowed/ })).toHaveCount(0);
+ await expect(start).toBeDisabled();
+ await onboarding.screenshot({ path: test.info().outputPath("simulate-permissions-reset.png") });
+});
+
+productionTest("confetti falls over the full window from above", async ({ page }) => {
+ await page.setViewportSize({ width: 760, height: 760 });
+ await page.emulateMedia({ reducedMotion: "no-preference" });
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getAppSetting = (key) =>
+ key === "recordly.onboarding.permissionsRequested" ? true : null;
+ });
+ await page.goto("/?windowType=onboarding");
+ const rain = page.locator("[data-permissions-confetti]");
+ await expect(rain).toBeVisible({ timeout: 30000 });
+ await expect(rain.locator("canvas")).toHaveJSProperty("width", 760);
+ const bounds = (await rain.boundingBox())!;
+ expect(bounds).toEqual({ x: 0, y: 0, width: 760, height: 760 });
+ expect(
+ await rain.evaluate((element) => {
+ const backdrop = document.querySelector(".modal__backdrop")!;
+ return (
+ Number(getComputedStyle(element).zIndex) > Number(getComputedStyle(backdrop).zIndex)
+ );
+ }),
+ ).toBe(true);
+ // Let the first pieces enter the frame for visual review of their direction.
+ await page.waitForTimeout(500);
+ await page.screenshot({ path: test.info().outputPath("confetti-rain.png") });
+ await expect(rain).toHaveCount(0, { timeout: 6000 });
+});
diff --git a/tests/ui/permission-recovery.spec.ts b/tests/ui/permission-recovery.spec.ts
new file mode 100644
index 00000000..31117095
--- /dev/null
+++ b/tests/ui/permission-recovery.spec.ts
@@ -0,0 +1,106 @@
+import { expect } from "@playwright/test";
+import { productionTest as test } from "./production";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test("a pending permission request still requires an expired session to sign in", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false, onboarded: true });
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getAppSetting = (key) =>
+ key === "recordly.onboarding.permissionsRequested" ||
+ key === "recordly.onboarding.v1.seen";
+ });
+ await page.goto("/?windowType=onboarding");
+ await expect(page.getByPlaceholder("you@example.com")).toBeVisible({ timeout: 15000 });
+ await expect(page.getByRole("dialog", { name: "Permissions", exact: true })).not.toBeVisible();
+ await expect(page.getByRole("button", { name: "Start recording", exact: true })).toHaveCount(0);
+});
+
+test("a recording permission request opens recovery and can be closed and reopened", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: true, onboarded: true });
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.onRecordingPermissionsRequested = (callback) => {
+ window.addEventListener("test-permissions-requested", callback);
+ return () => window.removeEventListener("test-permissions-requested", callback);
+ };
+ });
+ await page.goto("/?windowType=editor");
+ await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible({
+ timeout: 15000,
+ });
+ const permissions = page.getByRole("dialog", { name: "Permissions", exact: true });
+ for (let attempt = 0; attempt < 2; attempt++) {
+ await page.evaluate(() => window.dispatchEvent(new Event("test-permissions-requested")));
+ await expect(permissions).toBeVisible();
+ await expect(permissions.getByRole("navigation")).toHaveCount(0);
+ await expect(permissions.getByRole("button", { name: "Back to account" })).toHaveCount(0);
+ await permissions.getByRole("button", { name: "Close", exact: true }).click();
+ await expect(permissions).not.toBeVisible();
+ await expect(page.getByRole("dialog", { name: "Welcome to Recordly" })).not.toBeVisible();
+ }
+});
+
+for (const standalone of [false, true]) {
+ test(`permission recovery is separate from onboarding (${standalone ? "window" : "inline"})`, async ({
+ page,
+ }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: true, onboarded: true });
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getAppSetting = (key) => {
+ if (key === "recordly.onboarding.v1.seen") return true;
+ if (key === "recordly.onboarding.permissionsRequested")
+ return localStorage.getItem("permission-recovery-done") !== "true";
+ return null;
+ };
+ window.electronAPI.setAppSetting = (key, value) => {
+ if (key === "recordly.onboarding.v1.seen")
+ document.documentElement.dataset.onboardingRewritten = "true";
+ if (key === "recordly.onboarding.permissionsRequested" && value === false)
+ localStorage.setItem("permission-recovery-done", "true");
+ return true;
+ };
+ let allowed = false;
+ window.electronAPI.getScreenRecordingPermissionStatus = async () => ({
+ success: true,
+ status: allowed ? "granted" : "denied",
+ });
+ window.electronAPI.openScreenRecordingPreferences = async () => {
+ allowed = true;
+ return { success: true };
+ };
+ });
+ await page.setViewportSize({ width: 760, height: 760 });
+ await page.goto(`/?windowType=${standalone ? "onboarding" : "editor"}`);
+ const permissions = page.getByRole("dialog", { name: "Permissions", exact: true });
+ await expect(permissions).toBeVisible();
+ await expect(
+ permissions.getByRole("heading", { name: "Allow Recordly to record" }),
+ ).toBeVisible();
+ await expect(permissions.getByRole("navigation")).toHaveCount(0);
+ await expect(permissions.getByRole("button", { name: "Back to account" })).toHaveCount(0);
+ await expect(permissions.getByPlaceholder("you@example.com")).toHaveCount(0);
+ await expect(permissions.getByRole("button", { name: "Mute welcome music" })).toHaveCount(
+ 0,
+ );
+ const start = permissions.getByRole("button", { name: "Start recording", exact: true });
+ await expect(start).toBeDisabled();
+ await permissions
+ .getByRole("button", { name: "Allow Screen Recording", exact: true })
+ .click();
+ await expect(start).toBeEnabled();
+ await permissions.screenshot({ path: test.info().outputPath("permission-recovery.png") });
+ await start.click();
+ await expect(permissions).not.toBeVisible();
+ await expect(page.locator("html")).toHaveAttribute("data-hud-opened", "true");
+ await expect(page.locator("html")).toHaveAttribute("data-onboarding-closed", "true");
+ await expect(page.locator("html")).not.toHaveAttribute("data-onboarding-rewritten");
+ await expect
+ .poll(() => page.evaluate(() => localStorage.getItem("permission-recovery-done")))
+ .toBe("true");
+ await page.reload();
+ await expect(permissions).not.toBeVisible();
+ });
+}
diff --git a/tests/ui/permission-refresh.spec.ts b/tests/ui/permission-refresh.spec.ts
new file mode 100644
index 00000000..c737bb71
--- /dev/null
+++ b/tests/ui/permission-refresh.spec.ts
@@ -0,0 +1,67 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+import { productionTest } from "./production";
+
+productionTest("permission indicators follow grants and revocations without a manual check", async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getAppSetting = (key) =>
+ key === "recordly.onboarding.permissionsRequested";
+ window.electronAPI.getScreenRecordingPermissionStatus = async () => ({
+ success: true,
+ status: localStorage.getItem("screen-allowed") === "true" ? "granted" : "denied",
+ });
+ window.electronAPI.getAccessibilityPermissionStatus = async () => ({
+ success: true,
+ trusted: localStorage.getItem("accessibility-allowed") === "true",
+ });
+ });
+ await page.goto("/?windowType=onboarding");
+ const panel = page.getByRole("dialog", { name: "Permissions", exact: true });
+ const start = panel.getByRole("button", { name: "Start recording", exact: true });
+ await expect(start).toBeDisabled();
+ await expect(panel.getByRole("button", { name: "Allow Screen Recording", exact: true })).toBeVisible();
+ await expect(panel.getByRole("button", { name: "Check again", exact: true })).toHaveCount(0);
+ await expect(panel.getByText("Permission changes may require restarting Recordly.")).toHaveCount(0);
+ await page.evaluate(() => {
+ localStorage.setItem("screen-allowed", "true");
+ localStorage.setItem("accessibility-allowed", "true");
+ });
+ await expect(panel.getByRole("status", { name: /Allowed/ })).toHaveCount(2);
+ await expect(start).toBeEnabled();
+ await page.evaluate(() => localStorage.setItem("screen-allowed", "false"));
+ await expect(panel.getByRole("button", { name: "Allow Screen Recording", exact: true })).toBeVisible();
+ await expect(panel.getByRole("status", { name: "Accessibility: Allowed", exact: true })).toBeVisible();
+ await expect(start).toBeDisabled();
+ await page.evaluate(() => localStorage.setItem("accessibility-allowed", "false"));
+ await expect(panel.getByRole("button", { name: "Allow Accessibility", exact: true })).toBeVisible();
+ await expect(panel.getByRole("status", { name: /Allowed/ })).toHaveCount(0);
+ await panel.screenshot({ path: test.info().outputPath("permissions-auto-refresh.png") });
+});
+
+test("permission polling stops when leaving the panel and resumes on return", async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ let checks = 0;
+ window.electronAPI.getScreenRecordingPermissionStatus = async () => {
+ document.documentElement.dataset.permissionChecks = String(++checks);
+ return { success: true, status: "denied" };
+ };
+ });
+ await page.goto("/?windowType=onboarding");
+ const panel = page.getByRole("dialog", { name: "Welcome to Recordly", exact: true });
+ await panel.getByRole("button", { name: "Continue", exact: true }).click();
+ await expect(panel.getByRole("button", { name: "Allow Screen Recording", exact: true })).toBeVisible();
+ const checks = () => page.locator("html").getAttribute("data-permission-checks");
+ const first = Number(await checks());
+ await expect.poll(async () => Number(await checks())).toBeGreaterThan(first);
+ await panel.getByRole("button", { name: "Back to account", exact: true }).click();
+ await expect(panel.locator(".onboarding-permissions")).toHaveCount(0);
+ const stopped = await checks();
+ await page.waitForTimeout(3300);
+ expect(await checks()).toBe(stopped);
+ await panel.getByRole("button", { name: "Continue", exact: true }).click();
+ await expect.poll(async () => Number(await checks())).toBeGreaterThan(Number(stopped));
+});
diff --git a/tests/ui/permissions-spacing.spec.ts b/tests/ui/permissions-spacing.spec.ts
new file mode 100644
index 00000000..ca51a8d6
--- /dev/null
+++ b/tests/ui/permissions-spacing.spec.ts
@@ -0,0 +1,78 @@
+import { expect } from "@playwright/test";
+import { productionTest as test } from "./production";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+for (const colorScheme of ["light", "dark"] as const) {
+ for (const side of [600, 760]) {
+ test(`permissions give spare height to the demo and keep controls together (${colorScheme}, ${side}px)`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: side, height: side });
+ await page.emulateMedia({ colorScheme, reducedMotion: "reduce" });
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getAppSetting = (key) =>
+ key === "recordly.onboarding.v1.seen" ||
+ key === "recordly.onboarding.permissionsRequested"
+ ? true
+ : null;
+ });
+ await page.goto("/?windowType=onboarding");
+ const dialog = page.getByRole("dialog", { name: "Permissions", exact: true });
+ await expect(dialog.getByText("Allowed", { exact: true })).toHaveCount(2);
+ const action = dialog.getByRole("button", { name: "Start recording", exact: true });
+ await expect(action).toBeEnabled();
+ await expect(action).toBeInViewport({ ratio: 1 });
+ await expect(dialog.getByRole("heading", { name: "Allow Recordly to record", exact: true })).toBeInViewport({ ratio: 1 });
+ const banner = (await dialog.locator("[data-onboarding-artwork]").boundingBox())!;
+ // Spare height belongs to the demo, keeping the permission rows near the footer.
+ expect(banner.height).toBeGreaterThan(180);
+ const primary = (await action.boundingBox())!;
+ const list = (await dialog.locator(".onboarding-permission-list").boundingBox())!;
+ const actionGap = primary.y - list.y - list.height;
+ expect(actionGap).toBeGreaterThanOrEqual(8);
+ expect(actionGap).toBeLessThanOrEqual(32);
+ expect(side - primary.y - primary.height).toBeGreaterThanOrEqual(8);
+ expect(side - primary.y - primary.height).toBeLessThanOrEqual(16);
+ expect(
+ await dialog
+ .locator("[data-onboarding-body]")
+ .evaluate((element) => element.scrollHeight - element.clientHeight),
+ ).toBeLessThanOrEqual(1);
+ await dialog.screenshot({
+ path: `test-results/permissions-spacing-${colorScheme}-${side}.png`,
+ animations: "disabled",
+ });
+ });
+ }
+}
+
+test("permissions keep the footer reachable when the banner must shrink", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 540 });
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getAppSetting = (key) =>
+ key === "recordly.onboarding.v1.seen" ||
+ key === "recordly.onboarding.permissionsRequested";
+ window.electronAPI.getScreenRecordingPermissionStatus = async () => ({
+ success: true,
+ status: "denied",
+ });
+ window.electronAPI.getAccessibilityPermissionStatus = async () => ({
+ success: true,
+ trusted: false,
+ });
+ });
+ await page.goto("/?windowType=onboarding");
+ const dialog = page.getByRole("dialog", { name: "Permissions", exact: true });
+ await expect(dialog.getByRole("button", { name: "Start recording", exact: true }))
+ .toBeInViewport({ ratio: 1 });
+ for (const name of ["Allow Screen Recording", "Allow Accessibility"]) {
+ const action = dialog.getByRole("button", { name, exact: true });
+ await action.scrollIntoViewIfNeeded();
+ // Scroll positions can round a fraction of a CSS pixel at the clipped edge.
+ await expect(action).toBeInViewport({ ratio: 0.99 });
+ }
+ await dialog.screenshot({ path: "test-results/permissions-spacing-narrow.png" });
+});
diff --git a/tests/ui/playback-shortcut.spec.ts b/tests/ui/playback-shortcut.spec.ts
index 0d07650e..9ff8bcd6 100644
--- a/tests/ui/playback-shortcut.spec.ts
+++ b/tests/ui/playback-shortcut.spec.ts
@@ -22,6 +22,8 @@ test("Space toggles once per press, including on the focused playback button", a
.evaluate((video: HTMLVideoElement) => video.readyState),
)
.toBeGreaterThanOrEqual(2);
+ // The decoded video can be ready before Pixi creates the playback transport.
+ await expect(page.locator("[data-preview-overlay]")).toBeVisible({ timeout: 20000 });
await play.focus();
await page.keyboard.down("Space");
await expect(pause).toBeVisible();
diff --git a/tests/ui/preview-performance.html b/tests/ui/preview-performance.html
new file mode 100644
index 00000000..615ba933
--- /dev/null
+++ b/tests/ui/preview-performance.html
@@ -0,0 +1 @@
+Preview rendering performance
diff --git a/tests/ui/preview-performance.spec.ts b/tests/ui/preview-performance.spec.ts
new file mode 100644
index 00000000..397a74e7
--- /dev/null
+++ b/tests/ui/preview-performance.spec.ts
@@ -0,0 +1,155 @@
+import { expect, test } from "@playwright/test";
+import type {} from "./preview-performance";
+import { installDesktopBridge } from "./bridge";
+
+test.afterEach(async ({ page }, testInfo) => {
+ if (testInfo.status !== testInfo.expectedStatus)
+ console.log(
+ "preview state",
+ await page.evaluate(() => ({
+ probe: !!window.performancePreview,
+ api: window.performancePreview?.api
+ ? {
+ app: !!window.performancePreview.api.app,
+ sprite: !!window.performancePreview.api.videoSprite,
+ visible: window.performancePreview.api.videoSprite?.visible,
+ ticker: window.performancePreview.api.app?.ticker.started,
+ }
+ : null,
+ videos: [...document.querySelectorAll("video")].map((v) => ({
+ time: v.currentTime,
+ seeking: v.seeking,
+ ready: v.readyState,
+ error: v.error?.message,
+ })),
+ })),
+ );
+});
+
+// Wait through browser frames rather than forcing Pixi updates: a clean pause
+// must remain idle on its own while edits and decoded seeks still wake it.
+async function observe(page: import("@playwright/test").Page) {
+ return page.evaluate(async () => {
+ const probe = window.performancePreview;
+ const before = probe.renders;
+ for (let i = 0; i < 20; i++) await new Promise(requestAnimationFrame);
+ return { renders: probe.renders - before, ticking: probe.api!.app!.ticker.started };
+ });
+}
+
+test("paused preview sleeps, refreshes edits and seeks, and resumes continuous playback", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/preview-performance.html");
+ await expect
+ .poll(
+ () =>
+ page.evaluate(() => Boolean(window.performancePreview?.api?.videoSprite?.visible)),
+ { timeout: 60000 },
+ )
+ .toBe(true);
+ await expect.poll(async () => (await observe(page)).renders).toBe(0);
+ expect((await observe(page)).ticking).toBe(false);
+ // Reproduce the previous paused policy with the same scene for comparison.
+ await page.evaluate(() => window.performancePreview.api!.app!.ticker.start());
+ const continuousPause = await observe(page);
+ expect(continuousPause.renders).toBeGreaterThan(5);
+ await page.evaluate(() => window.performancePreview.api!.app!.ticker.stop());
+ const sleepingPause = await observe(page);
+ expect(sleepingPause.renders).toBe(0);
+ console.log("Paused render comparison over 20 browser frames:", {
+ continuousPause,
+ sleepingPause,
+ });
+ const initial = await page.evaluate(() => window.performancePreview.api!.videoSprite!.width);
+ await page.getByRole("button", { name: "Change padding", exact: true }).click();
+ await expect
+ .poll(() => page.evaluate(() => window.performancePreview.api!.videoSprite!.width))
+ .not.toBe(initial);
+ await expect.poll(async () => (await observe(page)).renders).toBe(0);
+ for (const action of ["Toggle cursor", "Toggle annotation"]) {
+ const count = await page.evaluate(() => window.performancePreview.renders);
+ await page.getByRole("button", { name: action, exact: true }).click();
+ await expect
+ .poll(() => page.evaluate(() => window.performancePreview.renders))
+ .toBeGreaterThan(count);
+ await expect.poll(async () => (await observe(page)).renders).toBe(0);
+ }
+ await expect(page.locator('[data-annotation-id="text"]')).toBeVisible();
+ await page.evaluate(() => window.performancePreview.api!.seekTimeline(5.5));
+ await expect(page.locator('[data-annotation-id="text"]')).toHaveCount(0);
+ await expect.poll(async () => (await observe(page)).renders).toBe(0);
+ await page.evaluate(() => window.performancePreview.api!.seekTimeline(1));
+ await expect(page.locator('[data-annotation-id="text"]')).toBeVisible();
+ const playbackStart = await page.evaluate(() => window.performancePreview.renders);
+ await page.getByRole("button", { name: "Play", exact: true }).click();
+ await expect
+ .poll(() => page.evaluate(() => window.performancePreview.renders), { timeout: 20000 })
+ .toBeGreaterThan(playbackStart + 1);
+ expect(await page.evaluate(() => window.performancePreview.api!.app!.ticker.started)).toBe(
+ true,
+ );
+ await page.getByRole("button", { name: "Pause", exact: true }).click();
+ await expect.poll(async () => (await observe(page)).renders).toBe(0);
+ expect((await observe(page)).ticking).toBe(false);
+ const position = await page.evaluate(() => ({
+ scale: window.performancePreview.api!.videoContainer!.parent!.parent!.scale.x,
+ x: window.performancePreview.api!.videoContainer!.parent!.parent!.x,
+ }));
+ await observe(page);
+ expect(
+ await page.evaluate(() => ({
+ scale: window.performancePreview.api!.videoContainer!.parent!.parent!.scale.x,
+ x: window.performancePreview.api!.videoContainer!.parent!.parent!.x,
+ })),
+ ).toEqual(position);
+ await page.getByRole("button", { name: "Toggle suspension", exact: true }).click();
+ await expect.poll(async () => (await observe(page)).renders).toBe(0);
+ await page.getByRole("button", { name: "Toggle suspension", exact: true }).click();
+ await expect.poll(async () => (await observe(page)).renders).toBe(0);
+ const resumeStart = await page.evaluate(() => window.performancePreview.renders);
+ await page.getByRole("button", { name: "Play", exact: true }).click();
+ await expect
+ .poll(() => page.evaluate(() => window.performancePreview.renders), { timeout: 20000 })
+ .toBeGreaterThan(resumeStart + 1);
+});
+
+test("caption wrapping and text measurements are reused across paused seeks", async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.addInitScript(() => {
+ Object.assign(window, { captionMeasurements: 0 });
+ const original = CanvasRenderingContext2D.prototype.measureText;
+ CanvasRenderingContext2D.prototype.measureText = function (text) {
+ const probe = window as unknown as { captionMeasurements: number };
+ probe.captionMeasurements++;
+ return original.call(this, text);
+ };
+ });
+ await page.goto("/tests/ui/preview-performance.html");
+ await expect
+ .poll(
+ () =>
+ page.evaluate(() => Boolean(window.performancePreview?.api?.videoSprite?.visible)),
+ { timeout: 60000 },
+ )
+ .toBe(true);
+ await expect(page.getByText("Follow", { exact: true })).toBeVisible();
+ await expect.poll(async () => (await observe(page)).renders).toBe(0);
+ const before = await page.evaluate(
+ () => (window as unknown as { captionMeasurements: number }).captionMeasurements,
+ );
+ expect(before).toBeGreaterThan(0);
+ for (const time of [2.2, 4.2, 1.2]) {
+ await page.evaluate((time) => window.performancePreview.api!.seekTimeline(time), time);
+ await expect
+ .poll(() => page.evaluate(() => window.performancePreview.api!.video!.seeking))
+ .toBe(false);
+ await observe(page);
+ }
+ expect(
+ await page.evaluate(
+ () => (window as unknown as { captionMeasurements: number }).captionMeasurements,
+ ),
+ ).toBe(before);
+});
diff --git a/tests/ui/preview-performance.tsx b/tests/ui/preview-performance.tsx
new file mode 100644
index 00000000..6e044c2d
--- /dev/null
+++ b/tests/ui/preview-performance.tsx
@@ -0,0 +1,148 @@
+import { useEffect, useRef, useState } from "react";
+import { createRoot } from "react-dom/client";
+import VideoPlayback, { type VideoPlaybackRef } from "@/components/video-editor/VideoPlayback";
+import {
+ type AnnotationRegion,
+ DEFAULT_ANNOTATION_STYLE,
+ DEFAULT_AUTO_CAPTION_SETTINGS,
+} from "@/components/video-editor/types";
+import { I18nProvider } from "@/contexts/I18nContext";
+import "@/index.css";
+
+const captionSettings = { ...DEFAULT_AUTO_CAPTION_SETTINGS, enabled: true };
+const captions = [
+ {
+ id: "caption",
+ startMs: 0,
+ endMs: 6000,
+ text: "Follow every word",
+ words: [
+ { text: "Follow", startMs: 0, endMs: 2000 },
+ { text: "every", startMs: 2000, endMs: 4000, leadingSpace: true },
+ { text: "word", startMs: 4000, endMs: 6000, leadingSpace: true },
+ ],
+ },
+];
+const clips = Array.from({ length: 20 }, (_, index) => ({
+ id: `clip-${index}`,
+ startMs: index * 6000,
+ endMs: (index + 1) * 6000,
+ sourceStartMs: 0,
+ speed: 1,
+}));
+const zooms = [
+ {
+ id: "zoom",
+ startMs: 500,
+ endMs: 4000,
+ depth: 2 as const,
+ focus: { cx: 0.65, cy: 0.45 },
+ mode: "manual" as const,
+ },
+];
+const empty: AnnotationRegion[] = [];
+const noop = () => undefined;
+const onError = (error: string) => {
+ throw new Error(error);
+};
+const annotation: AnnotationRegion = {
+ id: "text",
+ type: "text",
+ startMs: 0,
+ endMs: 5000,
+ content: "Example",
+ position: { x: 20, y: 30 },
+ size: { width: 30, height: 20 },
+ style: { ...DEFAULT_ANNOTATION_STYLE },
+ zIndex: 1,
+};
+
+declare global {
+ interface Window {
+ performancePreview: { readonly api: VideoPlaybackRef | null; renders: number };
+ }
+}
+
+function Preview() {
+ const playback = useRef(null);
+ const [ready, setReady] = useState(false);
+ const [time, setTime] = useState(1);
+ const [playing, setPlaying] = useState(false);
+ const [padding, setPadding] = useState(20);
+ const [showCursor, setShowCursor] = useState(false);
+ const [showAnnotation, setShowAnnotation] = useState(false);
+ const [suspended, setSuspended] = useState(false);
+ useEffect(() => {
+ if (!ready) return;
+ let pending = 0;
+ let dispose = () => undefined;
+ const install = () => {
+ const app = playback.current?.app;
+ if (!app || !playback.current?.videoSprite) {
+ pending = requestAnimationFrame(install);
+ return;
+ }
+ const probe = {
+ get api() {
+ return playback.current;
+ },
+ renders: 0,
+ };
+ window.performancePreview = probe;
+ const listener = {
+ postrender: () => {
+ probe.renders++;
+ },
+ };
+ app.renderer.runners.postrender.add(listener);
+ dispose = () => {
+ app.renderer.runners.postrender.remove(listener);
+ };
+ };
+ install();
+ return () => {
+ cancelAnimationFrame(pending);
+ dispose();
+ };
+ }, [ready]);
+ return (
+
+ playback.current?.play()}>Play
+ playback.current?.pause()}>Pause
+ setPadding(padding === 20 ? 60 : 20)}>Change padding
+ setShowCursor(!showCursor)}>Toggle cursor
+ setShowAnnotation(!showAnnotation)}>Toggle annotation
+ setSuspended(!suspended)}>Toggle suspension
+
+
+ );
+}
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/tests/ui/project-dashboard.spec.ts b/tests/ui/project-dashboard.spec.ts
index 18b489ba..72fc8d49 100644
--- a/tests/ui/project-dashboard.spec.ts
+++ b/tests/ui/project-dashboard.spec.ts
@@ -1,22 +1,66 @@
import fs from "node:fs/promises";
import path from "node:path";
+import { expect, type Locator, type Page, test } from "@playwright/test";
import { hasFreshProjectThumbnail } from "../../electron/ipc/project/thumbnailFreshness";
-import { expect, test } from "@playwright/test";
+import { encodeComposedThumbnail } from "../../electron/ipc/project/composedThumbnail";
import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+// These tests exercise the local project library, with healthy unrelated services.
+test.beforeEach(async ({ page }) => {
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.listRecordings = async () => ({ success: true, value: [] });
+ window.electronAPI.onCloudShareProgress = () => () => undefined;
+ window.electronAPI.cloudShareManage = async () => ({
+ success: true,
+ videos: [],
+ usage: {
+ recordings: 0,
+ recordingLimit: 5,
+ storageBytes: 0,
+ storageLimitBytes: 1e11,
+ maxRecordingBytes: 1e9,
+ },
+ });
+ window.electronAPI.setHasUnsavedChanges = (dirty) => {
+ document.documentElement.dataset.projectDirty = String(dirty);
+ };
+ });
+});
+async function openDashboardSettings(page: Page, home: Locator) {
+ await home.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true })
+ .click();
+ await expect(page.getByRole("dialog", { name: "Account menu", exact: true })).not.toBeVisible();
+}
+async function waitForInitialProject(page: Page) {
+ await expect(page.locator("html")).toHaveAttribute("data-project-creates", "1");
+ await expect
+ .poll(() =>
+ page
+ .locator("video")
+ .first()
+ .evaluate((video: HTMLVideoElement) => video.readyState >= 2),
+ )
+ .toBe(true);
+ await expect(page.locator("html")).toHaveAttribute("data-project-dirty", "false");
+}
+
test("Home opens with a wireframe while saving and returns to the main library", async ({
page,
}) => {
await installDesktopBridge(page);
await page.goto("/?windowType=editor");
- await expect(page.locator("html")).toHaveAttribute("data-project-creates", "1");
+ await waitForInitialProject(page);
await page.evaluate(() => {
const save = window.electronAPI.saveProjectFile;
window.electronAPI.saveProjectFile = async (...args) => {
- await new Promise((resolve) => {
- window.addEventListener("test-release-save", () => resolve(), { once: true });
- document.documentElement.dataset.savePending = "true";
- });
+ if (args[3])
+ await new Promise((resolve) => {
+ window.addEventListener("test-release-save", () => resolve(), { once: true });
+ document.documentElement.dataset.savePending = "true";
+ });
return save(...args);
};
});
@@ -28,9 +72,9 @@ test("Home opens with a wireframe while saving and returns to the main library",
await page.screenshot({ path: "test-results/home-loading.png", animations: "disabled" });
await page.evaluate(() => window.dispatchEvent(new Event("test-release-save")));
await expect(home.getByLabel("Loading projects")).toHaveCount(0);
- await home.getByRole("button", { name: "Settings", exact: true }).click();
+ await openDashboardSettings(page, home);
await expect(home.getByRole("region", { name: "Dashboard settings" })).toBeVisible();
- await page.keyboard.press("Escape");
+ await home.getByRole("button", { name: "Resume Untitled Project", exact: true }).click();
await expect(home).not.toBeVisible();
await page.evaluate(() => {
delete document.documentElement.dataset.savePending;
@@ -46,7 +90,7 @@ test("home dashboard searches, sorts, opens projects and returns to the editor",
page,
}) => {
await installDesktopBridge(page);
- await page.addInitScript(() => {
+ await installDesktopBridgeOverrides(page, () => {
let entries = [
{
path: "/projects/launch.recordly",
@@ -103,7 +147,7 @@ test("home dashboard searches, sorts, opens projects and returns to the editor",
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();
+ await home.getByRole("button", { name: "Clear filters" }).click();
await home.getByRole("button", { name: "App walkthrough", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Project details" })).toHaveCount(0);
await expect(page.locator("html")).toHaveAttribute(
@@ -125,7 +169,9 @@ test("home dashboard searches, sorts, opens projects and returns to the editor",
).toBeVisible();
await page.keyboard.press("Escape");
await home.getByRole("button", { name: "Home", exact: true }).click();
- await expect(home.locator('[aria-label="Local profile"]')).toHaveCount(3);
+ await expect(
+ home.getByRole("list", { name: "Your projects" }).locator('[aria-label="Test User"]'),
+ ).toHaveCount(0);
await home.getByRole("button", { name: "Options for App walkthrough", exact: true }).click();
await expect(page.getByRole("menuitem", { name: "No folder", exact: true })).toHaveCount(0);
@@ -143,7 +189,7 @@ test("home dashboard searches, sorts, opens projects and returns to the editor",
await expect(cards).toHaveCount(0);
await home.getByRole("button", { name: "All", exact: true }).click();
await expect(cards).toHaveCount(3);
- await home.getByRole("button", { name: "Settings", exact: true }).click();
+ await openDashboardSettings(page, home);
await expect(home.getByRole("region", { name: "Dashboard settings" })).toBeVisible();
await home.getByRole("button", { name: "Home", exact: true }).click();
await home
@@ -154,8 +200,9 @@ test("home dashboard searches, sorts, opens projects and returns to the editor",
await home.getByRole("button", { name: "Select projects to delete" }).click();
await cards.first().click();
await expect(cards.first()).toHaveAttribute("aria-pressed", "true");
- await home.getByRole("button", { name: "Delete", exact: true }).click();
- await expect(page.getByRole("dialog", { name: "Delete 1 project?" })).not.toBeVisible();
+ await home.getByRole("button", { name: "Review deletion…", exact: true }).click();
+ await page.getByRole("dialog", { name: "Review deletion", exact: true }).getByRole("button", { name: "Move to Trash", exact: true }).click();
+ await expect(page.getByRole("dialog", { name: "Review deletion", exact: true })).not.toBeVisible();
await expect(cards).toHaveCount(2);
await page.screenshot({ path: "test-results/project-dashboard.png", animations: "disabled" });
await page.evaluate(() => document.documentElement.classList.add("dark"));
@@ -179,7 +226,7 @@ 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("It's looking empty in here...")).toBeVisible();
+ await expect(page.getByText("No projects yet", { exact: true })).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");
@@ -192,7 +239,9 @@ test("home dashboard explains an empty library", async ({ page }) => {
.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 expect(
+ page.getByRole("button", { name: "Resume Untitled Project", exact: true }),
+ ).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "Rename project" })).toBeVisible();
});
@@ -201,7 +250,7 @@ test("autosave creates one untitled project, stays idle without edits, and refre
page,
}, testInfo) => {
await installDesktopBridge(page);
- await page.addInitScript(() => {
+ await installDesktopBridgeOverrides(page, () => {
const save = window.electronAPI.saveProjectFile;
window.electronAPI.saveProjectFile = async (...args) => {
const result = await save(...args);
@@ -228,6 +277,7 @@ test("autosave creates one untitled project, stays idle without edits, and refre
await expect(page.getByRole("button", { name: "Rename project" })).toContainText(
"Untitled Project",
);
+ await waitForInitialProject(page);
// Observe more than two debounce periods: idle must not perform periodic saves.
const before = await page.locator("html").getAttribute("data-project-saves");
await page.waitForTimeout(1800);
@@ -242,8 +292,9 @@ test("autosave creates one untitled project, stays idle without edits, and refre
const thumbnail = await page.locator("html").getAttribute("data-saved-thumbnail");
const file = testInfo.outputPath("generated-preview.png");
await fs.mkdir(path.dirname(file), { recursive: true });
- await fs.writeFile(file, Buffer.from(thumbnail!.split(",")[1], "base64"));
- // Exercise the actual main-process acceptance check against renderer output.
+ // Main stamps the composed marker when persisting renderer output.
+ await fs.writeFile(file, encodeComposedThumbnail(thumbnail!));
+ // Exercise the actual main-process acceptance check against its encoded file.
expect(await hasFreshProjectThumbnail(file, 0)).toBe(true);
const image = page
.getByRole("button", { name: "Generated preview", exact: true })
@@ -257,7 +308,7 @@ test("autosave creates one untitled project, stays idle without edits, and refre
test("deletion refreshes the grid and keeps unselected projects", async ({ page }) => {
await installDesktopBridge(page);
- await page.addInitScript(() => {
+ await installDesktopBridgeOverrides(page, () => {
let entries = ["Keep", "Delete"].map((name) => ({
path: `/projects/${name}.recordly`,
name,
@@ -278,11 +329,12 @@ test("deletion refreshes the grid and keeps unselected projects", async ({ page
await home.getByRole("button", { name: "Select projects to delete" }).click();
await home.getByRole("list").getByRole("button", { name: "Delete", exact: true }).click();
await home
- .getByRole("button", { name: "Delete", exact: true })
+ .getByRole("button", { name: "Review deletion…", exact: true })
.filter({ hasNot: page.locator("img") })
.first()
.click();
- await expect(page.getByRole("dialog", { name: "Delete 1 project?" })).not.toBeVisible();
+ await page.getByRole("dialog", { name: "Review deletion", exact: true }).getByRole("button", { name: "Move to Trash", exact: true }).click();
+ await expect(page.getByRole("dialog", { name: "Review deletion", exact: true })).not.toBeVisible();
await expect(
home.getByRole("list").getByRole("button", { name: "Delete", exact: true }),
).toHaveCount(0);
@@ -293,7 +345,7 @@ test("cards rename inline, preserve folder chips and use existing share links",
page,
}) => {
await installDesktopBridge(page);
- await page.addInitScript(() => {
+ await installDesktopBridgeOverrides(page, () => {
let entry = {
path: "/projects/demo.recordly",
name: "Demo",
@@ -308,7 +360,12 @@ test("cards rename inline, preserve folder chips and use existing share links",
);
localStorage.setItem(
"recordly.project-share-links.v1",
- JSON.stringify({ [entry.path]: "https://example.com/shared/demo" }),
+ JSON.stringify({
+ [entry.path]: {
+ url: "https://example.com/shared/demo",
+ sharedAt: Date.now() + 1000,
+ },
+ }),
);
window.electronAPI.listProjectFiles = async () => ({ success: true, entries: [entry] });
window.electronAPI.renameLibraryProject = async (_path, name) => {
@@ -325,11 +382,11 @@ test("cards rename inline, preserve folder chips and use existing share links",
const home = page.getByRole("dialog", { name: "Projects dashboard" });
await expect(home.getByRole("button", { name: "Remove Demo from Work" })).toContainText("Work");
await home.getByRole("button", { name: "Options for Demo" }).click();
- await page.getByRole("menuitem", { name: "View in web", exact: true }).click();
- await expect(page.locator("html")).toHaveAttribute(
- "data-opened-url",
+ await page.getByRole("menuitem", { name: "View link", exact: true }).click();
+ await expect(page.getByLabel("Share link", { exact: true })).toHaveValue(
"https://example.com/shared/demo",
);
+ await page.keyboard.press("Escape");
await home.getByRole("button", { name: "Options for Demo" }).click();
await page.getByRole("menuitem", { name: "Rename", exact: true }).click();
const renameInput = home.getByRole("textbox", { name: "Project name" });
@@ -347,14 +404,14 @@ test("cards rename inline, preserve folder chips and use existing share links",
"Work",
);
await home.getByRole("button", { name: "Options for Renamed" }).click();
- await expect(page.getByRole("menuitem", { name: "View in web", exact: true })).toBeVisible();
+ await expect(page.getByRole("menuitem", { name: "View link", exact: true })).toBeVisible();
});
test("dashboard supports creation sort, independent folders, shared settings and precise captions", async ({
page,
}) => {
await installDesktopBridge(page);
- await page.addInitScript(() => {
+ await installDesktopBridgeOverrides(page, () => {
localStorage.setItem(
"recordly.project-folders.v1",
JSON.stringify([
@@ -387,6 +444,7 @@ test("dashboard supports creation sort, independent folders, shared settings and
});
});
await page.goto("/?windowType=editor");
+ await waitForInitialProject(page);
await page.getByRole("button", { name: "Clips", exact: true }).click();
await expect(page.getByRole("complementary", { name: "Clips" })).toBeVisible();
await page.getByRole("button", { name: "Home", exact: true }).click();
@@ -400,6 +458,14 @@ test("dashboard supports creation sort, independent folders, shared settings and
await home.getByRole("button", { name: "Add folder to Old" }).click();
await page.getByRole("menuitem", { name: "Personal", exact: true }).click();
await expect(home.getByRole("button", { name: "Remove Old from Work" })).toBeVisible();
+ await expect(home.locator('[aria-label="1 more folders: Personal"]')).toBeVisible();
+ const oldCard = home
+ .getByRole("list", { name: "Your projects" })
+ .locator("li")
+ .filter({ has: page.getByRole("button", { name: "Old", exact: true }) });
+ await oldCard.evaluate((card) => {
+ card.style.width = "600px";
+ });
await expect(home.getByRole("button", { name: "Remove Old from Personal" })).toBeVisible();
await expect(home.locator('[aria-label="1 more folders: Personal"]')).toHaveCount(0);
const membership = await page.evaluate(() =>
@@ -408,106 +474,59 @@ test("dashboard supports creation sort, independent folders, shared settings and
expect(
membership.every((folder: { paths: string[] }) => folder.paths.includes("/old.recordly")),
).toBe(true);
- const heights = await home
- .getByRole("list", { name: "Your projects" })
- .locator("li")
- .evaluateAll((cards) =>
- cards.map((card) => [
- card.querySelector('[aria-label="Local profile"]')!.getBoundingClientRect().height,
- card.querySelector("[data-project-caption]")!.getBoundingClientRect().height,
- ]),
- );
- for (const [avatar, caption] of heights) {
- expect(avatar).toBe(caption);
- expect(avatar).toBe(48);
- }
- const spacing = await home
- .locator("[data-project-caption]")
- .first()
- .evaluate((element) => {
- const caption = element.getBoundingClientRect();
- const avatar = element.previousElementSibling!.getBoundingClientRect();
- return { gap: caption.left - avatar.right, top: caption.top - avatar.top };
- });
- expect(spacing).toEqual({ gap: 16, top: 0 });
- const avatar = home.locator('[aria-label="Local profile"]').first();
- const circle = await avatar.evaluate((element) => {
- const style = getComputedStyle(element);
- const rect = element.getBoundingClientRect();
- return { width: rect.width, height: rect.height, radius: parseFloat(style.borderRadius) };
- });
- expect(circle.width).toBe(circle.height);
- expect(circle.radius).toBeGreaterThanOrEqual(circle.width / 2);
- await expect(avatar).toHaveText("LP");
- await home.getByRole("button", { name: "Settings", exact: true }).click();
+ // Local cards intentionally show project captions without account avatars.
+ await expect(
+ home.getByRole("list", { name: "Your projects" }).locator('[aria-label="Test User"]'),
+ ).toHaveCount(0);
+ await openDashboardSettings(page, home);
await expect(home.getByRole("textbox", { name: "Search projects" })).toHaveCount(0);
await expect(home.getByRole("button", { name: "Sort projects" })).toHaveCount(0);
- await expect(home.getByRole("row", { name: "Dark", exact: true })).toBeVisible();
- await expect(home.getByRole("grid", { name: "Settings sections" })).toHaveCount(1);
+ await expect(home.getByRole("tablist", { name: "Settings sections" })).toHaveCount(1);
await expect(home.getByRole("switch", { name: "Connect Zooms" })).toHaveCount(0);
- await home.getByRole("row", { name: "Advanced", exact: true }).click();
+ await home.getByRole("tab", { name: "General", exact: true }).click();
await expect(home.getByRole("switch", { name: "Experimental updates" })).toBeVisible();
- await expect(home.getByText("Preview update UI", { exact: true })).toBeVisible();
- await home.getByRole("row", { name: "Motion", exact: true }).click();
+
+ await home.getByRole("tab", { name: "Motion", exact: true }).click();
await expect(home.getByRole("switch", { name: "Connect Zooms" })).toBeVisible();
- const motion = home.getByRole("region", { name: "Motion settings", exact: true });
- const label = await motion.getByText("Connect Zooms", { exact: true }).boundingBox();
- const description = await motion
- .getByText("Smooth consecutive zoom regions into a continuous camera move.", {
- exact: true,
- })
- .boundingBox();
- expect(description!.y - (label!.y + label!.height)).toBeGreaterThanOrEqual(3);
- expect(description!.x).toBe(label!.x);
+ const motion = home.getByRole("tabpanel", { name: /^Motion settings/ });
+ const description = "Smooth consecutive zoom regions into a continuous camera move.";
+ await expect(motion.getByText(description, { exact: true })).not.toBeVisible();
+ await motion.locator(".settings-row .settings-info").first().hover();
+ await expect(page.getByRole("tooltip")).toHaveText(description);
+ await page.keyboard.press("Escape");
await page.screenshot({ path: "test-results/settings-motion.png", animations: "disabled" });
- await home.getByRole("row", { name: "Recording", exact: true }).click();
+ await home.getByRole("tab", { name: "General", exact: true }).click();
await expect(home.getByText("Recordings folder", { exact: true })).toBeVisible();
await home.getByRole("button", { name: "Change folder" }).click();
await expect(home.getByText("/new-recordings", { exact: true })).toBeVisible();
- const capture = home.getByRole("switch", { name: "Hide HUD from recordings" });
+ await home.getByRole("tab", { name: "Recording", exact: true }).click();
+ const capture = home.getByRole("switch", {
+ name: "Hide recording controls from captured video",
+ exact: true,
+ });
await capture.press("Space");
await expect(page.locator("html")).toHaveAttribute("data-hide-hud", "true");
- await home.getByRole("row", { name: "Files", exact: true }).click();
- await expect(home.getByRole("button", { name: "Open file", exact: true })).toBeVisible();
- await home.getByRole("row", { name: "Recording", exact: true }).click();
+ await home.getByRole("tab", { name: "General", exact: true }).click();
+ await expect(home.getByRole("button", { name: "Open folder", exact: true })).toHaveCount(2);
await expect(home.getByText("/new-recordings", { exact: true })).toBeVisible();
- await home.getByRole("row", { name: "General", exact: true }).click();
- await expect(home.getByRole("row", { name: "Dark", exact: true })).toBeVisible();
+ await home.getByRole("tab", { name: "General", exact: true }).click();
+ await expect(home.getByRole("radio", { name: "Dark", exact: true })).toBeVisible();
await page.screenshot({ path: "test-results/dashboard-settings.png", animations: "disabled" });
});
-test("Solar navigation selection, circular initials, and Raw sources are consistent", async ({
- page,
-}) => {
+test("account menu opens the raw folder without a sidebar shortcut", async ({ page }) => {
+ test.setTimeout(120000);
await installDesktopBridge(page);
- await page.addInitScript(() => {
- window.electronAPI.listRecordings = async (includeSources) => ({
- success: true,
- value: (includeSources
- ? ["screen.mp4", "screen.webcam.mp4", "screen.mic.wav"]
- : ["screen.mp4"]
- ).map((name) => ({
- path: `/recordings/${name}`,
- name,
- createdAt: Date.now(),
- bytes: 1048576,
- url: `${location.origin}/tests/ui/fixtures/preview.mp4`,
- })),
- });
- window.electronAPI.revealInFolder = async (path) => {
- document.documentElement.dataset.revealed = path;
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.openRecordingsFolder = async () => {
+ document.documentElement.dataset.rawFolderOpened = "true";
return { success: true };
};
- window.electronAPI.getRecordingThumbnail = async () => ({
- success: true,
- value: `${location.origin}/tests/ui/fixtures/recording-thumbnail.jpg`,
- });
});
await page.goto("/?windowType=editor");
const scene = page.getByRole("radio", { name: "Scene", exact: true });
const videos = page.getByRole("button", { name: "Clips", exact: true });
- await expect(page.getByRole("radio", { name: "Clips", exact: true })).toHaveCount(0);
- await expect(scene).toBeChecked();
+ await expect(scene).toBeChecked({ timeout: 30000 });
await expect(scene.locator("svg")).toHaveAttribute("data-icon-style", "bold");
await videos.click();
await expect(videos).toHaveAttribute("aria-expanded", "true");
@@ -516,54 +535,25 @@ test("Solar navigation selection, circular initials, and Raw sources are consist
await scene.click();
await expect(scene).toBeChecked();
await expect(videos).toHaveAttribute("aria-expanded", "false");
- await expect(page.getByRole("complementary", { name: "Clips" })).toHaveCount(0);
- const homeButton = page.getByRole("button", { name: "Home", exact: true });
- await expect(homeButton.locator("svg")).toHaveAttribute("data-icon-style", "bold");
- await homeButton.click();
+ await page.getByRole("button", { name: "Home", exact: true }).click();
const home = page.getByRole("dialog", { name: "Projects dashboard" });
- await home.getByRole("button", { name: "Raw", exact: true }).click();
- await expect(home.getByRole("list", { name: "Raw files" }).locator("li")).toHaveCount(3);
- await expect(home.getByRole("textbox", { name: "Search projects" })).toHaveCount(0);
- await home.getByRole("textbox", { name: "Search raw files" }).fill("mic");
- await expect(home.getByRole("list", { name: "Raw files" }).locator("li")).toHaveCount(1);
- await home.getByRole("button", { name: "Options for screen.mic.wav" }).click();
- await page.getByRole("menuitem", { name: "Show screen.mic.wav in folder" }).click();
- await expect(page.locator("html")).toHaveAttribute(
- "data-revealed",
- "/recordings/screen.mic.wav",
- );
- await home.getByRole("textbox", { name: "Search raw files" }).fill("");
- await home.getByRole("button", { name: "Sort raw files" }).click();
- await page.getByRole("menuitem", { name: "Name", exact: true }).click();
- await expect(
- home.getByRole("list", { name: "Raw files" }).locator("li > button").first(),
- ).toHaveAccessibleName("screen.mic.wav");
- await expect(page.getByRole("menu", { name: "Sort raw files" })).toHaveCount(0);
- await page.mouse.move(200, 40);
- await page.screenshot({ path: "test-results/dashboard-raw.png", animations: "disabled" });
- await home.getByRole("button", { name: "New folder", exact: true }).click();
- await home.getByRole("button", { name: "Add folder to screen.mp4", exact: true }).click();
- await page.getByRole("menuitem", { name: "Untitled folder", exact: true }).click();
- await home.getByRole("button", { name: "Untitled folder", exact: true }).click();
- await expect(home.getByRole("list", { name: "Raw files" }).locator("li")).toHaveCount(1);
- await home.getByRole("button", { name: "Options for screen.mp4" }).click();
- await page.getByRole("menuitem", { name: "Rename", exact: true }).click();
- await home.getByRole("textbox", { name: "Raw file name" }).fill("Original take");
- await home.getByRole("textbox", { name: "Raw file name" }).press("Enter");
- await expect(home.getByRole("button", { name: "Original take", exact: true })).toBeVisible();
- await home.getByRole("button", { name: "Original take", exact: true }).click();
- await expect(page.getByRole("dialog", { name: "Raw file preview" })).toBeVisible();
- await page.keyboard.press("Escape");
- await home.getByRole("button", { name: "Select raw files to remove" }).click();
- await home.getByRole("button", { name: "Original take", exact: true }).click();
- await home.getByRole("button", { name: "Remove", exact: true }).click();
- await expect(home.getByRole("button", { name: "Original take", exact: true })).toHaveCount(0);
- await home.getByRole("button", { name: "Raw", exact: true }).click();
+ const sidebar = home.getByRole("complementary", { name: "Library navigation" });
+ await expect(sidebar.getByRole("button", { name: /Open raw/ })).toHaveCount(0);
+ await expect(sidebar.getByRole("button", { name: "Original recordings", exact: true })).toHaveCount(0);
+ await home.getByRole("button", { name: "Recordly account", exact: true }).click();
+ const menu = page.getByRole("dialog", { name: "Account menu", exact: true });
+ await expect(menu.getByRole("button", { name: "Open raw folder", exact: true })).toBeVisible();
+ await menu.screenshot({ path: test.info().outputPath("raw-folder-menu.png"), animations: "disabled" });
+ await menu.getByRole("button", { name: "Open raw folder", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-raw-folder-opened", "true");
+ await expect(menu).not.toBeVisible();
+ await expect(home.getByRole("heading", { name: "Your projects", exact: true })).toBeVisible();
+ await expect(home.getByRole("textbox", { name: "Search original recordings" })).toHaveCount(0);
});
test("project hover plays a muted five-second preview and stops on exit", async ({ page }) => {
await installDesktopBridge(page);
- await page.addInitScript(() => {
+ await installDesktopBridgeOverrides(page, () => {
window.electronAPI.listProjectFiles = async () => ({
success: true,
projects: [],
@@ -604,11 +594,16 @@ test("project hover plays a muted five-second preview and stops on exit", async
await expect(page.getByRole("button", { name: "Open presets" })).toHaveCount(0);
await page.getByRole("button", { name: "Home", exact: true }).click();
const card = page.getByRole("button", { name: "Hover preview", exact: true });
- await expect(card).toBeVisible();
- await expect(page.locator("html")).not.toHaveAttribute("data-preview-requests");
+ await expect(card).toBeVisible({ timeout: 15000 });
+ await expect(card.locator("[data-thumbnail-duration]")).toHaveText("0:06");
+ await expect(page.locator("html")).toHaveAttribute("data-preview-requests", "1");
+ const cardBounds = (await card.boundingBox())!;
await card.hover();
+ // The preview overlays the thumbnail without changing the library grid geometry.
+ expect(Math.abs((await card.boundingBox())!.width - cardBounds.width)).toBeLessThan(1);
const preview = page.locator("[data-project-hover-preview]");
await expect(preview).toBeVisible();
+ await expect(card.locator("[data-thumbnail-duration]")).toBeVisible();
await expect
.poll(() =>
preview
@@ -634,8 +629,8 @@ test("project hover plays a muted five-second preview and stops on exit", async
};
});
expect(fill, JSON.stringify(fill)).toMatchObject({ covers: true });
- expect(fill.frame.width / fill.frame.height).toBeCloseTo(4 / 3, 2);
- expect(fill.frame.width).toBeCloseTo(fill.card.width, 0);
+ expect(fill.frame.width / fill.frame.height).toBeCloseTo(16 / 9, 2);
+ expect(fill.frame.height).toBeCloseTo(fill.card.height, 0);
await expect(preview).toHaveCount(0, { timeout: 8000 });
await page.mouse.move(0, 0);
await card.hover();
@@ -646,13 +641,14 @@ test("project hover plays a muted five-second preview and stops on exit", async
test("Home opens normally without a current recording", async ({ page }) => {
await installDesktopBridge(page);
- await page.addInitScript(() => {
+ await installDesktopBridgeOverrides(page, () => {
window.electronAPI.getCurrentVideoPath = async () => ({ success: false });
localStorage.setItem("recordly.open-dashboard", String(Date.now()));
});
await page.goto("/?windowType=editor");
const home = page.getByRole("dialog", { name: "Projects dashboard", exact: true });
- await expect(home).toBeVisible();
+ await expect(home).toBeVisible({ timeout: 30000 });
+ await expect(home.getByRole("button", { name: /^Resume / })).toHaveCount(0);
await expect(home.getByRole("textbox", { name: "Search projects" })).toBeVisible();
await expect(page.getByText("No video to load.", { exact: false })).toHaveCount(0);
await expect(page.getByRole("alert")).toHaveCount(0);
@@ -660,7 +656,7 @@ test("Home opens normally without a current recording", async ({ page }) => {
test("folder actions stay inside narrow project cards without clipping", async ({ page }) => {
await installDesktopBridge(page);
- await page.addInitScript(() => {
+ await installDesktopBridgeOverrides(page, () => {
const entries = Array.from({ length: 4 }, (_, index) => ({
path: `/projects/card-${index}.recordly`,
name: `Project ${index + 1}`,
@@ -750,15 +746,15 @@ test("sidebar cards, separate Import, and shortcut settings use the dashboard fl
const sidebar = home.getByRole("complementary", { name: "Library navigation" });
await expect(sidebar.getByRole("button", { name: "Import", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("img", { name: "Placeholder banner" })).toHaveCount(0);
- const settings = sidebar.getByRole("button", { name: "Settings", exact: true });
+ await expect(sidebar.getByRole("button", { name: "Settings", exact: true })).toHaveCount(0);
const importButton = home.getByRole("button", { name: "Import", exact: true });
const all = home.getByRole("button", { name: "All", exact: true });
expect((await importButton.boundingBox())!.y - (await all.boundingBox())!.y).toBeLessThan(0);
expect(await all.evaluate((element) => element.parentElement!.textContent)).not.toContain(
"Import",
);
- await settings.click();
- await home.getByRole("button", { name: "Customize", exact: true }).click();
+ await openDashboardSettings(page, home);
+ await home.getByRole("button", { name: "Edit shortcuts", exact: true }).click();
const shortcuts = page.getByRole("dialog", { name: "Keyboard Shortcuts", exact: true });
await expect(shortcuts).toBeVisible();
const change = shortcuts.getByRole("button", { name: /^Change Add Zoom shortcut, currently / });
@@ -773,7 +769,7 @@ test("sidebar cards, separate Import, and shortcut settings use the dashboard fl
await page.screenshot({ path: "test-results/dashboard-shortcuts.png", animations: "disabled" });
await shortcuts.getByRole("button", { name: "Save", exact: true }).click();
await expect(shortcuts).not.toBeVisible();
- await home.getByRole("button", { name: "Customize", exact: true }).click();
+ await home.getByRole("button", { name: "Edit shortcuts", exact: true }).click();
await expect(change).toHaveText("J");
await expect(change).toHaveAccessibleName("Change Add Zoom shortcut, currently J");
await page.setViewportSize({ width: 800, height: 600 });
@@ -803,6 +799,125 @@ test("empty filtered libraries do not prompt for a first recording", async ({ pa
await expect(first).toBeVisible();
await home.getByRole("button", { name: "New folder", exact: true }).click();
await home.getByRole("button", { name: "Untitled folder", exact: true }).click();
- await expect(home.getByText("No matching projects", { exact: true })).toBeVisible();
+ await expect(home.getByText("This folder is empty", { exact: true })).toBeVisible();
await expect(first).toHaveCount(0);
});
+
+for (const stale of [false, true]) {
+ test(`saved project links open the finished share view${stale ? " with Regenerate for a newer project" : " without uploading again"}`, async ({
+ page,
+ }) => {
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(
+ page,
+ ({ stale }) => {
+ const updatedAt = Date.now();
+ localStorage.setItem(
+ "recordly.project-share-links.v1",
+ JSON.stringify({
+ "/projects/shared.recordly": {
+ url: "https://example.test/s/existing",
+ revision: "saved",
+ sharedAt: updatedAt + (stale ? -1000 : 1000),
+ },
+ }),
+ );
+ window.electronAPI.listProjectFiles = async () => ({
+ success: true,
+ projects: [],
+ entries: [
+ {
+ path: "/projects/shared.recordly",
+ name: "Shared demo",
+ updatedAt,
+ thumbnailPath: null,
+ isCurrent: false,
+ isInProjectsDirectory: true,
+ },
+ ],
+ });
+ window.electronAPI.onCloudShareProgress = () => () => undefined;
+ window.electronAPI.cloudShareManage = async () => ({ success: true, videos: [] });
+ window.electronAPI.writeClipboardText = async (text) => {
+ document.documentElement.dataset.copiedShare = text;
+ return { success: true };
+ };
+ },
+ { stale },
+ );
+ await page.goto("/?windowType=editor");
+ await expect(page.locator("html")).toHaveAttribute("data-project-creates", "1", {
+ timeout: 20000,
+ });
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ const home = page.getByRole("dialog", { name: "Projects dashboard", exact: true });
+ const shareBadge = home.getByRole("button", {
+ name: "View share link for Shared demo",
+ exact: true,
+ });
+ await expect(shareBadge).toHaveText(stale ? "Changes unpublished" : "Shared");
+ await home
+ .getByRole("button", { name: "View share link for Shared demo", exact: true })
+ .click();
+ await expect(page.getByLabel("Share link", { exact: true })).toHaveValue(
+ "https://example.test/s/existing",
+ );
+ await expect(page.getByRole("button", { name: "Regenerate", exact: true })).toHaveCount(
+ stale ? 1 : 0,
+ );
+ await expect(
+ page.getByRole("progressbar", { name: "Create link", exact: true }),
+ ).toHaveCount(0);
+ if (stale) {
+ await expect(page.getByRole("button", { name: "Copy link", exact: true })).toHaveCount(
+ 0,
+ );
+ return;
+ }
+ await page.getByRole("button", { name: "Copy link", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-copied-share",
+ "https://example.test/s/existing",
+ );
+ });
+}
+
+test("projects use four columns on wide windows and adapt below 1440px", async ({ page }) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.listProjectFiles = async () => ({
+ success: true,
+ projects: [],
+ entries: Array.from({ length: 8 }, (_, i) => ({
+ path: `/projects/grid-${i}.recordly`,
+ name: `Grid project ${i}`,
+ updatedAt: 1758000000000 + i,
+ thumbnailPath: null,
+ isCurrent: false,
+ isInProjectsDirectory: true,
+ })),
+ });
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ const grid = page.getByRole("list", { name: "Your projects" });
+ await expect(grid.locator(":scope > li")).toHaveCount(8);
+ for (const [width, columns] of [
+ [1440, 4],
+ [1920, 4],
+ [1366, 3],
+ ] as const) {
+ await page.setViewportSize({ width, height: 1000 });
+ await expect
+ .poll(() =>
+ grid.evaluate((el) => getComputedStyle(el).gridTemplateColumns.split(" ").length),
+ )
+ .toBe(columns);
+ }
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.screenshot({
+ path: "test-results/projects-four-columns.png",
+ animations: "disabled",
+ });
+});
diff --git a/tests/ui/project-error-recovery.spec.ts b/tests/ui/project-error-recovery.spec.ts
new file mode 100644
index 00000000..13841b6a
--- /dev/null
+++ b/tests/ui/project-error-recovery.spec.ts
@@ -0,0 +1,160 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test("preview failures use a dismissible popup without replacing the editor", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(60_000);
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible({
+ timeout: 20_000,
+ });
+ const preview = page.locator('video[src*="fixtures/preview.mp4"]').first();
+ await expect(preview).toBeAttached();
+ const originalPreview = await preview.elementHandle();
+ if (!originalPreview) throw new Error("Missing preview fixture");
+ await preview.dispatchEvent("error");
+ // One automatic URL refresh still runs before showing recovery controls.
+ await expect.poll(() => originalPreview.evaluate((video) => video.isConnected)).toBe(false);
+ const popup = page.getByRole("dialog", {
+ name: "The recording couldn’t be played",
+ exact: true,
+ });
+ await expect(popup).toHaveCount(0);
+ const currentPreview = await preview.elementHandle();
+ if (!currentPreview) throw new Error("Missing recovered preview fixture");
+ await preview.dispatchEvent("error");
+ await expect(popup).toBeVisible();
+ await expect(popup).toContainText("preview.mp4");
+ await expect(popup.getByRole("button", { name: "Open Projects", exact: true })).toHaveCount(0);
+ await expect(popup.getByRole("button", { name: "Open another file", exact: true })).toHaveCount(
+ 0,
+ );
+ expect(await currentPreview.evaluate((video) => video.isConnected)).toBe(true);
+ const bounds = await popup.boundingBox();
+ expect(bounds?.width).toBeLessThanOrEqual(480);
+ expect(bounds?.height).toBeLessThan(600);
+ await page.screenshot({
+ path: testInfo.outputPath("playback-error-popup.png"),
+ animations: "disabled",
+ });
+ const reports: Record[] = [];
+ await page.route("**/functions/v1/submit-export-error", async (route) => {
+ reports.push(route.request().postDataJSON());
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: '{"success":true}',
+ });
+ });
+ await popup.getByRole("button", { name: "Report error", exact: true }).click();
+ await expect(popup.getByRole("button", { name: "Report sent", exact: true })).toBeDisabled();
+ expect(reports).toHaveLength(1);
+ expect(reports[0]).toMatchObject({ schemaVersion: 2, context: "project-load" });
+ expect(JSON.parse(reports[0].logs as string).logs.join("\n")).toContain("Video load error");
+ await popup.getByRole("button", { name: "Try again", exact: true }).click();
+ await expect(popup).toHaveCount(0);
+ await preview.dispatchEvent("error");
+ await expect(popup).toBeVisible();
+ const failedPreview = await preview.elementHandle();
+ if (!failedPreview) throw new Error("Missing retry preview fixture");
+ await popup.getByRole("button", { name: "Close", exact: true }).click();
+ await expect(popup).toHaveCount(0);
+ expect(await failedPreview.evaluate((video) => video.isConnected)).toBe(true);
+ await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible();
+ await page.getByRole("radio", { name: "Captions", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Parakeet v3 Speech model", exact: true })).toBeVisible();
+ // Repeated error callbacks from the same failed preview must not reopen it.
+ await preview.dispatchEvent("error");
+ await expect(popup).toHaveCount(0);
+});
+
+test("failed project opens preserve the editor and retry the selected project", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.listProjectFiles = async () => ({
+ success: true,
+ entries: [
+ {
+ path: "/projects/demo.recordly",
+ name: "Demo project",
+ updatedAt: Date.now(),
+ thumbnailPath: null,
+ isCurrent: false,
+ isInProjectsDirectory: true,
+ },
+ ],
+ });
+ let attempts = 0;
+ window.electronAPI.openProjectFileAtPath = async (path) => {
+ attempts += 1;
+ document.documentElement.dataset.openAttempts = String(attempts);
+ if (attempts === 1) throw new Error("EIO: temporary disk read failure");
+ return {
+ success: true,
+ path,
+ project: {
+ version: 2,
+ videoPath: `${location.origin}/tests/ui/fixtures/preview.mp4`,
+ editor: {},
+ },
+ };
+ };
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ const home = page.getByRole("dialog", { name: "Projects dashboard" });
+ await home.getByRole("button", { name: "Demo project", exact: true }).click();
+ await expect(
+ page.getByText("We couldn’t open this file: demo.recordly", { exact: true }),
+ ).toBeVisible();
+ await expect(home).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(home).not.toBeVisible();
+ await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible();
+ await expect(
+ page.getByRole("heading", { level: 1, name: "We couldn’t open this file" }),
+ ).toHaveCount(0);
+ await page.getByRole("button", { name: "Try again", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-open-attempts", "2");
+ await expect(home).not.toBeVisible();
+ await expect(
+ page.getByText("We couldn’t open this file: demo.recordly", { exact: true }),
+ ).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible();
+});
+
+test("startup errors offer details in a compact popup and dismiss back to the editor", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(60_000);
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getCurrentVideoPath = async () => {
+ throw new Error("EACCES: permission denied");
+ };
+ });
+ await page.goto("/?windowType=editor");
+ const popup = page.getByRole("dialog", { name: "Access to this file was denied", exact: true });
+ await expect(popup).toBeVisible({ timeout: 20_000 });
+ await expect(popup.getByRole("button", { name: "Open Projects", exact: true })).toHaveCount(0);
+ await expect(popup.getByRole("button", { name: "Open another file", exact: true })).toHaveCount(
+ 0,
+ );
+ const details = popup.getByText("Error loading video: Error: EACCES: permission denied", {
+ exact: true,
+ });
+ await expect(details).not.toBeVisible();
+ await popup.getByText("Technical details", { exact: true }).click();
+ await expect(details).toBeVisible();
+ await page.screenshot({
+ path: testInfo.outputPath("startup-error-popup.png"),
+ animations: "disabled",
+ });
+ await page.keyboard.press("Escape");
+ await expect(popup).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible();
+});
diff --git a/tests/ui/project-relink.spec.ts b/tests/ui/project-relink.spec.ts
new file mode 100644
index 00000000..07c0fd17
--- /dev/null
+++ b/tests/ui/project-relink.spec.ts
@@ -0,0 +1,69 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test("missing startup recording can be located without autosaving an empty edit", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await page.addInitScript(() => {
+ const failSave = async () => {
+ document.documentElement.dataset.unexpectedSave = "true";
+ return { success: false, error: "Recovery must preserve the saved edits" };
+ };
+ window.electronAPI.createProjectFile = failSave;
+ window.electronAPI.saveProjectFile = failSave;
+ window.electronAPI.saveProjectFileNamed = failSave;
+ window.electronAPI.loadCurrentProjectFile = async () => ({
+ success: false,
+ path: "/projects/Keep my edits.recordly",
+ missingRecordingPath: "/disconnected/original.mp4",
+ message: "Project video file not found: /disconnected/original.mp4",
+ });
+ let attempts = 0;
+ window.electronAPI.relinkProjectRecording = async (path) => {
+ document.documentElement.dataset.relinkedProject = path;
+ if (attempts++ === 0) return { success: false, canceled: true };
+ return {
+ success: true,
+ path,
+ project: {
+ version: 2,
+ projectId: "original-identity",
+ videoPath: `${location.origin}/tests/ui/fixtures/preview.mp4`,
+ editor: {
+ wallpaper: "#aabbcc",
+ clipRegions: [
+ {
+ id: "saved-clip",
+ startMs: 0,
+ endMs: 2000,
+ sourceStartMs: 1000,
+ speed: 1,
+ },
+ ],
+ },
+ },
+ };
+ };
+ });
+ await page.goto("/?windowType=editor");
+ const error = page.getByRole("dialog", { name: "The project’s recording is unavailable" });
+ await expect(error).toBeVisible();
+ const locate = error.getByRole("button", { name: "Locate recording", exact: true });
+ await locate.click();
+ await expect(error).toBeVisible();
+ // Give the existing autosave debounce time to run; a source-less recovery must never save.
+ await page.waitForTimeout(800);
+ await expect(page.locator("html")).not.toHaveAttribute("data-unexpected-save", "true");
+ await locate.click();
+ await expect(error).toHaveCount(0);
+ await expect(page.locator('[data-timeline-item][data-variant="clip"]')).toHaveCount(1);
+ await expect(page.getByRole("button", { name: "Rename project" })).toContainText(
+ "Keep my edits",
+ );
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-relinked-project",
+ "/projects/Keep my edits.recordly",
+ );
+ await expect(page.locator("html")).not.toHaveAttribute("data-unexpected-save", "true");
+});
diff --git a/tests/ui/publish-height.spec.ts b/tests/ui/publish-height.spec.ts
new file mode 100644
index 00000000..7ab865d9
--- /dev/null
+++ b/tests/ui/publish-height.spec.ts
@@ -0,0 +1,42 @@
+import { expect, test } from "@playwright/test";
+
+test("Publish grows with its contents and only scrolls at the window boundary", async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await page.setViewportSize({ width: 1280, height: 800 });
+ await page.goto("/tests/ui/publish.html");
+ await page.getByRole("button", { name: "Publish", exact: true }).click();
+ await page.getByRole("row", { name: "Save video", exact: true }).click();
+ const panel = page.getByRole("dialog", { name: "Publish", exact: true });
+ const exportButton = panel.getByRole("button", { name: "Export Video", exact: true });
+ const bounds = () => panel.evaluate((dialog) => {
+ const rect = dialog.getBoundingClientRect();
+ return {
+ top: rect.top,
+ bottom: rect.bottom,
+ height: rect.height,
+ scrolling: [...dialog.querySelectorAll("*")].some(
+ (element) => ["auto", "scroll"].includes(getComputedStyle(element).overflowY)
+ && element.scrollHeight > element.clientHeight + 1,
+ ),
+ };
+ });
+ await expect(exportButton).toBeInViewport({ ratio: 1 });
+ await expect.poll(async () => (await bounds()).scrolling).toBe(false);
+ const normalHeight = (await bounds()).height;
+ await panel.getByText("Advanced", { exact: true }).click();
+ await page.setViewportSize({ width: 1280, height: 1000 });
+ await expect.poll(async () => (await bounds()).height).toBeGreaterThan(normalHeight);
+ await expect.poll(async () => (await bounds()).scrolling).toBe(false);
+ await expect(exportButton).toBeInViewport({ ratio: 1 });
+ await page.screenshot({ path: test.info().outputPath("publish-expanded.png"), animations: "disabled" });
+ await page.setViewportSize({ width: 1280, height: 706 });
+ const destination = panel.getByRole("row", { name: "Save video", exact: true });
+ await expect.poll(async () => (await destination.boundingBox())?.height).toBe(96);
+ await expect(panel.locator(".card__content")).toHaveCSS("gap", "16px");
+ await page.screenshot({ path: test.info().outputPath("publish-spacing.png"), animations: "disabled" });
+ await page.setViewportSize({ width: 1280, height: 500 });
+ await expect.poll(async () => (await bounds()).bottom).toBeLessThanOrEqual(488);
+ await expect.poll(async () => (await bounds()).top).toBeGreaterThanOrEqual(12);
+ await exportButton.scrollIntoViewIfNeeded();
+ await expect(exportButton).toBeInViewport({ ratio: 1 });
+});
diff --git a/tests/ui/publish.tsx b/tests/ui/publish.tsx
index ce8c8ac0..17cab312 100644
--- a/tests/ui/publish.tsx
+++ b/tests/ui/publish.tsx
@@ -1,31 +1,57 @@
-import { useState, type ComponentProps } from "react";
+import { type ComponentProps, useState } from "react";
import { createRoot } from "react-dom/client";
-import { I18nProvider, useI18n } from "@/contexts/I18nContext";
import { EditorExportMenu } from "@/components/video-editor/layout/EditorExportMenu";
+import { I18nProvider, useI18n } from "@/contexts/I18nContext";
import "@/index.css";
import { loadEditorPreferences } from "@/components/video-editor/editorPreferences";
import { useExportSettings } from "@/components/video-editor/export/useExportSettings";
+import { removeProjectShareLinksByUrl } from "@/components/video-editor/cloud/projectShareLinks";
+import { CloudShareButton } from "@/components/video-editor/cloud/CloudShareButton";
+
const noop = () => undefined;
+let begunUploadId: string | undefined;
let finishUpload: (() => void) | undefined;
+let finishPreparation: (() => void) | undefined;
+const pendingCapacityChecks: (() => void)[] = [];
+let capacityChecks = 0;
let reportUpload:
| ((event: { uploadId: string; uploadedBytes: number; totalBytes: number }) => void)
| undefined;
window.electronAPI = {
- cloudShareManage: async ({ token }: { token: string }) => ({
- success: true,
- videos:
- token === "fixture" && new URLSearchParams(location.search).has("full")
- ? Array(5).fill({ ready: true })
- : [],
- }),
+ writeClipboardText: async (text: string) => {
+ document.documentElement.dataset.copiedShareUrl = text;
+ return { success: true };
+ },
+ cloudShareBeginExport: async ({ uploadId }: { uploadId: string }) => {
+ begunUploadId = uploadId;
+ return { success: true };
+ },
+ cloudShareManage: async ({ token }: { token: string }) => {
+ if (new URLSearchParams(location.search).has("controlled-capacity")) {
+ document.documentElement.dataset.capacityChecks = String(++capacityChecks);
+ await new Promise((resolve) => pendingCapacityChecks.push(resolve));
+ }
+ return {
+ success: true,
+ videos:
+ token === "fixture" && new URLSearchParams(location.search).has("full")
+ ? Array(5).fill({ ready: true })
+ : [],
+ };
+ },
onCloudShareProgress: (callback: typeof reportUpload) => {
reportUpload = callback;
return () => {
reportUpload = undefined;
};
},
- discardExportedTemp: async () => ({}),
+ discardExportedTemp: async (filePath: string) => {
+ document.documentElement.dataset.discardedTemp = filePath;
+ return {};
+ },
cloudShareUpload: async ({ uploadId }: { uploadId: string }) => {
+ document.documentElement.dataset.uploadStarted = "true";
+ document.body.dataset.shareUploadIdPreserved = String(uploadId === begunUploadId);
await new Promise((resolve) => setTimeout(resolve, 800));
reportUpload?.({ uploadId, uploadedBytes: 50, totalBytes: 100 });
if (new URLSearchParams(location.search).has("controlled")) {
@@ -39,17 +65,31 @@ window.electronAPI = {
throw new Error("Fixture old-account upload failed");
return { success: true, shareUrl: "https://example.test/s/preview" };
},
- cloudShareCancel: async () => ({ success: true }),
+ cloudShareCancel: async (uploadId: string) => {
+ document.documentElement.dataset.canceledUpload = uploadId;
+ document.documentElement.dataset.canceledExpectedUpload = String(
+ uploadId === begunUploadId,
+ );
+ return { success: true };
+ },
} as unknown as typeof window.electronAPI;
function Preview() {
+ const [mounted, setMounted] = useState(true);
const [open, setOpen] = useState(false);
const [authToken, setAuthToken] = useState("fixture");
const [accountId, setAccountId] = useState("fixture-account");
const [percentage, setPercentage] = useState(0);
+ const [isExporting, setIsExporting] = useState(false);
const { t } = useI18n();
const [preferences] = useState(loadEditorPreferences);
const exportSettings = useExportSettings(preferences, [], []);
const props = {
+ projectPath: new URLSearchParams(location.search).has("saved")
+ ? "/fixture.recordly"
+ : undefined,
+ projectRevision: new URLSearchParams(location.search).has("edited")
+ ? "revision-2"
+ : "revision-1",
t,
projectTitle: "Preview",
authToken,
@@ -60,30 +100,75 @@ function Preview() {
showExportDropdown: open,
setShowExportDropdown: setOpen,
exportProgress: { percentage },
+ isExporting,
exportError: new URLSearchParams(location.search).has("export-error")
? "[VIDEO_DECODE_ENCODING_ERROR] sourceTimeSec=0.500"
: undefined,
},
- exportDimensions: { gifOutputDimensions: { width: 800, height: 450 } },
+ exportDimensions: {
+ gifOutputDimensions: { width: 800, height: 450 },
+ mp4OutputDimensions: Object.fromEntries(
+ ["hd", "custom", "medium", "good", "high", "source"].map((quality) => [
+ quality,
+ { width: 1920, height: 1080 },
+ ]),
+ ),
+ },
exportStatus: {},
handleOpenExportDropdown: () => setOpen(true),
handleExportDropdownClose: () => setOpen(false),
- handleCancelExport: noop,
+ handleCancelExport: () => {
+ document.documentElement.dataset.preparationCanceled = "true";
+ },
handleStartExportFromDropdown: noop,
prepareExportForShare: async () => {
+ document.body.dataset.shareArmedBeforeExport = String(Boolean(begunUploadId));
+ document.documentElement.dataset.preparationStarted = "true";
setPercentage(0);
+ setIsExporting(true);
+ if (new URLSearchParams(location.search).has("controlled-preparation")) {
+ await new Promise((resolve) => {
+ finishPreparation = resolve;
+ });
+ }
await new Promise((resolve) => setTimeout(resolve, 800));
setPercentage(50);
await new Promise((resolve) => setTimeout(resolve, 800));
setPercentage(100);
+ setIsExporting(false);
return "synthetic-preview.mp4";
},
onRequestShareSignIn: noop,
} as unknown as ComponentProps;
return (
+ {new URLSearchParams(location.search).has("controlled-capacity") && (
+
pendingCapacityChecks.splice(0).forEach((resolve) => resolve())}
+ >
+ Fixture finish capacity checks
+
+ )}
{new URLSearchParams(location.search).has("switch") && (
+
+ removeProjectShareLinksByUrl("https://example.test/s/original")
+ }
+ >
+ Fixture delete original share
+
+ setMounted(false)}>
+ Fixture unmount sharing
+
+ setMounted(true)}>
+ Fixture mount sharing
+
+ finishPreparation?.()}>
+ Fixture finish preparation
+
finishUpload?.()}>
Fixture finish upload
@@ -110,7 +195,18 @@ function Preview() {
)}
-
+ {mounted &&
+ (new URLSearchParams(location.search).has("stale-prop") ? (
+
+ ) : (
+
+ ))}
);
}
diff --git a/tests/ui/recording-finalization-handoff.spec.ts b/tests/ui/recording-finalization-handoff.spec.ts
new file mode 100644
index 00000000..9c43a016
--- /dev/null
+++ b/tests/ui/recording-finalization-handoff.spec.ts
@@ -0,0 +1,82 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test("Stop opens the loading editor while native finalization is pending", async ({
+ page,
+ context,
+}) => {
+ test.setTimeout(60000);
+ const editor = await context.newPage();
+ await installDesktopBridge(editor);
+ await editor.addInitScript(() => {
+ let complete!: () => void;
+ const pending = new Promise((resolve) => {
+ complete = resolve;
+ });
+ Object.assign(window, { completeRecording: complete });
+ window.electronAPI.waitForRecordingEditor = async () => {
+ await pending;
+ return {};
+ };
+ });
+ await installDesktopBridge(page);
+ let opened = false;
+ await page.exposeFunction("openTestEditor", async () => {
+ opened = true;
+ await editor.goto("/?windowType=editor");
+ return { success: true };
+ });
+ await page.exposeFunction("finishTestEditor", async () => {
+ await editor.evaluate(() =>
+ (window as unknown as { completeRecording: () => void }).completeRecording(),
+ );
+ });
+ await page.addInitScript(() => {
+ const testWindow = window as unknown as {
+ openTestEditor: () => Promise<{ success: boolean }>;
+ finishTestEditor: () => Promise;
+ };
+ Object.assign(window.electronAPI, {
+ getPlatform: async () => "darwin",
+ getCountdownDelay: async () => ({ success: true, delay: 0 }),
+ getRecordingPreferences: async () => ({
+ success: true,
+ microphoneEnabled: false,
+ webcamEnabled: false,
+ systemAudioEnabled: false,
+ }),
+ startNativeScreenRecording: async () => ({ success: true }),
+ stopNativeScreenRecording: () =>
+ new Promise((resolve) => {
+ document.documentElement.dataset.nativeStopPending = "true";
+ window.addEventListener(
+ "release-native-stop",
+ () => {
+ document.documentElement.dataset.nativeStopPending = "false";
+ resolve({ success: true, path: "/take.mp4" });
+ },
+ { once: true },
+ );
+ }),
+ openLoadingRecordingEditor: testWindow.openTestEditor,
+ finishRecordingEditor: testWindow.finishTestEditor,
+ });
+ });
+ await page.goto("/?windowType=hud-overlay");
+ await page.getByRole("button", { name: "Record", exact: true }).click();
+ const controls = page.getByRole("group", { name: "Recording controls", exact: true });
+ await expect(controls.getByRole("timer")).not.toHaveText("0:00");
+ await controls.getByRole("button", { name: "Stop", exact: true }).click();
+ await expect.poll(() => opened).toBe(true);
+ await expect(editor.getByRole("status", { name: "Loading editor", exact: true })).toBeVisible();
+ await expect(editor.locator(".loading-recordly-mark")).toBeVisible();
+ await expect(page.locator("html")).toHaveAttribute("data-native-stop-pending", "true");
+ await editor.screenshot({
+ path: "/tmp/recordly-editor-finalization-20261105/loading-editor.png",
+ });
+ await page.evaluate(() => window.dispatchEvent(new Event("release-native-stop")));
+ await expect(editor.getByRole("navigation", { name: "Editor tools" })).toBeVisible();
+ await expect(editor.getByRole("status", { name: "Loading editor", exact: true })).toHaveCount(
+ 0,
+ );
+});
diff --git a/tests/ui/rounded-mask.html b/tests/ui/rounded-mask.html
new file mode 100644
index 00000000..5da809f9
--- /dev/null
+++ b/tests/ui/rounded-mask.html
@@ -0,0 +1 @@
+Rounded recording mask
diff --git a/tests/ui/rounded-mask.spec.ts b/tests/ui/rounded-mask.spec.ts
new file mode 100644
index 00000000..d1ded958
--- /dev/null
+++ b/tests/ui/rounded-mask.spec.ts
@@ -0,0 +1,14 @@
+import { expect, test } from "@playwright/test";
+
+test("rounded recording masks retain antialiasing through blur passes and radius changes", async ({ page }) => {
+ const errors: string[] = [];
+ page.on("pageerror", error => errors.push(error.message));
+ await page.goto("/tests/ui/rounded-mask.html");
+ await expect(page.locator("body")).toHaveAttribute("data-result", /blended/);
+ const result = JSON.parse((await page.locator("body").getAttribute("data-result"))!);
+ expect(result.defaultBlended).toBe(0);
+ expect(result.blended).toBeGreaterThan(10);
+ expect(result.squareBlended).toBe(0);
+ expect(result.replacedBlended).toBe(result.blended);
+ expect(errors).toEqual([]);
+});
diff --git a/tests/ui/rounded-mask.ts b/tests/ui/rounded-mask.ts
new file mode 100644
index 00000000..e7e76e0b
--- /dev/null
+++ b/tests/ui/rounded-mask.ts
@@ -0,0 +1,56 @@
+import { Application, Container, Graphics, Sprite, Texture } from "pixi.js";
+import { MotionBlurFilter } from "pixi-filters/motion-blur";
+import { ZoomBlurFilter } from "pixi-filters/zoom-blur";
+import { createVideoBlurFilters } from "@/lib/exporter/videoBlurFilters";
+import { drawSquircleOnGraphics } from "@/lib/geometry/squircle";
+
+async function run() {
+ const app = new Application();
+ await app.init({ width: 320, height: 180, background: "#000000", antialias: true,
+ resolution: 1, preference: "webgl", autoStart: false });
+ document.body.append(app.canvas);
+ app.stage.addChild(new Graphics().rect(0, 0, 320, 180).fill(0x000000));
+ const effects = new Container();
+ app.stage.addChild(effects);
+ const content = new Container();
+ effects.addChild(content);
+ const sprite = new Sprite(Texture.WHITE);
+ sprite.width = 260;
+ sprite.height = 120;
+ sprite.position.set(30, 30);
+ content.addChild(sprite);
+ const mask = new Graphics();
+ app.stage.addChild(mask);
+ const round = () => {
+ mask.clear();
+ drawSquircleOnGraphics(mask, { x: 30.25, y: 30.25, width: 260, height: 120, radius: 36 });
+ mask.fill(0xffffff);
+ };
+ round();
+ content.mask = mask;
+ const zoom = new ZoomBlurFilter({ strength: 0, maxKernelSize: 13 });
+ const motion = new MotionBlurFilter([0, 0], 5, 0);
+ effects.filters = [motion, zoom];
+ const readPixels = () => {
+ app.render();
+ const { pixels, width } = app.renderer.extract.pixels(app.stage);
+ let blended = 0;
+ for (let y = 30; y < 67; y++) {
+ for (let x = 30; x < 67; x++) {
+ const channel = pixels[(y * width + x) * 4];
+ if (channel > 0 && channel < 255) blended++;
+ }
+ }
+ return blended;
+ };
+ const defaultBlended = readPixels();
+ const antialiased = createVideoBlurFilters();
+ effects.filters = [antialiased.motion, antialiased.zoom];
+ const blended = readPixels();
+ mask.clear().rect(30, 30, 260, 120).fill(0xffffff);
+ const squareBlended = readPixels();
+ round();
+ const replacedBlended = readPixels();
+ document.body.dataset.result = JSON.stringify({ defaultBlended, blended, squareBlended, replacedBlended });
+}
+run().catch(error => { document.body.dataset.error = String(error); });
diff --git a/tests/ui/scrollbars.spec.ts b/tests/ui/scrollbars.spec.ts
new file mode 100644
index 00000000..d40296f9
--- /dev/null
+++ b/tests/ui/scrollbars.spec.ts
@@ -0,0 +1,30 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+for (const theme of ["light", "dark"]) {
+ test(`Cloud content scrolls without a persistent scrollbar in ${theme} mode`, async ({ page }) => {
+ await installDesktopBridge(page);
+ await page.addInitScript((theme) => localStorage.setItem("recordly.theme", theme), theme);
+ await page.setViewportSize({ width: 1000, height: 700 });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: /^Upgrade to Cloud/ }).first().click();
+ const modal = page.getByRole("dialog", { name: "Recordly Cloud", exact: true });
+ await expect(modal).toBeVisible();
+ const body = modal.locator(".pricing-body");
+ await expect(body).toHaveCSS("scrollbar-width", "none");
+ await expect(body).toHaveCSS("scrollbar-gutter", "auto");
+ expect(await body.evaluate((el) => el.scrollHeight > el.clientHeight)).toBe(true);
+ await body.hover();
+ await page.mouse.wheel(0, 500);
+ await expect.poll(() => body.evaluate((el) => el.scrollTop)).toBeGreaterThan(100);
+ await expect(modal.getByRole("button", { name: "Close", exact: true })).toBeVisible();
+ await modal.screenshot({ path: test.info().outputPath(`plus-${theme}.png`) });
+ await modal.getByRole("button", { name: "Close", exact: true }).click();
+ await expect(modal).toBeHidden();
+
+ // The editor's custom scrollbar utility must follow the same app policy.
+ const inspector = page.locator(".custom-scrollbar").filter({ visible: true }).first();
+ await expect(inspector).toHaveCSS("scrollbar-width", "none");
+ await expect(inspector).toHaveCSS("scrollbar-gutter", "auto");
+ });
+}
diff --git a/tests/ui/settings-fixed-sidebar.spec.ts b/tests/ui/settings-fixed-sidebar.spec.ts
new file mode 100644
index 00000000..b22ca25c
--- /dev/null
+++ b/tests/ui/settings-fixed-sidebar.spec.ts
@@ -0,0 +1,51 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+for (const height of [720, 600, 480]) {
+ test(`settings sidebar needs no scrolling at ${height}px tall`, async ({ page }) => {
+ await page.setViewportSize({ width: 1000, height: 720 });
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Settings", exact: true }).click();
+ const settings = page.getByRole("dialog", { name: "Settings", exact: true });
+ await expect(settings).not.toHaveAttribute("data-entering");
+ await page.setViewportSize({ width: 1000, height });
+ const navigation = settings.locator(".dashboard-settings-navigation");
+ const account = navigation.getByRole("button", { name: "Manage account", exact: true });
+ await expect(account).toBeInViewport({ ratio: 1 });
+ for (const tab of await navigation.getByRole("tab").all()) {
+ await expect(tab).toBeInViewport({ ratio: 1 });
+ }
+ const positions = await navigation
+ .getByRole("tab")
+ .evaluateAll((tabs) => tabs.map((tab) => tab.getBoundingClientRect().y));
+ await navigation.hover();
+ await page.mouse.wheel(0, 1000);
+ await expect
+ .poll(() =>
+ navigation
+ .getByRole("tab")
+ .evaluateAll((tabs) => tabs.map((tab) => tab.getBoundingClientRect().y)),
+ )
+ .toEqual(positions);
+ await expect(navigation.locator(".tabs__list-container__scroll-next:visible")).toHaveCount(
+ 0,
+ );
+ await expect(navigation.locator(".tabs__list-container__scroll-prev:visible")).toHaveCount(
+ 0,
+ );
+ const panel = settings.getByRole("tabpanel");
+ await panel.hover();
+ await page.mouse.wheel(0, 10000);
+ await expect.poll(() => panel.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
+ await expect
+ .poll(() =>
+ navigation
+ .getByRole("tab")
+ .evaluateAll((tabs) => tabs.map((tab) => tab.getBoundingClientRect().y)),
+ )
+ .toEqual(positions);
+ await expect(account).toBeInViewport({ ratio: 1 });
+ await settings.screenshot({ path: test.info().outputPath("fixed-sidebar.png") });
+ });
+}
diff --git a/tests/ui/settings-scroll.spec.ts b/tests/ui/settings-scroll.spec.ts
new file mode 100644
index 00000000..bfaf2fd8
--- /dev/null
+++ b/tests/ui/settings-scroll.spec.ts
@@ -0,0 +1,53 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test("editor sections open at the top after scrolling another section", async ({ page }) => {
+ await page.setViewportSize({ width: 1200, height: 720 });
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ const scroller = page.locator(".settings-panel > .custom-scrollbar").filter({ visible: true });
+ await scroller.hover();
+ await page.mouse.wheel(0, 450);
+ await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(50);
+ await page.getByRole("radio", { name: "Cursor", exact: true }).click();
+ await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBe(0);
+ await expect(page.getByText("Cursor Size", { exact: true })).toBeInViewport();
+ await page.getByRole("radio", { name: "Scene", exact: true }).click();
+ await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBe(0);
+ await expect(page.getByRole("button", { name: "Add wallpaper", exact: true })).toBeInViewport();
+ await scroller.hover();
+ await page.mouse.wheel(0, 10000);
+ await expect(page.getByText("Remove background", { exact: true })).toBeInViewport();
+ await page.screenshot({ path: test.info().outputPath("editor-scrolled.png") });
+});
+
+for (const width of [1200, 420]) {
+ test(`settings content scrolls independently and resets on category change at ${width}px`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 720 });
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Settings", exact: true }).click();
+ const settings = page.getByRole("dialog", { name: "Settings", exact: true });
+ await expect(settings).not.toHaveAttribute("data-entering");
+ const panel = settings.getByRole("tabpanel");
+ const heading = settings.getByRole("heading", { name: "Settings", exact: true });
+ const navigation = settings.getByRole("tablist");
+ const headingBounds = await heading.boundingBox();
+ const navigationBounds = await navigation.boundingBox();
+ await panel.hover();
+ await page.mouse.wheel(0, 10000);
+ await expect.poll(() => panel.evaluate((element) => element.scrollTop)).toBeGreaterThan(50);
+ await expect(settings.getByText("Experimental updates", { exact: true })).toBeInViewport();
+ expect(await heading.boundingBox()).toEqual(headingBounds);
+ expect(await navigation.boundingBox()).toEqual(navigationBounds);
+ await settings.screenshot({ path: test.info().outputPath("general-scrolled.png") });
+ await settings.getByRole("tab", { name: "Shortcuts", exact: true }).click();
+ await expect.poll(() => panel.evaluate((element) => element.scrollTop)).toBe(0);
+ await panel.hover();
+ await page.mouse.wheel(0, 10000);
+ await expect(settings.getByRole("button", { name: "Save", exact: true })).toBeInViewport();
+ await settings.getByRole("tab", { name: "General", exact: true }).click();
+ await expect.poll(() => panel.evaluate((element) => element.scrollTop)).toBe(0);
+ await expect(settings.getByRole("radiogroup", { name: "Appearance", exact: true })).toBeInViewport();
+ });
+}
diff --git a/tests/ui/settings-sidebar.spec.ts b/tests/ui/settings-sidebar.spec.ts
new file mode 100644
index 00000000..91e38571
--- /dev/null
+++ b/tests/ui/settings-sidebar.spec.ts
@@ -0,0 +1,91 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test("settings anchors live usage and account actions below compact navigation", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.cloudBilling = async ({ action }) =>
+ action === "catalog"
+ ? {
+ success: true,
+ prices: [],
+ limits: {
+ freeRecordingLimit: 5,
+ freeMaxRecordingBytes: 1e9,
+ plusMaxRecordingBytes: 1e10,
+ storageLimitBytes: null,
+ },
+ }
+ : {
+ success: true,
+ billing: {
+ plan: "free",
+ subscriptionStatus: null,
+ currentPeriodEnd: null,
+ cancelAtPeriodEnd: false,
+ portalAvailable: false,
+ checkoutAvailable: false,
+ checkoutIntervals: [],
+ prices: [],
+ },
+ usage: {
+ recordings: 4,
+ recordingLimit: 5,
+ maxRecordingBytes: 1e9,
+ storageBytes: 40e6,
+ storageLimitBytes: 100e9,
+ },
+ };
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true })
+ .click();
+ const settings = page.getByRole("dialog", { name: "Settings", exact: true });
+ await settings.getByRole("tab", { name: "General", exact: true }).click();
+ const usage = settings.getByRole("region", { name: "Cloud usage", exact: true });
+ await expect(usage.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "4");
+ await expect(usage.getByRole("progressbar")).toHaveAttribute("aria-valuemax", "5");
+ await expect(usage).toContainText("80%");
+ await expect(usage).toContainText("4 of 5 used");
+ const circleBounds = await usage.getByRole("progressbar").locator("svg").boundingBox();
+ const percentageBounds = await usage.getByText("80%", { exact: true }).boundingBox();
+ expect(circleBounds!.width).toBeGreaterThan(percentageBounds!.width + 16);
+ const navigation = settings.locator(".dashboard-settings-navigation");
+ const account = navigation.getByRole("button", { name: "Manage account", exact: true }).last();
+ await expect(account).toBeVisible();
+ await settings.screenshot({ path: "output/update-flow/settings-sidebar.png" });
+ await page.emulateMedia({ colorScheme: "dark" });
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
+ await settings.screenshot({ path: "output/update-flow/settings-sidebar-dark.png" });
+ await page.setViewportSize({ width: 760, height: 720 });
+ await expect(account).toBeVisible();
+ await settings.getByRole("tab", { name: "General", exact: true }).scrollIntoViewIfNeeded();
+ await expect(settings.getByRole("tab", { name: "General", exact: true })).toBeVisible();
+ const tabBounds = await settings
+ .getByRole("tab", { name: "General", exact: true })
+ .boundingBox();
+ const nextArrow = navigation.locator(".tabs__list-container__scroll-next:visible");
+ if (await nextArrow.count()) {
+ const arrowBounds = await nextArrow.boundingBox();
+ expect(arrowBounds!.y).toBeGreaterThanOrEqual(tabBounds!.y + tabBounds!.height - 1);
+ }
+ await settings.screenshot({ path: "output/update-flow/settings-sidebar-narrow.png" });
+ await usage.getByRole("button", { name: "Dismiss", exact: true }).click();
+ await expect(usage).toHaveCount(0);
+ await expect(account).toBeFocused();
+ await account.click();
+ await expect(settings.getByRole("tab", { name: "Account", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await page.setViewportSize({ width: 600, height: 720 });
+ await expect(settings.locator(".dashboard-settings-sidebar-footer")).toBeHidden();
+ await expect(settings.getByRole("tab", { name: "Account", exact: true })).toBeVisible();
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await expect(usage).toHaveCount(0);
+});
diff --git a/tests/ui/share-resolution.spec.ts b/tests/ui/share-resolution.spec.ts
new file mode 100644
index 00000000..2a34a859
--- /dev/null
+++ b/tests/ui/share-resolution.spec.ts
@@ -0,0 +1,26 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+for (const free of [false, true]) {
+ for (const portrait of [false, true]) {
+ test(`shared encoding is capped before probing (${free ? "free" : "Cloud"}, ${portrait ? "portrait" : "landscape"})`, async ({ page }) => {
+ await page.goto(`/tests/ui/export-resolution-presets.html?limit=1920${free ? "&free" : ""}${portrait ? "&portrait" : ""}`);
+ await page.getByRole("row", { name: free ? /^QHD,/ : /^4K \(Cloud\),/ }).click();
+ await page.getByRole("button", { name: "Export Video", exact: true }).click();
+ await expect(page.getByTestId("export-result")).toContainText("cannot export");
+ await page.getByRole("button", { name: "Prepare shared video" }).click();
+ const long = free ? 1280 : 1920;
+ const short = free ? 720 : 1080;
+ await expect(page.getByTestId("export-result")).toHaveText(portrait ? `${short} × ${long}` : `${long} × ${short}`);
+ });
+ }
+}
+
+test("upgrade modal explains shared video quality", async ({ page }, testInfo) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn: false });
+ await page.goto("/tests/ui/billing-usage.html");
+ await page.getByRole("button", { name: "Open test pricing", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "Recordly Cloud" });
+ await expect(dialog.getByRole("list", { name: "Your Cloud benefits" }).getByRole("listitem").filter({ hasText: "Share videos in high-definition 1080p" })).toBeVisible();
+ await page.screenshot({ path: testInfo.outputPath("share-quality-upgrade.png") });
+});
diff --git a/tests/ui/shared-app-frame.spec.ts b/tests/ui/shared-app-frame.spec.ts
new file mode 100644
index 00000000..a7d3e311
--- /dev/null
+++ b/tests/ui/shared-app-frame.spec.ts
@@ -0,0 +1,97 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+for (const width of [1440, 800]) {
+ test(`Home and editor share chrome and account placement at ${width}px`, async ({ page }) => {
+ test.setTimeout(60000);
+ await page.setViewportSize({ width, height: 1000 });
+ if (width === 800)
+ await page.emulateMedia({ colorScheme: "dark", reducedMotion: "reduce" });
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.listProjectFiles = async () => ({
+ success: true,
+ entries: [
+ {
+ path: "/projects/Untitled Project.recordly",
+ name: "Untitled Project",
+ updatedAt: Date.now(),
+ thumbnailPath: null,
+ isCurrent: true,
+ isInProjectsDirectory: true,
+ },
+ {
+ path: "/projects/Other.recordly",
+ name: "Other project",
+ updatedAt: Date.now(),
+ thumbnailPath: null,
+ isCurrent: false,
+ isInProjectsDirectory: true,
+ },
+ ],
+ });
+ window.electronAPI.openProjectFileAtPath = async () => {
+ document.documentElement.dataset.projectReopened = "true";
+ return { success: false, canceled: true };
+ };
+ });
+ await page.goto("/?windowType=editor");
+ const editorHeader = page.locator(".editor-header");
+ await expect(editorHeader).toBeVisible({ timeout: 30000 });
+ await expect(page.getByRole("button", { name: "Join Discord", exact: true })).toHaveCount(0);
+ const editorAccount = editorHeader.getByRole("button", {
+ name: "Recordly account",
+ exact: true,
+ });
+ const headerBounds = await editorHeader.boundingBox();
+ const accountBounds = await editorAccount.boundingBox();
+ await expect(page.locator(".editor-inspector")).toBeVisible();
+ const editorSurface = await page
+ .locator(".editor-inspector .app-frame-surface")
+ .evaluate((element) => {
+ const style = getComputedStyle(element);
+ return { background: style.backgroundColor, radius: style.borderRadius };
+ });
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ const home = page.getByRole("dialog", { name: "Projects dashboard", exact: true });
+ await expect(home).toBeVisible();
+ await expect(home).not.toHaveAttribute("data-entering");
+ await expect(home.getByRole("button", { name: "Join Discord", exact: true })).toBeVisible();
+ const homeHeader = home.locator(".app-frame-header");
+ expect(await homeHeader.boundingBox()).toEqual(headerBounds);
+ const homeAccount = homeHeader.getByRole("button", {
+ name: "Recordly account",
+ exact: true,
+ });
+ expect(await homeAccount.boundingBox()).toEqual(accountBounds);
+ expect(
+ await home.locator(".app-frame-surface").evaluate((element) => {
+ const style = getComputedStyle(element);
+ return { background: style.backgroundColor, radius: style.borderRadius };
+ }),
+ ).toEqual(editorSurface);
+ await expect(home.getByLabel("Library navigation")).toBeVisible();
+ await expect(home.getByText("Currently editing", { exact: true })).toHaveCount(1);
+ await expect(
+ home.getByRole("button", { name: "Untitled Project", exact: true }),
+ ).toHaveAttribute("aria-current", "true");
+ await homeAccount.click();
+ await expect(page.getByRole("dialog", { name: "Account menu", exact: true })).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("dialog", { name: "Account menu", exact: true })).toHaveCount(
+ 0,
+ );
+ await expect(home).toBeVisible();
+ await home
+ .getByRole("textbox", { name: "Search projects", exact: true })
+ .fill("no matching projects");
+ const resume = home.getByRole("button", { name: "Resume Untitled Project", exact: true });
+ await expect(resume).toHaveClass(/button--ghost/);
+ await resume.click();
+ await expect(home).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Join Discord", exact: true })).toHaveCount(0);
+ await expect(editorAccount).toBeVisible();
+ await expect(page.locator(".editor-inspector")).toBeVisible();
+ await expect(page.locator("html")).not.toHaveAttribute("data-project-reopened");
+ });
+}
diff --git a/tests/ui/shared-link-deletion.spec.ts b/tests/ui/shared-link-deletion.spec.ts
new file mode 100644
index 00000000..acbf56b7
--- /dev/null
+++ b/tests/ui/shared-link-deletion.spec.ts
@@ -0,0 +1,134 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+for (const { fails, delayed, nativeFails } of [
+ { fails: false, delayed: false, nativeFails: false },
+ { fails: true, delayed: false, nativeFails: false },
+ { fails: false, delayed: true, nativeFails: false },
+ { fails: false, delayed: false, nativeFails: true },
+]) {
+ test(`deleting a cloud recording ${fails ? "preserves its saved link on failure" : delayed ? "clears a mounted Home card after leaving Shared" : nativeFails ? "warns when native settings cannot be updated" : "clears its saved local project links"}`, async ({
+ page,
+ }) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(
+ page,
+ ({ fails, delayed, nativeFails }) => {
+ const projectPath = "/projects/shared.recordly";
+ let links: unknown = {
+ [projectPath]: {
+ url: "https://example.test/s/deleted",
+ sharedAt: Date.now() + 1000,
+ },
+ };
+ let deleted = false;
+ window.electronAPI.getCurrentVideoPath = async () => ({ success: false });
+ window.electronAPI.getAppSetting = (key) =>
+ key === "recordly.onboarding.v1.seen"
+ ? true
+ : key === "recordly.project-share-links.v1"
+ ? structuredClone(links)
+ : null;
+ window.electronAPI.setAppSetting = (key, value) => {
+ if (key === "recordly.project-share-links.v1") {
+ if (nativeFails) return false;
+ links = structuredClone(value);
+ }
+ return true;
+ };
+ window.electronAPI.listProjectFiles = async () => ({
+ success: true,
+ entries: [
+ {
+ path: projectPath,
+ name: "Saved local project",
+ updatedAt: 1,
+ thumbnailPath: null,
+ isCurrent: false,
+ isInProjectsDirectory: true,
+ },
+ ],
+ });
+ window.electronAPI.cloudShareManage = async (input) => {
+ if (input.action === "delete") {
+ document.documentElement.dataset.deleteCalls = "1";
+ if (fails) return { success: false, error: "Fixture deletion failed" };
+ if (delayed)
+ await new Promise((resolve) => {
+ window.addEventListener("fixture-release-delete", () => resolve(), {
+ once: true,
+ });
+ });
+ deleted = true;
+ }
+ return {
+ success: true,
+ videos: deleted
+ ? []
+ : [
+ {
+ code: "deleted",
+ title: "Shared cloud recording",
+ ready: true,
+ size: 42,
+ url: "https://example.test/s/deleted",
+ createdAt: new Date().toISOString(),
+ },
+ ],
+ };
+ };
+ },
+ { fails, delayed, nativeFails },
+ );
+ await page.goto("/?windowType=editor");
+ const home = page.getByRole("dialog", { name: "Projects dashboard", exact: true });
+ await expect(home).toBeVisible();
+ const share = home.getByRole("button", {
+ name: "View share link for Saved local project",
+ exact: true,
+ });
+ await expect(share).toHaveText("Shared");
+ const navigation = home.getByRole("complementary", { name: "Library navigation" });
+ await navigation.getByRole("button", { name: "Shared links", exact: true }).click();
+ await home
+ .getByRole("button", { name: "Options for Shared cloud recording", exact: true })
+ .click();
+ page.once("dialog", (dialog) => dialog.dismiss());
+ await page.getByRole("menuitem", { name: "Delete recording", exact: true }).click();
+ await expect(page.locator("html")).not.toHaveAttribute("data-delete-calls");
+ await home
+ .getByRole("button", { name: "Options for Shared cloud recording", exact: true })
+ .click();
+ page.once("dialog", (dialog) => dialog.accept());
+ await page.getByRole("menuitem", { name: "Delete recording", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-delete-calls", "1");
+ if (delayed) {
+ await navigation.getByRole("button", { name: "Home", exact: true }).click();
+ await expect(share).toHaveText("Shared");
+ await page.evaluate(() => window.dispatchEvent(new Event("fixture-release-delete")));
+ await expect(share).toHaveCount(0);
+ return;
+ }
+ if (fails) await expect(home.getByRole("alert")).toBeVisible();
+ else
+ await expect(
+ home.getByRole("button", { name: "Shared cloud recording", exact: true }),
+ ).toHaveCount(0);
+ if (nativeFails)
+ await expect(
+ page.getByText("Could not save library preferences", { exact: true }),
+ ).toBeVisible();
+ await navigation.getByRole("button", { name: "Home", exact: true }).click();
+ if (fails) await expect(share).toHaveText("Shared");
+ else {
+ await expect(share).toHaveCount(0);
+ await home
+ .getByRole("button", { name: "Options for Saved local project", exact: true })
+ .click();
+ await expect(page.getByRole("menuitem", { name: "Share", exact: true })).toBeVisible();
+ await expect(
+ page.getByRole("menuitem", { name: "View link", exact: true }),
+ ).toHaveCount(0);
+ }
+ });
+}
diff --git a/tests/ui/shared-videos-carousel.spec.ts b/tests/ui/shared-videos-carousel.spec.ts
new file mode 100644
index 00000000..f42b26a3
--- /dev/null
+++ b/tests/ui/shared-videos-carousel.spec.ts
@@ -0,0 +1,99 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+async function installShares(page: import("@playwright/test").Page, failFirst = false) {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(
+ page,
+ ({ failFirst }) => {
+ window.electronAPI.cloudShareManage = async () => {
+ if (failFirst && !document.documentElement.dataset.shareRecovered) {
+ return { success: false, error: "Cloud is temporarily unavailable" };
+ }
+ return {
+ success: true,
+ videos: Array.from({ length: 8 }, (_, index) => ({
+ code: `demo-${index}`,
+ title:
+ index === 7 && document.documentElement.dataset.shareUpdated
+ ? "Newest shared demo"
+ : `Demo ${index}`,
+ size: 1024,
+ ready: index !== 0,
+ url: `https://share.example.test/s/demo-${index}`,
+ createdAt: `2026-10-${String(index + 1).padStart(2, "0")} 12:00:00`,
+ })),
+ };
+ };
+ window.electronAPI.openExternalUrl = async (url) => {
+ document.documentElement.dataset.openedSharedUrl = url;
+ return { success: true };
+ };
+ },
+ { failFirst },
+ );
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ return page.getByRole("dialog", { name: "Projects dashboard", exact: true });
+}
+
+test("Home shows recent completed shares, scrolls sideways, opens links, and refreshes", async ({
+ page,
+}) => {
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ const home = await installShares(page);
+ const featured = home.getByRole("region", { name: "Recently shared videos" });
+ const carousel = featured.getByRole("list", { name: "Shared video carousel" });
+ await expect(featured.getByRole("heading")).toBeVisible();
+ await expect(carousel.getByRole("listitem")).toHaveCount(7);
+ await expect(carousel.getByRole("listitem").first()).toContainText("Demo 7");
+ await expect(
+ carousel.getByRole("button", { name: "Open shared video: Demo 0", exact: true }),
+ ).toHaveCount(0);
+ await carousel.getByRole("button", { name: "Open shared video: Demo 7", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-opened-shared-url",
+ "https://share.example.test/s/demo-7",
+ );
+ await featured.getByRole("button", { name: "Next shared videos", exact: true }).click();
+ await expect.poll(() => carousel.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0);
+ await featured.getByRole("button", { name: "Previous shared videos", exact: true }).click();
+ await expect.poll(() => carousel.evaluate((element) => element.scrollLeft)).toBe(0);
+ await page.screenshot({ path: test.info().outputPath("home-shared-videos.png") });
+ await page.evaluate(() => {
+ document.documentElement.dataset.shareUpdated = "true";
+ window.dispatchEvent(new Event("focus"));
+ });
+ await expect(carousel.getByRole("listitem").first()).toContainText("Newest shared demo");
+ await featured.getByRole("button", { name: "View all", exact: true }).click();
+ await expect(home.getByRole("list", { name: "Shared projects" })).toBeVisible();
+ await expect(featured).toHaveCount(0);
+});
+
+test("Home can retry a failed shared-video load", async ({ page }) => {
+ const home = await installShares(page, true);
+ const featured = home.getByRole("region", { name: "Recently shared videos" });
+ const failure = featured.getByRole("status").filter({ hasText: "Could not load shared videos." });
+ await expect(failure).toContainText("Could not load shared videos.");
+ await expect(failure.getByText("Cloud is temporarily unavailable")).not.toBeVisible();
+ await failure.getByRole("button", { name: "Technical details", exact: true }).click();
+ await expect(failure.getByText("Cloud is temporarily unavailable")).toBeVisible();
+ await page.evaluate(() => {
+ document.documentElement.dataset.shareRecovered = "true";
+ });
+ await featured.getByRole("button", { name: "Retry", exact: true }).click();
+ await expect(featured.getByRole("listitem")).toHaveCount(7);
+ await expect(featured.getByText("Cloud is temporarily unavailable")).toHaveCount(0);
+});
+
+test("Home hides the featured row when there are no shared videos", async ({ page }) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.cloudShareManage = async () => ({ success: true, videos: [] });
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ const home = page.getByRole("dialog", { name: "Projects dashboard", exact: true });
+ await expect(home).toBeVisible();
+ await expect(home.getByRole("region", { name: "Recently shared videos" })).toHaveCount(0);
+});
diff --git a/tests/ui/shortcuts-settings.spec.ts b/tests/ui/shortcuts-settings.spec.ts
new file mode 100644
index 00000000..cd720fc1
--- /dev/null
+++ b/tests/ui/shortcuts-settings.spec.ts
@@ -0,0 +1,94 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test("Shortcuts has its own settings section with inline editing, save and reset", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(120000);
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getCurrentVideoPath = async () => ({ success: false, path: null });
+ window.electronAPI.getShortcuts = async () => null;
+ window.electronAPI.saveShortcuts = async (value) => {
+ document.documentElement.dataset.savedShortcuts = JSON.stringify(value);
+ return { success: true };
+ };
+ });
+ await page.goto("/?windowType=editor");
+ const dashboard = page.getByRole("dialog", { name: "Projects dashboard", exact: true });
+ await expect(dashboard).toBeVisible({ timeout: 60000 });
+ await dashboard.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true })
+ .click();
+ await expect(dashboard.getByRole("tab", { name: "General", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await expect(
+ dashboard.getByRole("button", { name: "Edit shortcuts", exact: true }),
+ ).toHaveCount(0);
+ await dashboard.getByRole("tab", { name: "Shortcuts", exact: true }).click();
+ const panel = dashboard.getByRole("tabpanel", { name: /^Shortcuts settings/ });
+ await expect(panel.getByText("Configurable", { exact: true })).toBeVisible();
+ await expect(panel.getByText("Fixed", { exact: true })).toBeVisible();
+ await expect(page.getByRole("dialog", { name: "Keyboard Shortcuts", exact: true })).toHaveCount(
+ 0,
+ );
+ const split = () => panel.getByRole("button", { name: /^Change Split Clip shortcut/ });
+ await expect(split()).toHaveText("⌘ + B");
+ await split().click();
+ await page.keyboard.press("Meta+k");
+ await expect(split()).toHaveText("⌘ + K");
+ await panel.getByRole("button", { name: "Save", exact: true }).click();
+ await expect
+ .poll(() => page.locator("html").getAttribute("data-saved-shortcuts"))
+ .toContain('"splitClip":{"key":"k","ctrl":true}');
+ await expect(dashboard.getByRole("tab", { name: "Shortcuts", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await panel.getByRole("button", { name: "Reset to defaults", exact: true }).click();
+ await expect(split()).toHaveText("⌘ + B");
+ await panel.getByRole("button", { name: "Save", exact: true }).click();
+ await split().click();
+ await dashboard.getByRole("tab", { name: "General", exact: true }).click();
+ // A hidden shortcut editor must not capture typing in another category.
+ await page.keyboard.press("x");
+ await dashboard.getByRole("tab", { name: "Shortcuts", exact: true }).click();
+ await expect(split()).toHaveText("⌘ + B");
+ for (const width of [1440, 760]) {
+ await page.setViewportSize({ width, height: 900 });
+ await expect
+ .poll(() => panel.evaluate((node) => node.scrollWidth - node.clientWidth), {
+ message: `Shortcuts panel overflow at ${width}px`,
+ })
+ .toBeLessThanOrEqual(0);
+ }
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.keyboard.press("Escape");
+ await expect(dashboard).not.toBeVisible();
+ await page.getByRole("button", { name: "Recordly account", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Account menu", exact: true })
+ .getByRole("button", { name: "Settings", exact: true })
+ .click();
+ const settings = page.getByRole("dialog", { name: "Settings", exact: true });
+ await settings.getByRole("tab", { name: "Shortcuts", exact: true }).click();
+ const settingsPanel = settings.getByRole("tabpanel");
+ await expect(settingsPanel.getByText("Configurable", { exact: true })).toBeVisible();
+ for (const width of [420, 320]) {
+ await page.setViewportSize({ width, height: 900 });
+ await expect
+ .poll(() => settingsPanel.evaluate((node) => node.scrollWidth - node.clientWidth), {
+ message: `Shortcuts dialog overflow at ${width}px`,
+ })
+ .toBeLessThanOrEqual(0);
+ }
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await settings.screenshot({
+ path: testInfo.outputPath("shortcuts-settings.png"),
+ animations: "disabled",
+ });
+});
diff --git a/tests/ui/signin-fit.spec.ts b/tests/ui/signin-fit.spec.ts
new file mode 100644
index 00000000..54b1891c
--- /dev/null
+++ b/tests/ui/signin-fit.spec.ts
@@ -0,0 +1,104 @@
+import { expect, type Locator, type Page } from "@playwright/test";
+import { productionTest as test } from "./production";
+import { installDesktopBridge } from "./bridge";
+
+async function expectFormFits(dialog: Locator) {
+ await expect
+ .poll(() =>
+ dialog.evaluate((element) => {
+ const bounds = element.getBoundingClientRect();
+ const targets = [
+ element,
+ ...element.querySelectorAll("[data-onboarding-body], .recordly-signin-content"),
+ ];
+ const controls = element.querySelectorAll(
+ "input, [data-signin-form] button, .pricing-legal-consent",
+ );
+ return [
+ ...targets
+ .filter((target) => target.scrollHeight - target.clientHeight > 1)
+ .map((target) => ({
+ problem: "overflow",
+ className: target.className,
+ client: target.clientHeight,
+ scroll: target.scrollHeight,
+ })),
+ ...Array.from(controls).flatMap((control) => {
+ const rect = control.getBoundingClientRect();
+ return rect.top >= bounds.top && rect.bottom <= bounds.bottom
+ ? []
+ : [
+ {
+ problem: "clipped",
+ control: control.textContent || control.tagName,
+ top: rect.top,
+ bottom: rect.bottom,
+ dialogTop: bounds.top,
+ dialogBottom: bounds.bottom,
+ },
+ ];
+ }),
+ ];
+ }),
+ )
+ .toEqual([]);
+}
+
+async function openSignIn(page: Page, onboarding: boolean) {
+ await installDesktopBridge(page, "preview.mp4", {
+ signedIn: false,
+ onboarded: !onboarding,
+ });
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await page.route("**/cloud/config", (route) =>
+ route.fulfill({
+ headers: { "Access-Control-Allow-Origin": "*" },
+ json: { authCaptcha: { enabled: false } },
+ }),
+ );
+ await page.goto(onboarding ? "/?windowType=onboarding" : "/?windowType=editor");
+ const dialog = page.getByRole("dialog", {
+ name: onboarding ? "Welcome to Recordly" : "Sign in to Recordly",
+ exact: true,
+ });
+ await expect(dialog.getByRole("button", { name: "Email me a code", exact: true })).toBeVisible({
+ timeout: 15000,
+ });
+ return dialog;
+}
+
+for (const onboarding of [true, false]) {
+ test(`${onboarding ? "first startup" : "popup"} sign-in fits without scrolling`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 600, height: onboarding ? 600 : 800 });
+ const dialog = await openSignIn(page, onboarding);
+ await expectFormFits(dialog);
+ const consent = dialog.locator(".pricing-legal-consent");
+ expect((await consent.boundingBox())!.height).toBeLessThanOrEqual(18);
+ await page.screenshot({
+ path: `test-results/signin-fit-${onboarding ? "onboarding" : "popup"}-600.png`,
+ });
+ await dialog.getByPlaceholder("you@example.com").fill("test@email.com");
+ await dialog.getByRole("button", { name: "Or use password to sign in", exact: true }).click();
+ await expect(dialog.getByLabel("Password", { exact: true })).toBeVisible();
+ await expectFormFits(dialog);
+ await dialog.getByRole("button", { name: "Sign in with email code", exact: true }).click();
+ for (const size of [
+ { width: 390, height: 600 },
+ { width: 450, height: 450 },
+ ]) {
+ await page.setViewportSize(size);
+ await expectFormFits(dialog);
+ await expect(
+ consent.getByRole("link", { name: "Privacy Policy", exact: true }),
+ ).toBeInViewport();
+ await page.screenshot({
+ path: `test-results/signin-fit-${onboarding ? "onboarding" : "popup"}-${size.width}x${size.height}.png`,
+ });
+ }
+ await dialog.getByRole("button", { name: "Email me a code", exact: true }).click();
+ await expect(dialog.getByText("Enter code sent to test@email.com")).toBeVisible();
+ await expectFormFits(dialog);
+ });
+}
diff --git a/tests/ui/slider-tooltip.spec.ts b/tests/ui/slider-tooltip.spec.ts
new file mode 100644
index 00000000..6df6af35
--- /dev/null
+++ b/tests/ui/slider-tooltip.spec.ts
@@ -0,0 +1,82 @@
+import { expect, test } from "@playwright/test";
+
+test("settings values follow the thumb while dragging and hide on release", async ({ page }) => {
+ await page.goto("/tests/ui/controls.html");
+ const slider = page.getByRole("slider", { name: "Padding", exact: true });
+ await expect(slider).toBeVisible();
+ const control = slider.locator("xpath=ancestor::*[@data-slot='slider']");
+ await expect(control.locator("output")).toHaveCount(0);
+ await expect(page.getByRole("tooltip")).toHaveCount(0);
+ const track = await control.locator("[data-slot='slider-track']").boundingBox();
+ expect(track).not.toBeNull();
+ // Start via the track, then move between equally wide two-digit values.
+ await page.mouse.move(track!.x + track!.width * 0.4, track!.y + track!.height / 2);
+ await page.mouse.down();
+ const tooltip = page.getByRole("tooltip");
+ for (const percent of [0.4, 0.6, 0.8]) {
+ await page.mouse.move(track!.x + track!.width * percent, track!.y + track!.height / 2, {
+ steps: 5,
+ });
+ await expect(tooltip).toHaveText(`${Math.round(Number(await slider.inputValue()))}px`);
+ await expect(tooltip).not.toHaveAttribute("data-entering");
+ await expect
+ .poll(async () => {
+ const bubble = await tooltip.boundingBox();
+ const thumb = await control.locator("[data-slot='slider-thumb']").boundingBox();
+ return Math.abs(bubble!.x + bubble!.width / 2 - thumb!.x - thumb!.width / 2);
+ })
+ .toBeLessThan(2);
+ const bubble = await tooltip.boundingBox();
+ expect(bubble!.y + bubble!.height).toBeLessThan(track!.y);
+ }
+ await page.screenshot({ path: test.info().outputPath("slider-drag-tooltip.png") });
+ await page.mouse.up();
+ await expect(tooltip).toHaveCount(0);
+
+ // Keyboard editing keeps a formatted value available too.
+ await slider.focus();
+ await page.keyboard.press("ArrowRight");
+ await expect(tooltip).toHaveText(`${await slider.inputValue()}px`);
+ await page.getByRole("button", { name: "Toggle theme", exact: true }).focus();
+ await expect(tooltip).toHaveCount(0);
+});
+
+test("dragging follows small pointer movements while retaining setting increments", async ({
+ page,
+}) => {
+ await page.goto("/tests/ui/controls.html");
+ const slider = page.getByRole("slider", { name: "Padding", exact: true });
+ const thumb = slider.locator("xpath=ancestor::*[@data-slot='slider-thumb']");
+ await expect(slider).toHaveValue("20");
+ const bounds = await thumb.boundingBox();
+ expect(bounds).not.toBeNull();
+ const x = bounds!.x + bounds!.width / 2;
+ const y = bounds!.y + bounds!.height / 2;
+ await page.mouse.move(x, y);
+ await page.mouse.down();
+ await page.mouse.move(x + 1, y);
+ await expect.poll(async () => (await thumb.boundingBox())!.x).toBeGreaterThan(bounds!.x + 0.25);
+ // The setting and readable tooltip retain whole pixels even between steps.
+ await expect(page.getByTestId("state")).toContainText('"value":20,');
+ await expect(page.getByRole("tooltip")).toHaveText("20px");
+ const finePosition = (await thumb.boundingBox())!.x;
+ await page.mouse.move(x + 2, y);
+ await expect
+ .poll(async () => (await thumb.boundingBox())!.x)
+ .toBeGreaterThan(finePosition + 0.25);
+ const bubble = await page.getByRole("tooltip").boundingBox();
+ const movedThumb = await thumb.boundingBox();
+ expect(
+ Math.abs(bubble!.x + bubble!.width / 2 - movedThumb!.x - movedThumb!.width / 2),
+ ).toBeLessThan(2);
+ await page.mouse.up();
+ await expect(slider).toHaveValue("20");
+ await expect(page.getByRole("tooltip")).toHaveCount(0);
+ await slider.focus();
+ await page.keyboard.press("ArrowRight");
+ await expect(slider).toHaveValue("21");
+ await page.keyboard.press("End");
+ await expect(slider).toHaveValue("100");
+ await page.keyboard.press("Home");
+ await expect(slider).toHaveValue("0");
+});
diff --git a/tests/ui/split-shortcut.spec.ts b/tests/ui/split-shortcut.spec.ts
new file mode 100644
index 00000000..086b2280
--- /dev/null
+++ b/tests/ui/split-shortcut.spec.ts
@@ -0,0 +1,42 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+for (const platform of ["darwin", "win32"] as const) {
+ test(`primary B splits a clip on ${platform}, including saved C-default settings`, async ({
+ page,
+ }) => {
+ test.setTimeout(180000);
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await installDesktopBridgeOverrides(
+ page,
+ (platform) => {
+ window.electronAPI.getPlatform = async () => platform;
+ window.electronAPI.getShortcuts = async () => ({ splitClip: { key: "c" } });
+ },
+ platform,
+ );
+ await page.goto("/?windowType=editor");
+ const clips = page.locator('[data-variant="clip"]');
+ await expect(clips).toHaveAttribute("data-end-ms", "6000", { timeout: 30000 });
+ await expect(
+ page.getByRole("button", {
+ name: platform === "darwin" ? "Split Clip (⌘ + B)" : "Split Clip (Ctrl + B)",
+ exact: true,
+ }),
+ ).toBeVisible();
+ await expect(page.locator("[data-preview-overlay]")).toBeVisible({ timeout: 60000 });
+ await expect(page.getByLabel("Loading preview", { exact: true })).toHaveCount(0, {
+ timeout: 60000,
+ });
+ const block = (await clips.locator(".timeline-block").boundingBox())!;
+ const row = (await page.locator('[data-timeline-row="row-clip"]').boundingBox())!;
+ await page.mouse.click(block.x + block.width / 2, row.y - 8);
+ await page.getByTestId("timeline-scroll").focus();
+ await page.keyboard.press("c");
+ await expect(clips).toHaveCount(1);
+ await page.keyboard.press(platform === "darwin" ? "Meta+b" : "Control+b");
+ await expect(clips).toHaveCount(2);
+ await expect(clips.first()).toHaveAttribute("data-end-ms", "3000");
+ await expect(clips.nth(1)).toHaveAttribute("data-start-ms", "3000");
+ });
+}
diff --git a/tests/ui/split-zoom.spec.ts b/tests/ui/split-zoom.spec.ts
new file mode 100644
index 00000000..504b9313
--- /dev/null
+++ b/tests/ui/split-zoom.spec.ts
@@ -0,0 +1,107 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+for (const action of ["button", "shortcut"] as const) {
+ test(`splitting with the ${action} splits zooms and preserves them through undo, redo and save`, async ({
+ page,
+ }) => {
+ test.setTimeout(90000);
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.loadCurrentProjectFile = async () => ({
+ success: true,
+ path: "/projects/split-zoom.recordly",
+ project: {
+ version: 2,
+ videoPath: `${location.origin}/tests/ui/fixtures/filmstrip.mp4`,
+ editor: {
+ clipRegions: [{ id: "clip-1", startMs: 0, endMs: 6000, speed: 1 }],
+ zoomRegions: [
+ {
+ id: "zoom-1",
+ startMs: 1000,
+ endMs: 5000,
+ depth: 3,
+ focus: { cx: 0.4, cy: 0.6 },
+ focusSpace: "viewport",
+ mode: "manual",
+ },
+ ],
+ },
+ },
+ });
+ window.electronAPI.onMenuSaveProject = (callback) => {
+ const handler = async () => {
+ await callback();
+ sessionStorage.setItem("test-save-split-complete", "true");
+ };
+ window.addEventListener("test-save-split-zoom", handler);
+ return () => window.removeEventListener("test-save-split-zoom", handler);
+ };
+ window.electronAPI.saveProjectFile = async (project) => {
+ sessionStorage.setItem("test-split-zoom", JSON.stringify(project.editor));
+ return { success: true, path: "/projects/split-zoom.recordly" };
+ };
+ });
+ await page.goto("/?windowType=editor");
+ const clips = page.locator('[data-variant="clip"]');
+ const zooms = page.locator('[data-variant="zoom"]');
+ await expect(clips).toHaveAttribute("data-end-ms", "6000", { timeout: 30000 });
+ await expect(zooms).toHaveCount(1);
+ await expect(page.locator("[data-preview-overlay]")).toBeVisible({ timeout: 60000 });
+ await expect(page.getByLabel("Loading preview", { exact: true })).toHaveCount(0, {
+ timeout: 60000,
+ });
+ const block = (await clips.locator(".timeline-block").boundingBox())!;
+ const row = (await page.locator('[data-timeline-row="row-clip"]').boundingBox())!;
+ await page.mouse.click(block.x + block.width / 2, row.y - 8);
+ if (action === "button") {
+ await page.getByRole("button", { name: /^Split Clip \(/ }).click();
+ } else {
+ await page.getByTestId("timeline-scroll").focus();
+ await page.keyboard.press("Meta+b");
+ }
+ await expect(clips).toHaveCount(2);
+ await expect(zooms).toHaveCount(2);
+ await expect(zooms.first()).toHaveAttribute("data-stored-end-ms", "3000");
+ await expect(zooms.nth(1)).toHaveAttribute("data-start-ms", "3000");
+ await page.keyboard.press("Meta+z");
+ await expect(clips).toHaveCount(1);
+ await expect(zooms).toHaveCount(1);
+ await expect(zooms).toHaveAttribute("data-stored-end-ms", "5000");
+ await page.keyboard.press("Meta+Shift+z");
+ await expect(clips).toHaveCount(2);
+ await expect(zooms).toHaveCount(2);
+ await page.evaluate(() => {
+ sessionStorage.removeItem("test-split-zoom");
+ sessionStorage.removeItem("test-save-split-complete");
+ window.dispatchEvent(new Event("test-save-split-zoom"));
+ });
+ await expect
+ .poll(() => page.evaluate(() => sessionStorage.getItem("test-save-split-complete")))
+ .toBe("true");
+ const editor = await page.evaluate(() =>
+ JSON.parse(sessionStorage.getItem("test-split-zoom")!),
+ );
+ expect(editor.zoomRegions).toEqual([
+ {
+ id: "zoom-1",
+ startMs: 1000,
+ endMs: 3000,
+ depth: 3,
+ focus: { cx: 0.4, cy: 0.6 },
+ focusSpace: "viewport",
+ mode: "manual",
+ },
+ {
+ id: "zoom-2",
+ startMs: 3000,
+ endMs: 5000,
+ depth: 3,
+ focus: { cx: 0.4, cy: 0.6 },
+ focusSpace: "viewport",
+ mode: "manual",
+ },
+ ]);
+ });
+}
diff --git a/tests/ui/startup-loading.spec.ts b/tests/ui/startup-loading.spec.ts
index c7afa90e..f74a3d57 100644
--- a/tests/ui/startup-loading.spec.ts
+++ b/tests/ui/startup-loading.spec.ts
@@ -41,7 +41,7 @@ for (const colorScheme of ["light", "dark"] as const) {
await page.goto("/?windowType=editor&startupView=home");
await expect(page.getByRole("status", { name: "Loading home", exact: true })).toBeVisible();
await expect(page.locator(".boot-home-sidebar")).toBeVisible();
- await expect(page.locator(".boot-home-grid > div")).toHaveCount(3);
+ await expect(page.locator(".boot-home-grid > div")).toHaveCount(4);
await expect(page.locator(".boot-timeline")).not.toBeVisible();
await page.screenshot({ path: `test-results/home-startup-${colorScheme}.png` });
});
@@ -91,7 +91,9 @@ test("Home retains its skeleton during session lookup and then opens the dashboa
window.electronAPI.getCurrentVideoPath = async () => ({ success: false, path: null });
});
await page.goto("/?windowType=editor");
- await expect(page.locator("html")).toHaveAttribute("data-home-lookup-pending", "true");
+ await expect(page.locator("html")).toHaveAttribute("data-home-lookup-pending", "true", {
+ timeout: 30000,
+ });
await expect(page.getByRole("status", { name: "Loading home", exact: true })).toBeVisible();
await expect(page.getByRole("status", { name: "Loading editor", exact: true })).toHaveCount(0);
await page.evaluate(() => window.dispatchEvent(new Event("test-release-initial-project")));
@@ -169,7 +171,9 @@ test("Home navigation keeps the Home skeleton while a retained project finishes
};
});
await page.goto("/?windowType=editor");
- await expect(page.locator("html")).toHaveAttribute("data-retained-library-pending", "true");
+ await expect(page.locator("html")).toHaveAttribute("data-retained-library-pending", "true", {
+ timeout: 30000,
+ });
await expect(page.getByRole("status", { name: "Loading home", exact: true })).toBeVisible();
await expect(page.getByRole("status", { name: "Loading editor", exact: true })).toHaveCount(0);
await page.evaluate(() => window.dispatchEvent(new Event("test-release-retained-library")));
@@ -177,3 +181,45 @@ test("Home navigation keeps the Home skeleton while a retained project finishes
page.getByRole("dialog", { name: "Projects dashboard", exact: true }),
).toBeVisible();
});
+
+for (const width of [1440, 800]) {
+ test(`startup editor wireframe matches the live frame at ${width}px`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 900 });
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ // Compare the normal editor frame; the bootstrap has no update notification.
+ window.electronAPI.getCurrentUpdateToastPayload = async () => null;
+ });
+ await page.route("**/src/main.tsx*", (route) => route.abort());
+ await page.goto("/?windowType=editor");
+ const wireframe = {
+ header: await page.locator(".boot-editor .boot-header").boundingBox(),
+ inspector: await page.locator(".boot-settings").boundingBox(),
+ timeline: await page.locator(".boot-timeline").boundingBox(),
+ preview: await page.locator(".boot-canvas .loading-preview-frame").boundingBox(),
+ playback: await page.locator(".boot-editor .loading-playback").boundingBox(),
+ };
+ await expect(page.locator(".boot-rail i")).toHaveCount(4);
+ await page.screenshot({ path: `test-results/editor-wireframe-${width}.png` });
+ await page.unroute("**/src/main.tsx*");
+ await page.reload();
+ await expect(page.locator(".editor-inspector")).toBeVisible();
+ await expect(page.locator(".app-update-banner")).toHaveCount(0);
+ const live = {
+ header: await page.locator(".editor-header").boundingBox(),
+ inspector: await page.locator(".editor-inspector .app-frame-surface").boundingBox(),
+ timeline: await page.locator("[data-timeline-panel]").boundingBox(),
+ preview: await page.locator(".editor-preview-frame").boundingBox(),
+ playback: await page.locator(".editor-playback").boundingBox(),
+ };
+ for (const region of ["header", "inspector", "timeline", "preview", "playback"] as const) {
+ for (const dimension of ["x", "y", "width", "height"] as const) {
+ expect(
+ Math.abs(wireframe[region]![dimension] - live[region]![dimension]),
+ `${region} ${dimension}`,
+ ).toBeLessThan(2);
+ }
+ }
+ await page.screenshot({ path: `test-results/editor-live-${width}.png` });
+ });
+}
diff --git a/tests/ui/storage-visibility.spec.ts b/tests/ui/storage-visibility.spec.ts
new file mode 100644
index 00000000..6cce8832
--- /dev/null
+++ b/tests/ui/storage-visibility.spec.ts
@@ -0,0 +1,124 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+for (const storageBytes of [
+ 220_200_000, 94_999_999_999, 95_000_000_000, 100_000_000_000, 101_000_000_000,
+]) {
+ test(`Cloud sidebar storage visibility at ${storageBytes} bytes; account usage stays available`, async ({
+ page,
+ }) => {
+ await installDesktopBridge(page);
+ await page.addInitScript((storageBytes) => {
+ window.electronAPI.cloudBilling = async () => ({
+ success: true,
+ billing: {
+ plan: "pro",
+ subscriptionStatus: "active",
+ currentPeriodEnd: null,
+ cancelAtPeriodEnd: false,
+ checkoutAvailable: false,
+ checkoutIntervals: [],
+ portalAvailable: false,
+ prices: [],
+ },
+ usage: {
+ recordings: 8,
+ recordingLimit: null,
+ maxRecordingBytes: 10_000_000_000,
+ storageBytes,
+ storageLimitBytes: 100_000_000_000,
+ },
+ });
+ }, storageBytes);
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ const dashboard = page.getByRole("dialog", { name: "Projects dashboard", exact: true });
+ const sidebar = dashboard.getByLabel("Sidebar announcements", { exact: true });
+ // Wait for the account response before checking that a healthy account is hidden.
+ await expect(sidebar.getByText("Loading usage…")).toHaveCount(0);
+ await expect(sidebar.getByText("Cloud recordings", { exact: true })).toHaveCount(0);
+ await expect(sidebar.getByLabel("Cloud usage", { exact: true })).toHaveCount(0);
+ const navigation = dashboard.getByRole("complementary", { name: "Library navigation" });
+ await expect(navigation.getByRole("button", { name: "Upgrade", exact: true })).toHaveCount(
+ 0,
+ );
+ await navigation.getByRole("button", { name: "Settings", exact: true }).click();
+ const settings = dashboard.getByRole("region", { name: "Dashboard settings", exact: true });
+ await expect(settings).toBeVisible();
+ await expect(settings.getByRole("region", { name: "Cloud usage", exact: true })).toHaveCount(0);
+ await settings.getByRole("button", { name: "Manage account", exact: true }).click();
+ await settings.getByRole("tab", { name: "Plan & billing", exact: true }).click();
+ await expect(settings.getByLabel("Cloud usage", { exact: true })).toContainText("Cloud storage");
+ });
+}
+
+for (const signedIn of [false, true]) {
+ test(`${signedIn ? "Free" : "Signed-out"} library keeps footer actions on one line and opens Settings`, async ({
+ page,
+ }) => {
+ await installDesktopBridge(page, "preview.mp4", { signedIn, onboarded: true });
+ await page.addInitScript(() => {
+ window.electronAPI.cloudBilling = async () => ({
+ success: true,
+ billing: {
+ plan: "free",
+ subscriptionStatus: null,
+ currentPeriodEnd: null,
+ cancelAtPeriodEnd: false,
+ checkoutAvailable: false,
+ checkoutIntervals: [],
+ portalAvailable: false,
+ prices: [],
+ },
+ usage: {
+ recordings: 2,
+ recordingLimit: 5,
+ maxRecordingBytes: 1_000_000_000,
+ storageBytes: 100_000_000,
+ storageLimitBytes: 100_000_000_000,
+ },
+ });
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ const dashboard = page.getByRole("dialog", { name: "Projects dashboard", exact: true });
+ const navigation = dashboard.getByRole("complementary", { name: "Library navigation" });
+ const usage = navigation.getByLabel("Cloud usage", { exact: true });
+ if (signedIn) await expect(usage).toContainText("2 of 5 used");
+ else await expect(usage).toHaveCount(0);
+ for (const width of [1440, 800]) {
+ await page.setViewportSize({ width, height: 1000 });
+ const bounds = [];
+ const settingsButton = navigation.getByRole("button", { name: "Settings", exact: true });
+ await expect(settingsButton).toHaveText("Settings");
+ const settingsBounds = (await settingsButton.boundingBox())!;
+ for (const name of ["Upgrade", "Join Discord", "GitHub"]) {
+ const button = navigation.getByRole("button", { name, exact: true });
+ await expect(button).toBeVisible();
+ bounds.push((await button.boundingBox())!);
+ }
+ expect(settingsBounds.y + settingsBounds.height).toBeLessThanOrEqual(bounds[0].y);
+ for (let index = 1; index < bounds.length; index++) {
+ expect(
+ Math.abs(
+ bounds[index].y +
+ bounds[index].height / 2 -
+ bounds[0].y -
+ bounds[0].height / 2,
+ ),
+ ).toBeLessThan(1);
+ expect(bounds[index].x).toBeGreaterThanOrEqual(
+ bounds[index - 1].x + bounds[index - 1].width,
+ );
+ }
+ const navBounds = (await navigation.boundingBox())!;
+ expect(bounds.at(-1)!.x + bounds.at(-1)!.width).toBeLessThanOrEqual(
+ navBounds.x + navBounds.width,
+ );
+ }
+ await navigation.getByRole("button", { name: "Settings", exact: true }).click();
+ await expect(
+ dashboard.getByRole("region", { name: "Dashboard settings", exact: true }),
+ ).toBeVisible();
+ });
+}
diff --git a/tests/ui/thumbnail-duration.spec.ts b/tests/ui/thumbnail-duration.spec.ts
new file mode 100644
index 00000000..9c108ee6
--- /dev/null
+++ b/tests/ui/thumbnail-duration.spec.ts
@@ -0,0 +1,493 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test("project thumbnails show timeline and source durations in the bottom right", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await page.addInitScript(() => {
+ window.electronAPI.listProjectFiles = async () => ({
+ success: true,
+ entries: ["Edited", "Long", "Legacy"].map((name) => ({
+ path: `/projects/${name}.recordly`,
+ name,
+ updatedAt: 1,
+ thumbnailPath: `${location.origin}/tests/ui/fixtures/recording-thumbnail.jpg`,
+ isCurrent: false,
+ isInProjectsDirectory: true,
+ })),
+ });
+ window.electronAPI.getProjectPreview = async (path) => ({
+ success: true,
+ value: {
+ videoUrl: `${location.origin}/tests/ui/fixtures/preview.mp4`,
+ webcamUrl: null,
+ project: {
+ version: 1,
+ videoPath: "/recordings/preview.mp4",
+ editor: {
+ clipRegions: path.includes("Legacy")
+ ? []
+ : [
+ {
+ id: "clip",
+ startMs: 0,
+ endMs: path.includes("Long") ? 3723000 : 12000,
+ sourceStartMs: 0,
+ speed: 2,
+ },
+ ],
+ },
+ },
+ },
+ });
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ const home = page.getByRole("dialog", { name: "Projects dashboard" });
+ for (const [name, duration] of [
+ ["Edited", "0:12"],
+ ["Long", "1:02:03"],
+ ["Legacy", "0:06"],
+ ]) {
+ const card = home.getByRole("button", { name, exact: true });
+ const badge = card.locator("[data-thumbnail-duration]");
+ await expect(badge).toHaveText(duration);
+ const placement = await badge.evaluate((element) => {
+ const badge = element.getBoundingClientRect();
+ const thumbnail = element.parentElement!.getBoundingClientRect();
+ return {
+ right: thumbnail.right - badge.right,
+ bottom: thumbnail.bottom - badge.bottom,
+ };
+ });
+ expect(placement.right).toBeCloseTo(8, 0);
+ expect(placement.bottom).toBeCloseTo(8, 0);
+ }
+ await page.mouse.move(0, 0);
+ await page.screenshot({ path: "test-results/thumbnail-durations.png", animations: "disabled" });
+});
+
+
+test("saved local thumbnails use approved HTTP transport without losing capability queries", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ const blockedFiles: string[] = [];
+ page.on("console", (message) => {
+ if (message.text().includes("Not allowed to load local resource"))
+ blockedFiles.push(message.text());
+ });
+ await page.addInitScript(() => {
+ const path = "/Users/demo/Projects/Cat #1.recordly.preview.png";
+ window.electronAPI.getLocalMediaUrl = async (filePath) => {
+ document.documentElement.dataset.thumbnailLocalPath = filePath;
+ return {
+ success: true,
+ url: `${location.origin}/tests/ui/fixtures/recording-thumbnail.jpg?token=fixture-capability`,
+ };
+ };
+ window.electronAPI.listProjectFiles = async () => ({
+ success: true,
+ entries: [
+ {
+ path: "/projects/Cat.recordly",
+ name: "Native saved thumbnail",
+ updatedAt: 123,
+ thumbnailPath: path,
+ isCurrent: false,
+ isInProjectsDirectory: true,
+ },
+ ],
+ });
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ const image = page
+ .getByRole("button", { name: "Native saved thumbnail", exact: true })
+ .locator("img");
+ await expect(image).toBeVisible();
+ await expect
+ .poll(() => image.evaluate((image: HTMLImageElement) => image.naturalWidth))
+ .toBeGreaterThan(0);
+ const src = new URL((await image.getAttribute("src"))!);
+ expect(src.protocol).toBe("http:");
+ expect(src.searchParams.get("token")).toBe("fixture-capability");
+ expect(src.searchParams.get("v")).toBe("123");
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-thumbnail-local-path",
+ "/Users/demo/Projects/Cat #1.recordly.preview.png",
+ );
+ expect(blockedFiles).toEqual([]);
+});
+
+test("projects without usable thumbnails generate their opening frame without hovering", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await page.addInitScript(() => {
+ window.electronAPI.listProjectFiles = async () => ({
+ success: true,
+ entries: ["Missing", "Broken", "Existing"].map((name) => ({
+ path: `/projects/${name}.recordly`,
+ name,
+ updatedAt: 1,
+ thumbnailPath:
+ name === "Missing"
+ ? null
+ : `${location.origin}/tests/ui/fixtures/${name === "Broken" ? "absent.png" : "recording-thumbnail.jpg"}`,
+ isCurrent: false,
+ isInProjectsDirectory: true,
+ })),
+ });
+ window.electronAPI.getProjectPreview = async (path) => ({
+ success: true,
+ value: {
+ videoUrl: `${location.origin}/tests/ui/fixtures/preview.mp4`,
+ webcamUrl: null,
+ project: {
+ version: 1,
+ videoPath: "/recordings/preview.mp4",
+ editor: {
+ wallpaper: "#ff0000",
+ aspectRatio: path.includes("Broken") ? "9:16" : "16:9",
+ borderRadius: 0,
+ shadowIntensity: 0,
+ padding: { top: 40, bottom: 40, left: 40, right: 40, linked: true },
+ clipRegions: [
+ {
+ id: "trimmed",
+ startMs: 0,
+ endMs: 3000,
+ sourceStartMs: 2000,
+ speed: 1,
+ },
+ ],
+ },
+ },
+ },
+ });
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ await page.mouse.move(0, 0);
+ const home = page.getByRole("dialog", { name: "Projects dashboard" });
+ for (const name of ["Missing", "Broken"]) {
+ const card = home.getByRole("button", { name, exact: true });
+ // Thumbnail decoding/rendering has a 15s deadline and shares the GPU work queue.
+ await expect(card.locator('img[src^="data:image/png"]')).toBeVisible({ timeout: 20_000 });
+ await expect(card.locator("[data-thumbnail-duration]")).toHaveText("0:03");
+ }
+ for (const name of ["Missing", "Broken"]) {
+ const pixels = await home
+ .getByRole("button", { name, exact: true })
+ .locator("img")
+ .evaluate((image: HTMLImageElement) => {
+ const canvas = document.createElement("canvas");
+ canvas.width = image.naturalWidth;
+ canvas.height = image.naturalHeight;
+ const context = canvas.getContext("2d")!;
+ context.drawImage(image, 0, 0);
+ return {
+ edges: [
+ [10, 10],
+ [320, 1],
+ [320, 478],
+ [1, 240],
+ [638, 240],
+ ].map(([x, y]) => [...context.getImageData(x, y, 1, 1).data]),
+ center: [...context.getImageData(320, 240, 1, 1).data],
+ };
+ });
+ // The landscape composition is cropped horizontally to cover the card;
+ // its deliberate top/bottom padding remains, without inventing side bars.
+ for (const edge of name === "Missing" ? pixels.edges.slice(0, 3) : pixels.edges)
+ expect(edge).toEqual([255, 0, 0, 255]);
+ if (name === "Missing")
+ for (const edge of pixels.edges.slice(3)) expect(edge).not.toEqual([255, 0, 0, 255]);
+ expect(pixels.center).not.toEqual([255, 0, 0, 255]);
+ }
+ await expect(
+ home.getByRole("button", { name: "Existing", exact: true }).locator("img"),
+ ).toHaveAttribute("src", /recording-thumbnail.jpg/);
+ await page.screenshot({
+ path: "test-results/generated-project-thumbnails.png",
+ animations: "disabled",
+ });
+});
+
+test("native landscape and cropped portrait thumbnails cover the card and keep their composition on hover", async ({
+ page,
+}) => {
+ test.setTimeout(60_000);
+ await installDesktopBridge(page);
+ await page.addInitScript(() => {
+ window.electronAPI.listProjectFiles = async () => ({
+ success: true,
+ entries: ["Landscape full", "Portrait crop"].map((name) => ({
+ path: `/projects/${name}.recordly`,
+ name,
+ updatedAt: 1,
+ thumbnailPath: null,
+ isCurrent: false,
+ isInProjectsDirectory: true,
+ })),
+ });
+ window.electronAPI.getProjectPreview = async (path) => ({
+ success: true,
+ value: {
+ videoUrl: `${location.origin}/tests/ui/fixtures/preview.mp4`,
+ webcamUrl: null,
+ project: {
+ version: 1,
+ videoPath: "/recordings/preview.mp4",
+ editor: {
+ aspectRatio: "native",
+ wallpaper: "#ed13a4",
+ padding: 0,
+ borderRadius: 0,
+ shadowIntensity: 0,
+ cropRegion: path.includes("Portrait")
+ ? { x: 0.25, y: 0, width: 0.5, height: 1 }
+ : { x: 0, y: 0, width: 1, height: 1 },
+ },
+ },
+ },
+ });
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ await page.mouse.move(0, 0);
+ const home = page.getByRole("dialog", { name: "Projects dashboard" });
+ for (const name of ["Landscape full", "Portrait crop"]) {
+ const card = home.getByRole("button", { name, exact: true });
+ const image = card.locator('img[src^="data:image/png"]');
+ await expect(image).toBeVisible({ timeout: 20_000 });
+ const pixels = await image.evaluate((image: HTMLImageElement) => {
+ const canvas = document.createElement("canvas");
+ canvas.width = image.naturalWidth;
+ canvas.height = image.naturalHeight;
+ const context = canvas.getContext("2d")!;
+ context.drawImage(image, 0, 0);
+ return [
+ [320, 2],
+ [320, 477],
+ [2, 240],
+ [637, 240],
+ ].map(([x, y]) => [...context.getImageData(x, y, 1, 1).data]);
+ });
+ for (const pixel of pixels) {
+ expect(pixel).not.toEqual([237, 19, 164, 255]);
+ expect(pixel[3]).toBe(255);
+ }
+ await card.hover();
+ const hover = card.locator("[data-project-hover-preview]");
+ await expect(hover).toHaveCSS("opacity", "1", { timeout: 20_000 });
+ const dimensions = await hover.evaluate((element) => {
+ const card = element.getBoundingClientRect();
+ const frame = element.firstElementChild!.firstElementChild!.getBoundingClientRect();
+ return {
+ cardWidth: card.width,
+ cardHeight: card.height,
+ width: frame.width,
+ height: frame.height,
+ };
+ });
+ expect(dimensions.width + 1).toBeGreaterThanOrEqual(dimensions.cardWidth);
+ expect(dimensions.height + 1).toBeGreaterThanOrEqual(dimensions.cardHeight);
+ expect(dimensions.width / dimensions.height).toBeCloseTo(
+ name.includes("Portrait") ? 8 / 9 : 16 / 9,
+ 1,
+ );
+ await page.mouse.move(0, 0);
+ await expect(hover).toHaveCount(0);
+ }
+ await page.screenshot({
+ path: "test-results/project-thumbnail-cover.png",
+ animations: "disabled",
+ });
+});
+
+test("generated thumbnails survive scrolling and filtering without another render", async ({
+ page,
+}) => {
+ // Generate real previews before exercising scrolling and filter remounts.
+ test.setTimeout(60_000);
+ await installDesktopBridge(page);
+ await page.addInitScript(() => {
+ const writes: Record = {};
+ window.electronAPI.cacheProjectThumbnail = async (path) => {
+ writes[path] = (writes[path] ?? 0) + 1;
+ document.documentElement.dataset.thumbnailWrites = JSON.stringify(writes);
+ return { success: true, value: `${path}.png` };
+ };
+ window.electronAPI.listProjectFiles = async () => ({
+ success: true,
+ entries: Array.from({ length: 32 }, (_, index) => ({
+ path: `/projects/scroll-${index}.recordly`,
+ name: `Scroll project ${index}`,
+ updatedAt: 1,
+ thumbnailPath: null,
+ isCurrent: false,
+ isInProjectsDirectory: true,
+ })),
+ });
+ window.electronAPI.getProjectPreview = async () => ({
+ success: true,
+ value: {
+ revision: "fixture-revision",
+ videoUrl: `${location.origin}/tests/ui/fixtures/preview.mp4`,
+ webcamUrl: null,
+ project: {
+ version: 1,
+ videoPath: "/recordings/preview.mp4",
+ editor: {
+ wallpaper: "#ff0000",
+ padding: { top: 40, bottom: 40, left: 40, right: 40, linked: true },
+ },
+ },
+ },
+ });
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ await page.mouse.move(0, 0);
+ const home = page.getByRole("dialog", { name: "Projects dashboard" });
+ const first = home.getByRole("button", { name: "Scroll project 0", exact: true });
+ const image = first.locator('img[src^="data:image/png"]');
+ // Wait for real queued generation before testing reuse across scrolling/remounts.
+ await expect(image).toBeVisible({ timeout: 20_000 });
+ const source = await image.getAttribute("src");
+ const scroller = home.locator("main");
+ await scroller.evaluate((element) => {
+ element.scrollTop = element.scrollHeight;
+ });
+ await expect
+ .poll(() => first.evaluate((element) => element.getBoundingClientRect().bottom))
+ .toBeLessThan(-800);
+ // Moving outside the prefetch region must not clear the completed image.
+ await expect(image).toHaveAttribute("src", source!);
+ await scroller.evaluate((element) => {
+ element.scrollTop = 0;
+ });
+ await expect(image).toBeVisible();
+ await expect(image).toHaveAttribute("src", source!);
+ const search = home.getByPlaceholder("Search projects…");
+ await search.fill("Scroll project 31");
+ await expect(first).toHaveCount(0);
+ await search.clear();
+ await expect(image).toBeVisible();
+ await expect(image).toHaveAttribute("src", source!);
+ await expect
+ .poll(async () => {
+ const writes = JSON.parse(
+ (await page.locator("html").getAttribute("data-thumbnail-writes")) ?? "{}",
+ );
+ return writes["/projects/scroll-0.recordly"];
+ })
+ .toBe(1);
+});
+
+test("project posters include the recorded cursor and honor the saved visibility setting", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await page.addInitScript(() => {
+ window.electronAPI.listProjectFiles = async () => ({
+ success: true,
+ entries: ["Cursor visible", "Cursor hidden"].map((name) => ({
+ path: `/projects/${name}.recordly`,
+ name,
+ updatedAt: 1,
+ thumbnailPath: null,
+ isCurrent: false,
+ isInProjectsDirectory: true,
+ })),
+ });
+ window.electronAPI.getCursorTelemetry = async (path) => {
+ document.documentElement.dataset.cursorSource = path;
+ return {
+ success: true,
+ samples: [
+ { timeMs: 0, cx: 0.5, cy: 0.5, interactionType: "move" },
+ { timeMs: 6000, cx: 0.5, cy: 0.5, interactionType: "move" },
+ ],
+ };
+ };
+ window.electronAPI.getProjectPreview = async (path) => ({
+ success: true,
+ value: {
+ videoUrl: `${location.origin}/tests/ui/fixtures/preview.mp4`,
+ webcamUrl: null,
+ project: {
+ version: 1,
+ videoPath: "/recordings/cursor-source.mp4",
+ editor: {
+ aspectRatio: "4:3",
+ padding: 0,
+ borderRadius: 0,
+ shadowIntensity: 0,
+ showCursor: path.includes("visible"),
+ cursorStyle: "tahoe",
+ cursorSize: 5,
+ },
+ },
+ },
+ });
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Home", exact: true }).click();
+ await page.mouse.move(0, 0);
+ const home = page.getByRole("dialog", { name: "Projects dashboard" });
+ const visible = home
+ .getByRole("button", { name: "Cursor visible", exact: true })
+ .locator('img[src^="data:image/png"]');
+ const hidden = home
+ .getByRole("button", { name: "Cursor hidden", exact: true })
+ .locator('img[src^="data:image/png"]');
+ await expect(visible).toBeVisible({ timeout: 20_000 });
+ await expect(hidden).toBeVisible({ timeout: 20_000 });
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-cursor-source",
+ "/recordings/cursor-source.mp4",
+ );
+ const difference = await page.evaluate(
+ async ([withCursor, withoutCursor]) => {
+ const read = async (src: string) => {
+ const image = new Image();
+ image.src = src;
+ await image.decode();
+ const canvas = document.createElement("canvas");
+ canvas.width = 640;
+ canvas.height = 480;
+ const context = canvas.getContext("2d")!;
+ context.drawImage(image, 0, 0);
+ return context.getImageData(0, 0, 640, 480).data;
+ };
+ const [a, b] = await Promise.all([read(withCursor), read(withoutCursor)]);
+ let nearCursor = 0,
+ elsewhere = 0;
+ for (let y = 0; y < 480; y++)
+ for (let x = 0; x < 640; x++) {
+ const i = (y * 640 + x) * 4;
+ if (
+ Math.abs(a[i] - b[i]) +
+ Math.abs(a[i + 1] - b[i + 1]) +
+ Math.abs(a[i + 2] - b[i + 2]) <
+ 30
+ )
+ continue;
+ if (x >= 270 && x < 420 && y >= 190 && y < 340) nearCursor++;
+ else elsewhere++;
+ }
+ return { nearCursor, elsewhere };
+ },
+ [(await visible.getAttribute("src"))!, (await hidden.getAttribute("src"))!],
+ );
+ expect(difference.nearCursor).toBeGreaterThan(30);
+ expect(difference.elsewhere).toBeLessThan(10);
+ await home.screenshot({
+ path: "test-results/project-thumbnail-cursor.png",
+ animations: "disabled",
+ });
+});
diff --git a/tests/ui/timeline-gap-snapping.spec.ts b/tests/ui/timeline-gap-snapping.spec.ts
index ca9d4ac9..23a433c1 100644
--- a/tests/ui/timeline-gap-snapping.spec.ts
+++ b/tests/ui/timeline-gap-snapping.spec.ts
@@ -1,4 +1,4 @@
-import { expect, test, type Page, type Locator } from "@playwright/test";
+import { expect, type Locator, type Page, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
async function prepare(page: Page) {
@@ -11,7 +11,7 @@ async function prepare(page: Page) {
const row = (await page.locator('[data-timeline-row="row-clip"]').boundingBox())!;
await page.mouse.click(row.x + row.width / 2, row.y - 8);
await expect(page.getByTestId("playhead-cap")).toHaveAttribute("aria-label", "Playhead 3.0s");
- await page.getByRole("button", { name: "Split Clip (C)", exact: true }).click();
+ await page.getByRole("button", { name: /^Split Clip \(/ }).click();
await expect(clips).toHaveCount(2);
return { clips, row };
}
@@ -33,6 +33,8 @@ test("zoom ends snap to clip cuts, release, and can span the gutter", async ({ p
await expect(zoom).toHaveCount(1);
await resizeEnd(page, zoom, first.x + first.width - 4);
await expect(zoom).toHaveAttribute("data-end-ms", "3000");
+ // The displayed grip snaps to the cut while the full exit keeps its settling time.
+ expect(Number(await zoom.getAttribute("data-stored-end-ms"))).toBeGreaterThan(3000);
let box = (await zoom.boundingBox())!;
expect(box.x + box.width).toBeCloseTo(first.x + first.width, 0);
await resizeEnd(page, zoom, first.x + first.width - 30);
@@ -88,6 +90,7 @@ test("dragging a zoom soft-snaps its end to the cut without changing duration",
await expect(zoom).toHaveCount(1);
const start = Number(await zoom.getAttribute("data-start-ms"));
const end = Number(await zoom.getAttribute("data-end-ms"));
+ await zoom.locator(".timeline-block").hover();
const box = (await zoom.boundingBox())!;
const delta = first.width * ((3000 - (end - start) - start) / 3000) - 4;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
diff --git a/tests/ui/timeline-interactions.spec.ts b/tests/ui/timeline-interactions.spec.ts
index 0eb611ae..d95d7e63 100644
--- a/tests/ui/timeline-interactions.spec.ts
+++ b/tests/ui/timeline-interactions.spec.ts
@@ -19,9 +19,14 @@ test("zoom hover, adaptive lanes, clip edges, volume and playhead", async ({ pag
expect((await zoomRow.boundingBox())!.height).toBe((await clipRow.boundingBox())!.height);
await zoomRow.hover({ position: { x: 250, y: 25 } });
await expect(page.getByTestId("timeline-add-preview")).toBeVisible();
+ const hoverPreview = (await page
+ .getByTestId("timeline-add-preview")
+ .locator(":scope > div")
+ .boundingBox())!;
await zoomRow.click({ position: { x: 250, y: 25 } });
const zoom = page.locator('[data-variant="zoom"]');
await expect(zoom).toHaveCount(1);
+ expect((await zoom.boundingBox())!.width).toBeCloseTo(hoverPreview.width, 0);
await expect(zoom.locator(".timeline-block")).toHaveCSS("background-image", "none");
const normalHeight = (await zoomRow.boundingBox())!.height;
await scroll.click({ position: { x: 600, y: 5 } });
diff --git a/tests/ui/timeline-loading.spec.ts b/tests/ui/timeline-loading.spec.ts
new file mode 100644
index 00000000..c085bd39
--- /dev/null
+++ b/tests/ui/timeline-loading.spec.ts
@@ -0,0 +1,33 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test("timeline keeps its wireframe until video metadata loads", async ({ page }) => {
+ await installDesktopBridge(page);
+ let release!: () => void;
+ const pending = new Promise((resolve) => {
+ release = resolve;
+ });
+ await page.route("**/tests/ui/fixtures/preview.mp4*", async (route) => {
+ await pending;
+ await route.continue();
+ });
+ try {
+ await page.goto("/?windowType=editor", { waitUntil: "domcontentloaded" });
+ const timeline = page.locator("[data-timeline-panel]");
+ const loading = timeline.locator("[data-loading-timeline]");
+ await expect(loading).toBeVisible({ timeout: 20000 });
+ await expect(loading).toHaveAttribute("aria-busy", "true");
+ await expect(loading).toHaveAccessibleName("Loading timeline");
+ await expect(loading.locator(".skeleton")).toHaveCount(4);
+ await expect(page.getByText("No Video Loaded", { exact: true })).toHaveCount(0);
+ await expect(page.getByText("Drag and drop a video to start editing")).toHaveCount(0);
+ await page.screenshot({ path: "test-results/timeline-metadata-loading.png" });
+
+ release();
+ await expect(timeline.getByTestId("timeline-scroll")).toBeVisible({ timeout: 20000 });
+ await expect(loading).toHaveCount(0);
+ await expect(timeline.locator('[data-variant="clip"]')).not.toHaveCount(0);
+ } finally {
+ release();
+ }
+});
diff --git a/tests/ui/titlebar-drag.spec.ts b/tests/ui/titlebar-drag.spec.ts
new file mode 100644
index 00000000..097344e9
--- /dev/null
+++ b/tests/ui/titlebar-drag.spec.ts
@@ -0,0 +1,77 @@
+import { expect, type Locator, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+async function expectDraggableGap(header: Locator, after: Locator, before: Locator) {
+ const left = await after.boundingBox();
+ const right = await before.boundingBox();
+ expect(left).not.toBeNull();
+ expect(right).not.toBeNull();
+ expect(right!.x - left!.x - left!.width).toBeGreaterThan(8);
+ // Electron subtracts every no-drag rectangle, including empty wrapper space.
+ const point = {
+ x: (left!.x + left!.width + right!.x) / 2,
+ y: left!.y + left!.height / 2,
+ };
+ const regions = await header.evaluate((element, point) => {
+ const coversPoint = (node: Element) => {
+ const rect = node.getBoundingClientRect();
+ return (
+ point.x >= rect.left &&
+ point.x < rect.right &&
+ point.y >= rect.top &&
+ point.y < rect.bottom
+ );
+ };
+ return [element, ...element.querySelectorAll("*")]
+ .filter(coversPoint)
+ .map((node) => getComputedStyle(node).getPropertyValue("-webkit-app-region"));
+ }, point);
+ expect(regions).toContain("drag");
+ expect(regions).not.toContain("no-drag");
+ for (const button of await header.getByRole("button").all()) {
+ if (await button.isVisible()) {
+ await expect(button).toHaveCSS("-webkit-app-region", "no-drag");
+ }
+ }
+}
+
+for (const width of [1440, 800]) {
+ test(`empty title bar space stays draggable at button height (${width}px)`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width, height: 1000 });
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ const editor = page.locator(".editor-header");
+ const clips = editor.getByRole("button", { name: "Clips", exact: true });
+ await expect(clips).toBeVisible({ timeout: 30000 });
+ await expectDraggableGap(
+ editor,
+ editor.getByRole("button", { name: "Home", exact: true }),
+ editor.getByRole("button", { name: "Rename project", exact: true }),
+ );
+ if (width === 1440) {
+ await expectDraggableGap(
+ editor,
+ clips,
+ editor.getByRole("button", { name: /Upgrade to Cloud/ }),
+ );
+ }
+ await editor.getByRole("button", { name: "Rename project", exact: true }).click();
+ await expect(editor.getByRole("textbox", { name: "Project name", exact: true })).toHaveCSS(
+ "-webkit-app-region",
+ "no-drag",
+ );
+ await page.keyboard.press("Escape");
+ await editor.getByRole("button", { name: "Home", exact: true }).click();
+ const home = page.getByRole("dialog", { name: "Projects dashboard", exact: true });
+ await expect(home).toBeVisible();
+ await expect(home).not.toHaveAttribute("data-entering");
+ const header = home.locator(".app-frame-header");
+ await expectDraggableGap(
+ header,
+ header.getByText("Library", { exact: true }),
+ header.getByRole("button", { name: /Upgrade to Cloud/ }),
+ );
+ });
+}
diff --git a/tests/ui/update-notification.spec.ts b/tests/ui/update-notification.spec.ts
new file mode 100644
index 00000000..16be979a
--- /dev/null
+++ b/tests/ui/update-notification.spec.ts
@@ -0,0 +1,182 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test("HUD has no update controls even when an update is available", async ({ page }) => {
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=hud-overlay");
+ await expect(page.getByRole("group", { name: "Recording setup", exact: true })).toBeVisible();
+ await expect(page.locator("[data-hud-update-pill]")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: /update|restart/i })).toHaveCount(0);
+});
+
+test("separate update notification downloads without restarting and shows progress", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ let payload: Awaited> = {
+ version: "9.9.9",
+ phase: "available",
+ detail: "Preview",
+ delayMs: 60000,
+ isPreview: true,
+ };
+ window.electronAPI.getCurrentUpdateToastPayload = async () => payload;
+ window.electronAPI.onUpdateToastStateChanged = (callback) => {
+ const listener = (event: Event) => {
+ payload = (event as CustomEvent).detail;
+ callback(payload);
+ };
+ window.addEventListener("test-update", listener);
+ return () => window.removeEventListener("test-update", listener);
+ };
+ window.electronAPI.downloadAvailableUpdate = async (autoRestart) => {
+ document.documentElement.dataset.autoRestart = String(autoRestart);
+ window.dispatchEvent(
+ new CustomEvent("test-update", {
+ detail: { ...payload, phase: "downloading", progressPercent: 42 },
+ }),
+ );
+ return { success: true };
+ };
+ });
+ await page.goto("/?windowType=update-toast");
+ await expect(
+ page.getByRole("heading", { name: "Update available", exact: true }),
+ ).toBeVisible();
+ await page.getByRole("button", { name: "Download update", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-auto-restart", "false");
+ await expect(page.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "42");
+ await page.screenshot({ path: "output/update-flow/notification.png" });
+});
+
+test("downloaded update notification waits for an explicit restart", async ({ page }) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getCurrentUpdateToastPayload = async () => ({
+ version: "9.9.9",
+ phase: "ready",
+ detail: "Ready",
+ delayMs: 60000,
+ isPreview: true,
+ });
+ window.electronAPI.installDownloadedUpdate = async () => {
+ document.documentElement.dataset.restartRequested = "true";
+ return { success: true };
+ };
+ });
+ await page.goto("/?windowType=update-toast");
+ await expect(
+ page.getByRole("heading", { name: "Ready to restart", exact: true }),
+ ).toBeVisible();
+ await expect(page.getByRole("button", { name: "Update & restart", exact: true })).toHaveCount(
+ 0,
+ );
+ await expect(page.locator("html")).not.toHaveAttribute("data-restart-requested");
+ await page.getByRole("button", { name: "Restart to update", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-restart-requested", "true");
+});
+
+test("a blocked restart stays visible and can be retried from the notification", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 420, height: 236 });
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getCurrentUpdateToastPayload = async () => ({
+ version: "1.5.0",
+ phase: "ready",
+ detail: "Ready",
+ delayMs: 10_800_000,
+ });
+ window.electronAPI.installDownloadedUpdate = async () => ({
+ success: false,
+ code: "restart-blocked",
+ message:
+ "Finish recording or exporting and save your project before restarting to update.",
+ });
+ });
+ await page.goto("/?windowType=update-toast");
+ await page.getByRole("button", { name: "Restart to update", exact: true }).click();
+ await expect(
+ page
+ .getByRole("alert")
+ .getByText(
+ "Finish recording or exporting and save your project before restarting to update.",
+ { exact: true },
+ ),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Restart to update", exact: true }),
+ ).toBeEnabled();
+ await expect(
+ page.getByRole("heading", { name: "Ready to restart", exact: true }),
+ ).toBeVisible();
+ const card = page.locator('[data-slot="card"]');
+ const bounds = await card.boundingBox();
+ expect(bounds).not.toBeNull();
+ expect(bounds!.y).toBeGreaterThanOrEqual(0);
+ expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(236);
+ await page.screenshot({ path: "output/update-flow/notification-ready-blocked.png" });
+});
+
+for (const locale of ["de", "fr", "zh-TW"]) {
+ test(`experimental notification fits its desktop window in ${locale}`, async ({ page }) => {
+ await page.setViewportSize({ width: 420, height: 236 });
+ await installDesktopBridge(page);
+ await page.addInitScript(
+ (language) => localStorage.setItem("recordly.locale", language),
+ locale,
+ );
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getCurrentUpdateToastPayload = async () => ({
+ version: "2.0.0-beta.2",
+ phase: "available",
+ detail: "Experimental",
+ delayMs: 10_800_000,
+ isExperimental: true,
+ });
+ });
+ await page.goto("/?windowType=update-toast");
+ await expect(page.getByRole("heading")).toBeVisible();
+ const bounds = await page.locator('[data-slot="card"]').boundingBox();
+ expect(bounds).not.toBeNull();
+ expect(bounds!.y).toBeGreaterThanOrEqual(0);
+ expect(bounds!.x).toBeGreaterThanOrEqual(0);
+ expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(236);
+ expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(420);
+ await page.screenshot({ path: `output/update-flow/notification-${locale}.png` });
+ });
+}
+
+test("the notification can be hidden while its download request is pending", async ({ page }) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ const payload = {
+ version: "1.5.0",
+ phase: "available" as const,
+ detail: "Available",
+ delayMs: 10_800_000,
+ };
+ let listener: Parameters[0];
+ window.electronAPI.getCurrentUpdateToastPayload = async () => payload;
+ window.electronAPI.onUpdateToastStateChanged = (callback) => {
+ listener = callback;
+ return () => undefined;
+ };
+ window.electronAPI.downloadAvailableUpdate = () => {
+ listener({ ...payload, phase: "downloading", progressPercent: 42 });
+ return new Promise(() => undefined);
+ };
+ window.electronAPI.dismissUpdateToast = async () => {
+ document.documentElement.dataset.dismissedDuringDownload = "true";
+ listener(null);
+ return { success: true };
+ };
+ });
+ await page.goto("/?windowType=update-toast");
+ await page.getByRole("button", { name: "Download update", exact: true }).click();
+ await expect(page.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "42");
+ await page.getByRole("button", { name: "Not now", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-dismissed-during-download", "true");
+});
diff --git a/tests/ui/upgrade-modal.spec.ts b/tests/ui/upgrade-modal.spec.ts
new file mode 100644
index 00000000..a3d26447
--- /dev/null
+++ b/tests/ui/upgrade-modal.spec.ts
@@ -0,0 +1,174 @@
+import { expect, type Page, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+async function openUpgrade(page: Page, yearly = false, classic = false) {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(
+ page,
+ ({ yearly }) => {
+ const prices = [
+ {
+ interval: "monthly",
+ unitAmount: 900,
+ regularUnitAmount: 1500,
+ promotion: "early_bird",
+ currency: "usd",
+ },
+ ...(yearly ? [{ interval: "yearly", unitAmount: 8100, currency: "usd" }] : []),
+ ];
+ window.electronAPI.cloudBilling = async ({ action, interval }) => {
+ if (action === "catalog")
+ return {
+ success: true,
+ prices,
+ limits: {
+ freeRecordingLimit: 5,
+ freeMaxRecordingBytes: 1e9,
+ plusMaxRecordingBytes: 1e10,
+ plusWeeklyUploadLimit: 200,
+ storageLimitBytes: 1e11,
+ },
+ };
+ if (action === "status")
+ return {
+ success: true,
+ billing: {
+ plan: "free",
+ subscriptionStatus: null,
+ currentPeriodEnd: null,
+ cancelAtPeriodEnd: false,
+ checkoutAvailable: true,
+ checkoutIntervals: yearly ? ["monthly", "yearly"] : ["monthly"],
+ portalAvailable: false,
+ prices,
+ },
+ usage: {
+ recordings: 0,
+ recordingLimit: 5,
+ maxRecordingBytes: 1e9,
+ storageBytes: 0,
+ storageLimitBytes: 1e11,
+ },
+ };
+ document.documentElement.dataset.billingAction = action;
+ document.documentElement.dataset.billingInterval = interval;
+ return { success: true };
+ };
+ },
+ { yearly },
+ );
+ await page.goto(`/tests/ui/billing-usage.html${classic ? "?upgradeModal=classic" : ""}`);
+ await page.getByRole("button", { name: "Open test pricing", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "Recordly Cloud", exact: true });
+ await expect(page.getByTestId("verified-plan")).toHaveText("free");
+ await expect(dialog.getByRole("button", { name: "Get started", exact: true })).toBeEnabled();
+ return dialog;
+}
+
+for (const viewport of [
+ { width: 1440, height: 1000 },
+ { width: 1024, height: 640 },
+ { width: 820, height: 700 },
+ { width: 390, height: 844 },
+ { width: 320, height: 640 },
+]) {
+ test(`split upgrade fits and dismisses at ${viewport.width}px`, async ({ page }) => {
+ await page.setViewportSize(viewport);
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ const dialog = await openUpgrade(page);
+ await expect(
+ dialog.getByRole("heading", { name: "Unlock Recordly's full power", exact: true }),
+ ).toBeVisible();
+ await expect(dialog.getByRole("navigation", { name: "Legal and support" })).toHaveCount(0);
+ await expect(dialog.locator(".pricing-legal-consent")).toHaveCount(0);
+ await expect(dialog.getByRole("radio")).toHaveCount(1);
+ await expect(dialog.getByRole("radio", { name: /Monthly/ })).toBeChecked();
+ await expect(dialog.getByRole("region", { name: "Your Cloud benefits" })).toBeVisible();
+ await expect(dialog.locator(".split-upgrade-benefits li")).toHaveText([
+ "Full commercial license",
+ "Share any video with a link",
+ "Unlimited cloud storage and bandwidth",
+ "Play cloud videos in FHD 1080p",
+ "Export caption SRT and VTT files",
+ "Priority support service",
+ "❤️Support solo development",
+ ]);
+ await expect(
+ dialog.locator("[data-onboarding-artwork] .recordly-banner-light"),
+ ).toHaveAttribute("src", /auth\/recordly-banner-(light|dark)\.png/);
+ await expect(
+ dialog.locator(".split-upgrade-preview, .split-upgrade-window-bar"),
+ ).toHaveCount(0);
+ await expect(dialog.locator(".pricing-price")).toHaveText("$9 USD / month");
+ expect(await dialog.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
+ const banner = dialog.locator("[data-onboarding-artwork]");
+ await expect(banner).toBeInViewport();
+ const bannerBox = await banner.boundingBox();
+ await expect(banner).toHaveCSS("mask-image", /linear-gradient/);
+ expect(
+ await banner.evaluate((el) => Number.parseFloat(getComputedStyle(el).marginBottom)),
+ ).toBeLessThan(0);
+ await expect(dialog.locator(".onboarding-ambience .onboarding-blob")).toHaveCount(3);
+ const box = await dialog.boundingBox();
+ expect(Math.abs(bannerBox!.width - box!.width)).toBeLessThan(20);
+ const headerBox = await dialog.locator(".split-upgrade-header").boundingBox();
+ expect(headerBox!.y).toBeGreaterThan(bannerBox!.y + bannerBox!.height / 2);
+ expect(headerBox!.y).toBeLessThan(bannerBox!.y + bannerBox!.height);
+ expect(box!.x).toBeGreaterThanOrEqual(0);
+ expect(box!.y).toBeGreaterThanOrEqual(0);
+ expect(box!.y + box!.height).toBeLessThanOrEqual(viewport.height);
+ for (const theme of ["light", "dark"]) {
+ await page.evaluate((theme) => {
+ document.documentElement.classList.toggle("dark", theme === "dark");
+ document.documentElement.classList.toggle("light", theme === "light");
+ document.documentElement.dataset.theme = theme;
+ document.documentElement.style.colorScheme = theme;
+ }, theme);
+ await expect(banner.locator("img:visible")).toHaveAttribute(
+ "src",
+ new RegExp(`auth/recordly-banner-${theme}\\.png$`),
+ );
+ await page.screenshot({
+ path: test.info().outputPath(`upgrade-${theme}-${viewport.width}.png`),
+ animations: "disabled",
+ });
+ }
+ const purchase = dialog.getByRole("button", { name: "Get started", exact: true });
+ await dialog.locator(".split-upgrade-purchase").scrollIntoViewIfNeeded();
+ await expect(purchase).toBeInViewport();
+ await expect(dialog.getByRole("img", { name: "Apple Pay", exact: true })).toBeInViewport();
+ await expect(dialog.getByRole("button", { name: "Maybe later", exact: true })).toHaveCount(
+ 0,
+ );
+ await dialog.getByRole("button", { name: "Close", exact: true }).click();
+ await expect(dialog).not.toBeVisible();
+ await expect(page.locator("html")).not.toHaveAttribute("data-billing-action");
+ await page.getByRole("button", { name: "Open test pricing", exact: true }).click();
+ await page.keyboard.press("Escape");
+ await expect(dialog).not.toBeVisible();
+ });
+}
+
+test("the redesign preserves monthly-only checkout when a catalog includes yearly pricing", async ({
+ page,
+}) => {
+ const dialog = await openUpgrade(page, true);
+ await expect(dialog.getByRole("radio")).toHaveCount(1);
+ await expect(dialog.locator(".pricing-heading-offer")).toContainText("40% off");
+ await expect(dialog.getByRole("radio", { name: /Monthly/ })).toBeChecked();
+ await dialog.getByRole("button", { name: "Get started", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-billing-action", "checkout");
+ await expect(page.locator("html")).toHaveAttribute("data-billing-interval", "monthly");
+ await expect(dialog.getByRole("radio", { name: /Monthly/ })).toBeDisabled();
+});
+
+test("the original upgrade modal remains available for comparison", async ({ page }) => {
+ const dialog = await openUpgrade(page, false, true);
+ await expect(dialog).toHaveClass(/upgrade-modal/);
+ await expect(
+ dialog.getByRole("heading", { name: "Unlock the full power of Recordly" }),
+ ).toBeVisible();
+ await expect(dialog.getByRole("region", { name: "Community testimonials" })).toBeAttached();
+ await dialog.getByRole("button", { name: "Close", exact: true }).click();
+ await expect(dialog).not.toBeVisible();
+});
diff --git a/tests/ui/videos-library.spec.ts b/tests/ui/videos-library.spec.ts
index 20d3a72c..7e03611a 100644
--- a/tests/ui/videos-library.spec.ts
+++ b/tests/ui/videos-library.spec.ts
@@ -1,9 +1,13 @@
-import { expect, test, type Page } from "@playwright/test";
+import { expect, type Page, test } from "@playwright/test";
import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
async function setup(page: Page) {
await installDesktopBridge(page, "filmstrip.mp4");
await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.openRecordingsFolder = async () => {
+ document.documentElement.dataset.recordingsFolderOpened = "true";
+ return { success: true };
+ };
const removed = new Set();
const entries = ["first.mp4", "second.mp4"].map((name, i) => ({
name,
@@ -64,6 +68,11 @@ test("Clips supports selection, remove all, undo and real timeline drag insertio
page.on("pageerror", (error) => errors.push(error.message));
await setup(page);
const panel = page.getByRole("complementary", { name: "Clips" });
+ await panel.getByRole("button", { name: "Open recordings folder", exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-recordings-folder-opened", "true");
+ await expect(
+ panel.getByRole("button", { name: "Clip library actions", exact: true }),
+ ).toHaveCount(0);
await expect(panel.locator("video")).toHaveCount(0);
await expect(panel.getByRole("img", { name: "Preview of first.mp4" })).toBeVisible();
await expect
@@ -79,7 +88,7 @@ test("Clips supports selection, remove all, undo and real timeline drag insertio
(await page.getByRole("button", { name: "Rename project", exact: true }).boundingBox())!.x,
);
expect((await panel.boundingBox())!.x).toBeLessThan(100);
- await expect(videosButton).toHaveClass(/button--secondary/);
+ await expect(videosButton).toHaveAttribute("aria-expanded", "true");
await panel
.locator('[data-recording-path="/recordings/first.mp4"] [data-slot="checkbox-control"]')
.click();
@@ -88,8 +97,8 @@ test("Clips supports selection, remove all, undo and real timeline drag insertio
await expect(page.getByLabel("Select first.mp4")).toHaveCount(0);
await page.keyboard.press("Meta+z");
await expect(page.getByLabel("Select first.mp4")).toBeVisible();
- await panel.getByRole("button", { name: "Clip library actions", exact: true }).click();
- await page.getByRole("menuitem", { name: "Move all to Trash", exact: true }).click();
+ await panel.locator('[data-slot="checkbox-control"]').first().click();
+ await panel.getByRole("button", { name: "Move selected videos to Trash", exact: true }).click();
await expect(panel.getByText("Your recordings appear here")).toBeVisible();
await page.keyboard.press("Control+z");
await expect(page.getByLabel("Select second.mp4")).toBeVisible();
@@ -116,9 +125,13 @@ test("Clips supports selection, remove all, undo and real timeline drag insertio
await panel.getByRole("searchbox", { name: "Search videos" }).fill("");
await expect(panel.locator("[data-recording-path]")).toHaveCount(2);
await expect(panel.getByRole("img", { name: "Preview of second.mp4" })).toBeVisible();
- await page.screenshot({ path: "test-results/videos-panel.png", animations: "disabled" });
+ await page.screenshot({
+ path: "test-results/videos-panel.png",
+ animations: "disabled",
+ });
await page.getByRole("radio", { name: "Scene", exact: true }).click();
await expect(panel).toHaveCount(0);
+ await page.getByRole("button", { name: "Background details", exact: true }).click();
await expect(page.getByRole("slider", { name: "Blur", exact: true })).toBeVisible();
expect(errors).toEqual([]);
});
@@ -136,28 +149,58 @@ test("generating captions leaves the current media session intact and shows the
success: true,
url: `${location.origin}/tests/ui/fixtures/filmstrip.mp4`,
});
- window.electronAPI.getWhisperSmallModelStatus = async () => ({
+ window.electronAPI.getCaptionModelStatus = async () => ({
success: true,
exists: true,
- path: "/models/small.bin",
- });
- window.electronAPI.generateAutoCaptions = async () => ({
- success: true,
- cues: [{ id: "cue-1", startMs: 0, endMs: 4000, text: "Generated caption is visible" }],
+ path: "/models/parakeet-v3",
});
+ window.electronAPI.generateAutoCaptions = async (options) => {
+ document.documentElement.dataset.captionEngine = options.engine;
+ document.documentElement.dataset.captionModel = options.parakeetModelPath;
+ return {
+ success: true,
+ cues: [
+ {
+ id: "cue-1",
+ startMs: 0,
+ endMs: 4000,
+ text: "Generated caption is visible",
+ words: [
+ { text: "Generated", startMs: 0, endMs: 700 },
+ { text: "caption", startMs: 900, endMs: 1800, leadingSpace: true },
+ { text: "is", startMs: 2100, endMs: 2400, leadingSpace: true },
+ { text: "visible", startMs: 2700, endMs: 4000, leadingSpace: true },
+ ],
+ },
+ ],
+ };
+ };
window.electronAPI.setCurrentVideoPath = async () => {
document.documentElement.dataset.sessionReset = "true";
return { success: true };
};
});
await page.goto("/?windowType=editor");
- await expect(page.locator('[data-variant="clip"] img').first()).toBeVisible();
+ await expect(page.locator('[data-variant="clip"] img').first()).toBeVisible({
+ timeout: 20000,
+ });
await page.getByRole("radio", { name: "Captions", exact: true }).click();
await page.getByRole("button", { name: "Generate Captions", exact: true }).click();
await expect(
page.getByRole("button", { name: "Edit current caption", exact: true }),
).toHaveText("Generated caption is visible");
+ const preview = page.getByRole("button", { name: "Edit current caption", exact: true });
+ await expect(preview.getByText("Generated", { exact: true })).toHaveCSS(
+ "color",
+ "rgb(255, 255, 255)",
+ );
+ await expect(preview.getByText("caption", { exact: true })).toHaveCSS(
+ "color",
+ "rgb(163, 163, 163)",
+ );
await expect(page.locator("html")).not.toHaveAttribute("data-session-reset", "true");
+ await expect(page.locator("html")).toHaveAttribute("data-caption-engine", "parakeet");
+ await expect(page.locator("html")).toHaveAttribute("data-caption-model", "/models/parakeet-v3");
await page.getByRole("button", { name: "Play", exact: true }).click();
await expect
.poll(() =>
@@ -187,7 +230,9 @@ test("preview recovers once when a local video URL fails to load", async ({ page
});
await page.route("**/missing-source.mp4", (route) => route.abort("connectionfailed"));
await page.goto("/?windowType=editor");
- await expect(page.locator('[data-variant="clip"]')).toHaveAttribute("data-end-ms", "6000");
+ await expect(page.locator('[data-variant="clip"]')).toHaveAttribute("data-end-ms", "6000", {
+ timeout: 20000,
+ });
await expect(page.getByLabel("Loading preview")).toHaveCount(0);
await expect(page.getByText(/Failed to load video/)).toHaveCount(0);
});
@@ -212,28 +257,54 @@ test("dashboard project list scrolls vertically with long names", async ({ page
await page.goto("/?windowType=editor");
await page.getByRole("button", { name: "Home", exact: true }).click();
const home = page.getByRole("dialog", { name: "Projects dashboard" });
- const entry = home.getByRole("button", { name: /^An extremely long project/ });
+ const entry = home.getByRole("button", {
+ name: /^An extremely long project/,
+ });
await expect(entry).toHaveCount(30);
const content = home.getByRole("main");
expect(await content.evaluate((node) => node.scrollWidth <= node.clientWidth)).toBe(true);
await entry.last().scrollIntoViewIfNeeded();
await expect(entry.last()).toBeInViewport();
- await page.screenshot({ path: "test-results/dashboard-projects-vertical.png" });
+ await page.screenshot({
+ path: "test-results/dashboard-projects-vertical.png",
+ });
});
-test("HUD source controls have no default gray fill or outline", async ({ page }) => {
+test("HUD source control and Linux source rows have no default gray fill or outline", async ({
+ page,
+}) => {
await installDesktopBridge(page);
await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getPlatform = async () => "linux";
window.electronAPI.getSources = async () => [
- { id: "screen:1:0", name: "Built-in Display", thumbnail: "", display_id: "1" },
- { id: "window:2:0", name: "Another Window", thumbnail: "", display_id: "" },
+ {
+ id: "screen:1:0",
+ name: "Built-in Display",
+ thumbnail: null,
+ appIcon: null,
+ display_id: "1",
+ },
+ {
+ id: "window:2:0",
+ name: "Another Window",
+ thumbnail: null,
+ appIcon: null,
+ display_id: "",
+ },
];
});
await page.goto("/?windowType=hud-overlay");
- const source = page.getByRole("button", { name: "Built-in Display", exact: true });
+ const source = page.getByRole("button", {
+ name: "Choose recording source",
+ exact: true,
+ });
+ await expect(source).toContainText("Built-in Display");
expect(await source.evaluate((node) => getComputedStyle(node).borderWidth)).toBe("0px");
- await source.click();
- const row = page.locator(".source-selector-item").filter({ hasText: "Another Window" });
+ await page.mouse.move(1, 1);
+ await expect(source).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
+ // The source list now lives in its own Linux window; macOS uses the native picker.
+ await page.goto("/?windowType=source-selector");
+ const row = page.getByRole("button", { name: "Another Window", exact: true });
await expect(row).toBeVisible();
await page.mouse.move(1, 1);
await expect(row).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
@@ -284,11 +355,14 @@ test("Clips shows original filenames and truncates long names beside real still
)
.toBe(true);
expect(await panel.evaluate((node) => node.scrollWidth <= node.clientWidth + 1)).toBe(true);
- await page.screenshot({ path: "test-results/videos-filenames.png", animations: "disabled" });
+ await page.screenshot({
+ path: "test-results/videos-filenames.png",
+ animations: "disabled",
+ });
});
for (const enabled of [true, false]) {
- test(`selected recordings drag together; imported click zooms ${enabled ? "on" : "off"}`, async ({
+ test(`imported recordings stay unzoomed with legacy auto zooms ${enabled ? "on" : "off"}`, async ({
page,
}) => {
await setup(page);
@@ -334,24 +408,19 @@ for (const enabled of [true, false]) {
await expect(page.getByLabel("Select second.mp4")).toBeChecked();
await panel
.locator('[data-recording-path="/recordings/second.mp4"]')
- .dragTo(page.locator('[data-variant="clip"]'), { targetPosition: { x: 5, y: 15 } });
+ .dragTo(page.locator('[data-variant="clip"]'), {
+ targetPosition: { x: 5, y: 15 },
+ });
await expect(page.locator('[data-variant="clip"]')).toHaveCount(3);
await expect(page.locator("html")).toHaveAttribute(
"data-import-order",
"/recordings/first.mp4;/recordings/second.mp4;",
);
await expect(page.locator("html")).toHaveAttribute("data-import-source", "/sequence-1.mp4");
- await expect(page.locator('[data-variant="zoom"]')).toHaveCount(enabled ? 2 : 0);
- if (enabled) {
- await expect(page.locator('[data-variant="zoom"]').first()).toHaveAttribute(
- "data-start-ms",
- "0",
- );
- await expect(page.locator('[data-variant="zoom"]').nth(1)).toHaveAttribute(
- "data-start-ms",
- "1000",
- );
- }
+ await expect(page.locator('[data-variant="zoom"]')).toHaveCount(0);
+ await expect(
+ page.getByRole("button", { name: "Apply auto zooms", exact: true }),
+ ).toBeVisible();
});
}
@@ -389,7 +458,9 @@ test("cancelling a batch keeps completed clips and stops the remaining import",
await panel.locator('[data-slot="checkbox-control"]').first().click();
await panel
.locator('[data-recording-path="/recordings/second.mp4"]')
- .dragTo(page.locator('[data-variant="clip"]'), { targetPosition: { x: 5, y: 15 } });
+ .dragTo(page.locator('[data-variant="clip"]'), {
+ targetPosition: { x: 5, y: 15 },
+ });
await expect(page.locator("html")).toHaveAttribute("data-pending-import", "true");
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(page.getByText("Adding video…")).toHaveCount(0);
diff --git a/tests/ui/wallpaper.spec.ts b/tests/ui/wallpaper.spec.ts
index 1c51f458..8e28319e 100644
--- a/tests/ui/wallpaper.spec.ts
+++ b/tests/ui/wallpaper.spec.ts
@@ -84,3 +84,90 @@ test("video wallpapers share the gallery's add and remove controls", async ({ pa
"true",
);
});
+
+for (const format of ["png", "jpeg-empty-mime", "heic"] as const) {
+ test(`imports and decodes a ${format} wallpaper`, async ({ page }) => {
+ test.setTimeout(60000);
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ const add = page.getByRole("button", { name: "Add wallpaper", exact: true });
+ await expect(add).toBeVisible({ timeout: 20000 });
+ const chooser = page.waitForEvent("filechooser");
+ await add.click();
+ if (format === "png") {
+ await (await chooser).setFiles({
+ name: "background.PNG",
+ mimeType: "image/png",
+ buffer: Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l9sAAAAASUVORK5CYII=",
+ "base64",
+ ),
+ });
+ } else if (format === "heic") {
+ await (await chooser).setFiles("tests/ui/fixtures/wallpaper.heic");
+ } else {
+ const { readFile } = await import("node:fs/promises");
+ await (await chooser).setFiles({
+ name: "background.JPEG",
+ mimeType: "",
+ buffer: await readFile("tests/ui/fixtures/recording-thumbnail.jpg"),
+ });
+ }
+ const custom = page.getByRole("button", { name: "Custom wallpaper 1", exact: true });
+ await expect(custom).toHaveAttribute("aria-pressed", "true", { timeout: 20000 });
+ const image = custom.locator("img");
+ await expect.poll(() => image.evaluate((img) => img.naturalWidth)).toBeGreaterThan(0);
+ if (format === "heic") await expect(image).toHaveAttribute("src", /^data:image\/png;/);
+ await page.reload();
+ await expect(custom).toHaveAttribute("aria-pressed", "true", { timeout: 20000 });
+ });
+}
+
+test("the image picker routes a video wallpaper into the Video tab", async ({ page }) => {
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ await page.evaluate(() => {
+ window.electronAPI.importWallpaperVideo = async () => ({
+ success: true,
+ path: `${location.origin}/tests/ui/fixtures/background.webm`,
+ });
+ });
+ const chooser = page.waitForEvent("filechooser");
+ await page.getByRole("button", { name: "Add wallpaper", exact: true }).click();
+ await (await chooser).setFiles("tests/ui/fixtures/background.webm");
+ await expect(
+ page.getByRole("button", { name: "background.webm", exact: true }),
+ ).toHaveAttribute("aria-pressed", "true");
+ await expect(page.locator('[data-background-panel="video"]')).toBeVisible();
+ await expect
+ .poll(() =>
+ page
+ .getByRole("button", { name: "background.webm", exact: true })
+ .locator("video")
+ .evaluate((video) => video.videoWidth),
+ )
+ .toBeGreaterThan(0);
+});
+
+test("a corrupt image does not add a broken wallpaper or change selection", async ({ page }) => {
+ await installDesktopBridge(page);
+ await page.goto("/?windowType=editor");
+ const original = page.locator('aside button[aria-pressed="true"]');
+ await expect(original).toBeVisible();
+ const name = await original.getAttribute("aria-label");
+ const chooser = page.waitForEvent("filechooser");
+ await page.getByRole("button", { name: "Add wallpaper", exact: true }).click();
+ await (await chooser).setFiles({
+ name: "broken.png",
+ mimeType: "image/png",
+ buffer: Buffer.from("not an image"),
+ });
+ await expect(page.getByText("Could not open this wallpaper.", { exact: false })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Custom wallpaper 1", exact: true })).toHaveCount(
+ 0,
+ );
+ await expect(page.getByRole("button", { name: name!, exact: true })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+});
diff --git a/tests/ui/webcam-defaults.spec.ts b/tests/ui/webcam-defaults.spec.ts
index 44e0a033..75b0a0c3 100644
--- a/tests/ui/webcam-defaults.spec.ts
+++ b/tests/ui/webcam-defaults.spec.ts
@@ -1,17 +1,20 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
-for (const savedRoundness of [undefined, 69]) {
- test(`new recording webcam uses ${savedRoundness === undefined ? "100% by default" : "saved roundness"}`, async ({
+for (const savedRoundness of [undefined, 0, 69, 100]) {
+ test(`squircle camera uses ${savedRoundness === undefined ? "50% by default" : `saved ${savedRoundness}% roundness`}`, async ({
page,
}) => {
await installDesktopBridge(page);
await installDesktopBridgeOverrides(
page,
(roundness) => {
- if (roundness !== undefined) {
+ {
+ const getAppSetting = window.electronAPI.getAppSetting;
window.electronAPI.getAppSetting = (key) =>
- key === "recordly.editor.preferences" ? { webcam: { roundness } } : null;
+ key === "recordly.editor.preferences"
+ ? { webcam: { shape: "squircle", roundness } }
+ : getAppSetting(key);
}
window.electronAPI.getCurrentRecordingSession = async () => ({
success: true,
@@ -26,7 +29,7 @@ for (const savedRoundness of [undefined, 69]) {
);
await page.goto("/?windowType=editor");
await page.getByRole("radio", { name: "Webcam", exact: true }).click();
- const expected = savedRoundness ?? 100;
+ const expected = savedRoundness ?? 50;
await expect(page.getByRole("slider", { name: "Webcam Roundness" })).toHaveValue(
String(expected),
);
@@ -39,13 +42,84 @@ for (const savedRoundness of [undefined, 69]) {
getComputedStyle(node).clipPath.match(/M\s+([\d.]+)/)?.[1],
);
const box = node.getBoundingClientRect();
- return Math.round(100 * (radius / (Math.min(box.width, box.height) / 2)) ** 2);
+ return Math.round(100 * (radius / (Math.min(box.width, box.height) / 2)) ** 4);
}),
)
.toBe(expected);
+ // Full roundness should soften the corners while keeping a squircle.
+ if (expected === 100) {
+ const shape = await mask.evaluate((node) => {
+ const element = node as HTMLElement;
+ const previousPointerEvents = element.style.pointerEvents;
+ element.style.pointerEvents = "auto";
+ const box = node.getBoundingClientRect();
+ const x = box.left + box.width * 0.08;
+ const y = box.top + box.height * 0.08;
+ const result = {
+ clipPath: getComputedStyle(node).clipPath,
+ // Inside a squircle, outside a circle: catches accidentally switching shapes.
+ squircleShoulderVisible: document
+ .elementsFromPoint(box.left + box.width * 0.13, box.top + box.height * 0.13)
+ .includes(node),
+ cornerVisible: document.elementsFromPoint(x, y).includes(node),
+ centerVisible: document
+ .elementsFromPoint(box.left + box.width / 2, box.top + box.height / 2)
+ .includes(node),
+ };
+ element.style.pointerEvents = previousPointerEvents;
+ return result;
+ });
+ expect(shape.clipPath).toMatch(/^path\(/);
+ expect(shape.squircleShoulderVisible).toBe(true);
+ expect(shape.cornerVisible).toBe(false);
+ expect(shape.centerVisible).toBe(true);
+ }
await page.screenshot({
path: `test-results/webcam-new-recording-${expected}.png`,
animations: "disabled",
});
});
}
+
+test("new recordings default to a circle and retain the squircle option", async ({ page }) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getCurrentRecordingSession = async () => ({
+ success: true,
+ session: {
+ videoPath: `${location.origin}/tests/ui/fixtures/preview.mp4`,
+ webcamPath: `${location.origin}/tests/ui/fixtures/preview.mp4`,
+ timeOffsetMs: 0,
+ },
+ });
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("radio", { name: "Webcam", exact: true }).click();
+ const circle = page.getByRole("row", { name: "Circle", exact: true });
+ await expect(circle).toHaveAttribute("aria-selected", "true");
+ await page.getByRole("switch", { name: "Advanced settings" }).press("Space");
+ await expect(page.getByRole("slider", { name: "Webcam Shadow" })).toHaveValue("0.2");
+ const mask = page.locator('div[style*="clip-path"][style*="contain: paint"]:has(video)');
+ await expect(mask).toHaveCSS("clip-path", "circle(50% at 50% 50%)");
+ await expect(page.getByRole("slider", { name: "Webcam Roundness" })).toHaveCount(0);
+ const width = page.getByRole("slider", { name: "Webcam Width" });
+ await width.focus();
+ await width.press("ArrowRight");
+ await expect
+ .poll(() =>
+ mask.evaluate((el) => {
+ const box = el.getBoundingClientRect();
+ return Math.abs(box.width - box.height);
+ }),
+ )
+ .toBeLessThan(1);
+ await page.screenshot({
+ path: "test-results/webcam-circle-default.png",
+ animations: "disabled",
+ });
+ await page.getByRole("row", { name: "Squircle", exact: true }).click();
+ await expect(page.getByRole("slider", { name: "Webcam Roundness" })).toHaveValue("50");
+ await expect(mask).toHaveCSS("clip-path", /^path\(/);
+ await circle.click();
+ await expect(mask).toHaveCSS("clip-path", "circle(50% at 50% 50%)");
+});
diff --git a/tests/ui/webcam-drag.spec.ts b/tests/ui/webcam-drag.spec.ts
new file mode 100644
index 00000000..2a2887e9
--- /dev/null
+++ b/tests/ui/webcam-drag.spec.ts
@@ -0,0 +1,115 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test.beforeEach(async ({ page }) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ window.electronAPI.getEditorMode = async () => true;
+ window.electronAPI.getRecordingPreferences = async () => ({
+ success: true,
+ microphoneEnabled: false,
+ webcamEnabled: true,
+ systemAudioEnabled: false,
+ });
+ document.addEventListener("pointerup", () => {
+ document.documentElement.dataset.webcamReleasePassthrough =
+ document.documentElement.dataset.webcamPassthrough;
+ });
+ document.addEventListener(
+ "pointerdown",
+ (event) => {
+ document.documentElement.dataset.webcamPointerId = String(event.pointerId);
+ },
+ { capture: true },
+ );
+ window.electronAPI.hudOverlaySetIgnoreMouse = (ignore) => {
+ document.documentElement.dataset.webcamPassthrough = String(ignore);
+ };
+ });
+ await page.goto("/?windowType=hud-overlay");
+ await expect(page.locator("[data-floating-webcam-preview]")).toBeVisible();
+});
+
+test("camera stays interactive across consecutive drags and releases clicks outside it", async ({
+ page,
+}) => {
+ const preview = page.locator("[data-floating-webcam-preview]");
+ for (let index = 0; index < 2; index++) {
+ const before = (await preview.boundingBox())!;
+ const x = before.x + before.width / 2;
+ const y = before.y + before.height / 2;
+ await page.mouse.move(x, y);
+ await page.mouse.down();
+ await page.mouse.move(x - 100, y - 60, { steps: 5 });
+ await page.mouse.up();
+ const after = (await preview.boundingBox())!;
+ expect(after.x).toBeCloseTo(before.x - 100);
+ expect(after.y).toBeCloseTo(before.y - 60);
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-webcam-release-passthrough",
+ "false",
+ );
+ await expect(page.locator("html")).toHaveAttribute("data-webcam-passthrough", "false");
+ }
+ await page.mouse.move(10, 10);
+ await expect(page.locator("html")).toHaveAttribute("data-webcam-passthrough", "true");
+});
+
+test("quick release commits the last camera movement before the next animation frame", async ({
+ page,
+}) => {
+ const preview = page.locator("[data-floating-webcam-preview]");
+ const before = (await preview.boundingBox())!;
+ const x = before.x + before.width / 2;
+ const y = before.y + before.height / 2;
+ await page.mouse.move(x, y);
+ await page.mouse.down();
+ await page.evaluate(() => {
+ const original = window.requestAnimationFrame;
+ window.requestAnimationFrame = () => 2147483647;
+ window.addEventListener(
+ "test-restore-frames",
+ () => {
+ window.requestAnimationFrame = original;
+ },
+ { once: true },
+ );
+ });
+ await page.mouse.move(x - 150, y - 90);
+ await page.mouse.up();
+ await page.evaluate(() => window.dispatchEvent(new Event("test-restore-frames")));
+ const after = (await preview.boundingBox())!;
+ expect(after.x).toBeCloseTo(before.x - 150);
+ expect(after.y).toBeCloseTo(before.y - 90);
+});
+
+test("dragging past the viewport keeps the camera reachable and restores desktop clicks", async ({
+ page,
+}) => {
+ const preview = page.locator("[data-floating-webcam-preview]");
+ const before = (await preview.boundingBox())!;
+ await page.mouse.move(before.x + before.width / 2, before.y + before.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(-100, -100, { steps: 5 });
+ await page.mouse.up();
+ const after = (await preview.boundingBox())!;
+ expect(after.x).toBeCloseTo(0);
+ expect(after.y).toBeCloseTo(0);
+ await expect(page.locator("html")).toHaveAttribute("data-webcam-passthrough", "true");
+});
+
+test("camera has a fixed square size and keeps its drag cursor at the edges", async ({ page }) => {
+ const preview = page.locator("[data-floating-webcam-preview]");
+ const before = (await preview.boundingBox())!;
+ expect([before.width, before.height]).toEqual([230, 230]);
+ await expect(preview.locator("[data-resize-handle]")).toHaveCount(0);
+ await page.mouse.move(before.x + 1, before.y + before.height / 2);
+ await expect(page.locator("body")).toHaveCSS("cursor", "grab");
+ await page.mouse.down();
+ await page.mouse.move(before.x - 40, before.y + before.height / 2 - 30, { steps: 4 });
+ await page.mouse.up();
+ const after = (await preview.boundingBox())!;
+ expect([after.width, after.height]).toEqual([230, 230]);
+ expect(after.x).toBeLessThan(before.x);
+ expect(after.y).toBeLessThan(before.y);
+});
diff --git a/tests/ui/webcam-edge.html b/tests/ui/webcam-edge.html
new file mode 100644
index 00000000..96cffc56
--- /dev/null
+++ b/tests/ui/webcam-edge.html
@@ -0,0 +1,2 @@
+
+
diff --git a/tests/ui/webcam-edge.spec.ts b/tests/ui/webcam-edge.spec.ts
new file mode 100644
index 00000000..e4c2c217
--- /dev/null
+++ b/tests/ui/webcam-edge.spec.ts
@@ -0,0 +1,30 @@
+import { expect, test } from "@playwright/test";
+import type { checkEdges } from "./webcam-edge";
+
+test("webcam edges, content and shadows align with blur enabled, settled and disabled", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(60000);
+ const errors: string[] = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.goto("/tests/ui/webcam-edge.html");
+ const results = await page.evaluate(() =>
+ (window as unknown as { checkEdges: typeof checkEdges }).checkEdges(),
+ );
+ for (const result of results) {
+ const [still, shrinking, growing, stopped, settled, disabled] = result.exporterDifferences;
+ expect(
+ [still, stopped, settled, disabled],
+ `${result.shape}, crop/mirror ${result.cropped}`,
+ ).toEqual([0, 0, 0, 0]);
+ // Eight additive shutter samples can amplify a one-level VideoFrame/canvas
+ // conversion difference. Stationary edges above must match exactly.
+ expect(Math.max(shrinking, growing)).toBeLessThanOrEqual(8);
+ expect(result.stationaryDifferences).toEqual([
+ [0, 0, 0],
+ [0, 0, 0],
+ ]);
+ }
+ expect(errors).toEqual([]);
+ await page.screenshot({ path: testInfo.outputPath("webcam-edges.png"), fullPage: true });
+});
diff --git a/tests/ui/webcam-edge.ts b/tests/ui/webcam-edge.ts
new file mode 100644
index 00000000..82295c3f
--- /dev/null
+++ b/tests/ui/webcam-edge.ts
@@ -0,0 +1,118 @@
+import { DEFAULT_WEBCAM_OVERLAY } from "@/components/video-editor/types";
+import { FrameRenderer as LegacyRenderer } from "@/lib/exporter/frameRenderer";
+import { FrameRenderer as ModernRenderer } from "@/lib/exporter/modernFrameRenderer";
+import type {
+ ExportRendererProbe,
+ LegacyRendererProbe,
+ ModernRendererProbe,
+} from "./exportRendererProbe";
+
+export async function checkEdges() {
+ const source = document.createElement("canvas");
+ source.width = 400;
+ source.height = 240;
+ const sourceContext = source.getContext("2d")!;
+ sourceContext.fillStyle = "#b4b4b4";
+ sourceContext.fillRect(0, 0, 400, 240);
+ // Asymmetric landmarks reveal crop/mirror offsets as well as dark edge seams.
+ sourceContext.fillStyle = "#2563eb";
+ sourceContext.fillRect(80, 60, 20, 120);
+ sourceContext.fillStyle = "#ef4444";
+ sourceContext.fillRect(260, 100, 40, 20);
+ const results = [];
+ for (const shape of ["circle", "squircle"] as const) {
+ for (const cropped of [false, true]) {
+ const images: ImageData[][] = [];
+ for (const [name, Renderer] of [
+ ["modern", ModernRenderer],
+ ["legacy", LegacyRenderer],
+ ] as const) {
+ const renderer = new Renderer({
+ width: 640,
+ height: 360,
+ videoWidth: 640,
+ videoHeight: 360,
+ wallpaper: "#ffffff",
+ zoomRegions: [],
+ showShadow: false,
+ shadowIntensity: 0,
+ backgroundBlur: 0,
+ showCursor: false,
+ cropRegion: { x: 0, y: 0, width: 1, height: 1 },
+ zoomMotionBlur: 0.5,
+ preferredRenderBackend: "webgl",
+ webcam: {
+ ...DEFAULT_WEBCAM_OVERLAY,
+ enabled: true,
+ shape,
+ width: 51.7,
+ height: 63.3,
+ roundness: 20,
+ shadow: 0.2,
+ mirror: cropped,
+ cropRegion: cropped
+ ? { x: 0.1, y: 0.1, width: 0.8, height: 0.75 }
+ : { x: 0, y: 0, width: 1, height: 1 },
+ },
+ }) as unknown as ExportRendererProbe;
+ const modern = renderer as unknown as ModernRendererProbe;
+ const legacy = renderer as unknown as LegacyRendererProbe;
+ if (name === "modern") await modern.initialize();
+ const media = name === "modern" ? modern.webcamMedia : legacy;
+ const motion = name === "modern" ? modern.motion : legacy;
+ media.webcamDecodedFrame = new VideoFrame(source, { timestamp: 0 });
+ media.lastSyncedWebcamTime = 0;
+ const output = document.createElement("canvas");
+ output.width = 640;
+ output.height = 360;
+ const context = output.getContext("2d")!;
+ const frames: ImageData[] = [];
+ const draw = (scale: number, timeMs: number) => {
+ motion.animationState.appliedScale = scale;
+ context.fillStyle = "white";
+ context.fillRect(0, 0, 640, 360);
+ if (name === "modern") {
+ modern.webcam.updateWebcamOverlay(0, timeMs);
+ modern.scene.app.render();
+ context.drawImage(renderer.getCanvas(), 0, 0);
+ } else {
+ legacy.webcamMotionTimeMs = timeMs;
+ legacy.drawWebcamOverlay(context, 640, 360);
+ }
+ return context.getImageData(0, 0, 640, 360);
+ };
+ frames.push(draw(1, 0));
+ frames.push(draw(1.03, 16));
+ frames.push(draw(1, 32));
+ frames.push(draw(1, 48));
+ frames.push(draw(1, 200));
+ renderer.config.zoomMotionBlur = 0;
+ frames.push(draw(1, 216));
+ images.push(frames);
+ output.dataset.case = `${name}-${shape}-${cropped}`;
+ document.body.append(output);
+ renderer.destroy();
+ }
+ const difference = (first: ImageData, second: ImageData) => {
+ let max = 0;
+ for (let i = 0; i < first.data.length; i++)
+ max = Math.max(max, Math.abs(first.data[i] - second.data[i]));
+ return max;
+ };
+ results.push({
+ shape,
+ cropped,
+ // Both production exporters must agree on every pixel, including the rim,
+ // shadow, landmarks, and fractional-size edges throughout the shutter.
+ exporterDifferences: images[0].map((frame, index) =>
+ difference(frame, images[1][index]),
+ ),
+ stationaryDifferences: images.map((frames) =>
+ [3, 4, 5].map((index) => difference(frames[0], frames[index])),
+ ),
+ });
+ }
+ }
+ return results;
+}
+Object.assign(window, { checkEdges });
diff --git a/tests/ui/webcam-motion-blur.html b/tests/ui/webcam-motion-blur.html
new file mode 100644
index 00000000..0c5be2e9
--- /dev/null
+++ b/tests/ui/webcam-motion-blur.html
@@ -0,0 +1 @@
+Camera motion blur regression
diff --git a/tests/ui/webcam-motion-blur.spec.ts b/tests/ui/webcam-motion-blur.spec.ts
new file mode 100644
index 00000000..8f21d2d0
--- /dev/null
+++ b/tests/ui/webcam-motion-blur.spec.ts
@@ -0,0 +1,152 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+import type { exportChecks } from "./webcam-motion-blur";
+
+type MotionWindow = Window & {
+ cameraSamples: { time: number; width: number; blurred: boolean }[];
+ exportChecks: typeof exportChecks;
+};
+
+test("preview camera blurs on shrink and grow, then clears on pause", async ({ page }) => {
+ const errors: string[] = [];
+ page.on("pageerror", (e) => errors.push(e.message));
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/webcam-motion-blur.html");
+ await expect(page.locator("main")).toHaveAttribute("data-ready", "true");
+ await expect
+ .poll(() =>
+ page.locator("[data-webcam-overlay] video").evaluate((node) => {
+ const video = node as HTMLVideoElement;
+ return video.readyState >= 2 && !video.seeking;
+ }),
+ )
+ .toBe(true);
+ await page.evaluate(() => {
+ const samples: { time: number; width: number; blurred: boolean }[] = [];
+ Object.assign(window, { cameraSamples: samples });
+ const sample = () => {
+ const camera = document.querySelector("[data-webcam-overlay]");
+ const blur = document.querySelector("[data-webcam-motion-blur]");
+ if (camera && blur)
+ samples.push({
+ time: Number(document.querySelector("main")?.getAttribute("data-time")),
+ width: parseFloat(camera.style.width),
+ blurred: blur.style.display === "block",
+ });
+ requestAnimationFrame(sample);
+ };
+ requestAnimationFrame(sample);
+ });
+ await page.getByRole("button", { name: "Play", exact: true }).click();
+ await expect(page.locator("[data-webcam-motion-blur]")).toBeVisible({ timeout: 5000 });
+ await page.getByRole("button", { name: "Pause", exact: true }).click();
+ await expect(page.locator("[data-webcam-motion-blur]")).toBeHidden();
+ await expect(page.locator("[data-webcam-overlay]")).toHaveCSS("visibility", "visible");
+ await page.getByRole("button", { name: "Play", exact: true }).click();
+ await expect
+ .poll(() => page.locator("main").getAttribute("data-time"), { timeout: 10000 })
+ .toMatch(/^[4-6]/);
+ const motion = await page.evaluate(() => {
+ const samples = (window as MotionWindow).cameraSamples;
+ return {
+ shrinking: samples.some(
+ (s, i) => i && s.blurred && s.width < samples[i - 1].width - 0.05,
+ ),
+ growing: samples.some(
+ (s, i) => i && s.blurred && s.width > samples[i - 1].width + 0.05,
+ ),
+ };
+ });
+ expect(motion).toEqual({ shrinking: true, growing: true });
+ await page.getByRole("button", { name: "Pause", exact: true }).click();
+ await expect(page.locator("[data-webcam-motion-blur]")).toBeHidden();
+ await expect(page.locator("[data-webcam-overlay]")).toHaveCSS("visibility", "visible");
+ expect(errors).toEqual([]);
+});
+
+test("both exporters blur camera edges in both directions and clear at rest", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(60000);
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/webcam-motion-blur.html");
+ const results = await page.evaluate(() => (window as MotionWindow).exportChecks());
+
+ for (const result of results) {
+ // A squircle exposes this shoulder; the default circle must clip it.
+ expect(result.circleShoulder).toEqual(
+ result.name === "modern" ? [34, 34, 34, 255] : [0, 0, 0, 0],
+ );
+ expect(result.circleCenter[3]).toBe(255);
+ expect(result.shrinking, `${result.name}: shrinking shutter`).toBeGreaterThan(
+ result.still + 200,
+ );
+ expect(result.growing, `${result.name}: growing shutter`).toBeGreaterThan(
+ result.still + 200,
+ );
+ expect(result.stopped, `${result.name}: clears blur immediately at rest`).toBeLessThan(
+ result.shrinking / 2,
+ );
+ expect(
+ result.settled,
+ `${result.name}: returns to the normal renderer after settling`,
+ ).toBe(result.still);
+ expect(result.disabled, `${result.name}: disabled blur`).toBeLessThan(result.shrinking / 2);
+ }
+ await page.screenshot({ path: testInfo.outputPath("camera-blur-exports.png"), fullPage: true });
+});
+
+test("camera shadows do not jump when resize blur starts or stops", async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/webcam-motion-blur.html");
+ const results = await page.evaluate(() => (window as MotionWindow).exportChecks(0.2, true));
+ for (const result of results) {
+ // Cover the whole shadow, allowing 4/255 for the subpixel shutter displacement.
+ expect(result.maxShadowDifference, `${result.name}: shadow continuity`).toBeLessThanOrEqual(
+ 4,
+ );
+ const channel = result.name === "modern" ? 0 : 3;
+ expect(
+ Math.abs(result.shadowMoving[channel] - result.shadowSettled[channel]),
+ `${result.name}: moving ${result.shadowMoving}, settled ${result.shadowSettled}`,
+ ).toBeLessThanOrEqual(2);
+ }
+});
+
+test("switching landscape to portrait does not stop the camera at 56 pixels mid-zoom", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/webcam-motion-blur.html");
+ await expect(page.locator("main")).toHaveAttribute("data-ready", "true");
+ await page.getByRole("button", { name: "Portrait", exact: true }).click();
+ const camera = page.locator("[data-webcam-overlay]");
+ await expect
+ .poll(() => camera.evaluate((el) => parseFloat((el as HTMLElement).style.width)))
+ .toBeCloseTo(80, 0);
+ await page.getByRole("button", { name: "Play", exact: true }).click();
+ await expect
+ .poll(() => camera.evaluate((el) => parseFloat((el as HTMLElement).style.width)), {
+ timeout: 5000,
+ })
+ .toBeLessThan(55);
+ await expect
+ .poll(() => page.locator("main").getAttribute("data-time"), { timeout: 10000 })
+ .toMatch(/^[4-6]/);
+ await page.getByRole("button", { name: "Pause", exact: true }).click();
+ await expect
+ .poll(() => camera.evaluate((el) => parseFloat((el as HTMLElement).style.width)))
+ .toBeCloseTo(80, 0);
+});
+
+test("zoom steps near the blur threshold do not alternate shadow renderers", async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/webcam-motion-blur.html");
+ const result = await page.evaluate(() =>
+ (
+ window as unknown as { shadowBoundaryChecks(): { active: boolean[]; settled: boolean } }
+ ).shadowBoundaryChecks(),
+ );
+ expect(result.active).toEqual([false, true, true, true, true, true, true, true, true]);
+ expect(result.settled).toBe(false);
+});
diff --git a/tests/ui/webcam-motion-blur.tsx b/tests/ui/webcam-motion-blur.tsx
new file mode 100644
index 00000000..56463f1d
--- /dev/null
+++ b/tests/ui/webcam-motion-blur.tsx
@@ -0,0 +1,265 @@
+import { useRef, useState } from "react";
+import { createRoot } from "react-dom/client";
+import { DEFAULT_WEBCAM_OVERLAY, type ZoomRegion } from "@/components/video-editor/types";
+import VideoPlayback, { type VideoPlaybackRef } from "@/components/video-editor/VideoPlayback";
+import { WebcamMotionBlur } from "@/components/video-editor/webcamMotionBlur";
+import { I18nProvider } from "@/contexts/I18nContext";
+import { FrameRenderer as LegacyRenderer } from "@/lib/exporter/frameRenderer";
+import { FrameRenderer as ModernRenderer } from "@/lib/exporter/modernFrameRenderer";
+import type {
+ ExportRendererProbe,
+ LegacyRendererProbe,
+ ModernRendererProbe,
+} from "./exportRendererProbe";
+import "@/index.css";
+
+const clips = [{ id: "clip", startMs: 0, endMs: 6000, speed: 1 }];
+const zooms: ZoomRegion[] = [
+ {
+ id: "zoom",
+ startMs: 1200,
+ endMs: 3000,
+ depth: 2,
+ focus: { cx: 0.5, cy: 0.5 },
+ mode: "manual",
+ },
+];
+const webcam = {
+ ...DEFAULT_WEBCAM_OVERLAY,
+ enabled: true,
+ width: 50,
+ height: 50,
+ shadow: 0,
+ reactToZoom: true,
+};
+const noop = () => undefined;
+const fail = (error: string) => {
+ throw new Error(error);
+};
+
+function Preview() {
+ const playback = useRef(null);
+ const [time, setTime] = useState(0);
+ const [ready, setReady] = useState(false);
+ const [playing, setPlaying] = useState(false);
+ const [portrait, setPortrait] = useState(false);
+ return (
+
+ setPortrait(true)}>Portrait
+ void playback.current?.play()}>Play
+ playback.current?.pause()}>Pause
+
+
+ );
+}
+
+function snapshot(canvas: HTMLCanvasElement) {
+ const copy = document.createElement("canvas");
+ copy.width = canvas.width;
+ copy.height = canvas.height;
+ copy.getContext("2d")!.drawImage(canvas, 0, 0);
+ const pixels = copy.getContext("2d")!.getImageData(0, 0, copy.width, copy.height).data;
+ let partial = 0;
+ for (let i = 3; i < pixels.length; i += 4)
+ if (
+ pixels[i] > 5 &&
+ (pixels[i] < 245 ||
+ (pixels[i - 3] > 5 && pixels[i - 3] < 250 && Math.abs(pixels[i - 3] - 34) > 2))
+ )
+ partial++;
+ return {
+ partial,
+ canvas: copy,
+ shadowProbe: Array.from(
+ pixels.slice((356 * copy.width + 542) * 4, (356 * copy.width + 542) * 4 + 4),
+ ),
+ };
+}
+
+export async function exportChecks(shadow = 0, nearStill = false) {
+ const source = document.createElement("canvas");
+ source.width = source.height = 200;
+ const ctx = source.getContext("2d")!;
+ ctx.fillStyle = "white";
+ ctx.fillRect(0, 0, 200, 200);
+ ctx.fillStyle = "black";
+ ctx.fillRect(85, 0, 30, 200);
+ ctx.fillRect(0, 85, 200, 30);
+ const config = {
+ width: 640,
+ height: 360,
+ videoWidth: 640,
+ videoHeight: 360,
+ wallpaper: "#222222",
+ zoomRegions: [],
+ showShadow: false,
+ shadowIntensity: 0,
+ backgroundBlur: 0,
+ cropRegion: { x: 0, y: 0, width: 1, height: 1 },
+ webcam: { ...webcam, shadow },
+ zoomMotionBlur: 0.5,
+ showCursor: false,
+ preferredRenderBackend: "webgl" as const,
+ };
+ const results = [];
+ for (const [name, Renderer] of [
+ ["modern", ModernRenderer],
+ ["legacy", LegacyRenderer],
+ ] as const) {
+ // Exercise the actual export overlay methods with a cached decoded frame.
+ const renderer = new Renderer({ ...config }) as unknown as ExportRendererProbe;
+ const modern = renderer as unknown as ModernRendererProbe;
+ const legacy = renderer as unknown as LegacyRendererProbe;
+ if (name === "modern") await modern.initialize();
+ const webcamFrames = name === "modern" ? modern.webcamFrames : legacy;
+ const motion = name === "modern" ? modern.motion : legacy;
+ webcamFrames.webcamFrameCacheCanvas = source;
+ webcamFrames.webcamFrameCacheCtx = source.getContext("2d");
+ if (name === "legacy") legacy.webcamVideoElement = { readyState: 0 };
+ const target = document.createElement("canvas");
+ target.width = 640;
+ target.height = 360;
+ let timeMs = 0;
+ const draw = (scale: number) => {
+ motion.animationState.appliedScale = scale;
+ timeMs += 1000 / 60;
+ if (name === "modern") {
+ modern.webcam.updateWebcamOverlay(0, timeMs);
+ modern.scene.app.render();
+ return snapshot(renderer.getCanvas());
+ }
+ legacy.webcamMotionTimeMs = timeMs;
+ const context = target.getContext("2d")!;
+ context.clearRect(0, 0, 640, 360);
+ legacy.drawWebcamOverlay(context, 640, 360);
+ return snapshot(target);
+ };
+ const still = draw(1);
+ const shrink = draw(nearStill ? 1.001 : 1.1);
+ const grow = draw(1);
+ const stopped = draw(1);
+ let settled = stopped;
+ for (let frame = 0; frame < 8; frame++) settled = draw(1);
+ renderer.config.zoomMotionBlur = 0;
+ const disabled = draw(1.1);
+ for (const [state, shot] of [
+ ["still", still],
+ ["shrinking", shrink],
+ ["growing", grow],
+ ["settled", settled],
+ ] as const) {
+ shot.canvas.dataset.export = `${name}-${state}`;
+ document.body.append(shot.canvas);
+ }
+ const movingPixels = grow.canvas.getContext("2d")!.getImageData(0, 0, 640, 360).data;
+ const settledPixels = settled.canvas.getContext("2d")!.getImageData(0, 0, 640, 360).data;
+ let maxShadowDifference = 0;
+ for (let y = 40; y < 360; y++) {
+ for (let x = 320; x < 640; x++) {
+ // Exclude only the circular camera and its antialiased edge, not the surrounding shadow.
+ if (Math.hypot(x - 542, y - 262) <= 93) continue;
+ const index = (y * 640 + x) * 4 + (name === "modern" ? 0 : 3);
+ maxShadowDifference = Math.max(
+ maxShadowDifference,
+ Math.abs(movingPixels[index] - settledPixels[index]),
+ );
+ }
+ }
+ const stillContext = still.canvas.getContext("2d")!;
+ results.push({
+ circleShoulder: Array.from(stillContext.getImageData(475, 195, 1, 1).data),
+ circleCenter: Array.from(stillContext.getImageData(542, 262, 1, 1).data),
+ name,
+ maxShadowDifference,
+ shadowMoving: grow.shadowProbe,
+ shadowSettled: settled.shadowProbe,
+ still: still.partial,
+ stopped: stopped.partial,
+ shrinking: shrink.partial,
+ growing: grow.partial,
+ settled: settled.partial,
+ disabled: disabled.partial,
+ });
+ if (name === "legacy") legacy.webcamVideoElement = null;
+ renderer.destroy();
+ }
+ return results;
+}
+function shadowBoundaryChecks() {
+ const source = document.createElement("canvas");
+ source.width = source.height = 200;
+ const context = source.getContext("2d")!;
+ context.fillStyle = "white";
+ context.fillRect(0, 0, 200, 200);
+ const renderer = new WebcamMotionBlur();
+ const sizes = [200, 198, 197.99, 197.5, 197.49, 197, 196.99, 196.5, 196.49];
+ const active = sizes.map(
+ (size, i) =>
+ !!renderer.render({
+ layout: {
+ x: 400 - size,
+ y: 400 - size,
+ width: size,
+ height: size,
+ radius: size / 2,
+ },
+ // A zoom can advance between two display ticks of the same video frame.
+ timeMs: (i > 1 ? i - 1 : i) * 16,
+ playing: true,
+ amount: 0.5,
+ source,
+ sourceWidth: 200,
+ sourceHeight: 200,
+ mirror: false,
+ shadow: 0.2,
+ shape: "circle",
+ }),
+ );
+ let settled = true;
+ for (let i = sizes.length; i < 25; i++)
+ settled = !!renderer.render({
+ layout: { x: 203.51, y: 203.51, width: 196.49, height: 196.49, radius: 196.49 / 2 },
+ timeMs: i * 16,
+ playing: true,
+ amount: 0.5,
+ source,
+ sourceWidth: 200,
+ sourceHeight: 200,
+ mirror: false,
+ shadow: 0.2,
+ shape: "circle",
+ });
+ renderer.destroy();
+ return { active, settled };
+}
+Object.assign(window, { exportChecks, shadowBoundaryChecks });
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/tests/ui/webcam-preferences.spec.ts b/tests/ui/webcam-preferences.spec.ts
new file mode 100644
index 00000000..40d2645b
--- /dev/null
+++ b/tests/ui/webcam-preferences.spec.ts
@@ -0,0 +1,95 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test("opening an older project preserves webcam defaults while subsequent edits still save", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ const key = "recordly.editor.preferences";
+ if (!localStorage.getItem(key)) {
+ localStorage.setItem(key, JSON.stringify({ webcam: { shadow: 0.2 } }));
+ }
+ const originalGet = window.electronAPI.getAppSetting;
+ const originalSet = window.electronAPI.setAppSetting;
+ window.electronAPI.getAppSetting = (name) =>
+ name === key ? JSON.parse(localStorage.getItem(key)!) : originalGet(name);
+ window.electronAPI.setAppSetting = (name, value) => {
+ if (name !== key) return originalSet(name, value);
+ localStorage.setItem(key, JSON.stringify(value));
+ return true;
+ };
+ const videoPath = `${location.origin}/tests/ui/fixtures/preview.mp4`;
+ window.electronAPI.loadCurrentProjectFile = async () =>
+ new URLSearchParams(location.search).has("older")
+ ? {
+ success: true,
+ path: "/projects/older.recordly",
+ project: {
+ version: 2,
+ videoPath,
+ editor: {
+ webcam: { enabled: true, sourcePath: videoPath, shadow: 0.67 },
+ clipRegions: [{ id: "clip-1", startMs: 0, endMs: 2000 }],
+ },
+ },
+ }
+ : { success: false };
+ window.electronAPI.getCurrentRecordingSession = async () => ({
+ success: true,
+ session: { videoPath, webcamPath: videoPath, timeOffsetMs: 0 },
+ });
+ });
+ const storedShadow = () =>
+ page.evaluate(
+ () => JSON.parse(localStorage.getItem("recordly.editor.preferences")!).webcam.shadow,
+ );
+ await page.goto("/?windowType=editor&older=1");
+ await page.getByRole("radio", { name: "Webcam", exact: true }).click();
+ await page.getByRole("switch", { name: "Advanced settings" }).press("Space");
+ const shadow = page.getByRole("slider", { name: "Webcam Shadow" });
+ await expect(shadow).toHaveValue("0.67");
+ expect(await storedShadow()).toBe(0.2);
+
+ // Editing another webcam control must not promote the old project's shadow.
+ await page.getByRole("slider", { name: "Webcam Width" }).press("ArrowRight");
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () => JSON.parse(localStorage.getItem("recordly.editor.preferences")!).webcam.width,
+ ),
+ )
+ .toBe(41);
+ expect(await storedShadow()).toBe(0.2);
+
+ await page.getByRole("radio", { name: "Scene", exact: true }).click();
+ await page.getByRole("slider", { name: "Shadow", exact: true }).press("ArrowRight");
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ JSON.parse(localStorage.getItem("recordly.editor.preferences")!)
+ .shadowIntensity,
+ ),
+ )
+ .toBe(0.68);
+ expect(await storedShadow()).toBe(0.2);
+ await page.getByRole("radio", { name: "Webcam", exact: true }).click();
+
+ await shadow.press("Home");
+ await expect.poll(storedShadow).toBe(0);
+ for (let i = 0; i < 20; i++) await shadow.press("ArrowRight");
+ await expect(shadow).toHaveValue("0.2");
+ await expect.poll(storedShadow).toBe(0.2);
+
+ // Reopening the original restores its own look without changing new recordings.
+ await page.reload();
+ await page.getByRole("radio", { name: "Webcam", exact: true }).click();
+ await page.getByRole("switch", { name: "Advanced settings" }).press("Space");
+ await expect(shadow).toHaveValue("0.67");
+ expect(await storedShadow()).toBe(0.2);
+ await page.goto("/?windowType=editor");
+ await page.getByRole("radio", { name: "Webcam", exact: true }).click();
+ await page.getByRole("switch", { name: "Advanced settings" }).press("Space");
+ await expect(shadow).toHaveValue("0.2");
+});
diff --git a/tests/ui/webcam-settings-flow.spec.ts b/tests/ui/webcam-settings-flow.spec.ts
new file mode 100644
index 00000000..44e3fa87
--- /dev/null
+++ b/tests/ui/webcam-settings-flow.spec.ts
@@ -0,0 +1,76 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test("webcam import, anchor placement, precise adjustment and removal stay discoverable", async ({
+ page,
+}) => {
+ await installDesktopBridge(page);
+ await installDesktopBridgeOverrides(page, () => {
+ let picks = 0;
+ window.electronAPI.openVideoFilePicker = async () => {
+ picks++;
+ return picks === 2
+ ? { success: false }
+ : { success: true, path: `${location.origin}/tests/ui/fixtures/preview.mp4` };
+ };
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("radio", { name: "Webcam", exact: true }).click();
+ const sidebar = page.locator("aside");
+ await expect(sidebar.getByText("Add a webcam video", { exact: true })).toBeVisible();
+ await expect(sidebar.getByRole("slider")).toHaveCount(0);
+ await page.getByRole("button", { name: "Upload footage", exact: true }).click();
+ await expect(sidebar.getByText("preview.mp4", { exact: true })).toBeVisible();
+ const camera = page.locator("[data-webcam-overlay]");
+ await expect(camera).toBeVisible();
+ for (const label of ["Appearance", "Shape"]) {
+ await expect(sidebar.getByText(label, { exact: true })).toHaveCount(0);
+ }
+ await page.getByRole("row", { name: "Top left", exact: true }).click();
+ await expect(page.getByRole("row", { name: "Top left", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ const top = (await camera.boundingBox())!;
+ await page.getByRole("row", { name: "Bottom right", exact: true }).click();
+ await expect.poll(async () => (await camera.boundingBox())!.x).toBeGreaterThan(top.x + 50);
+ await expect.poll(async () => (await camera.boundingBox())!.y).toBeGreaterThan(top.y + 50);
+ await page.screenshot({
+ path: "test-results/webcam-clean-controls.png",
+ animations: "disabled",
+ });
+ await page.getByRole("button", { name: "Replace footage", exact: true }).click();
+ // Cancelling replacement keeps the linked source and placement intact.
+ await expect(sidebar.getByText("preview.mp4", { exact: true })).toBeVisible();
+ await expect(page.getByRole("row", { name: "Bottom right", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await page.getByRole("switch", { name: "Advanced settings" }).press("Space");
+ await expect(page.getByRole("switch", { name: "Custom position", exact: true })).toHaveCount(0);
+ const horizontal = page.getByRole("slider", { name: "Horizontal", exact: true });
+ await horizontal.focus();
+ await horizontal.press("Home");
+ await horizontal.press("ArrowRight");
+ await expect(horizontal).toHaveValue("1");
+ await expect(sidebar.getByText("Custom position", { exact: true })).toBeVisible();
+ await page.getByRole("row", { name: "Center", exact: true }).click();
+ await expect(horizontal).toHaveValue("50");
+ const show = page.getByRole("switch", { name: "Show", exact: true });
+ await show.press("Space");
+ await expect(camera).toBeHidden();
+ await show.press("Space");
+ await expect(camera).toBeVisible();
+ await page.getByRole("button", { name: "Remove footage", exact: true }).click();
+ await expect(sidebar.getByText("Add a webcam video", { exact: true })).toBeVisible();
+ await expect(camera).toHaveCount(0);
+ await page.getByRole("button", { name: "Upload footage", exact: true }).click();
+ await expect(sidebar.getByText("preview.mp4", { exact: true })).toBeVisible();
+ await expect(camera).toBeVisible();
+ await expect(page.getByRole("row", { name: "Center", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ // All actions fit within the sidebar rather than scrolling horizontally.
+ expect(await sidebar.evaluate((node) => node.scrollWidth <= node.clientWidth + 1)).toBe(true);
+});
diff --git a/tests/ui/zoom-bounds.html b/tests/ui/zoom-bounds.html
new file mode 100644
index 00000000..08058a54
--- /dev/null
+++ b/tests/ui/zoom-bounds.html
@@ -0,0 +1 @@
+Zoom bounds regression test
diff --git a/tests/ui/zoom-bounds.spec.ts b/tests/ui/zoom-bounds.spec.ts
new file mode 100644
index 00000000..564e0bb7
--- /dev/null
+++ b/tests/ui/zoom-bounds.spec.ts
@@ -0,0 +1,178 @@
+import { expect, type Locator, type Page, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+async function prepare(page: Page, constraint?: string) {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto(`/tests/ui/zoom-bounds.html${constraint ? `?constraint=${constraint}` : ""}`);
+ const first = page.locator('[data-variant="zoom"]').filter({ hasText: "1.8" });
+ await expect(first).toBeVisible();
+ const clipItem = page.locator('[data-variant="clip"]');
+ const clip = (await clipItem.boundingBox())!;
+ const duration = Number(await clipItem.getAttribute("data-end-ms"));
+ return { first, pxPerMs: clip.width / duration, clip };
+}
+async function bounds(item: Locator) {
+ const box = (await item.boundingBox())!;
+ return {
+ start: await item.getAttribute("data-start-ms"),
+ end: await item.getAttribute("data-end-ms"),
+ storedEnd: await item.getAttribute("data-stored-end-ms"),
+ x: box.x,
+ width: box.width,
+ };
+}
+async function resize(page: Page, item: Locator, edge: "start" | "end", delta: number) {
+ const box = (await item.boundingBox())!;
+ const x = edge === "start" ? box.x + 2 : box.x + box.width - 2;
+ await page.mouse.move(x, box.y + box.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(x + delta, box.y + box.height / 2, { steps: 12 });
+ await page.mouse.up();
+}
+
+test("nearby additions, removals and connection toggles preserve authored zoom bounds", async ({
+ page,
+}) => {
+ const { first } = await prepare(page);
+ const original = await bounds(first);
+ for (const [button, expected] of [
+ ["Add after", { count: 2, connected: "true" }],
+ ["Add before", { count: 3, connected: "true" }],
+ ["Toggle connections", { count: 3, connected: "false" }],
+ ["Toggle connections", { count: 3, connected: "true" }],
+ ["Move neighbor away", { count: 3, connected: "true" }],
+ ["Remove neighbors", { count: 1, connected: "true" }],
+ ["Add short after", { count: 2, connected: "true" }],
+ ] as const) {
+ await page.getByRole("button", { name: button, exact: true }).click();
+ await expect(page.locator('[data-variant="zoom"]')).toHaveCount(expected.count);
+ await expect(page.locator("main")).toHaveAttribute("data-connected", expected.connected);
+ if (button === "Move neighbor away") {
+ await expect(page.locator('[data-variant="zoom"][data-start-ms="8000"]')).toBeVisible();
+ }
+ expect(await bounds(first)).toEqual(original);
+ }
+ const short = page.locator('[data-variant="zoom"][data-start-ms="4500"]');
+ await expect(short).toHaveAttribute("data-stored-end-ms", "4600");
+ expect(Number(await short.getAttribute("data-end-ms"))).toBeLessThanOrEqual(4600);
+});
+
+for (const constraint of ["timeline", "neighbor"]) {
+ test(`left resizing against a ${constraint} limit preserves the untouched visible edge`, async ({
+ page,
+ }) => {
+ const { first, pxPerMs } = await prepare(page, constraint);
+ const original = await bounds(first);
+ await resize(page, first, "start", -500 * pxPerMs);
+ const resized = await bounds(first);
+ expect(resized.end).toBe(original.end);
+ expect(Math.abs(resized.x + resized.width - original.x - original.width)).toBeLessThan(0.2);
+ expect(Number(resized.storedEnd)).toBeLessThanOrEqual(
+ constraint === "timeline" ? 1500 : 1501,
+ );
+ // When the hidden exit has no room, the active grip stops at its limit.
+ expect(Number(resized.start)).toBeGreaterThan(990);
+ expect(Number(resized.start)).toBeLessThanOrEqual(1000);
+ });
+}
+
+test("connected zoom grips resize short blocks without expanding either neighbor", async ({
+ page,
+}) => {
+ const { first, pxPerMs } = await prepare(page);
+ const original = await bounds(first);
+ await page.getByRole("button", { name: "Add after", exact: true }).click();
+ const after = page.locator('[data-variant="zoom"]').filter({ hasText: "1.5" });
+ await resize(page, after, "end", -600 * pxPerMs);
+ await expect
+ .poll(async () => Number(await after.getAttribute("data-stored-end-ms")))
+ .toBeLessThan(5000);
+ const shortened = await bounds(after);
+ await resize(page, after, "start", 100 * pxPerMs);
+ // Pointer activation and whole-pixel movement can shift a free edge by a few ms.
+ await expect
+ .poll(async () => Number(await after.getAttribute("data-start-ms")))
+ .toBeGreaterThan(4575);
+ expect(Number(await after.getAttribute("data-start-ms"))).toBeLessThan(4625);
+ expect(await after.getAttribute("data-end-ms")).toBe(shortened.end);
+ const resized = await bounds(after);
+ expect(Math.abs(resized.x + resized.width - shortened.x - shortened.width)).toBeLessThan(0.2);
+ expect(await bounds(first)).toEqual(original);
+ // dnd-kit suppresses document clicks for 50 ms after a pointer drag.
+ await page.getByRole("button", { name: "Undo edit", exact: true }).click({ delay: 100 });
+ await expect.poll(() => bounds(after)).toEqual(shortened);
+});
+
+test("hover placement and dragging across the connection threshold keep widths stable", async ({
+ page,
+}) => {
+ const { first, clip, pxPerMs } = await prepare(page);
+ const original = await bounds(first);
+ const row = (await page.locator('[data-timeline-row="row-zoom"]').boundingBox())!;
+ const x = clip.x + 4500 * pxPerMs;
+ const y = row.y + row.height / 2;
+ await page.mouse.move(x, y);
+ const preview = page.getByTestId("timeline-add-preview").locator(":scope > div");
+ await expect(preview).toBeVisible();
+ const expected = (await preview.boundingBox())!;
+ await page.mouse.click(x, y);
+ const after = page.locator('[data-variant="zoom"]').filter({ hasText: "1.5" });
+ await expect(after).toBeVisible();
+ const beforeDrag = await bounds(after);
+ expect(Number(beforeDrag.storedEnd) - Number(beforeDrag.start)).toBe(2000);
+ expect(beforeDrag.width).toBeCloseTo(expected.width, 0);
+ expect(beforeDrag.x).toBeCloseTo(expected.x, 0);
+ expect(await bounds(first)).toEqual(original);
+ const box = (await after.boundingBox())!;
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(box.x + box.width / 2 + 3000 * pxPerMs, box.y + box.height / 2, {
+ steps: 15,
+ });
+ expect(await bounds(first)).toEqual(original);
+ await page.mouse.up();
+ await expect
+ .poll(async () => Number(await after.getAttribute("data-start-ms")))
+ .toBeGreaterThan(7000);
+ const moved = await bounds(after);
+ expect(moved.width).toBeCloseTo(beforeDrag.width, 0);
+ expect(Number(moved.storedEnd) - Number(moved.start)).toBe(
+ Number(beforeDrag.storedEnd) - Number(beforeDrag.start),
+ );
+ expect(await bounds(first)).toEqual(original);
+ // dnd-kit suppresses document clicks for 50 ms after a pointer drag.
+ await page.getByRole("button", { name: "Undo edit", exact: true }).click({ delay: 100 });
+ await expect
+ .poll(async () => {
+ const regions = JSON.parse((await page.locator("main").getAttribute("data-regions"))!);
+ return regions.find((r: { depth: number }) => r.depth === 2)?.startMs;
+ })
+ .toBe(Number(beforeDrag.start));
+ await expect.poll(() => bounds(after)).toEqual(beforeDrag);
+});
+
+// The exact exit is fractional, while the visible timeline edge is rounded.
+// At the project boundary that rounding must not force a multi-second trim.
+test("shortening a boundary zoom follows the left grip without collapsing", async ({ page }) => {
+ const { first, pxPerMs } = await prepare(page, "rounded-boundary");
+ const original = await bounds(first);
+ const box = (await first.boundingBox())!;
+ const x = box.x + 2;
+ const y = box.y + box.height / 2;
+ await page.mouse.move(x, y);
+ await page.mouse.down();
+ for (const deltaMs of [100, 300, 600, 300]) {
+ await page.mouse.move(x + deltaMs * pxPerMs, y, { steps: 4 });
+ const preview = (await first.boundingBox())!;
+ expect(preview.x).toBeCloseTo(box.x + deltaMs * pxPerMs, 0);
+ expect(preview.x + preview.width).toBeCloseTo(box.x + box.width, 0);
+ }
+ await page.mouse.up();
+ const resized = await bounds(first);
+ expect(Number(resized.start)).toBeGreaterThan(11490);
+ expect(Number(resized.start)).toBeLessThan(11565);
+ expect(resized.end).toBe(original.end);
+ expect(Number(resized.storedEnd)).toBeLessThanOrEqual(16256);
+ await page.getByRole("button", { name: "Undo edit", exact: true }).click({ delay: 100 });
+ await expect.poll(() => bounds(first)).toEqual(original);
+});
diff --git a/tests/ui/zoom-bounds.tsx b/tests/ui/zoom-bounds.tsx
new file mode 100644
index 00000000..f20f6fcc
--- /dev/null
+++ b/tests/ui/zoom-bounds.tsx
@@ -0,0 +1,134 @@
+import { useEffect, useRef, useState } from "react";
+import { createRoot } from "react-dom/client";
+import { CURSOR_MOTION_PRESETS } from "@/components/video-editor/cursorMotionPresets";
+import { useEditorHistory } from "@/components/video-editor/hooks/useEditorHistory";
+import { useTimelineState } from "@/components/video-editor/state/useTimelineState";
+import TimelineEditor from "@/components/video-editor/timeline/TimelineEditor";
+import type { ZoomRegion } from "@/components/video-editor/types";
+import { I18nProvider } from "@/contexts/I18nContext";
+import { ShortcutsProvider } from "@/contexts/ShortcutsContext";
+import "@/index.css";
+
+const first: ZoomRegion = {
+ id: "first",
+ startMs: 1000,
+ endMs: 4000,
+ depth: 3,
+ focus: { cx: 0.35, cy: 0.5 },
+ mode: "manual",
+};
+const constraint = new URLSearchParams(location.search).get("constraint");
+const totalMs =
+ constraint === "timeline" ? 1500 : constraint === "rounded-boundary" ? 16256 : 12000;
+const clips = [{ id: "clip", startMs: 0, endMs: totalMs, speed: 1 }];
+function ZoomBounds() {
+ const timeline = useTimelineState();
+ const {
+ zoomRegions: regions,
+ setZoomRegions: commit,
+ selectedZoomId: selected,
+ setSelectedZoomId: setSelected,
+ } = timeline;
+ const [connected, setConnected] = useState(true);
+ const nextId = useRef(0);
+ const history = useEditorHistory({
+ timeline,
+ nextZoomIdRef: nextId,
+ nextClipIdRef: useRef(1),
+ nextAnnotationIdRef: useRef(1),
+ nextAudioIdRef: useRef(1),
+ nextAnnotationZIndexRef: useRef(1),
+ });
+ useEffect(() => {
+ commit(
+ constraint === "rounded-boundary"
+ ? [{ ...first, startMs: 11228, endMs: totalMs }]
+ : constraint
+ ? [
+ { ...first, endMs: 1500 },
+ ...(constraint === "neighbor"
+ ? [
+ {
+ ...first,
+ id: "after",
+ startMs: 1501,
+ endMs: 2500,
+ depth: 2 as const,
+ },
+ ]
+ : []),
+ ]
+ : [first],
+ );
+ timeline.setClipRegions(clips);
+ }, [commit, timeline.setClipRegions]);
+ const add = (id: string, startMs: number, endMs: number) =>
+ commit([...regions.filter((r) => r.id !== id), { ...first, id, startMs, endMs, depth: 2 }]);
+ // Match the editor's appearance object: connection settings belong to playback,
+ // while TimelineEditor consumes only the nominal entrance/exit settings.
+ const appearance = { ...CURSOR_MOTION_PRESETS.focused, connectZooms: connected };
+ return (
+
+ add("before", 0, 500)}>Add before
+ add("after", 4500, 5500)}>Add after
+ add("after", 4500, 4600)}>Add short after
+ commit(regions.filter((r) => r.id === "first"))}>
+ Remove neighbors
+
+
+ commit(
+ regions.map((r) =>
+ r.id === "after"
+ ? { ...r, startMs: 8000, endMs: 8000 + r.endMs - r.startMs }
+ : r,
+ ),
+ )
+ }
+ >
+ Move neighbor away
+
+ setConnected((v) => !v)}>Toggle connections
+
+ Undo edit
+
+
+ add(`added-${nextId.current++}`, span.start, span.end)}
+ onZoomSpanChange={(id, span) =>
+ commit(
+ regions.map((r) =>
+ r.id === id
+ ? {
+ ...r,
+ startMs: Math.round(span.start),
+ endMs: Math.round(span.end),
+ }
+ : r,
+ ),
+ )
+ }
+ onZoomDelete={(id) => commit(regions.filter((r) => r.id !== id))}
+ />
+
+
+ );
+}
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+ ,
+);
diff --git a/tests/ui/zoom-drag.spec.ts b/tests/ui/zoom-drag.spec.ts
new file mode 100644
index 00000000..f57669da
--- /dev/null
+++ b/tests/ui/zoom-drag.spec.ts
@@ -0,0 +1,86 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+test("zoom follows the pointer forward and backward without accumulating preview offsets", async ({
+ page,
+}) => {
+ test.setTimeout(90000);
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/?windowType=editor");
+ const clip = page.locator('[data-variant="clip"]');
+ await expect(clip).toHaveAttribute("data-end-ms", "6000", { timeout: 30000 });
+ await page.getByRole("button", { name: "Add Zoom (Z)", exact: true }).click();
+ const zoom = page.locator('[data-variant="zoom"]');
+ await expect(zoom).toHaveCount(1);
+ const row = (await page.locator('[data-timeline-row="row-zoom"]').boundingBox())!;
+ const original = (await zoom.boundingBox())!;
+ const start = Number(await zoom.getAttribute("data-start-ms"));
+ const duration = Number(await zoom.getAttribute("data-stored-end-ms")) - start;
+ const x = original.x + original.width / 2;
+ const y = original.y + original.height / 2;
+ await page.mouse.move(x, y);
+ await page.mouse.down();
+ for (const offset of [60, 120, 240, 180, 90]) {
+ await page.mouse.move(x + offset, y, { steps: 12 });
+ await expect
+ .poll(async () => (await zoom.boundingBox())!.x)
+ .toBeCloseTo(original.x + offset, 0);
+ }
+ await page.mouse.up();
+ const expectedStart = Math.round(start + (90 / row.width) * 6000);
+ await expect(zoom).toHaveAttribute("data-start-ms", String(expectedStart));
+ expect(Number(await zoom.getAttribute("data-stored-end-ms")) - expectedStart).toBe(duration);
+ await page.keyboard.press("Meta+z");
+ await expect(zoom).toHaveAttribute("data-start-ms", String(start));
+ // Cancelling and picking up again must reset the origin without saving an edit.
+ await page.mouse.move(x, y);
+ await page.mouse.down();
+ await page.mouse.move(x + 60, y, { steps: 12 });
+ await page.keyboard.press("Escape");
+ await page.mouse.up();
+ await expect(zoom).toHaveAttribute("data-start-ms", String(start));
+ await expect.poll(async () => (await zoom.boundingBox())!.x).toBeCloseTo(original.x, 0);
+ await page.mouse.move(x, y);
+ await page.mouse.down();
+ await page.mouse.move(x + 120, y + 15, { steps: 12 });
+ await expect.poll(async () => (await zoom.boundingBox())!.x).toBeCloseTo(original.x + 120, 0);
+ await expect.poll(async () => (await zoom.boundingBox())!.y).toBeCloseTo(original.y + 15, 0);
+ await page.mouse.up();
+ const repeatedStart = Math.round(start + (120 / row.width) * 6000);
+ await expect(zoom).toHaveAttribute("data-start-ms", String(repeatedStart));
+ expect(Number(await zoom.getAttribute("data-stored-end-ms")) - repeatedStart).toBe(duration);
+});
+
+test("dragging a zoom snaps across a split clip gutter without changing duration", async ({
+ page,
+}) => {
+ test.setTimeout(90000);
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/?windowType=editor");
+ const clips = page.locator('[data-variant="clip"]');
+ await expect(clips).toHaveAttribute("data-end-ms", "6000", { timeout: 30000 });
+ await page.getByRole("button", { name: "Add Zoom (Z)", exact: true }).click();
+ const zoom = page.locator('[data-variant="zoom"]');
+ await expect(zoom).toHaveCount(1);
+ const row = (await page.locator('[data-timeline-row="row-clip"]').boundingBox())!;
+ await page.mouse.click(row.x + row.width / 2, row.y - 8);
+ await page.getByRole("button", { name: /^Split Clip \(/ }).click();
+ await expect(clips).toHaveCount(2);
+ const first = (await clips.first().boundingBox())!;
+ const original = (await zoom.boundingBox())!;
+ const start = Number(await zoom.getAttribute("data-start-ms"));
+ const end = Number(await zoom.getAttribute("data-end-ms"));
+ const duration = Number(await zoom.getAttribute("data-stored-end-ms")) - start;
+ const delta = first.width * ((3000 - (end - start) - start) / 3000) - 4;
+ await page.mouse.move(original.x + original.width / 2, original.y + original.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(
+ original.x + original.width / 2 + delta,
+ original.y + original.height / 2,
+ { steps: 16 },
+ );
+ await page.mouse.up();
+ await expect(zoom).toHaveAttribute("data-end-ms", "3000");
+ const placedStart = Number(await zoom.getAttribute("data-start-ms"));
+ expect(Number(await zoom.getAttribute("data-stored-end-ms")) - placedStart).toBe(duration);
+});
diff --git a/tests/ui/zoom-presets.spec.ts b/tests/ui/zoom-presets.spec.ts
new file mode 100644
index 00000000..5a5c8e50
--- /dev/null
+++ b/tests/ui/zoom-presets.spec.ts
@@ -0,0 +1,53 @@
+import { expect, test } from "@playwright/test";
+import { CURSOR_MOTION_PRESETS } from "../../src/components/video-editor/cursorMotionPresets";
+import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
+
+test("zoom presets work in the basic panel and preserve cursor settings", async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await installDesktopBridgeOverrides(page, () => {
+ const save = window.electronAPI.saveProjectFile;
+ window.electronAPI.saveProjectFile = async (...args) => {
+ document.documentElement.dataset.savedZoomPresetProject = JSON.stringify(args[0]);
+ return save(...args);
+ };
+ });
+ await page.goto("/?windowType=editor");
+ await page.getByRole("button", { name: "Add Zoom (Z)", exact: true }).click();
+ await page.locator('[data-variant="zoom"]').click();
+ const presets = page.getByRole("grid", { name: "Presets", exact: true });
+ await expect(presets).toBeVisible();
+ const preferences = () =>
+ page.evaluate(
+ () => JSON.parse(document.documentElement.dataset.savedZoomPresetProject ?? "{}").editor,
+ );
+ await expect.poll(preferences).toBeDefined();
+ const before = await preferences();
+ for (const preset of [CURSOR_MOTION_PRESETS.smooth, CURSOR_MOTION_PRESETS.focused]) {
+ const option = presets.getByRole("row", { name: preset.label, exact: true });
+ await option.click();
+ await expect(option).toHaveAttribute("aria-selected", "true");
+ await expect.poll(preferences).toMatchObject({
+ zoomInDurationMs: preset.zoomInDurationMs,
+ zoomOutDurationMs: preset.zoomOutDurationMs,
+ cursorSize: before.cursorSize,
+ cursorSmoothing: before.cursorSmoothing,
+ cursorClickBounce: before.cursorClickBounce,
+ });
+ }
+ await page.locator("aside").getByText("Advanced", { exact: true }).click();
+ const classic = page.getByRole("switch", { name: "Classic Animation", exact: true });
+ await classic.press("Space");
+ await expect(classic).toBeChecked();
+ await expect(presets.locator('[aria-selected="true"]')).toHaveCount(0);
+ await presets.getByRole("row", { name: "Cinematic", exact: true }).click();
+ await expect(classic).not.toBeChecked();
+ await page.locator("aside").getByText("Advanced", { exact: true }).click();
+ await expect(presets.getByRole("row", { name: "Cinematic", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await page.locator("aside").screenshot({
+ path: "test-results/zoom-presets-panel.png",
+ animations: "disabled",
+ });
+});
diff --git a/tests/ui/zoom-resize-add.spec.ts b/tests/ui/zoom-resize-add.spec.ts
new file mode 100644
index 00000000..20361dae
--- /dev/null
+++ b/tests/ui/zoom-resize-add.spec.ts
@@ -0,0 +1,57 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+
+for (const edge of ["start", "end"] as const) {
+ test(`shortening the ${edge} edge cannot add a second zoom`, async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/zoom-bounds.html");
+ const zooms = page.locator('[data-variant="zoom"]');
+ await expect(zooms).toHaveCount(1);
+ const zoom = zooms.first();
+ const box = (await zoom.boundingBox())!;
+ const x = edge === "start" ? box.x + 2 : box.x + box.width - 2;
+ const y = box.y + box.height / 2;
+ const dx = edge === "start" ? 70 : -70;
+ const row = (await page.locator('[data-timeline-row="row-zoom"]').boundingBox())!;
+ await page.mouse.move(x, y);
+ await page.mouse.down();
+ for (const delta of [15, 30, 45, 60, 70]) {
+ await page.mouse.move(x + Math.sign(dx) * delta, y, { steps: 4 });
+ await expect(zooms).toHaveCount(1);
+ await expect(page.getByTestId("timeline-add-preview")).toHaveCount(0);
+ }
+ await page.mouse.move(x + dx, row.y + 1, { steps: 3 });
+ await page.mouse.up();
+ await expect(zooms).toHaveCount(1);
+ await page.mouse.move(x + dx - 10, row.y + 1);
+ await expect(zooms).toHaveCount(1);
+ });
+}
+
+test("a resize released beyond its minimum must not turn into an empty-lane add", async ({
+ page,
+}) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/zoom-bounds.html");
+ await page.getByRole("button", { name: "Add after", exact: true }).click();
+ await page.getByRole("button", { name: "Move neighbor away", exact: true }).click();
+ const zooms = page.locator('[data-variant="zoom"]');
+ const after = zooms.filter({ hasText: "1.5" });
+ const box = (await after.boundingBox())!;
+ const row = (await page.locator('[data-timeline-row="row-zoom"]').boundingBox())!;
+ const clip = (await page.locator('[data-variant="clip"]').boundingBox())!;
+ const original = await page.locator("main").getAttribute("data-regions");
+ const x = box.x + box.width - 2;
+ await page.mouse.move(x, box.y + box.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(clip.x + clip.width * 0.4, row.y + 1, { steps: 25 });
+ await expect(page.getByTestId("timeline-add-preview")).toHaveCount(0);
+ await page.mouse.up();
+ await expect(zooms).toHaveCount(2);
+ await expect(page.getByTestId("timeline-add-preview")).toHaveCount(0);
+ await page.getByRole("button", { name: "Undo edit", exact: true }).click();
+ await expect(page.locator("main")).toHaveAttribute("data-regions", original!);
+ // A subsequent deliberate click on empty space must still add normally.
+ await page.mouse.click(clip.x + clip.width * 0.4, row.y + row.height / 2);
+ await expect(zooms).toHaveCount(3);
+});