mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
chore(video): smooth marketing reel output
Note: the drag/drop reel works finally with the native app drag preview path.
This commit is contained in:
parent
a448053d6b
commit
1fc048df61
6 changed files with 478 additions and 176 deletions
|
|
@ -11,6 +11,7 @@ npm run video:full # full variant (~21s) → dist/video/reel-full*.{...}
|
|||
# under the hood
|
||||
npm run video:capture # Playwright records to .tmp/video/recordings/
|
||||
npm run video:build # ffmpeg → dist/video/, picks the most recent webm
|
||||
npm run video:open # opens an autoplay browser preview, skips in CI
|
||||
```
|
||||
|
||||
`REEL_VARIANT=<name>` switches the spec branch and adds a filename suffix so multiple variants coexist in `dist/video/`. `full` is the only one wired up so far; add more by branching on `isFull`-style flags in the spec.
|
||||
|
|
@ -23,22 +24,23 @@ npm run video:build # ffmpeg → dist/video/, picks the most recent webm
|
|||
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `playwright.store-video.config.ts` | Single chromium project, `video: 'off'` at project level (the fixture handles `recordVideo` itself because `browser.newContext()` doesn't inherit `use.video`). |
|
||||
| `store-video/fixture.ts` | Custom context with `recordVideo` enabled at 1024×1024 / DPR 2. Reuses the screenshot pipeline's seed builder. Init scripts handle: cursor highlight ring, dialog/snack/tooltip/mention suppression, app zoom. |
|
||||
| `store-video/overlays.ts` | DOM-injected overlay primitives: `showOverlay`, `showIntegrationsCard`, `showEndCard`, `cutToScene`, `fadeTransition`, `loopBoundary`, `attachDragGhost`. Plus inline brand SVGs in the `LOGOS` constant. |
|
||||
| `store-video/overlays.ts` | DOM-injected overlay primitives: `showOverlay`, `showCaption`, `showIntegrationsCard`, `showEndCard`, `cutToScene`, `fadeTransition`, `loopBoundary`, `attachDragGhost`, `smoothMouseMove`. Plus inline brand SVGs in the `LOGOS` constant. |
|
||||
| `store-video/scenarios/reel.spec.ts` | Six-beat choreography. `REEL_VARIANT=full` triggers the optional "No account. No tracking." beat and relaxes hold timings. |
|
||||
| `store-video/build-video.ts` | Picks the most recent `.webm` under `.tmp/video/recordings/`, applies the trim sidecar (cuts the seed-import lead-in), produces mp4/webm/gif via ffmpeg, optionally `gifsicle`-optimizes. |
|
||||
| `store-video/open-video.ts` | Opens an autoplay browser preview after `npm run video`. Prefers mp4, respects `REEL_VARIANT`, seeks slightly past the black first frame for preview only, and skips auto-open in CI. |
|
||||
|
||||
## Beat structure (current)
|
||||
|
||||
```
|
||||
Lead-in black fades to SP UI with schedule day-panel already open
|
||||
1 Capture in seconds. global add-task-bar (scaled 1.45×, max-width
|
||||
520px); types "A task 1h #urgent @17" with
|
||||
55ms keystroke delay. Captured task carries
|
||||
through to beats 2 and 3.
|
||||
1 Capture in seconds. global add-task-bar; types
|
||||
"A task 1h" with 55ms keystroke delay.
|
||||
Captured task carries through to beats 2 and 3.
|
||||
1.5 [full only] No account. No tracking.
|
||||
2 Plan your day. drags first task onto schedule panel with
|
||||
`attachDragGhost` cloning the source element
|
||||
under the cursor.
|
||||
2 Plan your day. drags the newly captured "A task" item onto
|
||||
the schedule panel with the app's native CDK
|
||||
drag behavior and Playwright's stepped mouse
|
||||
movement.
|
||||
3 Focus on what matters. dispatches `[Task] SetCurrentTask` /
|
||||
`[FocusMode] Show Overlay` /
|
||||
`[FocusMode] Start Session` directly via
|
||||
|
|
@ -56,17 +58,16 @@ Lead-in black fades to SP UI with schedule day-panel already open
|
|||
Boundary black fades in so the gif loop seam is black-to-black
|
||||
```
|
||||
|
||||
## Scene transitions: cutToScene
|
||||
## Scene transitions
|
||||
|
||||
Every beat handoff uses `cutToScene(page, async () => { ... })` for a fade-to-black scene cut. The helper:
|
||||
The main reel uses two transition styles:
|
||||
|
||||
1. Fades a max-z-index black overlay from transparent to opaque (covers everything, including any beat overlays/cards).
|
||||
2. Runs the callback while the screen is fully black — this is where state changes happen (close add-task-bar, dispatch focus mode, hide previous overlay, prime next overlay/card).
|
||||
3. Fades the black back out to reveal whatever the callback set up.
|
||||
1. `cutToScene(page, async () => { ... })` for app state changes and bigger app-to-screen jumps. It fades a max-z-index black overlay in, runs setup while black covers the app, then fades back out. Beat 1 → 2 closes the add-task-bar by clicking the real backdrop inside this covered callback, so list reflow and cursor reset are hidden before the drag starts. Pass a `label` to log how long setup spent behind black.
|
||||
2. Direct card crossfade for controlled full-screen cards. Beat 4 → 5 shows the end card above the integrations card, then hides the integrations card underneath once the end card is mostly opaque.
|
||||
|
||||
**Always pass `noWait: true` to the next overlay/card inside the callback.** Without it, the call awaits its own fade-in (and stagger animation, for `showIntegrationsCard`/`showEndCard`) — which would play behind the still-opaque black and be wasted. With `noWait`, the call returns as soon as the DOM is in place, so the fade-in animates concurrently with `cutToScene`'s fade-from-black. The viewer sees: scene → black → next-scene-emerging-with-its-animation.
|
||||
**Always pass `noWait: true` to the next overlay/card inside a `cutToScene` callback.** Without it, the call awaits its own fade-in (and stagger animation, for `showIntegrationsCard`/`showEndCard`) — which would play behind the still-opaque black and be wasted. With `noWait`, the call returns as soon as the DOM is in place, so the fade-in animates concurrently with `cutToScene`'s fade-from-black. The viewer sees: scene → black → next-scene-emerging-with-its-animation.
|
||||
|
||||
`fadeTransition` (partial-dim, lower z-index) is no longer used by the main spec — it was unreliable for big scene jumps because the dim's transparent regions let the underlying state change show through. Kept in `overlays.ts` in case a future beat wants a softer mid-beat dim, but `cutToScene` is the default.
|
||||
`fadeTransition` remains available in `overlays.ts`, but keep it out of the drag setup path: its transparent dim can let underlying app reflow leak through and make the first drag frames look wrong.
|
||||
|
||||
## Architecture decisions / gotchas
|
||||
|
||||
|
|
@ -80,16 +81,18 @@ Every beat handoff uses `cutToScene(page, async () => { ... })` for a fade-to-bl
|
|||
|
||||
**Schedule day-panel width.** Pre-seeded in `ONBOARDING_INIT` via `localStorage.setItem('SUP_RIGHT_PANEL_WIDTH', '250')` — that's `RIGHT_PANEL_CONFIG.MIN_WIDTH`, the smallest the panel allows before its 200px CLOSE_THRESHOLD kicks in. Pre-seeding through the panel's own persistence path means the inner schedule grid computes its column widths against 250 and the event blocks don't overflow. Earlier iterations forced `width !important` on `.side`, which sized the chrome but didn't propagate to the grid — events then spilled past the panel's right edge and required ugly `overflow-x: hidden` belt-and-braces clips. Don't go that route.
|
||||
|
||||
**Add-task-bar overlays.** The fixture only hides the *overlay surfaces* that pop on top of the bar while typing (mat-autocomplete suggestions, mention-list, loading spinner) — those would otherwise read as glitchy white boxes mid-gif. The bar itself is not styled by the fixture; it uses its real `:host` rules.
|
||||
**Add-task-bar overlays.** The fixture only hides the *overlay surfaces* that pop on top of the bar while typing (mat-autocomplete suggestions, mention-list, loading spinner) — those would otherwise read as glitchy white boxes mid-gif. The bar itself is not styled by the fixture; it uses its real `:host` rules. Beat 1 → 2 closes it by clicking the real `.backdrop`, matching normal UI behavior instead of dispatching layout state directly.
|
||||
|
||||
**Cursor highlight.** Soft white radial-gradient ring at `z-index: 2147483640`, follows mousemove. Toggle visibility per-beat via `body.__sp-hide-cursor-highlight` — used during the capture beat where the focused input would otherwise show the ring as a stray dot.
|
||||
|
||||
**Main-text consistency.** `.__sp-video-overlay-text`, `.__sp-video-int-card-title`, `.__sp-video-end-card-title` share a single rule with `font-size: clamp(48px, 6.4vw, 96px) !important`. The `!important` flag is required because `.mat-typography h1` has specificity (0,1,1) which outranks our class-only selector. Card titles are `<p>` rather than `<h1>` for belt-and-braces — even with `!important`, the typography font shorthand can sneak through.
|
||||
|
||||
**Drag ghost.** Beat 2 uses `attachDragGhost(page, sourceLocator)` which clones the source via `outerHTML`, attaches a mousemove listener, and follows the cursor with a 2° tilt + drop shadow. Detaches on mouse-up. SP's cdkDrag may or may not show its own preview depending on the drop target's `cdkDropList` wiring; the ghost guarantees the act of dragging reads regardless.
|
||||
**Drag preview.** Beat 2 intentionally does not use a synthetic video ghost. It relies on the app's real CDK drag behavior so the visual preview matches what a user sees while dragging a task onto the schedule panel. Keep the source locator tied to `CAPTURED_TASK_DISPLAY_TITLE`; using `task().first()` can accidentally drag a larger seeded task and make the preview look zoomed.
|
||||
|
||||
**Loop boundary.** `loopBoundary(page, 'in', ms)` shows full-black opacity 1 then fades to 0 over `ms` (lead-in). `loopBoundary(page, 'out', ms)` fades from 0 to 1 (closing). Gif seam is black-to-black, no jump cut. `z-index: 2147483647` (max safe) so it covers everything including end card.
|
||||
|
||||
**Output cadence.** Playwright's recorder emits 25fps webm in this pipeline. `build-video.ts` keeps MP4, WebM, and GIF at 25fps to avoid duplicate/drop-frame judder during fades and cursor movement.
|
||||
|
||||
**Build script picks most-recent webm.** No need to clean `.tmp/video/recordings/` between runs. Old webms accumulate but only the most recent `.mtime` is built into outputs.
|
||||
|
||||
**Variant filename suffix.** `build-video.ts` reads `process.env.REEL_VARIANT` and appends `-${variant}` to all output filenames when set. `npm run video:full` works because env vars propagate through `npm run`.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* Post-process the latest Playwright video capture into shippable formats:
|
||||
* - reel.mp4 1920×1080, 30fps, H.264 yuv420p landing-page fallback
|
||||
* - reel.webm 1920×1080, 30fps, VP9 landing-page primary
|
||||
* - reel.gif 1024 wide, 24fps, two-pass palette README embed
|
||||
* - reel.mp4 1024×1024, 25fps, H.264 yuv420p landing-page fallback
|
||||
* - reel.webm 1024×1024, 25fps, VP9 landing-page primary
|
||||
* - reel.gif 1024 wide, 25fps, two-pass palette README embed
|
||||
*
|
||||
* Inputs: the most recent `.webm` under `.tmp/video/recordings/` (where the
|
||||
* fixture's `recordVideo` setting writes). Outputs: `dist/video/`.
|
||||
|
|
@ -29,6 +29,9 @@ const OUT_DIR = path.join(REPO_ROOT, 'dist', 'video');
|
|||
*/
|
||||
const VARIANT = process.env.REEL_VARIANT ?? '';
|
||||
const SUFFIX = VARIANT ? `-${VARIANT}` : '';
|
||||
// Playwright's recorder currently emits 25fps VP8 webm. Keeping all derived
|
||||
// formats on that cadence avoids duplicate/drop-frame judder in fades.
|
||||
const OUTPUT_FPS = 25;
|
||||
|
||||
/**
|
||||
* Read the trim offset (seconds) from the sidecar the fixture writes when
|
||||
|
|
@ -99,9 +102,11 @@ const main = (): void => {
|
|||
console.log(`[video] source: ${path.relative(REPO_ROOT, src)}`);
|
||||
|
||||
const trimSeconds = readTrimSeconds();
|
||||
// `-ss` BEFORE `-i` is input seek (fast, frame-accurate when re-encoding).
|
||||
// Empty array when no trim, so the ffmpeg arg list is clean.
|
||||
const trimArgs = trimSeconds > 0 ? ['-ss', trimSeconds.toFixed(3)] : [];
|
||||
// Trim in the filter graph so ffmpeg decodes to the exact trim point for
|
||||
// every output. Seeking before `-i` is faster, but VP8 keyframes can be
|
||||
// sparse enough to drop the opening beat from the generated reel.
|
||||
const trimFilter =
|
||||
trimSeconds > 0 ? `trim=start=${trimSeconds.toFixed(3)},setpts=PTS-STARTPTS,` : '';
|
||||
if (trimSeconds > 0) {
|
||||
console.log(
|
||||
`[video] trimming first ${trimSeconds.toFixed(3)}s (seed-import lead-in)`,
|
||||
|
|
@ -113,11 +118,10 @@ const main = (): void => {
|
|||
const palette = path.join(OUT_DIR, `.palette${SUFFIX}.png`);
|
||||
const gif = path.join(OUT_DIR, `reel${SUFFIX}.gif`);
|
||||
|
||||
// 1. mp4 — Playwright records VFR; force CFR 30fps for predictable playback.
|
||||
// 1. mp4 — Playwright records VFR; force CFR for predictable playback.
|
||||
console.log('[video] -> mp4');
|
||||
run('ffmpeg', [
|
||||
'-y',
|
||||
...trimArgs,
|
||||
'-i',
|
||||
src,
|
||||
'-c:v',
|
||||
|
|
@ -129,7 +133,7 @@ const main = (): void => {
|
|||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-vf',
|
||||
'fps=30',
|
||||
`${trimFilter}fps=${OUTPUT_FPS}`,
|
||||
'-movflags',
|
||||
'+faststart',
|
||||
'-an',
|
||||
|
|
@ -140,7 +144,6 @@ const main = (): void => {
|
|||
console.log('[video] -> webm');
|
||||
run('ffmpeg', [
|
||||
'-y',
|
||||
...trimArgs,
|
||||
'-i',
|
||||
src,
|
||||
'-c:v',
|
||||
|
|
@ -152,7 +155,7 @@ const main = (): void => {
|
|||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-vf',
|
||||
'fps=30',
|
||||
`${trimFilter}fps=${OUTPUT_FPS}`,
|
||||
'-an',
|
||||
webm,
|
||||
]);
|
||||
|
|
@ -168,23 +171,21 @@ const main = (): void => {
|
|||
console.log('[video] -> gif (palette pass)');
|
||||
run('ffmpeg', [
|
||||
'-y',
|
||||
...trimArgs,
|
||||
'-i',
|
||||
src,
|
||||
'-vf',
|
||||
'fps=24,scale=1024:-1:flags=lanczos,palettegen=stats_mode=full',
|
||||
`${trimFilter}fps=${OUTPUT_FPS},scale=1024:-1:flags=lanczos,palettegen=stats_mode=full`,
|
||||
palette,
|
||||
]);
|
||||
console.log('[video] -> gif (paletteuse)');
|
||||
run('ffmpeg', [
|
||||
'-y',
|
||||
...trimArgs,
|
||||
'-i',
|
||||
src,
|
||||
'-i',
|
||||
palette,
|
||||
'-lavfi',
|
||||
'fps=24,scale=1024:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=sierra2_4a',
|
||||
`${trimFilter}fps=${OUTPUT_FPS},scale=1024:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=sierra2_4a`,
|
||||
gif,
|
||||
]);
|
||||
fs.unlinkSync(palette);
|
||||
|
|
|
|||
124
e2e/store-video/open-video.ts
Normal file
124
e2e/store-video/open-video.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Opens the generated marketing reel after `npm run video` finishes building.
|
||||
*
|
||||
* Kept separate from `build-video.ts` so `npm run video:build` remains useful
|
||||
* for automation and CI without launching a local media player.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
||||
const OUT_DIR = path.join(REPO_ROOT, 'dist', 'video');
|
||||
|
||||
const VARIANT = process.env.REEL_VARIANT ?? '';
|
||||
const SUFFIX = VARIANT ? `-${VARIANT}` : '';
|
||||
const PREVIEW_START_SECONDS = 0.35;
|
||||
|
||||
const findOutput = (): string | null => {
|
||||
const candidates = [
|
||||
`reel${SUFFIX}.mp4`,
|
||||
`reel${SUFFIX}.webm`,
|
||||
`reel${SUFFIX}.gif`,
|
||||
`reel${SUFFIX}-optimized.gif`,
|
||||
].map((fileName) => path.join(OUT_DIR, fileName));
|
||||
|
||||
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
|
||||
};
|
||||
|
||||
const writePreview = (videoPath: string): string => {
|
||||
const previewPath = path.join(OUT_DIR, `preview${SUFFIX}.html`);
|
||||
const videoFileName = JSON.stringify(`./${path.basename(videoPath)}`);
|
||||
const startSeconds = JSON.stringify(PREVIEW_START_SECONDS);
|
||||
const html = [
|
||||
'<!doctype html>',
|
||||
'<html lang="en">',
|
||||
'<head>',
|
||||
' <meta charset="utf-8">',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1">',
|
||||
' <title>Super Productivity reel preview</title>',
|
||||
' <style>',
|
||||
' html, body {',
|
||||
' margin: 0;',
|
||||
' min-height: 100%;',
|
||||
' background: #050507;',
|
||||
' }',
|
||||
' body {',
|
||||
' display: grid;',
|
||||
' place-items: center;',
|
||||
' }',
|
||||
' video {',
|
||||
' width: min(100vw, 100vh);',
|
||||
' height: min(100vw, 100vh);',
|
||||
' background: #000;',
|
||||
' }',
|
||||
' </style>',
|
||||
'</head>',
|
||||
'<body>',
|
||||
` <video id="reel" src=${videoFileName} controls autoplay muted loop playsinline></video>`,
|
||||
' <script>',
|
||||
' const video = document.getElementById("reel");',
|
||||
` const startSeconds = ${startSeconds};`,
|
||||
' const start = () => {',
|
||||
' if (Number.isFinite(video.duration) && video.duration > startSeconds) {',
|
||||
' video.currentTime = startSeconds;',
|
||||
' }',
|
||||
' video.play().catch(() => undefined);',
|
||||
' };',
|
||||
' video.addEventListener("loadedmetadata", start, { once: true });',
|
||||
' </script>',
|
||||
'</body>',
|
||||
'</html>',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
fs.writeFileSync(previewPath, html);
|
||||
return previewPath;
|
||||
};
|
||||
|
||||
const openFile = (filePath: string): void => {
|
||||
const platform = process.platform;
|
||||
const command =
|
||||
platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
|
||||
const args = platform === 'win32' ? ['/c', 'start', '', filePath] : [filePath];
|
||||
|
||||
const result = spawnSync(command, args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
const detail = result.error?.message ?? `exit status ${result.status}`;
|
||||
console.warn(
|
||||
`[video] could not open ${path.relative(REPO_ROOT, filePath)}: ${detail}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const main = (): void => {
|
||||
if (process.env.CI) {
|
||||
console.log('[video] CI detected; skipping auto-open.');
|
||||
return;
|
||||
}
|
||||
|
||||
const output = findOutput();
|
||||
if (!output) {
|
||||
console.warn(
|
||||
`[video] no built reel found under ${path.relative(REPO_ROOT, OUT_DIR)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = writePreview(output);
|
||||
console.log(
|
||||
`[video] opening ${path.relative(REPO_ROOT, preview)} for ${path.relative(
|
||||
REPO_ROOT,
|
||||
output,
|
||||
)}`,
|
||||
);
|
||||
openFile(preview);
|
||||
};
|
||||
|
||||
main();
|
||||
|
|
@ -68,6 +68,10 @@ export type OverlayHandle = {
|
|||
hide: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type CaptionHandle = OverlayHandle & {
|
||||
update: (text: string, options?: { fadeMs?: number }) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* One line on the end card. Plain string renders as-is. The object form
|
||||
* animates the `{n}` placeholder in `template` from 0 up to `to` over the
|
||||
|
|
@ -115,6 +119,7 @@ export type IntegrationsCardContent = {
|
|||
const STYLE_ID = '__sp-video-overlay-style';
|
||||
const OVERLAY_ID_PREFIX = '__sp-video-overlay-';
|
||||
const END_CARD_ID = '__sp-video-end-card';
|
||||
const CAPTION_ID = '__sp-video-caption';
|
||||
|
||||
let overlayCounter = 0;
|
||||
|
||||
|
|
@ -157,6 +162,11 @@ const ensureStyleInjected = async (page: Page): Promise<void> => {
|
|||
.__sp-video-overlay.visible .__sp-video-overlay-bg {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.__sp-video-caption .__sp-video-overlay-text {
|
||||
transition:
|
||||
opacity var(--__sp-caption-text-ms, 170ms) ease-out,
|
||||
transform var(--__sp-caption-text-ms, 170ms) ease-out;
|
||||
}
|
||||
.__sp-video-overlay.center .__sp-video-overlay-bg {
|
||||
border-radius: 14px;
|
||||
max-width: 80vw;
|
||||
|
|
@ -426,6 +436,89 @@ export const showOverlay = async (
|
|||
};
|
||||
};
|
||||
|
||||
export const showCaption = async (
|
||||
page: Page,
|
||||
text: string,
|
||||
options: Pick<OverlayOptions, 'fadeMs' | 'noWait' | 'position'> = {},
|
||||
): Promise<CaptionHandle> => {
|
||||
const position: OverlayPosition = options.position ?? 'lower';
|
||||
const fadeMs = options.fadeMs ?? 420;
|
||||
await ensureStyleInjected(page);
|
||||
await page.evaluate(
|
||||
(args) => {
|
||||
document.getElementById(args.id)?.remove();
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.id = args.id;
|
||||
el.className = `__sp-video-overlay __sp-video-caption ${args.position}`;
|
||||
el.style.setProperty('--__sp-fade-ms', `${args.fadeMs}ms`);
|
||||
|
||||
const bg = document.createElement('div');
|
||||
bg.className = '__sp-video-overlay-bg';
|
||||
|
||||
const p = document.createElement('p');
|
||||
p.className = '__sp-video-overlay-text';
|
||||
p.textContent = args.text;
|
||||
bg.appendChild(p);
|
||||
|
||||
el.appendChild(bg);
|
||||
document.body.appendChild(el);
|
||||
void el.offsetWidth;
|
||||
el.classList.add('visible');
|
||||
},
|
||||
{ id: CAPTION_ID, text, position, fadeMs },
|
||||
);
|
||||
if (!options.noWait) {
|
||||
await page.waitForTimeout(fadeMs);
|
||||
}
|
||||
|
||||
return {
|
||||
update: async (nextText, updateOptions = {}): Promise<void> => {
|
||||
const textFadeMs = updateOptions.fadeMs ?? 170;
|
||||
const shouldUpdate = await page.evaluate(
|
||||
(args) => {
|
||||
const textEl = document.querySelector<HTMLElement>(
|
||||
`#${args.id} .__sp-video-overlay-text`,
|
||||
);
|
||||
if (!textEl || textEl.textContent === args.text) return false;
|
||||
textEl.style.setProperty('--__sp-caption-text-ms', `${args.fadeMs}ms`);
|
||||
textEl.style.opacity = '0';
|
||||
textEl.style.transform = 'translateY(8px)';
|
||||
return true;
|
||||
},
|
||||
{ id: CAPTION_ID, text: nextText, fadeMs: textFadeMs },
|
||||
);
|
||||
if (!shouldUpdate) return;
|
||||
|
||||
await page.waitForTimeout(textFadeMs);
|
||||
await page.evaluate(
|
||||
(args) => {
|
||||
const textEl = document.querySelector<HTMLElement>(
|
||||
`#${args.id} .__sp-video-overlay-text`,
|
||||
);
|
||||
if (!textEl) return;
|
||||
textEl.textContent = args.text;
|
||||
textEl.style.transform = 'translateY(-8px)';
|
||||
void textEl.offsetWidth;
|
||||
textEl.style.opacity = '1';
|
||||
textEl.style.transform = 'translateY(0)';
|
||||
},
|
||||
{ id: CAPTION_ID, text: nextText },
|
||||
);
|
||||
await page.waitForTimeout(textFadeMs);
|
||||
},
|
||||
hide: async (): Promise<void> => {
|
||||
await page.evaluate((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.classList.remove('visible');
|
||||
}, CAPTION_ID);
|
||||
await page.waitForTimeout(fadeMs);
|
||||
await page.evaluate((id) => document.getElementById(id)?.remove(), CAPTION_ID);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const INT_CARD_ID = '__sp-video-int-card';
|
||||
const TRANSITION_ID = '__sp-video-transition';
|
||||
const LOOP_BOUNDARY_ID = '__sp-video-loop-boundary';
|
||||
|
|
@ -520,6 +613,7 @@ export const loopBoundary = async (
|
|||
await page.evaluate(
|
||||
(args) => {
|
||||
let el = document.getElementById(args.id);
|
||||
const transition = `opacity ${args.durationMs}ms cubic-bezier(0.4, 0, 0.2, 1)`;
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = args.id;
|
||||
|
|
@ -532,19 +626,21 @@ export const loopBoundary = async (
|
|||
// Material's standard motion curve (cubic-bezier(0.4, 0, 0.2, 1)
|
||||
// — slow start, fast middle, slow finish, asymmetrically biased
|
||||
// toward a snappier reveal) reads smoother for scene cuts than
|
||||
// a generic `ease-in-out`. At the gif's 24fps a 200ms fade is
|
||||
// a generic `ease-in-out`. At the gif's 25fps a 200ms fade is
|
||||
// ~5 frames; the curve concentrates the visible opacity change
|
||||
// into the middle frames so it reads as a gradient rather than
|
||||
// a stepped staircase.
|
||||
`transition:opacity ${args.durationMs}ms cubic-bezier(0.4, 0, 0.2, 1)`,
|
||||
`transition:${transition}`,
|
||||
// Start opacity matches the mode: 'in' starts opaque (revealing
|
||||
// SP underneath); 'out' starts transparent (covering it back up).
|
||||
`opacity:${args.mode === 'in' ? 1 : 0}`,
|
||||
].join(';');
|
||||
document.body.appendChild(el);
|
||||
// Force a paint before the opacity flip so the transition fires.
|
||||
void el.offsetWidth;
|
||||
}
|
||||
el.style.transition = transition;
|
||||
// Force a paint before the opacity flip so duration changes on the
|
||||
// reused boundary element take effect for this transition.
|
||||
void el.offsetWidth;
|
||||
el.style.opacity = args.mode === 'in' ? '0' : '1';
|
||||
},
|
||||
{ id: LOOP_BOUNDARY_ID, mode, durationMs },
|
||||
|
|
@ -576,10 +672,10 @@ export const loopBoundary = async (
|
|||
export const cutToScene = async (
|
||||
page: Page,
|
||||
setupNextScene: () => Promise<void> | void,
|
||||
options: { fadeMs?: number } = {},
|
||||
options: { fadeMs?: number; label?: string } = {},
|
||||
): Promise<void> => {
|
||||
// 200ms × 2 (fade-to-black + fade-from-black) = 400ms per scene cut.
|
||||
// At the gif's 24fps that's ~5 frames per fade. Combined with Material
|
||||
// At the gif's 25fps that's ~5 frames per fade. Combined with Material
|
||||
// motion curve and sierra2_4a dither (see build-video.ts) this reads as
|
||||
// a smooth gradient at this duration. Going shorter starts to look
|
||||
// stepped; much longer drags out the reel.
|
||||
|
|
@ -588,7 +684,13 @@ export const cutToScene = async (
|
|||
// higher than any beat overlay/card, so it covers everything).
|
||||
await loopBoundary(page, 'out', fadeMs);
|
||||
// Behind black: prepare next scene.
|
||||
const setupStartedAt = Date.now();
|
||||
await setupNextScene();
|
||||
if (options.label) {
|
||||
console.log(
|
||||
`[video] ${options.label}: setup behind black ${Date.now() - setupStartedAt}ms`,
|
||||
);
|
||||
}
|
||||
// Fade black away to reveal the next scene.
|
||||
await loopBoundary(page, 'in', fadeMs);
|
||||
};
|
||||
|
|
@ -604,7 +706,7 @@ export const cutToScene = async (
|
|||
export const fadeTransition = async (
|
||||
page: Page,
|
||||
during: () => Promise<void> | void,
|
||||
options: { fadeMs?: number; opacity?: number } = {},
|
||||
options: { fadeMs?: number; opacity?: number; label?: string } = {},
|
||||
): Promise<void> => {
|
||||
// Slightly longer fade with a softer dim — the lower-third overlay
|
||||
// text rides above the dim layer (higher z-index), so reducing the
|
||||
|
|
@ -634,7 +736,13 @@ export const fadeTransition = async (
|
|||
{ id: TRANSITION_ID, fadeMs, opacity },
|
||||
);
|
||||
await page.waitForTimeout(fadeMs);
|
||||
const setupStartedAt = Date.now();
|
||||
await during();
|
||||
if (options.label) {
|
||||
console.log(
|
||||
`[video] ${options.label}: setup under dim ${Date.now() - setupStartedAt}ms`,
|
||||
);
|
||||
}
|
||||
await page.evaluate(
|
||||
(args) => {
|
||||
const el = document.getElementById(args.id);
|
||||
|
|
@ -647,6 +755,43 @@ export const fadeTransition = async (
|
|||
await page.waitForTimeout(fadeMs);
|
||||
};
|
||||
|
||||
const easeInOutCubic = (t: number): number => {
|
||||
if (t < 0.5) {
|
||||
return 4 * t * t * t;
|
||||
}
|
||||
const scaled = -2 * t;
|
||||
const shifted = scaled + 2;
|
||||
const cubed = Math.pow(shifted, 3);
|
||||
const halved = cubed / 2;
|
||||
return 1 - halved;
|
||||
};
|
||||
|
||||
export const smoothMouseMove = async (
|
||||
page: Page,
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
options: { durationMs?: number; steps?: number } = {},
|
||||
): Promise<void> => {
|
||||
const durationMs = options.durationMs ?? 520;
|
||||
const steps = options.steps ?? Math.max(10, Math.round(durationMs / 16));
|
||||
const delayMs = Math.max(8, Math.round(durationMs / steps));
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
|
||||
for (let i = 1; i <= steps; i += 1) {
|
||||
const t = i / steps;
|
||||
const eased = easeInOutCubic(t);
|
||||
const xDelta = dx * eased;
|
||||
const yDelta = dy * eased;
|
||||
const x = from.x + xDelta;
|
||||
const y = from.y + yDelta;
|
||||
await page.mouse.move(x, y);
|
||||
if (i < steps) {
|
||||
await page.waitForTimeout(delayMs);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const showIntegrationsCard = async (
|
||||
page: Page,
|
||||
content: IntegrationsCardContent,
|
||||
|
|
|
|||
|
|
@ -3,13 +3,11 @@
|
|||
*
|
||||
* Lead-in Black fades to SP UI with schedule panel open.
|
||||
* 1 Capture in seconds. type a task with short syntax
|
||||
* (`A task 1h #urgent @17`).
|
||||
* (`A task 1h`).
|
||||
* 1.5 [full only] No account. No tracking.
|
||||
* 2 Plan your day. drag the captured task onto the
|
||||
* schedule panel; a synthetic ghost
|
||||
* follows the cursor so the act of
|
||||
* dragging reads regardless of SP's
|
||||
* cdkDrag wiring.
|
||||
* schedule panel using the app's real
|
||||
* cdkDrag behavior.
|
||||
* 3 Focus on what matters. focus-mode in progress on the
|
||||
* captured task. clock.resume() lets
|
||||
* the timer tick visibly.
|
||||
|
|
@ -31,7 +29,6 @@ import { test } from '../fixture';
|
|||
import type { OverlayHandle } from '../overlays';
|
||||
import {
|
||||
LOGOS,
|
||||
attachDragGhost,
|
||||
cutToScene,
|
||||
loopBoundary,
|
||||
showEndCard,
|
||||
|
|
@ -56,10 +53,9 @@ const parkCursor = async (page: import('@playwright/test').Page): Promise<void>
|
|||
* Short-syntax string typed into the global add-task bar in beat 1.
|
||||
* "A task" title
|
||||
* "1h" time estimate
|
||||
* "#urgent" tag (auto-created if missing)
|
||||
* "@17" due time today at 17:00
|
||||
*/
|
||||
const CAPTURED_TASK_TITLE = 'A task 1h #urgent @17';
|
||||
const CAPTURED_TASK_TITLE = 'A task 1h';
|
||||
const CAPTURED_TASK_DISPLAY_TITLE = 'A task';
|
||||
|
||||
test.describe('@video reel', () => {
|
||||
test.use({ locale: 'en', theme: 'dark' });
|
||||
|
|
@ -132,52 +128,75 @@ test.describe('@video reel', () => {
|
|||
let capturedTaskId: string | null = null;
|
||||
let bExtra: OverlayHandle | undefined;
|
||||
let b2: OverlayHandle | undefined;
|
||||
await cutToScene(page, async () => {
|
||||
await page.evaluate(() => {
|
||||
const helper = (
|
||||
window as unknown as {
|
||||
__e2eTestHelpers?: { store?: { dispatch: (a: unknown) => void } };
|
||||
}
|
||||
).__e2eTestHelpers;
|
||||
helper?.store?.dispatch({ type: '[Layout] Hide AddTaskBar' });
|
||||
});
|
||||
await page
|
||||
.locator('add-task-bar.global')
|
||||
.first()
|
||||
.waitFor({ state: 'hidden', timeout: 3_000 })
|
||||
.catch(() => undefined);
|
||||
capturedTaskId = await page
|
||||
.locator('task')
|
||||
.first()
|
||||
.getAttribute('data-task-id')
|
||||
.catch(() => null);
|
||||
// Restore the cursor highlight — drag in beat 2 needs it visible.
|
||||
await page.evaluate(() =>
|
||||
document.body.classList.remove('__sp-hide-cursor-highlight'),
|
||||
);
|
||||
await parkCursor(page);
|
||||
void b1.hide();
|
||||
if (isFull) {
|
||||
bExtra = await showOverlay(page, 'No account. No tracking.', {
|
||||
noWait: true,
|
||||
await cutToScene(
|
||||
page,
|
||||
async () => {
|
||||
const backdrop = page.locator('.backdrop').first();
|
||||
await backdrop.waitFor({ state: 'visible', timeout: 1_000 }).catch(() => {
|
||||
/* Add-task bar may have already closed itself after Enter. */
|
||||
});
|
||||
} else {
|
||||
b2 = await showOverlay(page, 'Plan your day.', { noWait: true });
|
||||
}
|
||||
});
|
||||
if (await backdrop.isVisible().catch(() => false)) {
|
||||
await backdrop.click({ force: true });
|
||||
await backdrop.waitFor({ state: 'hidden', timeout: 3_000 }).catch(() => {
|
||||
/* Non-fatal: backdrop can detach during the cut. */
|
||||
});
|
||||
}
|
||||
await page
|
||||
.locator('add-task-bar.global')
|
||||
.first()
|
||||
.waitFor({ state: 'hidden', timeout: 3_000 })
|
||||
.catch(() => undefined);
|
||||
const capturedTask = page
|
||||
.locator('task')
|
||||
.filter({ hasText: CAPTURED_TASK_DISPLAY_TITLE })
|
||||
.first();
|
||||
await capturedTask.waitFor({ state: 'visible', timeout: 3_000 });
|
||||
capturedTaskId = await capturedTask
|
||||
.getAttribute('data-task-id')
|
||||
.catch(() => null);
|
||||
// Restore the cursor highlight — drag in beat 2 needs it visible.
|
||||
await page.evaluate(() =>
|
||||
document.body.classList.remove('__sp-hide-cursor-highlight'),
|
||||
);
|
||||
await parkCursor(page);
|
||||
void b1.hide();
|
||||
if (isFull) {
|
||||
bExtra = await showOverlay(page, 'No account. No tracking.', {
|
||||
noWait: true,
|
||||
});
|
||||
} else {
|
||||
b2 = await showOverlay(page, 'Plan your day.', { noWait: true });
|
||||
}
|
||||
},
|
||||
{
|
||||
fadeMs: 260,
|
||||
label: isFull ? 'beat 1 to 1.5' : 'beat 1 to 2',
|
||||
},
|
||||
);
|
||||
|
||||
// ── Beat 1.5 → 2 transition (full variant only) ──────────────────────
|
||||
if (isFull) {
|
||||
await page.waitForTimeout(1500);
|
||||
await cutToScene(page, async () => {
|
||||
void bExtra!.hide();
|
||||
b2 = await showOverlay(page, 'Plan your day.', { noWait: true });
|
||||
});
|
||||
await cutToScene(
|
||||
page,
|
||||
async () => {
|
||||
void bExtra!.hide();
|
||||
b2 = await showOverlay(page, 'Plan your day.', { noWait: true });
|
||||
},
|
||||
{
|
||||
fadeMs: 260,
|
||||
label: 'beat 1.5 to 2',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Beat 2 — Plan your day. (drag with ghost preview) ────────────────
|
||||
// ── Beat 2 — Plan your day. (native app drag) ────────────────────────
|
||||
const schedulePanel = page.locator('schedule-day-panel').first();
|
||||
const dragSource = page.locator('task').first();
|
||||
const dragSource = page
|
||||
.locator('task')
|
||||
.filter({ hasText: CAPTURED_TASK_DISPLAY_TITLE })
|
||||
.first();
|
||||
await dragSource.waitFor({ state: 'visible', timeout: 5_000 });
|
||||
const taskBox = await dragSource.boundingBox();
|
||||
const panelBox = await schedulePanel.boundingBox();
|
||||
if (taskBox && panelBox) {
|
||||
|
|
@ -192,14 +211,10 @@ test.describe('@video reel', () => {
|
|||
await page.mouse.move(startX, startY);
|
||||
await page.waitForTimeout(120);
|
||||
await page.mouse.down();
|
||||
// Ghost attaches AFTER mouse.down so the initial cursor-arrives-on-
|
||||
// task moment isn't visually competing with the ghost popping in.
|
||||
const ghost = await attachDragGhost(page, dragSource);
|
||||
await page.waitForTimeout(120);
|
||||
await page.mouse.move(endX, endY, { steps: 25 });
|
||||
await page.waitForTimeout(180);
|
||||
await page.mouse.up();
|
||||
await ghost.detach();
|
||||
await page.waitForTimeout(isFull ? 250 : 150);
|
||||
await parkCursor(page);
|
||||
}
|
||||
|
|
@ -207,103 +222,116 @@ test.describe('@video reel', () => {
|
|||
|
||||
// ── Beat 2 → 3 transition: cut to black, dispatch focus mode ─────────
|
||||
let b3: OverlayHandle | undefined;
|
||||
await cutToScene(page, async () => {
|
||||
void b2!.hide();
|
||||
if (await scheduleBtn.isVisible().catch(() => false)) {
|
||||
await scheduleBtn.click();
|
||||
}
|
||||
if (capturedTaskId) {
|
||||
await page.evaluate((id) => {
|
||||
await cutToScene(
|
||||
page,
|
||||
async () => {
|
||||
void b2!.hide();
|
||||
if (await scheduleBtn.isVisible().catch(() => false)) {
|
||||
await scheduleBtn.click();
|
||||
}
|
||||
if (capturedTaskId) {
|
||||
await page.evaluate((id) => {
|
||||
const helper = (
|
||||
window as unknown as {
|
||||
__e2eTestHelpers?: { store?: { dispatch: (a: unknown) => void } };
|
||||
}
|
||||
).__e2eTestHelpers;
|
||||
if (!helper?.store) return;
|
||||
helper.store.dispatch({ type: '[Task] SetCurrentTask', id });
|
||||
helper.store.dispatch({ type: '[FocusMode] Show Overlay' });
|
||||
helper.store.dispatch({
|
||||
type: '[FocusMode] Start Session',
|
||||
duration: 1500000,
|
||||
});
|
||||
}, capturedTaskId);
|
||||
await page
|
||||
.locator('focus-mode-main')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
.catch(() => undefined);
|
||||
await page.clock.runFor(5500).catch(() => undefined);
|
||||
await page
|
||||
.locator('focus-mode-main .bottom-controls')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 5_000 })
|
||||
.catch(() => undefined);
|
||||
await page.clock.resume().catch(() => undefined);
|
||||
}
|
||||
await parkCursor(page);
|
||||
b3 = await showOverlay(page, 'Focus on what matters.', { noWait: true });
|
||||
},
|
||||
{
|
||||
fadeMs: 260,
|
||||
label: 'beat 2 to 3',
|
||||
},
|
||||
);
|
||||
await page.waitForTimeout(isFull ? 1800 : 1200);
|
||||
|
||||
// ── Beat 3 → 4 transition: cut to black, swap to integrations card ──
|
||||
let b4: OverlayHandle | undefined;
|
||||
await cutToScene(
|
||||
page,
|
||||
async () => {
|
||||
if (b3) void b3.hide();
|
||||
await page.evaluate(() => {
|
||||
const helper = (
|
||||
window as unknown as {
|
||||
__e2eTestHelpers?: { store?: { dispatch: (a: unknown) => void } };
|
||||
}
|
||||
).__e2eTestHelpers;
|
||||
if (!helper?.store) return;
|
||||
helper.store.dispatch({ type: '[Task] SetCurrentTask', id });
|
||||
helper.store.dispatch({ type: '[FocusMode] Show Overlay' });
|
||||
helper.store.dispatch({
|
||||
type: '[FocusMode] Start Session',
|
||||
duration: 1500000,
|
||||
});
|
||||
}, capturedTaskId);
|
||||
helper?.store?.dispatch({ type: '[FocusMode] Hide Overlay' });
|
||||
helper?.store?.dispatch({ type: '[FocusMode] Cancel Session' });
|
||||
});
|
||||
await page
|
||||
.locator('focus-mode-main')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
.waitFor({ state: 'hidden', timeout: 3_000 })
|
||||
.catch(() => undefined);
|
||||
await page.clock.runFor(5500).catch(() => undefined);
|
||||
await page
|
||||
.locator('focus-mode-main .bottom-controls')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 5_000 })
|
||||
.catch(() => undefined);
|
||||
await page.clock.resume().catch(() => undefined);
|
||||
}
|
||||
await parkCursor(page);
|
||||
b3 = await showOverlay(page, 'Focus on what matters.', { noWait: true });
|
||||
});
|
||||
await page.waitForTimeout(isFull ? 1800 : 1200);
|
||||
|
||||
// ── Beat 3 → 4 transition: cut to black, swap to integrations card ──
|
||||
let b4: OverlayHandle | undefined;
|
||||
await cutToScene(page, async () => {
|
||||
if (b3) void b3.hide();
|
||||
await page.evaluate(() => {
|
||||
const helper = (
|
||||
window as unknown as {
|
||||
__e2eTestHelpers?: { store?: { dispatch: (a: unknown) => void } };
|
||||
}
|
||||
).__e2eTestHelpers;
|
||||
helper?.store?.dispatch({ type: '[FocusMode] Hide Overlay' });
|
||||
helper?.store?.dispatch({ type: '[FocusMode] Cancel Session' });
|
||||
});
|
||||
await page
|
||||
.locator('focus-mode-main')
|
||||
.first()
|
||||
.waitFor({ state: 'hidden', timeout: 3_000 })
|
||||
.catch(() => undefined);
|
||||
b4 = await showIntegrationsCard(
|
||||
page,
|
||||
{
|
||||
title: 'Plays well with GitHub, Jira & many more',
|
||||
logos: [
|
||||
{ svg: LOGOS.github, label: 'GitHub' },
|
||||
{ svg: LOGOS.gitlab, label: 'GitLab', color: '#fc6d26' },
|
||||
{ svg: LOGOS.jira, label: 'Jira', color: '#2684ff' },
|
||||
{ svg: LOGOS.linear, label: 'Linear', color: '#5e6ad2' },
|
||||
{ svg: LOGOS.trello, label: 'Trello', color: '#0079bf' },
|
||||
{ svg: LOGOS.calendar, label: 'Calendar' },
|
||||
],
|
||||
},
|
||||
{ noWait: true },
|
||||
);
|
||||
});
|
||||
b4 = await showIntegrationsCard(
|
||||
page,
|
||||
{
|
||||
title: 'Plays well with GitHub, Jira & many more',
|
||||
logos: [
|
||||
{ svg: LOGOS.github, label: 'GitHub' },
|
||||
{ svg: LOGOS.gitlab, label: 'GitLab', color: '#fc6d26' },
|
||||
{ svg: LOGOS.jira, label: 'Jira', color: '#2684ff' },
|
||||
{ svg: LOGOS.linear, label: 'Linear', color: '#5e6ad2' },
|
||||
{ svg: LOGOS.trello, label: 'Trello', color: '#0079bf' },
|
||||
{ svg: LOGOS.calendar, label: 'Calendar' },
|
||||
],
|
||||
},
|
||||
{ noWait: true },
|
||||
);
|
||||
},
|
||||
{
|
||||
fadeMs: 260,
|
||||
label: 'beat 3 to 4',
|
||||
},
|
||||
);
|
||||
await page.waitForTimeout(isFull ? 2500 : 2000);
|
||||
|
||||
// ── Beat 4 → 5 transition: cut to black, swap to end card ────────────
|
||||
await cutToScene(page, async () => {
|
||||
if (b4) void b4.hide();
|
||||
await showEndCard(
|
||||
page,
|
||||
{
|
||||
logo: {
|
||||
src: '/assets/icons/sp.svg',
|
||||
alt: 'Super Productivity',
|
||||
monochrome: true,
|
||||
},
|
||||
title: 'Free and open source.',
|
||||
subtitle: 'superproductivity.com',
|
||||
stats: [
|
||||
{ template: '★ {n}K on GitHub', to: 19 },
|
||||
{ template: '{n} ★ on Google Play', to: 4.8, decimals: 1 },
|
||||
'Web · iOS · Android · macOS · Linux · Windows & many more',
|
||||
],
|
||||
// ── Beat 4 → 5 transition: crossfade between controlled cards ───────
|
||||
await showEndCard(
|
||||
page,
|
||||
{
|
||||
logo: {
|
||||
src: '/assets/icons/sp.svg',
|
||||
alt: 'Super Productivity',
|
||||
monochrome: true,
|
||||
},
|
||||
{ noWait: true },
|
||||
);
|
||||
});
|
||||
await page.waitForTimeout(isFull ? 3000 : 2500);
|
||||
title: 'Free and open source.',
|
||||
subtitle: 'superproductivity.com',
|
||||
stats: [
|
||||
{ template: '★ {n}K on GitHub', to: 19 },
|
||||
{ template: '{n} ★ on Google Play', to: 4.8, decimals: 1 },
|
||||
'Web · iOS · Android · macOS · Linux · Windows & many more',
|
||||
],
|
||||
},
|
||||
{ fadeMs: 560, noWait: true },
|
||||
);
|
||||
await page.waitForTimeout(260);
|
||||
if (b4) void b4.hide();
|
||||
await page.waitForTimeout(isFull ? 2740 : 2240);
|
||||
|
||||
// ── Loop boundary ────────────────────────────────────────────────────
|
||||
// Don't hide the end card — let it stay live. The loop boundary
|
||||
|
|
|
|||
|
|
@ -77,9 +77,10 @@
|
|||
"screenshots:build": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\",\"moduleResolution\":\"node\"}' npx ts-node --transpile-only e2e/store-screenshots/build-store-assets.ts",
|
||||
"screenshots:capture:electron": "npm run electron:build && SCREENSHOT_MODE=electron npx playwright test --config e2e/playwright.store-screenshots.electron.config.ts",
|
||||
"screenshots:electron": "npm run screenshots:capture:electron && npm run screenshots:build",
|
||||
"video": "npm run video:capture && npm run video:build",
|
||||
"video": "npm run video:capture && npm run video:build && npm run video:open",
|
||||
"video:capture": "npx playwright test --config e2e/playwright.store-video.config.ts",
|
||||
"video:build": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\",\"moduleResolution\":\"node\"}' npx ts-node --transpile-only e2e/store-video/build-video.ts",
|
||||
"video:open": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\",\"moduleResolution\":\"node\"}' npx ts-node --transpile-only e2e/store-video/open-video.ts",
|
||||
"video:full": "REEL_VARIANT=full npm run video",
|
||||
"electron": "NODE_ENV=PROD electron .",
|
||||
"electron:build": "node ./tools/build-wayland-idle-helper.js && tsc -p electron/tsconfig.electron.json && node electron/scripts/bundle-preload.js",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue