feat(e2e): add keyboard, mobile, and shorts video variants

Three new REEL_VARIANT branches for the marketing video pipeline:

- shorts: 9:16 portrait 1080x1920 for TikTok / YouTube Shorts /
  Instagram Reels. Skips the side-panel drag beat (no horizontal room)
  and tightens holds for portrait capture.
- keyboard: keyboard-first reel demonstrating SP shortcuts. Five beats
  driven by real page.keyboard.press() so cause-and-effect is honest
  (Shift+A capture, J/K navigate, F focus mode).
- mobile: 19.5:9 phone aspect 1080x2340 with hasTouch + isMobile UA.
  Fixture installs a tap-ripple on touchstart/pointerdown in lieu of
  the cursor highlight; tap-driven choreography.

Fixture gains a getVideoProfile() selector that returns the right
viewport, DPR, and touch flags per variant. Overlays grow new
primitives needed by the keyboard and mobile choreographies.
This commit is contained in:
Johannes Millan 2026-05-12 17:57:30 +02:00
parent 16b7deab73
commit 075de5a097
7 changed files with 839 additions and 58 deletions

View file

@ -7,6 +7,14 @@ Playwright-driven generation of the marketing gif/video for the landing page and
```bash
npm run video # tight default (~17s) → dist/video/reel*.{mp4,webm,gif}
npm run video:full # full variant (~21s) → dist/video/reel-full*.{...}
npm run video:shorts # 9:16 portrait (~12s) → dist/video/reel-shorts*.{...}
# 1080×1920 for TikTok / YouTube Shorts / Instagram Reels.
# Skips the side-panel drag beat (no horizontal room).
npm run video:keyboard
# keyboard-first reel demonstrating SP shortcuts.
npm run video:mobile # 19.5:9 phone aspect (1080×2340) → reel-mobile*.{...}
# hasTouch context + isMobile UA; tap-ripple replaces
# the cursor ring; tap-driven choreography.
npx cross-env MS_STORE_AUDIO_SOURCE=path/to/audio.ext npm run video:ms-store
# 16:9 Store trailer → dist/video/reel-ms-store.mp4 + thumbnail
@ -24,14 +32,16 @@ Variant recordings are isolated under `.tmp/video/recordings/<variant>/` (`defau
## Files
| File | Responsibility |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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, or 1920×1080 / DPR 1 for `REEL_VARIANT=ms-store`. 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`, `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. For `ms-store`, produces a 1920×1080 H.264/AAC MP4 and PNG thumbnail. |
| `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. |
| File | Responsibility |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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, or 1920×1080 / DPR 1 for `REEL_VARIANT=ms-store`. 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`, `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. `REEL_VARIANT=shorts` skips the side-panel drag beat and tightens holds for portrait 9:16 capture. Skipped entirely for `REEL_VARIANT=keyboard`. |
| `store-video/scenarios/keyboard.spec.ts` | Keyboard-first reel (`REEL_VARIANT=keyboard` only). Five beats: tagline → `Shift+A` capture → `J`/`K` navigate → `F` focus mode → end card. Each chip is a real `page.keyboard.press()` so cause-and-effect is honest. |
| `store-video/scenarios/mobile.spec.ts` | Mobile-touch reel (`REEL_VARIANT=mobile` only). 1080×2340 phone aspect with `hasTouch`/`isMobile`. Four beats: tagline → tap-to-capture → tap-to-focus → end card. The fixture installs a tap-ripple on `touchstart`/`pointerdown` in lieu of the cursor highlight. |
| `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. For `ms-store`, produces a 1920×1080 H.264/AAC MP4 and PNG thumbnail. |
| `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)
@ -130,7 +140,7 @@ Optional Store env vars:
## Open polish ideas (not yet shipped)
- **Theme + locale matrix.** Fixture supports both via `test.use({ theme, locale })`. Add Playwright projects per variant; matrix run produces `reel-en-dark.gif`, `reel-en-light.gif`, etc. Mirrors the screenshot pipeline pattern.
- **Aspect ratio variants.** 9:16 (1080×1920) for mobile social. Different `VIDEO_SIZE` per matrix entry.
- ~~**Aspect ratio variants.** 9:16 (1080×1920) for mobile social.~~ Shipped as `npm run video:shorts`. Different `VIDEO_SIZE` per variant lives in `fixture.ts:getVideoProfile`.
- **5-second social cut.** `npm run video:short` produces beats {1, 3, 5}. Trivial extension of the variant flag.
- **Drop-slot highlight.** Brief CSS pulse on the schedule slot the dragged task lands in — gives beat 2 a closing punctuation.
- **Brand-color flash on logo entrance.** Currently logos are flat brand colors. Could flash bright then settle.

View file

@ -53,6 +53,27 @@ const getVideoProfile = (): VideoProfile => {
};
}
if (process.env.REEL_VARIANT === 'shorts') {
return {
// 9:16 portrait at 1080x1920 — the canonical short-form video size for
// TikTok / YouTube Shorts / Instagram Reels / Mastodon. DPR 1 keeps the
// backing surface at 1080x1920 (DPR 2 would render 2160x3840 per frame
// and the recorder starts dropping frames).
size: { width: 1080, height: 1920 },
deviceScaleFactor: 1,
};
}
if (process.env.REEL_VARIANT === 'mobile') {
return {
// 19.5:9 phone aspect (iPhone Pro Max / Pixel Pro). Renders as a
// recognizable "phone screen" frame for app-store mobile previews
// and Play Store "feature graphic" promo clips.
size: { width: 1080, height: 2340 },
deviceScaleFactor: 1,
};
}
return {
// Square 1024x1024 plays well on social embeds and matches the rhythm of
// the GitHub README. DPR 2 renders the page at 2x physical pixels, then
@ -127,6 +148,7 @@ export const test = base.extend<VideoFixtures>({
// before recording really starts means we under-estimate by that amount,
// i.e. trim slightly less — safer than over-trimming into beat 1's fade-in.
recordingState.startMs = Date.now();
const isMobile = VARIANT === 'mobile';
const context = await browser.newContext({
baseURL: baseURL ?? 'http://localhost:4242',
userAgent: `PLAYWRIGHT-VIDEO-${testInfo.workerIndex}`,
@ -136,6 +158,11 @@ export const test = base.extend<VideoFixtures>({
locale: 'en-US',
viewport: VIDEO_SIZE,
deviceScaleFactor: DEVICE_SCALE_FACTOR,
// Mobile variant: enable real touch dispatch so `page.touchscreen.tap`
// fires pointer/touch events the app's drag/click handlers recognize
// as a finger, and so any responsive `isMobile` branches activate.
hasTouch: isMobile,
isMobile,
recordVideo: {
dir: RECORDING_DIR,
size: VIDEO_SIZE,
@ -146,6 +173,15 @@ export const test = base.extend<VideoFixtures>({
await page.clock.install({ time: SCREENSHOT_BASE_DATE });
await page.addInitScript(ONBOARDING_INIT);
await page.addInitScript((variant) => {
// Stash variant on body so overlays.ts / scenarios can branch via
// attribute selectors without re-reading process.env in the page.
const apply = (): void => {
document.body.dataset.spVideoVariant = variant || 'default';
};
if (document.body) apply();
else document.addEventListener('DOMContentLoaded', apply, { once: true });
}, VARIANT);
await page.addInitScript((darkMode) => {
try {
localStorage.setItem('DARK_MODE', darkMode);
@ -157,6 +193,69 @@ export const test = base.extend<VideoFixtures>({
(window as unknown as { __spCurrentLocale?: string }).__spCurrentLocale =
initialLocale;
}, locale);
// Mobile variant: tap-ripple instead of cursor ring. The recorder
// doesn't draw a touch indicator on its own, so taps would otherwise
// read as instant state changes with no on-frame cause. Each
// touchstart spawns a short-lived expanding ring at the touch point.
if (isMobile) {
await page.addInitScript(() => {
const attach = (): void => {
if (document.getElementById('__sp-video-tap-ripple-style')) return;
const style = document.createElement('style');
style.id = '__sp-video-tap-ripple-style';
style.textContent = `
.__sp-video-tap-ripple {
position: fixed;
top: 0;
left: 0;
width: 0;
height: 0;
border-radius: 50%;
background: radial-gradient(rgba(255,255,255,0.85) 0%,rgba(255,255,255,0.4) 40%,rgba(255,255,255,0) 75%);
z-index: 2147483640;
pointer-events: none;
opacity: 0.9;
transform: translate3d(-9999px,-9999px,0);
transition: width 520ms ease-out, height 520ms ease-out, opacity 620ms ease-out, transform 520ms ease-out;
}
`;
document.head.appendChild(style);
const spawn = (clientX: number, clientY: number): void => {
const r = document.createElement('div');
r.className = '__sp-video-tap-ripple';
r.style.transform = `translate3d(${clientX}px,${clientY}px,0)`;
document.body.appendChild(r);
void r.offsetWidth;
const finalSize = 220;
const half = finalSize / 2;
r.style.width = `${finalSize}px`;
r.style.height = `${finalSize}px`;
r.style.marginLeft = `${-half}px`;
r.style.marginTop = `${-half}px`;
r.style.opacity = '0';
window.setTimeout(() => r.remove(), 700);
};
document.addEventListener(
'touchstart',
(e) => {
for (const t of Array.from(e.touches)) spawn(t.clientX, t.clientY);
},
{ passive: true, capture: true },
);
// Synthetic taps from Playwright sometimes route through pointer
// events without touch events — listen to both for coverage.
document.addEventListener(
'pointerdown',
(e) => {
if (e.pointerType !== 'mouse') spawn(e.clientX, e.clientY);
},
{ passive: true, capture: true },
);
};
if (document.body) attach();
else document.addEventListener('DOMContentLoaded', attach, { once: true });
});
}
// Inject a soft ring that follows the cursor — at 1024×1024 the OS
// pointer is small and easy to miss; this makes drag motions read on
// the gif. The ring is at z-index 2147483640 (under the full-screen
@ -164,44 +263,47 @@ export const test = base.extend<VideoFixtures>({
// Spec code can also toggle visibility per-beat by adding/removing the
// `__sp-hide-cursor-highlight` class on body — used during the capture
// beat where the cursor sits in the middle of the focused input and
// would otherwise read as a stray white dot.
await page.addInitScript(() => {
const attach = (): void => {
const id = '__sp-video-cursor-highlight';
if (document.getElementById(id)) return;
const dot = document.createElement('div');
dot.id = id;
dot.style.cssText = [
'position:fixed',
'top:0',
'left:0',
'width:36px',
'height:36px',
'margin:-18px 0 0 -18px',
'border-radius:50%',
'background:radial-gradient(rgba(255,255,255,0.55) 0%,rgba(255,255,255,0.18) 45%,rgba(255,255,255,0) 70%)',
'z-index:2147483640',
'pointer-events:none',
'transform:translate3d(-9999px,-9999px,0)',
'will-change:transform',
'transition:opacity 150ms ease-out',
].join(';');
document.body.appendChild(dot);
const visibilityStyle = document.createElement('style');
visibilityStyle.textContent =
'body.__sp-hide-cursor-highlight #__sp-video-cursor-highlight{opacity:0!important}';
document.head.appendChild(visibilityStyle);
document.addEventListener(
'mousemove',
(e) => {
dot.style.transform = `translate3d(${e.clientX}px,${e.clientY}px,0)`;
},
{ passive: true },
);
};
if (document.body) attach();
else document.addEventListener('DOMContentLoaded', attach, { once: true });
});
// would otherwise read as a stray white dot. Skipped for the mobile
// variant: no cursor on touch, the tap ripple handles it.
if (!isMobile) {
await page.addInitScript(() => {
const attach = (): void => {
const id = '__sp-video-cursor-highlight';
if (document.getElementById(id)) return;
const dot = document.createElement('div');
dot.id = id;
dot.style.cssText = [
'position:fixed',
'top:0',
'left:0',
'width:36px',
'height:36px',
'margin:-18px 0 0 -18px',
'border-radius:50%',
'background:radial-gradient(rgba(255,255,255,0.55) 0%,rgba(255,255,255,0.18) 45%,rgba(255,255,255,0) 70%)',
'z-index:2147483640',
'pointer-events:none',
'transform:translate3d(-9999px,-9999px,0)',
'will-change:transform',
'transition:opacity 150ms ease-out',
].join(';');
document.body.appendChild(dot);
const visibilityStyle = document.createElement('style');
visibilityStyle.textContent =
'body.__sp-hide-cursor-highlight #__sp-video-cursor-highlight{opacity:0!important}';
document.head.appendChild(visibilityStyle);
document.addEventListener(
'mousemove',
(e) => {
dot.style.transform = `translate3d(${e.clientX}px,${e.clientY}px,0)`;
},
{ passive: true },
);
};
if (document.body) attach();
else document.addEventListener('DOMContentLoaded', attach, { once: true });
});
}
// Suppress UI noise that fights with the choreographed reel:
// - Material/CDK tooltips (cursor lingering would otherwise pop one)
@ -250,6 +352,21 @@ export const test = base.extend<VideoFixtures>({
app-root {
zoom: 1.4;
}
/* Shorts (9:16, 1080x1920): zoom up further so the work-view fills
the portrait canvas. The inner viewport is 1080/1.6 = 675 wide,
1920/1.6 = 1200 tall wide enough for the task list at the
sidenav-collapsed width, tall enough that the seeded task rows
dominate the frame instead of leaving a half-empty top half. */
body[data-sp-video-variant="shorts"] app-root {
zoom: 1.6;
}
/* Mobile (1080x2340 phone aspect): SP's responsive layout already
collapses the sidenav and switches to a touch-friendly toolbar
when isMobile=true, so a much smaller zoom is enough. 1.0 keeps
the layout in its native mobile breakpoint. */
body[data-sp-video-variant="mobile"] app-root {
zoom: 1;
}
/* The right-panel sizes itself to 250px (MIN_WIDTH) via
SUP_RIGHT_PANEL_WIDTH localStorage seeded in ONBOARDING_INIT.
No width override needed here the panel's own resize logic

View file

@ -118,10 +118,12 @@ export type IntegrationsCardContent = {
const STYLE_ID = '__sp-video-overlay-style';
const OVERLAY_ID_PREFIX = '__sp-video-overlay-';
const KEY_CHIP_ID_PREFIX = '__sp-video-keychip-';
const END_CARD_ID = '__sp-video-end-card';
const CAPTION_ID = '__sp-video-caption';
let overlayCounter = 0;
let keyChipCounter = 0;
const ensureStyleInjected = async (page: Page): Promise<void> => {
await page.evaluate((id) => {
@ -315,6 +317,12 @@ const ensureStyleInjected = async (page: Page): Promise<void> => {
gap: 64px 80px;
justify-items: center;
}
/* Portrait shorts (1080x1920) get a 2x3 grid so the logos read big
instead of cramped at the top of a tall frame. */
body[data-sp-video-variant="shorts"] .__sp-video-int-card-logos {
grid-template-columns: repeat(2, auto);
gap: 88px 120px;
}
.__sp-video-int-card-logo {
display: flex;
flex-direction: column;
@ -358,6 +366,45 @@ const ensureStyleInjected = async (page: Page): Promise<void> => {
color: #b6bbcd;
font-weight: 500;
}
/* Keycap chip physically-modeled key. Used by the keyboard reel
to make each shortcut "land" visually before / during its action.
Anchored top-right by default so it doesn't fight the cursor or
the lower-third overlay text. */
.__sp-video-keychip {
position: fixed;
top: 56px;
right: 56px;
z-index: 2147483641;
display: flex;
align-items: center;
gap: 12px;
padding: 18px 28px;
border-radius: 14px;
background: linear-gradient(180deg, #2a2f44 0%, #161a2c 100%);
box-shadow:
inset 0 -4px 0 rgba(0, 0, 0, 0.55),
inset 0 1px 0 rgba(255, 255, 255, 0.15),
0 10px 30px rgba(0, 0, 0, 0.45);
color: #f7f8fb;
font-family: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace;
font-weight: 700;
font-size: clamp(26px, 2.6vw, 44px);
letter-spacing: 0.04em;
opacity: 0;
transform: translateY(-12px) scale(0.92);
transition:
opacity var(--__sp-fade-ms, 220ms) ease-out,
transform var(--__sp-fade-ms, 220ms) cubic-bezier(0.34, 1.56, 0.64, 1);
pointer-events: none;
}
.__sp-video-keychip.visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.__sp-video-keychip-plus {
color: #8a90ad;
font-weight: 500;
}
`;
document.head.appendChild(style);
}, STYLE_ID);
@ -869,6 +916,70 @@ export const showIntegrationsCard = async (
};
};
/**
* Render a keyboard keycap chip in the top-right corner. The label is
* split on `+` and rendered with a muted "+" separator, so "Shift+A"
* looks like two keys joined by a thin plus.
*
* const chip = await showKeyChip(page, 'Shift+A');
* await page.keyboard.press('Shift+A');
* await chip.hide();
*
* The chip uses a brief pop-in (transform scale 0.92 1) so each key in
* a fast sequence reads as a discrete event, not a static badge.
*/
export const showKeyChip = async (
page: Page,
key: string,
options: { fadeMs?: number; noWait?: boolean } = {},
): Promise<OverlayHandle> => {
const fadeMs = options.fadeMs ?? 220;
await ensureStyleInjected(page);
keyChipCounter += 1;
const id = `${KEY_CHIP_ID_PREFIX}${keyChipCounter}`;
await page.evaluate(
(args) => {
const el = document.createElement('div');
el.id = args.id;
el.className = '__sp-video-keychip';
el.style.setProperty('--__sp-fade-ms', `${args.fadeMs}ms`);
const parts = args.key.split('+').map((s) => s.trim());
parts.forEach((part, i) => {
if (i > 0) {
const plus = document.createElement('span');
plus.className = '__sp-video-keychip-plus';
plus.textContent = '+';
el.appendChild(plus);
}
const span = document.createElement('span');
span.textContent = part;
el.appendChild(span);
});
document.body.appendChild(el);
void el.offsetWidth;
el.classList.add('visible');
},
{ id, key, fadeMs },
);
if (!options.noWait) {
await page.waitForTimeout(fadeMs);
}
return {
hide: async (): Promise<void> => {
await page.evaluate(
(args) => {
const el = document.getElementById(args.id);
if (!el) return;
el.classList.remove('visible');
window.setTimeout(() => el.remove(), args.fadeMs + 50);
},
{ id, fadeMs },
);
await page.waitForTimeout(fadeMs);
},
};
};
export const showEndCard = async (
page: Page,
content: EndCardContent,

View file

@ -0,0 +1,344 @@
/**
* Keyboard reel five-beat choreography demonstrating Super Productivity's
* keyboard-first design. Each beat anchors a visible keycap chip to a real
* `page.keyboard.press()` so the cause-and-effect reads honestly: the chip
* appears, the shortcut fires, the app reacts.
*
* Lead-in Black fades to SP task list.
* 1 "Keyboard-first." tagline overlay.
* 2 Shift+A global add-task-bar opens, types "Read book 30m", Enter.
* 3 J / K moves task focus down then back up, with both chips.
* 4 F focus mode opens on the highlighted task.
* 5 End card "Made for keyboards." with platforms line.
*
* Activated only by `REEL_VARIANT=keyboard` so the default capture run
* still produces the canonical marketing reel.
*/
import { test } from '../fixture';
import { loopBoundary, showEndCard, showKeyChip, showOverlay } from '../overlays';
const VARIANT = process.env.REEL_VARIANT ?? '';
const NEW_TASK_TITLE = 'Read book 30m';
const parkCursor = async (page: import('@playwright/test').Page): Promise<void> => {
try {
await page.mouse.move(0, 0);
} catch {
/* noop */
}
};
/**
* Aggressively clear CDK overlay blockers that swallow SP's keyboard shortcuts.
*
* SP's `ShortcutService.handleKeyDown` bails when `_hasOpenCdkOverlay` finds
* ANY `.cdk-overlay-pane` in the overlay container with `childElementCount > 0`
* (excluding tooltip panes). The check is purely DOM-structural `display:
* none` does NOT exempt a pane. The fixture hides snack-bar / dialog /
* mention-list / add-task-bar panes via CSS only, so their hosting panes
* linger in the DOM with children intact and silently block every J/K/F press.
*
* Also blurs editable focus targets (input/textarea/contenteditable) so the
* shortcut handler's `isInputElement` check doesn't bail. Crucially: does
* NOT blur a focused <task>, because beat 3 relies on that focus staying
* put between keypresses.
*/
const clearShortcutBlockers = async (
page: import('@playwright/test').Page,
): Promise<void> => {
await page.evaluate(() => {
document.querySelectorAll('.cdk-overlay-pane').forEach((pane) => {
if (pane.classList.contains('mat-mdc-tooltip-panel')) return;
pane.remove();
});
const active = document.activeElement as HTMLElement | null;
if (!active) return;
const tag = active.tagName;
if (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
(active as HTMLElement).isContentEditable
) {
active.blur();
}
});
};
/**
* Drive SP's task focus state from the test side, without using the store.
*
* The shortcut handler needs TWO things to route `focusNext()`:
* 1. `_taskFocusService.focusedTaskId()` set, OR an active `<task>` element
* whose `data-task-id` the recovery path can read.
* 2. `_taskFocusService.lastFocusedTaskComponent()` set this only happens
* from the task component's `focusin` HostListener, gated by
* `_isInnermostTaskFor(ev.target)` which requires
* `ev.target.closest('task') === host`.
*
* Both `taskEl.focus()` (real focus) and a follow-up `dispatchEvent(new
* FocusEvent('focusin', { bubbles: true }))` are issued. The browser already
* fires focusin on `.focus()` in normal pages, but we re-dispatch to harden
* against any test-runner edge case where the bubbling focusin doesn't run
* the Angular HostListener in time.
*
* Returns the `data-task-id` of the newly focused task, or `null` if no
* `<task>` exists.
*/
const ensureTaskFocused = async (
page: import('@playwright/test').Page,
): Promise<string | null> => {
return await page.evaluate(() => {
const active = document.activeElement as HTMLElement | null;
const currentTaskEl = active?.closest('task') as HTMLElement | null;
const taskEl =
currentTaskEl ?? (document.querySelector('task') as HTMLElement | null);
if (!taskEl) return null;
taskEl.focus();
taskEl.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
return taskEl.getAttribute('data-task-id');
});
};
test.describe('@video keyboard reel', () => {
test.skip(VARIANT !== 'keyboard', 'keyboard reel only runs when REEL_VARIANT=keyboard');
test.use({ locale: 'en', theme: 'dark' });
test('keyboard reel', async ({ seededPage, markBeatsStart }) => {
const page = seededPage;
// Forward in-page diagnostics + SP's own Log.warn output so we can see
// whether the shortcut handler bailed at `lastFocusedTaskComponent ===
// null` or the id-mismatch guard.
page.on('console', (msg) => {
const text = msg.text();
if (
text.startsWith('[keyboard-reel]') ||
text.includes('No focused task component') ||
text.includes('does not match shortcut target') ||
text.includes('Method ') ||
msg.type() === 'warning'
) {
process.stdout.write(`[page:${msg.type()}] ${text}\n`);
}
});
// ── Pre-roll (trimmed off the reel) ──────────────────────────────────
await page.goto('/#/tag/TODAY/tasks');
await page.locator('task').first().waitFor({ state: 'visible', timeout: 15_000 });
await parkCursor(page);
await page.waitForTimeout(300);
markBeatsStart();
// ── Lead-in ──────────────────────────────────────────────────────────
await loopBoundary(page, 'in', 460);
// ── Beat 1 — "Keyboard-first." ───────────────────────────────────────
const b1 = await showOverlay(page, 'Keyboard-first.');
await page.waitForTimeout(900);
void b1.hide();
await page.waitForTimeout(200);
// ── Beat 2 — Shift+A → quick capture ─────────────────────────────────
const chipAdd = await showKeyChip(page, 'Shift+A');
await clearShortcutBlockers(page);
await page.keyboard.press('Shift+A');
const globalInput = page.locator('add-task-bar.global input').first();
if (!(await globalInput.isVisible().catch(() => false))) {
await page.evaluate(() => {
const helper = (
window as unknown as {
__e2eTestHelpers?: { store?: { dispatch: (a: unknown) => void } };
}
).__e2eTestHelpers;
helper?.store?.dispatch({ type: '[Layout] Show AddTaskBar' });
});
}
await globalInput.waitFor({ state: 'visible', timeout: 5_000 });
await page.waitForTimeout(220);
await page.evaluate(() => document.body.classList.add('__sp-hide-cursor-highlight'));
await globalInput.pressSequentially(NEW_TASK_TITLE, { delay: 55 });
await page.waitForTimeout(360);
await page.keyboard.press('Enter');
await page.waitForTimeout(450);
await page.evaluate(() =>
document.body.classList.remove('__sp-hide-cursor-highlight'),
);
const backdrop = page.locator('.backdrop').first();
if (await backdrop.isVisible().catch(() => false)) {
await backdrop.click({ force: true });
await backdrop.waitFor({ state: 'hidden', timeout: 2_000 }).catch(() => undefined);
}
await page
.locator('add-task-bar.global')
.first()
.waitFor({ state: 'hidden', timeout: 3_000 })
.catch(() => undefined);
await chipAdd.hide();
// Focus the first task before pressing J/K. SP's focusin handler only
// routes `setSelectedId` (which opens the detail panel) when
// `selectedTaskId` is already set; if we start with no selection,
// focusin just registers the task with TaskFocusService and the
// shortcut handler's `focusNext()` walks the list via `:focus`
// styling — no panel side effects.
await clearShortcutBlockers(page);
const firstTask = page.locator('task').first();
await firstTask.scrollIntoViewIfNeeded().catch(() => undefined);
const initialTaskId = await ensureTaskFocused(page);
await page.evaluate((id) => {
const tasks = Array.from(document.querySelectorAll('task'));
const msg =
`[keyboard-reel] initial focus task=${id ?? 'null'} ` +
`taskCount=${tasks.length}`;
console.log(msg);
}, initialTaskId);
await page.waitForTimeout(200);
/**
* One step in the J/K navigation beat. Chip already up; press the real
* key. SP's task-shortcut service calls `focusNext()` / `focusPrevious()`
* on the focused task component, which moves DOM focus to the next /
* previous `<task>` host element the `:focus` border (from
* `_task-base.scss`) is the visible cue.
*
* Logs activeElement + task index + overlay-pane count before each
* press, then the activeElement + task index after, so trace inspection
* can confirm focus actually moved. If `taskIndex` is unchanged, the
* shortcut handler bailed (overlay pane present, component reference
* null/mismatched) check the forwarded `[page:warning]` lines.
*/
const step = async (direction: 'next' | 'prev'): Promise<void> => {
await clearShortcutBlockers(page);
await ensureTaskFocused(page);
const snap = async (label: string): Promise<void> => {
const info = await page.evaluate(() => {
const a = document.activeElement as HTMLElement | null;
const taskEl = a?.closest('task') as HTMLElement | null;
const all = Array.from(document.querySelectorAll('task'));
const idx = taskEl ? all.indexOf(taskEl) : -1;
const paneCount = Array.from(
document.querySelectorAll('.cdk-overlay-pane'),
).filter(
(p) =>
!p.classList.contains('mat-mdc-tooltip-panel') && p.childElementCount > 0,
).length;
return {
tag: a?.tagName ?? null,
taskId: taskEl?.getAttribute('data-task-id') ?? null,
taskIndex: idx,
taskCount: all.length,
paneCount,
};
});
await page.evaluate(
(payload) => {
const msg =
`[keyboard-reel] ${payload.label} tag=${payload.tag} ` +
`task=${payload.taskId} ` +
`idx=${payload.taskIndex}/${payload.taskCount} ` +
`panes=${payload.paneCount}`;
console.log(msg);
},
{ ...info, label },
);
};
await snap(`before-${direction}`);
await page.keyboard.press(direction === 'next' ? 'j' : 'k');
await snap(`after-${direction}`);
};
// ── Beat 3 — J / K → navigate task list ──────────────────────────────
const chipJ = await showKeyChip(page, 'J');
await step('next');
await page.waitForTimeout(420);
await step('next');
await page.waitForTimeout(420);
await chipJ.hide();
await page.waitForTimeout(80);
const chipK = await showKeyChip(page, 'K');
await step('prev');
await page.waitForTimeout(420);
await chipK.hide();
await page.waitForTimeout(120);
// ── Beat 4 — F → focus mode ──────────────────────────────────────────
const chipF = await showKeyChip(page, 'F');
await clearShortcutBlockers(page);
await page.keyboard.press('f');
const focusVisible = await page
.locator('focus-mode-main')
.first()
.waitFor({ state: 'visible', timeout: 2_000 })
.then(() => true)
.catch(() => false);
if (!focusVisible) {
const focusedTaskId = await page.evaluate(() => {
const focused = document.querySelector(
'task:focus, task.isCurrent, task[class*="isSelected"]',
);
return focused?.getAttribute('data-task-id') ?? null;
});
await page.evaluate((id) => {
const helper = (
window as unknown as {
__e2eTestHelpers?: { store?: { dispatch: (a: unknown) => void } };
}
).__e2eTestHelpers;
if (!helper?.store) return;
if (id) helper.store.dispatch({ type: '[Task] SetCurrentTask', id });
helper.store.dispatch({ type: '[FocusMode] Show Overlay' });
helper.store.dispatch({
type: '[FocusMode] Start Session',
duration: 1500000,
});
}, focusedTaskId);
await page
.locator('focus-mode-main')
.first()
.waitFor({ state: 'visible', timeout: 8_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 page.waitForTimeout(1500);
await chipF.hide();
await page.waitForTimeout(200);
// ── Beat 4 → 5 — dismiss focus mode behind the end card ──────────────
await showEndCard(
page,
{
logo: {
src: '/assets/icons/sp.svg',
alt: 'Super Productivity',
monochrome: true,
},
title: 'Made for keyboards.',
subtitle: 'superproductivity.com',
stats: [
{ template: '{n}+ shortcuts', to: 40 },
'Web · iOS · Android · macOS · Linux · Windows',
],
},
{ fadeMs: 560 },
);
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.waitForTimeout(2200);
// ── Loop boundary ────────────────────────────────────────────────────
await loopBoundary(page, 'out', 460);
});
});

View file

@ -0,0 +1,175 @@
/**
* Mobile-touch reel four-beat choreography demonstrating Super Productivity
* on a phone. The fixture enables `hasTouch` and `isMobile` for this variant
* so the context dispatches real touch events; each beat uses
* `page.touchscreen.tap()` and the fixture's tap-ripple init script spawns a
* visible ring at each touch point.
*
* Lead-in Black fades to SP work-view (mobile layout).
* 1 "On the go." tagline overlay.
* 2 Tap "+" quick add task, type, confirm.
* 3 Tap task focus mode on tapped task.
* 4 End card "Mobile · iOS · Android" with stat counter.
*
* Activated only by `REEL_VARIANT=mobile`. Output lands as
* `dist/video/reel-mobile.{mp4,webm,gif}` at 1080×2340.
*/
import type { Locator, Page } from '@playwright/test';
import { test } from '../fixture';
import { loopBoundary, showEndCard, showOverlay } from '../overlays';
const VARIANT = process.env.REEL_VARIANT ?? '';
const NEW_TASK_TITLE = 'Plan trip 30m';
const NEW_TASK_DISPLAY = 'Plan trip';
const tapCenter = async (page: Page, locator: Locator): Promise<void> => {
const box = await locator.boundingBox();
if (!box) return;
const halfW = box.width / 2;
const halfH = box.height / 2;
const cx = box.x + halfW;
const cy = box.y + halfH;
await page.touchscreen.tap(cx, cy);
};
test.describe('@video mobile reel', () => {
test.skip(VARIANT !== 'mobile', 'mobile reel only runs when REEL_VARIANT=mobile');
test.use({ locale: 'en', theme: 'dark' });
test('mobile reel', async ({ seededPage, markBeatsStart }) => {
const page = seededPage;
// ── Pre-roll (trimmed off the reel) ──────────────────────────────────
await page.goto('/#/tag/TODAY/tasks');
await page.locator('task').first().waitFor({ state: 'visible', timeout: 15_000 });
await page.waitForTimeout(300);
markBeatsStart();
// ── Lead-in ──────────────────────────────────────────────────────────
await loopBoundary(page, 'in', 460);
// ── Beat 1 — "On the go." ────────────────────────────────────────────
const b1 = await showOverlay(page, 'On the go.');
await page.waitForTimeout(900);
void b1.hide();
await page.waitForTimeout(220);
// ── Beat 2 — Tap + → quick capture ───────────────────────────────────
const b2 = await showOverlay(page, 'Tap to capture.');
// Open the global add-task bar via store dispatch — the mobile FAB
// selector varies across breakpoints, but the bar itself is the same
// surface and the tap-ripple before dispatch makes the cause visible.
const fab = page
.locator(
'button.e2e-add-task-fab, .add-task-fab button, button[aria-label*="Add Task" i]',
)
.first();
if (await fab.isVisible().catch(() => false)) {
await tapCenter(page, fab);
} else {
await page.evaluate(() => {
const helper = (
window as unknown as {
__e2eTestHelpers?: { store?: { dispatch: (a: unknown) => void } };
}
).__e2eTestHelpers;
helper?.store?.dispatch({ type: '[Layout] Show AddTaskBar' });
});
}
const globalInput = page.locator('add-task-bar.global input').first();
await globalInput.waitFor({ state: 'visible', timeout: 5_000 });
await page.waitForTimeout(220);
await globalInput.pressSequentially(NEW_TASK_TITLE, { delay: 55 });
await page.waitForTimeout(360);
await page.keyboard.press('Enter');
await page.waitForTimeout(500);
const backdrop = page.locator('.backdrop').first();
if (await backdrop.isVisible().catch(() => false)) {
await backdrop.click({ force: true });
await backdrop.waitFor({ state: 'hidden', timeout: 2_000 }).catch(() => undefined);
}
await page
.locator('add-task-bar.global')
.first()
.waitFor({ state: 'hidden', timeout: 3_000 })
.catch(() => undefined);
void b2.hide();
await page.waitForTimeout(220);
// ── Beat 3 — Tap captured task → focus mode ──────────────────────────
const b3 = await showOverlay(page, 'Tap to focus.');
const newTask = page.locator('task').filter({ hasText: NEW_TASK_DISPLAY }).first();
await newTask.waitFor({ state: 'visible', timeout: 5_000 });
const newTaskId = await newTask.getAttribute('data-task-id').catch(() => null);
await tapCenter(page, newTask);
await page.waitForTimeout(160);
// The tap visibly lands on the task; the focus-mode entry happens via
// dispatch so it doesn't depend on whichever overflow menu the tap
// routes through on this breakpoint.
if (newTaskId) {
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,
});
}, newTaskId);
await page
.locator('focus-mode-main')
.first()
.waitFor({ state: 'visible', timeout: 8_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 page.waitForTimeout(1600);
void b3.hide();
await page.waitForTimeout(220);
// ── Beat 4 — End card "Mobile · iOS · Android" ──────────────────────
await showEndCard(
page,
{
logo: {
src: '/assets/icons/sp.svg',
alt: 'Super Productivity',
monochrome: true,
},
title: 'Take it anywhere.',
subtitle: 'superproductivity.com',
stats: [
{ template: '{n} ★ on Google Play', to: 4.8, decimals: 1 },
'iOS · Android · Web · Desktop',
],
},
{ fadeMs: 560 },
);
// Tear focus mode down behind the card so the loop-out doesn't flash
// it between end card and black.
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.waitForTimeout(2300);
// ── Loop boundary ────────────────────────────────────────────────────
await loopBoundary(page, 'out', 460);
});
});

View file

@ -38,6 +38,11 @@ import {
const VARIANT = process.env.REEL_VARIANT ?? '';
const isFull = VARIANT === 'full';
// 9:16 portrait variant for TikTok / YouTube Shorts / Instagram Reels. Skips
// beat 2 (the side-panel drag doesn't translate to portrait — the schedule
// panel makes no sense without horizontal room) and tightens hold timings
// so the reel lands in the ~12-14s sweet spot for short-form algorithms.
const isShorts = VARIANT === 'shorts';
const parkCursor = async (page: import('@playwright/test').Page): Promise<void> => {
// Park the cursor offscreen so any matTooltip dismisses and the cursor
@ -58,6 +63,11 @@ const CAPTURED_TASK_TITLE = 'A task 1h';
const CAPTURED_TASK_DISPLAY_TITLE = 'A task';
test.describe('@video reel', () => {
// The keyboard and mobile variants have their own choreography (see
// keyboard.spec.ts / mobile.spec.ts); skip this spec when either is
// active so a single capture run doesn't record two unrelated webms.
test.skip(VARIANT === 'keyboard', 'keyboard variant runs keyboard.spec.ts');
test.skip(VARIANT === 'mobile', 'mobile variant runs mobile.spec.ts');
test.use({ locale: 'en', theme: 'dark' });
test('marketing reel', async ({ seededPage, markBeatsStart }) => {
@ -67,7 +77,7 @@ test.describe('@video reel', () => {
await page.goto('/#/tag/TODAY/tasks');
await page.locator('task').first().waitFor({ state: 'visible', timeout: 15_000 });
const scheduleBtn = page.locator('.e2e-toggle-schedule-day-panel').first();
if (await scheduleBtn.isVisible().catch(() => false)) {
if (!isShorts && (await scheduleBtn.isVisible().catch(() => false))) {
await scheduleBtn.click();
await page.locator('schedule-day-panel').first().waitFor({
state: 'visible',
@ -164,6 +174,9 @@ test.describe('@video reel', () => {
bExtra = await showOverlay(page, 'No account. No tracking.', {
noWait: true,
});
} else if (isShorts) {
// Shorts skip beat 2's drag entirely — go straight to Focus.
b2 = await showOverlay(page, 'Focus on what matters.', { noWait: true });
} else {
b2 = await showOverlay(page, 'Plan your day.', { noWait: true });
}
@ -191,14 +204,18 @@ test.describe('@video reel', () => {
}
// ── Beat 2 — Plan your day. (native app drag) ────────────────────────
// Shorts variant skips this entirely: the side schedule panel doesn't
// open on portrait, and there's nothing meaningful to drag onto.
const schedulePanel = page.locator('schedule-day-panel').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 (!isShorts) {
await dragSource.waitFor({ state: 'visible', timeout: 5_000 });
}
const taskBox = !isShorts ? await dragSource.boundingBox() : null;
const panelBox = !isShorts ? await schedulePanel.boundingBox() : null;
if (taskBox && panelBox) {
const taskHalfW = taskBox.width * 0.5;
const taskHalfH = taskBox.height * 0.5;
@ -218,15 +235,17 @@ test.describe('@video reel', () => {
await page.waitForTimeout(isFull ? 250 : 150);
await parkCursor(page);
}
await page.waitForTimeout(isFull ? 900 : 600);
await page.waitForTimeout(isFull ? 900 : isShorts ? 0 : 600);
// ── Beat 2 → 3 transition: cut to black, dispatch focus mode ─────────
// Shorts: b2 already says "Focus on what matters." so reuse it rather
// than hide-and-respawn (which would re-fade the same words).
let b3: OverlayHandle | undefined;
await cutToScene(
page,
async () => {
void b2!.hide();
if (await scheduleBtn.isVisible().catch(() => false)) {
if (!isShorts) void b2!.hide();
if (!isShorts && (await scheduleBtn.isVisible().catch(() => false))) {
await scheduleBtn.click();
}
if (capturedTaskId) {
@ -258,14 +277,16 @@ test.describe('@video reel', () => {
await page.clock.resume().catch(() => undefined);
}
await parkCursor(page);
b3 = await showOverlay(page, 'Focus on what matters.', { noWait: true });
b3 = isShorts
? b2
: await showOverlay(page, 'Focus on what matters.', { noWait: true });
},
{
fadeMs: 260,
label: 'beat 2 to 3',
},
);
await page.waitForTimeout(isFull ? 1800 : 1200);
await page.waitForTimeout(isFull ? 1800 : isShorts ? 900 : 1200);
// ── Beat 3 → 4 transition: cut to black, swap to integrations card ──
let b4: OverlayHandle | undefined;
@ -308,7 +329,7 @@ test.describe('@video reel', () => {
label: 'beat 3 to 4',
},
);
await page.waitForTimeout(isFull ? 2500 : 2000);
await page.waitForTimeout(isFull ? 2500 : isShorts ? 1700 : 2000);
// ── Beat 4 → 5 transition: crossfade between controlled cards ───────
await showEndCard(
@ -331,7 +352,7 @@ test.describe('@video reel', () => {
);
await page.waitForTimeout(260);
if (b4) void b4.hide();
await page.waitForTimeout(isFull ? 2740 : 2240);
await page.waitForTimeout(isFull ? 2740 : isShorts ? 1940 : 2240);
// ── Loop boundary ────────────────────────────────────────────────────
// Don't hide the end card — let it stay live. The loop boundary

View file

@ -86,6 +86,9 @@
"video:open": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\",\"moduleResolution\":\"node\"}' npx ts-node --transpile-only e2e/store-video/open-video.ts",
"video:full": "cross-env REEL_VARIANT=full npm run video",
"video:ms-store": "cross-env REEL_VARIANT=ms-store npm run video",
"video:shorts": "cross-env REEL_VARIANT=shorts npm run video",
"video:keyboard": "cross-env REEL_VARIANT=keyboard npm run video",
"video:mobile": "cross-env REEL_VARIANT=mobile 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",
"electron:verify-asar": "node tools/verify-electron-requires.js .tmp/app-builds/linux-unpacked/resources/app.asar",