diff --git a/e2e/store-video/CLAUDE.md b/e2e/store-video/CLAUDE.md index 4eba99a888..e82ff0b08f 100644 --- a/e2e/store-video/CLAUDE.md +++ b/e2e/store-video/CLAUDE.md @@ -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//` (`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. diff --git a/e2e/store-video/fixture.ts b/e2e/store-video/fixture.ts index 01d55f1688..4da28a7dae 100644 --- a/e2e/store-video/fixture.ts +++ b/e2e/store-video/fixture.ts @@ -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({ // 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({ 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({ 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({ (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({ // 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({ 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 diff --git a/e2e/store-video/overlays.ts b/e2e/store-video/overlays.ts index 1504b483e8..337295fee0 100644 --- a/e2e/store-video/overlays.ts +++ b/e2e/store-video/overlays.ts @@ -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 => { await page.evaluate((id) => { @@ -315,6 +317,12 @@ const ensureStyleInjected = async (page: Page): Promise => { 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 => { 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 => { + 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 => { + 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, diff --git a/e2e/store-video/scenarios/keyboard.spec.ts b/e2e/store-video/scenarios/keyboard.spec.ts new file mode 100644 index 0000000000..d7c7270e55 --- /dev/null +++ b/e2e/store-video/scenarios/keyboard.spec.ts @@ -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 => { + 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 , because beat 3 relies on that focus staying + * put between keypresses. + */ +const clearShortcutBlockers = async ( + page: import('@playwright/test').Page, +): Promise => { + 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 `` 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 + * `` exists. + */ +const ensureTaskFocused = async ( + page: import('@playwright/test').Page, +): Promise => { + 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 `` 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 => { + await clearShortcutBlockers(page); + await ensureTaskFocused(page); + const snap = async (label: string): Promise => { + 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); + }); +}); diff --git a/e2e/store-video/scenarios/mobile.spec.ts b/e2e/store-video/scenarios/mobile.spec.ts new file mode 100644 index 0000000000..bc5f345bf1 --- /dev/null +++ b/e2e/store-video/scenarios/mobile.spec.ts @@ -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 => { + 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); + }); +}); diff --git a/e2e/store-video/scenarios/reel.spec.ts b/e2e/store-video/scenarios/reel.spec.ts index f3261deb41..962909833b 100644 --- a/e2e/store-video/scenarios/reel.spec.ts +++ b/e2e/store-video/scenarios/reel.spec.ts @@ -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 => { // 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 diff --git a/package.json b/package.json index 487ad100d1..77cabd4a0e 100644 --- a/package.json +++ b/package.json @@ -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",