mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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:
parent
16b7deab73
commit
075de5a097
7 changed files with 839 additions and 58 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
344
e2e/store-video/scenarios/keyboard.spec.ts
Normal file
344
e2e/store-video/scenarios/keyboard.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
175
e2e/store-video/scenarios/mobile.spec.ts
Normal file
175
e2e/store-video/scenarios/mobile.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue