From 1d52843cd714f0e64a6b14771e5d089de4ca0589 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Thu, 7 May 2026 23:08:24 +0200 Subject: [PATCH] feat(video): Playwright-driven marketing reel pipeline Adds an end-to-end pipeline for generating the marketing reel via Playwright, including tight/full variants, drag ghost, integrations layout, animated stats, brand-color logos, fade-to-black scene cuts, and a handover CLAUDE.md for the reel pipeline. --- e2e/pages/import.page.ts | 9 +- e2e/playwright.store-screenshots.config.ts | 1 - ...right.store-screenshots.electron.config.ts | 1 - e2e/playwright.store-video.config.ts | 67 ++ e2e/store-screenshots/README.md | 26 +- e2e/store-screenshots/build-store-assets.ts | 136 --- e2e/store-screenshots/helpers.ts | 126 +-- e2e/store-screenshots/marketing-copy.ts | 10 - e2e/store-screenshots/matrix.ts | 39 +- e2e/store-screenshots/print-output-path.ts | 13 - .../scenarios/desktop/all.spec.ts | 69 +- .../scenarios/mobile/all.spec.ts | 42 +- .../scenarios/tablet/all.spec.ts | 26 +- e2e/store-screenshots/seed/seed.template.json | 26 +- e2e/store-video/CLAUDE.md | 120 +++ e2e/store-video/build-video.ts | 211 ++++ e2e/store-video/fixture.ts | 303 ++++++ e2e/store-video/overlays.ts | 833 ++++++++++++++++ e2e/store-video/scenarios/reel.spec.ts | 316 ++++++ package-lock.json | 537 +---------- package.json | 5 +- src/app/core/theme/custom-theme.service.ts | 19 - .../features/schedule/schedule.constants.ts | 4 - .../schedule/schedule/schedule.component.html | 63 +- .../schedule/schedule/schedule.component.scss | 21 +- .../schedule/schedule.component.spec.ts | 10 +- .../schedule/schedule/schedule.component.ts | 58 +- src/app/features/tasks/task.service.ts | 9 +- src/app/t.const.ts | 1 - src/assets/i18n/en.json | 1 - src/assets/themes/carbon.css | 272 ------ src/assets/themes/lines.css | 93 -- src/assets/themes/velvet.css | 904 ------------------ src/assets/themes/zen.css | 6 +- 34 files changed, 2017 insertions(+), 2360 deletions(-) create mode 100644 e2e/playwright.store-video.config.ts delete mode 100644 e2e/store-screenshots/marketing-copy.ts delete mode 100644 e2e/store-screenshots/print-output-path.ts create mode 100644 e2e/store-video/CLAUDE.md create mode 100644 e2e/store-video/build-video.ts create mode 100644 e2e/store-video/fixture.ts create mode 100644 e2e/store-video/overlays.ts create mode 100644 e2e/store-video/scenarios/reel.spec.ts delete mode 100644 src/assets/themes/carbon.css delete mode 100644 src/assets/themes/lines.css delete mode 100644 src/assets/themes/velvet.css diff --git a/e2e/pages/import.page.ts b/e2e/pages/import.page.ts index b1ec79b781..8fe9e9469d 100644 --- a/e2e/pages/import.page.ts +++ b/e2e/pages/import.page.ts @@ -141,16 +141,13 @@ export class ImportPage extends BasePage { // Handle encryption warning dialog if it appears. // The dialog may appear several seconds after setInputFiles because FileReader is async. // Race: either the dialog appears (handle it) or the import completes without it. - // Catch the dialog timeout so a slow import (cold dev server, large seed) doesn't - // reject the race before importCompletePromise has a chance to resolve. const encryptionWarning = this.page .locator('dialog-import-encryption-warning') .first(); const dialogOrImport = await Promise.race([ encryptionWarning .waitFor({ state: 'visible', timeout: 15000 }) - .then(() => 'dialog' as const) - .catch(() => 'no_dialog' as const), + .then(() => 'dialog' as const), importCompletePromise.then(() => 'import_complete' as const), ]); @@ -161,10 +158,6 @@ export class ImportPage extends BasePage { await encryptionWarning.waitFor({ state: 'hidden', timeout: 10000 }); // Now wait for the import to actually complete await importCompletePromise; - } else if (dialogOrImport === 'no_dialog') { - // Dialog didn't appear within 15s — import is slow but may still complete. - // Wait for the import-complete signal (its own 60s timeout will catch a true hang). - await importCompletePromise; } // If 'import_complete', the import finished without showing the dialog diff --git a/e2e/playwright.store-screenshots.config.ts b/e2e/playwright.store-screenshots.config.ts index 4c1a8c5e89..ce79b5d153 100644 --- a/e2e/playwright.store-screenshots.config.ts +++ b/e2e/playwright.store-screenshots.config.ts @@ -19,7 +19,6 @@ import { */ export default defineConfig({ testDir: path.join(__dirname, 'store-screenshots', 'scenarios'), - globalTeardown: path.join(__dirname, 'store-screenshots', 'print-output-path.ts'), fullyParallel: false, forbidOnly: !!process.env.CI, // Screenshots aren't flaky like e2e tests — failures should be investigated, not retried. diff --git a/e2e/playwright.store-screenshots.electron.config.ts b/e2e/playwright.store-screenshots.electron.config.ts index 052a83d2a3..310860fb77 100644 --- a/e2e/playwright.store-screenshots.electron.config.ts +++ b/e2e/playwright.store-screenshots.electron.config.ts @@ -16,7 +16,6 @@ import { VIEWPORTS, type ViewportSpec } from './store-screenshots/matrix'; */ export default defineConfig({ testDir: path.join(__dirname, 'store-screenshots', 'scenarios', 'desktop'), - globalTeardown: path.join(__dirname, 'store-screenshots', 'print-output-path.ts'), fullyParallel: false, forbidOnly: !!process.env.CI, retries: 0, diff --git a/e2e/playwright.store-video.config.ts b/e2e/playwright.store-video.config.ts new file mode 100644 index 0000000000..b227d87617 --- /dev/null +++ b/e2e/playwright.store-video.config.ts @@ -0,0 +1,67 @@ +import { defineConfig } from '@playwright/test'; +import path from 'path'; + +const repoRoot = path.resolve(__dirname, '..'); + +/** + * Playwright config for the marketing-reel video pipeline. Mirrors + * `playwright.store-screenshots.config.ts` but records video via Playwright's + * built-in `recordVideo` instead of capturing PNG frames. One run = one + * continuous webm in `.tmp/video/_results/`. Post-process to mp4 / webm / gif + * via `npm run video:build`. + * + * Capture: npm run video:capture + * Build: npm run video:build + * Both: npm run video + */ +export default defineConfig({ + testDir: path.join(__dirname, 'store-video', 'scenarios'), + fullyParallel: false, + forbidOnly: !!process.env.CI, + // Recordings aren't flaky — failures should be investigated, not retried. + retries: 0, + workers: 1, + reporter: 'line', + + use: { + baseURL: process.env.E2E_BASE_URL || 'http://localhost:4242', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + // Video recording, viewport, and deviceScaleFactor are all handled inside + // the fixture (`store-video/fixture.ts`) because that fixture creates its + // own browser context, which doesn't inherit project-level options. + video: 'off', + userAgent: 'PLAYWRIGHT-VIDEO', + navigationTimeout: 45_000, + actionTimeout: 20_000, + launchOptions: { + args: [ + '--disable-dev-shm-usage', + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-extensions', + '--disable-gpu', + '--disable-software-rasterizer', + '--hide-scrollbars', + ], + }, + }, + + webServer: process.env.E2E_BASE_URL + ? undefined + : { + command: process.env.CI + ? 'npm run serveFrontend:e2e:prod' + : 'npm run startFrontend:e2e', + url: 'http://localhost:4242', + reuseExistingServer: !process.env.CI, + timeout: 2 * 60 * 1000, + stdout: 'ignore', + stderr: 'pipe', + }, + + outputDir: path.join(repoRoot, '.tmp', 'video', '_results'), + + timeout: 180 * 1000, + expect: { timeout: 20 * 1000 }, +}); diff --git a/e2e/store-screenshots/README.md b/e2e/store-screenshots/README.md index 8155f897a9..e7538b211e 100644 --- a/e2e/store-screenshots/README.md +++ b/e2e/store-screenshots/README.md @@ -16,9 +16,9 @@ npm run screenshots:capture:electron # Electron build → .tmp/screenshots/_mast npm run screenshots:electron # capture:electron + build (lands in dist/) npm run screenshots:build # rebuild dist/ layout from existing masters -# One group while iterating +# One group while iterating (pattern matches `desktop dark|light|catppuccin` etc.) npx playwright test --config e2e/playwright.store-screenshots.config.ts \ - --project=desktopMaster --grep "desktop all" + --project=desktopMaster --grep "desktop dark \(en\)" ``` ## Environment overrides @@ -38,9 +38,6 @@ Per-store assets land in `dist/screenshots///NN-name.png` (and th | Slot | Platform | Theme | What it shows | | ---- | -------- | ----- | ------------- | -| mobile-00 | mobile | dark | Cover/hero — Today list with marketing caption overlay | -| desktop-00 | desktop | dark | Cover/hero — Today list with marketing caption overlay | -| tablet-00 | tablet | dark | Cover/hero — Today list with marketing caption overlay | | mobile-01 | mobile | dark | Planner | | mobile-02 | mobile | dark | Planner with calendar nav expanded | | mobile-03 | mobile | dark | Eisenhower matrix board | @@ -53,10 +50,9 @@ Per-store assets land in `dist/screenshots///NN-name.png` (and th | desktop-04 | desktop | light | Project (Work) + notes panel populated | | desktop-05 | desktop | dark | Focus mode | | desktop-06 | desktop | light | Schedule (light variant) | -| desktop-07 | desktop | dark | Project (Work) view, no wallpaper — regular palette reads cleanly | -| desktop-08 | desktop | dark | Planner | +| desktop-07 | desktop | catppuccin-mocha | Today list with custom theme | -Specs are platform-grouped: `scenarios/desktop/all.spec.ts` and `scenarios/mobile/all.spec.ts` each capture every slot in a single session, flipping `DARK_MODE` between groups via `applyTheme()` (Playwright `addInitScript` is append-only, so a later script wins on each reload). Each spec runs once per locale (en + de). +Specs are platform-grouped: `scenarios/desktop/all.spec.ts` and `scenarios/mobile/all.spec.ts` each capture every dark+light slot in a single session, flipping `DARK_MODE` between groups via `applyTheme()` (Playwright `addInitScript` is append-only, so a later script wins on each reload). The catppuccin slot stays in its own spec because changing `customTheme` requires a different seed file. Each spec runs once per locale (en + de). ## Files @@ -66,12 +62,11 @@ Specs are platform-grouped: `scenarios/desktop/all.spec.ts` and `scenarios/mobil | `seed/seed.template.json` | Curated dataset with date offsets and `@@PLANNER_OFFSET_+N` placeholders | | `seed/build-seed.ts` | Materializes offsets to absolute dates, injects `locale` + `customTheme` | | `fixture.ts` | Pins clock, applies dark-mode, imports seed via UI flow, exposes `screenshotMaster` (which reads live `DARK_MODE` so light/dark scenes land in the right directory) | -| `helpers.ts` | `gotoAndSettle`, `openNotesPanel`, `openSchedulePanel`, `resetView`, `applyTheme`, `applyLocale`, `applyTimeTrackingEnabled`, `applySideNavCollapsed`, `setPlannerCalendarExpanded`, `showMarketingOverlay` | -| `marketing-copy.ts` | Headline + subline shown on the slot-00 hero overlay | -| `scenarios/desktop/all.spec.ts` | 9 desktop slots: hero + 8 scenes | -| `scenarios/mobile/all.spec.ts` | 7 mobile slots: hero + 6 scenes | -| `scenarios/tablet/all.spec.ts` | 6 tablet slots: hero + 5 scenes | -| `build-store-assets.ts` | Renames + copies masters into per-store layouts; JPEG re-encode for `maxBytes`-capped stores (Snap); emits `_preview.html` contact sheet | +| `helpers.ts` | `gotoAndSettle`, `openNotesPanel`, `openSchedulePanel`, `resetView`, `applyTheme` | +| `scenarios/desktop/all.spec.ts` | 6 desktop slots (dark + light, single session) | +| `scenarios/desktop/catppuccin.spec.ts` | desktop-07 (different seed → standalone) | +| `scenarios/mobile/all.spec.ts` | 6 mobile slots (dark + light, single session) | +| `build-store-assets.ts` | Renames + copies masters into per-store directory layouts; JPEG re-encode for `maxBytes`-capped stores (Snap) | | `../playwright.store-screenshots.config.ts` | Separate Playwright config; one project per viewport | ## How it works @@ -139,8 +134,7 @@ The Mac App Store store rule has `masterDir: 'electron'` in `STORE_RULES`, so th ## Status - ✅ Foundation: matrix, seed builder, fixture (web + electron modes), helpers, post-processor -- ✅ 22 scenarios (3 hero + 6 mobile + 8 desktop + 5 tablet) covering planner, boards, schedule, focus, notes, project view -- ✅ Per-build `_preview.html` contact sheet under `dist/screenshots/` for one-click QA +- ✅ 13 scenarios (6 mobile + 7 desktop) covering planner, boards, schedule, focus, notes, custom theme - ✅ Electron-mode pipeline with OS-level chrome capture (`screencapture` / `grim` / `import`) - ✅ Mac App Store wired to source from `_master_electron/` - ✅ Flathub STORE_RULE (single-gallery, sourced from `_master_electron/`) diff --git a/e2e/store-screenshots/build-store-assets.ts b/e2e/store-screenshots/build-store-assets.ts index 65da0f8119..d1de841ad9 100644 --- a/e2e/store-screenshots/build-store-assets.ts +++ b/e2e/store-screenshots/build-store-assets.ts @@ -12,7 +12,6 @@ * Run: npm run screenshots:build */ -import { spawn } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import sharp from 'sharp'; @@ -215,142 +214,7 @@ const main = async (): Promise => { ); } console.log(`\nTotal files written: ${total}`); - const previewPath = writePreviewSheet(); console.log(`Output: ${OUT_DIR}`); - console.log(`Preview: ${previewPath}`); - openFolder(OUT_DIR); -}; - -type PreviewEntry = { relPath: string; group: string; label: string }; - -/** Walk OUT_DIR and collect every emitted png/jpg as a preview entry. */ -const collectPreviewEntries = (root: string, base = ''): PreviewEntry[] => { - const out: PreviewEntry[] = []; - if (!fs.existsSync(root)) return out; - for (const entry of fs.readdirSync(root)) { - const absPath = path.join(root, entry); - const relPath = base ? `${base}/${entry}` : entry; - const stat = fs.statSync(absPath); - if (stat.isDirectory()) { - out.push(...collectPreviewEntries(absPath, relPath)); - continue; - } - if (!/\.(png|jpe?g)$/i.test(entry)) continue; - // Group is everything up to the last directory separator; label is filename. - const lastSlash = relPath.lastIndexOf('/'); - const group = lastSlash === -1 ? '(root)' : relPath.slice(0, lastSlash); - out.push({ relPath, group, label: entry }); - } - return out; -}; - -/** - * Emit `_preview.html` — a single-page contact sheet of every capture in - * `dist/screenshots/`, grouped by store + locale. Open it in a browser to - * eyeball the whole batch instead of drilling through ~14 subfolders. - */ -const writePreviewSheet = (): string => { - const entries = collectPreviewEntries(OUT_DIR).sort((a, b) => - a.relPath.localeCompare(b.relPath), - ); - const grouped = new Map(); - for (const e of entries) { - const list = grouped.get(e.group) ?? []; - list.push(e); - grouped.set(e.group, list); - } - const escape = (s: string): string => - s.replace(/[&<>"']/g, (c) => { - switch (c) { - case '&': - return '&'; - case '<': - return '<'; - case '>': - return '>'; - case '"': - return '"'; - default: - return '''; - } - }); - const renderCard = (it: PreviewEntry): string => { - const href = escape(it.relPath); - const label = escape(it.label); - return [ - ``, - `${label}`, - `
${label}
`, - `
`, - ].join(''); - }; - const sections = Array.from(grouped.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([group, items]) => { - const cards = items.map(renderCard).join(''); - const heading = - `

${escape(group)} ` + `${items.length}

`; - return `
${heading}
${cards}
`; - }) - .join(''); - const html = ` - -Screenshots preview - - -

Store screenshots preview

-
${entries.length} captures across ${grouped.size} groups · ${OUT_DIR}
-${sections} - -`; - const target = path.join(OUT_DIR, '_preview.html'); - fs.writeFileSync(target, html, 'utf-8'); - return target; -}; - -/** - * Reveal `dir` in the OS file manager. Detached + unref'd so the build script - * exits immediately. Silently no-ops on headless / CI environments where the - * platform opener isn't available. Opt out with `SP_SCREENSHOTS_NO_OPEN=1`. - */ -const openFolder = (dir: string): void => { - if (process.env.SP_SCREENSHOTS_NO_OPEN) return; - const isWin = process.platform === 'win32'; - const isMac = process.platform === 'darwin'; - const cmd = isWin ? 'cmd' : isMac ? 'open' : 'xdg-open'; - const args = isWin ? ['/c', 'start', '', dir] : [dir]; - try { - const child = spawn(cmd, args, { detached: true, stdio: 'ignore' }); - child.on('error', () => { - /* opener not available on this system */ - }); - child.unref(); - } catch { - /* ignore */ - } }; main().catch((err) => { diff --git a/e2e/store-screenshots/helpers.ts b/e2e/store-screenshots/helpers.ts index 6b238e6fba..15d81aa321 100644 --- a/e2e/store-screenshots/helpers.ts +++ b/e2e/store-screenshots/helpers.ts @@ -124,132 +124,22 @@ export const applyLocale = async (page: Page, lng: string): Promise => { }; /** - * Inject a marketing caption overlay over the current scene. Used for the - * cover/hero slot — translucent gradient strip with a bold headline + thin - * subline; app remains visible behind as the proof point. Position is - * orientation-aware: landscape captures get the strip at the bottom (typical - * desktop hero), portrait captures get it at the top (where mobile users - * already track their gaze). Removed automatically on the next reload. + * Switch customTheme without re-importing the seed. CustomThemeService + * subscribes to `globalConfig.misc.customTheme` and lazy-loads the matching + * stylesheet — give the chunk a beat to land before capturing. */ -export const showMarketingOverlay = async ( - page: Page, - headline: string, - subline?: string, -): Promise => { - await page.evaluate( - ({ h, s }) => { - const id = '__sp-marketing-overlay'; - document.getElementById(id)?.remove(); - const isLandscape = window.innerWidth >= window.innerHeight; - const edgeRule = isLandscape ? 'bottom:0' : 'top:0'; - const gradientDir = isLandscape ? 'to top' : 'to bottom'; - // Slightly more padding on the gradient's "fade away" side so the text - // sits visually anchored to the screen edge. - const padding = isLandscape ? '8vh 6vw 6vh 6vw' : '6vh 6vw 8vh 6vw'; - const overlay = document.createElement('div'); - overlay.id = id; - overlay.style.cssText = [ - 'position:fixed', - edgeRule, - 'left:0', - 'right:0', - `padding:${padding}`, - `background:linear-gradient(${gradientDir},` + - 'rgba(0,0,0,0.92) 0%,rgba(0,0,0,0.7) 60%,rgba(0,0,0,0) 100%)', - 'color:#fff', - 'z-index:99999', - 'pointer-events:none', - 'font-family:system-ui,-apple-system,"Segoe UI",sans-serif', - 'text-align:center', - ].join(';'); - const head = document.createElement('div'); - head.textContent = h; - head.style.cssText = - 'font-size:clamp(28px,4.5vw,72px);font-weight:700;letter-spacing:-0.02em;line-height:1.1'; - overlay.appendChild(head); - if (s) { - const sub = document.createElement('div'); - sub.textContent = s; - sub.style.cssText = - 'font-size:clamp(15px,1.8vw,28px);font-weight:400;opacity:0.85;margin-top:0.6em'; - overlay.appendChild(sub); - } - document.body.appendChild(overlay); - }, - { h: headline, s: subline }, - ); - await page.waitForTimeout(150); -}; - -/** - * Flip `globalConfig.appFeatures.isTimeTrackingEnabled`. Disabling hides the - * per-task play / pause buttons (`task-hover-controls` gates them on this - * flag), giving the task rows a slicker, less control-heavy look. Useful for - * mobile captures where the play column adds visual noise. - */ -export const applyTimeTrackingEnabled = async ( - page: Page, - enabled: boolean, -): Promise => { +export const applyCustomTheme = async (page: Page, themeId: string): Promise => { await dispatchInPage(page, { type: '[Global Config] Update Global Config Section', - sectionKey: 'appFeatures', - sectionCfg: { isTimeTrackingEnabled: enabled }, + sectionKey: 'misc', + sectionCfg: { customTheme: themeId }, isSkipSnack: true, meta: { isPersistent: true, entityType: 'GLOBAL_CONFIG', - entityId: 'appFeatures', + entityId: 'misc', opType: 'UPDATE', }, }); - await page.waitForTimeout(200); -}; - -/** - * Toggle the desktop side nav between full ("expanded") and collapsed - * ("icons-only") modes. The side nav reads `SUP_NAV_SIDEBAR_EXPANDED` from - * localStorage on init, so we set the key and reload — hash navigation alone - * does not re-initialize the component. Use before scenes that open a right - * side panel to keep the main canvas readable. - */ -export const applySideNavCollapsed = async ( - page: Page, - collapsed: boolean, -): Promise => { - await page.evaluate((isCollapsed) => { - localStorage.setItem('SUP_NAV_SIDEBAR_EXPANDED', (!isCollapsed).toString()); - }, collapsed); - await page.reload({ waitUntil: 'domcontentloaded' }); - await waitForAppReady(page, { ensureRoute: false }); - await page.waitForTimeout(200); -}; - -/** - * Force the planner calendar nav into its expanded (multi-week) state. - * The component only responds to touch gestures (no click handler), so we - * flip its `isExpanded` signal directly via Angular's dev-mode debug - * helpers. Requires ngDevMode (enabled by `startFrontend:e2e` / ng serve). - */ -export const setPlannerCalendarExpanded = async ( - page: Page, - expanded: boolean, -): Promise => { - await page.evaluate((isExpanded) => { - const nav = document.querySelector('planner-calendar-nav'); - if (!nav) throw new Error('planner-calendar-nav not found'); - type NgDebug = { getComponent?: (el: Element) => unknown }; - const ng = (window as unknown as { ng?: NgDebug }).ng; - const cmp = ng?.getComponent?.(nav) as - | { isExpanded?: { set: (v: boolean) => void } } - | undefined; - if (!cmp?.isExpanded?.set) { - throw new Error( - 'planner-calendar-nav isExpanded signal not accessible — run against an ngDevMode build', - ); - } - cmp.isExpanded.set(isExpanded); - }, expanded); - // Settle frame so layout reflects the new state. - await page.waitForTimeout(150); + await page.waitForTimeout(800); }; diff --git a/e2e/store-screenshots/marketing-copy.ts b/e2e/store-screenshots/marketing-copy.ts deleted file mode 100644 index 69455234fd..0000000000 --- a/e2e/store-screenshots/marketing-copy.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Single source of truth for the cover/hero slot caption shown on the lead - * app-store screenshot. Keep it punchy — large type at the top of the - * capture, with the actual app visible below as proof. - * - * If you want to A/B different copy, change here and re-run capture; all - * spec classes (desktop / mobile / tablet) read these constants. - */ -export const MARKETING_HEADLINE = 'Plan it. Do it. Done.'; -export const MARKETING_SUBLINE = 'Tasks, time, focus – all in one place.'; diff --git a/e2e/store-screenshots/matrix.ts b/e2e/store-screenshots/matrix.ts index c1949e46a4..63f4e3010a 100644 --- a/e2e/store-screenshots/matrix.ts +++ b/e2e/store-screenshots/matrix.ts @@ -12,8 +12,7 @@ export type Theme = 'light' | 'dark'; export type Locale = 'en' | 'de'; -// 'de' temporarily disabled — re-add once translations are caught up. -export const LOCALES: readonly Locale[] = ['en'] as const; +export const LOCALES: readonly Locale[] = ['en', 'de'] as const; export const THEMES: readonly Theme[] = ['light', 'dark'] as const; /** @@ -33,27 +32,20 @@ export type ViewportSpec = { * The key is also the on-disk directory name under `_master//`. */ export const VIEWPORTS = { - /** Shared desktop master (16:10). 1280×800 CSS @2x → 2560×1600. The smaller - * of MAS's two listed Retina sizes — gives more apparent UI zoom than the - * 1440×900 baseline while still being a native MAS submission size. MS - * Store min is 1366×768 so this still exceeds spec. */ - desktopMaster: { width: 2560, height: 1600, deviceScaleFactor: 2 }, + /** Shared desktop master (16:10). 1440×900 CSS @2x → 2880×1800. Source for + * Mac App Store, MS Store, Snap, and the marketing site — MS Store accepts + * ≥1366×768 so this exceeds spec. */ + desktopMaster: { width: 2880, height: 1800, deviceScaleFactor: 2 }, /** Apple App Store iPhone 6.9" portrait. CSS 430×932 @3x → 1290×2796. */ iphone69: { width: 1290, height: 2796, deviceScaleFactor: 3, isMobile: true }, /** Apple App Store iPad 13" portrait. CSS 1032×1376 @2x → 2064×2752. */ ipad13: { width: 2064, height: 2752, deviceScaleFactor: 2, isMobile: true }, - /** Google Play phone portrait. CSS 412×915 @3x → 1236×2745. - * Matches modern flagships (Pixel 7/8, Galaxy S22+, OnePlus 11). 412 CSS - * stays below the 768px breakpoint so SP still renders its phone layout. */ - androidPhone: { width: 1236, height: 2745, deviceScaleFactor: 3, isMobile: true }, - /** Google Play 7" tablet landscape. CSS 960×600 @2x → 1920×1200. - * Portrait (600 CSS) was below the 768 desktop breakpoint and rendered - * the phone layout, which felt cramped — landscape stays in tablet - * layout and reads cleanly. */ - android7Tablet: { width: 1920, height: 1200, deviceScaleFactor: 2, isMobile: true }, - /** Google Play 10" tablet portrait. CSS 800×1280 @2x → 1600×2560. - * Standard 10" tablet portrait dimensions. */ - android10Tablet: { width: 1600, height: 2560, deviceScaleFactor: 2, isMobile: true }, + /** Google Play phone portrait. CSS 360×640 @3x → 1080×1920. */ + androidPhone: { width: 1080, height: 1920, deviceScaleFactor: 3, isMobile: true }, + /** Google Play 7" tablet portrait. CSS 600×960 @2x → 1200×1920. */ + android7Tablet: { width: 1200, height: 1920, deviceScaleFactor: 2, isMobile: true }, + /** Google Play 10" tablet landscape. CSS 960×600 @2x → 1920×1200. */ + android10Tablet: { width: 1920, height: 1200, deviceScaleFactor: 2, isMobile: true }, } as const satisfies Record; export type ViewportName = keyof typeof VIEWPORTS; @@ -77,22 +69,25 @@ export const MOBILE_VIEWPORTS = [ /** * "Phone-class": viewports < 768 CSS px wide where SP renders its mobile * layout (bottom-nav, no side panels). These run `scenarios/mobile/`. + * + * Note that `android7Tablet` is 600 CSS wide in portrait — below the 768 + * mobile-breakpoint — so it really is phone-class for layout purposes. */ export const PHONE_VIEWPORTS = [ 'iphone69', 'androidPhone', + 'android7Tablet', ] as const satisfies readonly ViewportName[]; /** * "Tablet-class": viewports ≥ 768 CSS px wide where SP renders the desktop - * layout (sidenav, panels) but the canvas is taller-than-wide (iPad / 10" - * portrait) or narrower than the desktopMaster (7" landscape). Routing these + * layout (sidenav, panels) but the canvas is taller-than-wide (iPad portrait) + * or narrower than the desktopMaster (Android 10" landscape). Routing these * to the desktop spec would push side panels off-screen; routing them to the * mobile spec wastes the canvas. They run `scenarios/tablet/` instead. */ export const TABLET_VIEWPORTS = [ 'ipad13', - 'android7Tablet', 'android10Tablet', ] as const satisfies readonly ViewportName[]; diff --git a/e2e/store-screenshots/print-output-path.ts b/e2e/store-screenshots/print-output-path.ts deleted file mode 100644 index 10aaff463c..0000000000 --- a/e2e/store-screenshots/print-output-path.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as fs from 'fs'; -import { MASTER_DIR_ELECTRON, MASTER_DIR_WEB } from './matrix'; - -const printOutputPath = (): void => { - const dir = - process.env.SCREENSHOT_MODE === 'electron' ? MASTER_DIR_ELECTRON : MASTER_DIR_WEB; - const exists = fs.existsSync(dir); - console.log( - `\nMaster captures: ${dir}${exists ? '' : ' (not created — no scenarios ran)'}`, - ); -}; - -export default printOutputPath; diff --git a/e2e/store-screenshots/scenarios/desktop/all.spec.ts b/e2e/store-screenshots/scenarios/desktop/all.spec.ts index eb1b4c79f6..8c65583c01 100644 --- a/e2e/store-screenshots/scenarios/desktop/all.spec.ts +++ b/e2e/store-screenshots/scenarios/desktop/all.spec.ts @@ -1,45 +1,40 @@ /** - * Single-session capture of every desktop scenario across both locales and - * both themes. Switches are driven via NgRx dispatch through - * `window.__e2eTestHelpers.store` (exposed in dev/stage builds — see - * src/main.ts), so we don't relaunch the browser / Electron app or - * re-import the seed between variants. + * Single-session capture of every desktop scenario across both locales, + * both themes, and the catppuccin custom theme. Switches are driven via + * NgRx dispatch through `window.__e2eTestHelpers.store` (exposed in dev/ + * stage builds — see src/main.ts), so we don't relaunch the + * browser / Electron app or re-import the seed between variants. * * Order: * en × dark (slots 01, 02, 03, 05) * en × light (slots 04, 06) * de × dark (slots 01, 02, 03, 05) * de × light (slots 04, 06) - * en × dark (slot 07 — project view, no wallpaper) + * en × dark × catppuccin-mocha (slot 07) */ import { test } from '../../fixture'; import { LOCALES, type Locale } from '../../matrix'; import { + applyCustomTheme, applyLocale, - applySideNavCollapsed, applyTheme, gotoAndSettle, openNotesPanel, openSchedulePanel, resetView, - showMarketingOverlay, } from '../../helpers'; -import { MARKETING_HEADLINE, MARKETING_SUBLINE } from '../../marketing-copy'; import type { Page } from '@playwright/test'; const captureDarkScenes = async ( page: Page, shoot: (scenario: string, name: string) => Promise, ): Promise => { - // 01 — Today + schedule day-panel open. Side nav collapsed so the right - // panel doesn't squeeze the task list. - await applySideNavCollapsed(page, true); + // 01 — Today + schedule day-panel open await gotoAndSettle(page, '/#/tag/TODAY/tasks'); await page.locator('task').first().waitFor({ state: 'visible' }); await openSchedulePanel(page); await page.waitForTimeout(500); await shoot('desktop-01-list-with-schedule', 'list-with-schedule'); - await applySideNavCollapsed(page, false); await resetView(page); // 02 — Eisenhower board @@ -58,7 +53,7 @@ const captureDarkScenes = async ( await shoot('desktop-03-schedule-dark', 'schedule-dark'); await resetView(page); - // 05 — Focus mode, running timer (not duration-selection). + // 05 — Focus mode (set current task first so the title renders). await gotoAndSettle(page, '/#/tag/TODAY/tasks'); const firstTask = page.locator('task').first(); await firstTask.waitFor({ state: 'visible' }); @@ -72,41 +67,20 @@ const captureDarkScenes = async ( .locator('focus-mode-overlay, focus-mode-main') .first() .waitFor({ state: 'visible', timeout: 10_000 }); - // Start the session, then skip the rocket countdown (5s + 900ms launch). - // The fixture pinned the clock with `page.clock.install`, so we have to - // advance simulated time to fire the rocket's RxJS timer + setTimeout. - await page.locator('focus-mode-main .play-button').click(); - await page.clock.runFor(6500); - await page - .locator('focus-mode-main .pause-resume-btn') - .waitFor({ state: 'visible', timeout: 10_000 }); - // Tick a minute of session time so the clock face shows progress and the - // remaining time isn't exactly the starting duration. - await page.clock.runFor(60_000); - await page.waitForTimeout(300); + await page.waitForTimeout(500); await shoot('desktop-05-focus-mode', 'focus-mode'); - await resetView(page); - - // 08 — Planner (desktop layout). Mobile already has planner-default and - // planner-expanded; desktop deserves its own canvas. - await gotoAndSettle(page, '/#/planner'); - await page.locator('planner, planner-day').first().waitFor({ state: 'visible' }); - await shoot('desktop-08-planner', 'planner'); }; const captureLightScenes = async ( page: Page, shoot: (scenario: string, name: string) => Promise, ): Promise => { - // 04 — Project (Work) + notes panel. Side nav collapsed so the notes - // panel can breathe. - await applySideNavCollapsed(page, true); + // 04 — Project (Work) + notes panel await gotoAndSettle(page, '/#/project/work/tasks'); await page.locator('task').first().waitFor({ state: 'visible' }); await openNotesPanel(page); await page.waitForTimeout(500); await shoot('desktop-04-list-with-notes', 'list-with-notes'); - await applySideNavCollapsed(page, false); await resetView(page); // 06 — Schedule (light variant) @@ -123,20 +97,8 @@ test.describe('@screenshot desktop all', () => { seededPage, screenshotMaster, }) => { - // Two locales × multiple scenarios can exceed the 180s default. - test.setTimeout(8 * 60 * 1000); const page = seededPage; - // 00 — Cover/hero. Today list with a marketing caption strip overlaid - // on top so the lead app-store gallery shot is branded but still shows - // the real app underneath. - await applyTheme(page, 'dark'); - await gotoAndSettle(page, '/#/tag/TODAY/tasks'); - await page.locator('task').first().waitFor({ state: 'visible' }); - await showMarketingOverlay(page, MARKETING_HEADLINE, MARKETING_SUBLINE); - await screenshotMaster('desktop-00-hero', 'hero'); - await resetView(page); - for (let i = 0; i < LOCALES.length; i += 1) { const lng = LOCALES[i] as Locale; // The seed already starts on `en`; only dispatch a switch when we move @@ -150,14 +112,13 @@ test.describe('@screenshot desktop all', () => { await captureLightScenes(page, screenshotMaster); } - // 07 — Project view, dark theme. Project contexts don't carry a - // background image (see seed/build-seed.ts: `applyBg` only runs on - // tag themes), so the regular palette reads cleanly without a - // wallpaper competing for attention. + // 07 — Catppuccin Mocha. Routed through a project view so the global + // background image is suppressed and the catppuccin palette reads clearly. await applyLocale(page, 'en'); await applyTheme(page, 'dark'); + await applyCustomTheme(page, 'catppuccin-mocha'); await gotoAndSettle(page, '/#/project/work/tasks'); await page.locator('task').first().waitFor({ state: 'visible' }); - await screenshotMaster('desktop-07-project-dark', 'project-dark'); + await screenshotMaster('desktop-07-project-catppuccin', 'project-catppuccin'); }); }); diff --git a/e2e/store-screenshots/scenarios/mobile/all.spec.ts b/e2e/store-screenshots/scenarios/mobile/all.spec.ts index dbea9bd41c..445afad0f3 100644 --- a/e2e/store-screenshots/scenarios/mobile/all.spec.ts +++ b/e2e/store-screenshots/scenarios/mobile/all.spec.ts @@ -10,16 +10,7 @@ */ import { test } from '../../fixture'; import { LOCALES, type Locale } from '../../matrix'; -import { - applyLocale, - applyTheme, - applyTimeTrackingEnabled, - gotoAndSettle, - resetView, - setPlannerCalendarExpanded, - showMarketingOverlay, -} from '../../helpers'; -import { MARKETING_HEADLINE, MARKETING_SUBLINE } from '../../marketing-copy'; +import { applyLocale, applyTheme, gotoAndSettle, resetView } from '../../helpers'; import type { Page } from '@playwright/test'; const captureDarkScenes = async ( @@ -32,11 +23,14 @@ const captureDarkScenes = async ( await shoot('mobile-01-planner', 'planner'); await resetView(page); - // 02 — Planner with calendar nav expanded (multi-week day picker) + // 02 — Planner expanded (calendar nav header click) await gotoAndSettle(page, '/#/planner'); await page.locator('planner, planner-day').first().waitFor({ state: 'visible' }); - await page.locator('planner-calendar-nav').waitFor({ state: 'visible' }); - await setPlannerCalendarExpanded(page, true); + const navHeader = page + .locator('planner-calendar-nav .header, planner-calendar-nav button') + .first(); + await navHeader.click({ trial: false }).catch(() => undefined); + await page.waitForTimeout(400); await shoot('mobile-02-planner-expanded', 'planner-expanded'); await resetView(page); @@ -66,11 +60,14 @@ const captureLightScenes = async ( page: Page, shoot: (scenario: string, name: string) => Promise, ): Promise => { - // 04 — Planner with calendar nav expanded (light variant) + // 04 — Planner expanded (light variant) await gotoAndSettle(page, '/#/planner'); await page.locator('planner, planner-day').first().waitFor({ state: 'visible' }); - await page.locator('planner-calendar-nav').waitFor({ state: 'visible' }); - await setPlannerCalendarExpanded(page, true); + const navHeader = page + .locator('planner-calendar-nav .header, planner-calendar-nav button') + .first(); + await navHeader.click({ trial: false }).catch(() => undefined); + await page.waitForTimeout(400); await shoot('mobile-04-planner-expanded-light', 'planner-expanded-light'); }; @@ -81,21 +78,8 @@ test.describe('@screenshot mobile all', () => { seededPage, screenshotMaster, }) => { - // 12 captures × ~15–20s each on a mobile viewport overruns the 180s default. - test.setTimeout(8 * 60 * 1000); const page = seededPage; - // Mobile rows look cleaner without the per-task play button column. - await applyTimeTrackingEnabled(page, false); - - // 00 — Cover/hero. Today list with a marketing caption strip overlaid. - await applyTheme(page, 'dark'); - await gotoAndSettle(page, '/#/tag/TODAY/tasks'); - await page.locator('task').first().waitFor({ state: 'visible' }); - await showMarketingOverlay(page, MARKETING_HEADLINE, MARKETING_SUBLINE); - await screenshotMaster('mobile-00-hero', 'hero'); - await resetView(page); - for (let i = 0; i < LOCALES.length; i += 1) { const lng = LOCALES[i] as Locale; if (i > 0) await applyLocale(page, lng); diff --git a/e2e/store-screenshots/scenarios/tablet/all.spec.ts b/e2e/store-screenshots/scenarios/tablet/all.spec.ts index ae8c40dac0..68dcf6b706 100644 --- a/e2e/store-screenshots/scenarios/tablet/all.spec.ts +++ b/e2e/store-screenshots/scenarios/tablet/all.spec.ts @@ -1,9 +1,9 @@ /** - * Tablet capture spec — runs against ipad13 (1032×1376 CSS portrait), - * android7Tablet (960×600 CSS landscape) and android10Tablet (800×1280 CSS - * portrait). All render SP's desktop layout (≥ 768 CSS px) but none has the - * room for the right-hand notes / schedule day-panel that the desktopMaster - * scenes rely on, so we keep the scenes panel-free. + * Tablet capture spec — runs against ipad13 (1032×1376 CSS portrait) and + * android10Tablet (960×600 CSS landscape). Both render SP's desktop layout + * (≥ 768 CSS px) but neither has the room for the right-hand notes / + * schedule day-panel that the desktopMaster scenes rely on, so we keep the + * scenes panel-free. * * Touch is emulated (`hasTouch: true` because the matrix marks these as * `isMobile`), so any hover-revealed action (e.g. desktop's @@ -14,13 +14,7 @@ * localized task content. */ import { test } from '../../fixture'; -import { - applyTheme, - gotoAndSettle, - resetView, - showMarketingOverlay, -} from '../../helpers'; -import { MARKETING_HEADLINE, MARKETING_SUBLINE } from '../../marketing-copy'; +import { applyTheme, gotoAndSettle, resetView } from '../../helpers'; import type { Page } from '@playwright/test'; const captureDarkScenes = async ( @@ -75,14 +69,6 @@ test.describe('@screenshot tablet all', () => { }) => { const page = seededPage; await applyTheme(page, 'dark'); - - // 00 — Cover/hero. Today list with a marketing caption strip overlaid. - await gotoAndSettle(page, '/#/tag/TODAY/tasks'); - await page.locator('task').first().waitFor({ state: 'visible' }); - await showMarketingOverlay(page, MARKETING_HEADLINE, MARKETING_SUBLINE); - await screenshotMaster('tablet-00-hero', 'hero'); - await resetView(page); - await captureDarkScenes(page, screenshotMaster); await applyTheme(page, 'light'); await captureLightScenes(page, screenshotMaster); diff --git a/e2e/store-screenshots/seed/seed.template.json b/e2e/store-screenshots/seed/seed.template.json index 6540d98f57..8a6cde6ba0 100644 --- a/e2e/store-screenshots/seed/seed.template.json +++ b/e2e/store-screenshots/seed/seed.template.json @@ -46,7 +46,7 @@ "id": "personal", "title": "Personal", "isHiddenFromMenu": false, - "taskIds": ["t-call-mom"], + "taskIds": ["t-yoga", "t-call-mom"], "backlogTaskIds": ["t-book-flight", "t-renew-passport"], "noteIds": ["n-ideas", "n-week-goals"], "theme": { @@ -269,6 +269,7 @@ "t-quarterly-plan", "t-quarterly-plan-sub-1", "t-quarterly-plan-sub-2", + "t-yoga", "t-call-mom", "t-book-flight", "t-renew-passport", @@ -303,7 +304,7 @@ "timeEstimate": 2700000, "isDone": false, "notes": "", - "tagIds": ["EM_URGENT", "EM_IMPORTANT"], + "tagIds": ["tag-deep-work", "EM_URGENT", "EM_IMPORTANT"], "created": 0, "attachments": [], "dueDayOffset": 0, @@ -331,7 +332,7 @@ "timeSpentOnDay": {}, "timeSpent": 0, "timeEstimate": 1800000, - "isDone": false, + "isDone": true, "notes": "", "tagIds": [], "parentId": "t-quarterly-plan", @@ -354,6 +355,22 @@ "attachments": [], "projectId": "work" }, + "t-yoga": { + "id": "t-yoga", + "title": "Morning yoga", + "subTaskIds": [], + "timeSpentOnDayKeysAreOffsets": true, + "timeSpentOnDayOffsets": { "0": 1500000 }, + "timeSpent": 1500000, + "timeEstimate": 1800000, + "isDone": true, + "notes": "", + "tagIds": ["tag-home"], + "created": 0, + "attachments": [], + "dueDayOffset": 0, + "projectId": "personal" + }, "t-call-mom": { "id": "t-call-mom", "title": "Call mom", @@ -491,6 +508,7 @@ "id": "TODAY", "title": "Today", "taskIds": [ + "t-yoga", "t-pr-review", "t-call-mom", "t-design-review", @@ -590,7 +608,7 @@ "tag-home": { "id": "tag-home", "title": "home", - "taskIds": [], + "taskIds": ["t-yoga"], "icon": "home", "created": 0, "theme": { diff --git a/e2e/store-video/CLAUDE.md b/e2e/store-video/CLAUDE.md new file mode 100644 index 0000000000..2aff062d99 --- /dev/null +++ b/e2e/store-video/CLAUDE.md @@ -0,0 +1,120 @@ +# Marketing reel pipeline + +Playwright-driven generation of the marketing gif/video for the landing page and GitHub README. Mirrors the screenshot pipeline (`e2e/store-screenshots/`) — same fixture/seed plumbing, similar npm-script shape. + +## Run + +```bash +npm run video # tight default (~17s) → dist/video/reel*.{mp4,webm,gif} +npm run video:full # full variant (~21s) → dist/video/reel-full*.{...} + +# under the hood +npm run video:capture # Playwright records to .tmp/video/recordings/ +npm run video:build # ffmpeg → dist/video/, picks the most recent webm +``` + +`REEL_VARIANT=` switches the spec branch and adds a filename suffix so multiple variants coexist in `dist/video/`. `full` is the only one wired up so far; add more by branching on `isFull`-style flags in the spec. + +`gifsicle` is optional — the build script falls back to ffmpeg's gif if it's missing. With it installed, you also get `reel-optimized.gif` (~30% smaller). + +## 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. Reuses the screenshot pipeline's seed builder. Init scripts handle: cursor highlight ring, dialog/snack/tooltip/mention suppression, app zoom. | +| `store-video/overlays.ts` | DOM-injected overlay primitives: `showOverlay`, `showIntegrationsCard`, `showEndCard`, `cutToScene`, `fadeTransition`, `loopBoundary`, `attachDragGhost`. Plus inline brand SVGs in the `LOGOS` constant. | +| `store-video/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. | + +## Beat structure (current) + +``` +Lead-in black fades to SP UI with schedule day-panel already open +1 Capture in seconds. global add-task-bar (scaled 1.45×, max-width + 520px); types "A task 1h #urgent @17" with + 55ms keystroke delay. Captured task carries + through to beats 2 and 3. +1.5 [full only] No account. No tracking. +2 Plan your day. drags first task onto schedule panel with + `attachDragGhost` cloning the source element + under the cursor. +3 Focus on what matters. dispatches `[Task] SetCurrentTask` / + `[FocusMode] Show Overlay` / + `[FocusMode] Start Session` directly via + `__e2eTestHelpers.store`. `clock.runFor(5500)` + skips the 5s countdown; `clock.resume()` lets + the timer tick during the hold. +4 Plays well with GitHub, full-screen integrations card. Title in a + Jira & more. lower-third bar at `bottom: 72px`. Logos + centered above; brand colors on GitLab + (#fc6d26) and Jira (#2684ff). Subtitle "& many + more" below the grid. +5 Free and open source. end card with monochrome SP logo, animated + stat counter-ups (★ 19K, 4.8 ★) staggered by + 280ms, platforms line on one line. +Boundary black fades in so the gif loop seam is black-to-black +``` + +## Scene transitions: cutToScene + +Every beat handoff uses `cutToScene(page, async () => { ... })` for a fade-to-black scene cut. The helper: + +1. Fades a max-z-index black overlay from transparent to opaque (covers everything, including any beat overlays/cards). +2. Runs the callback while the screen is fully black — this is where state changes happen (close add-task-bar, dispatch focus mode, hide previous overlay, prime next overlay/card). +3. Fades the black back out to reveal whatever the callback set up. + +**Always pass `noWait: true` to the next overlay/card inside the callback.** Without it, the call awaits its own fade-in (and stagger animation, for `showIntegrationsCard`/`showEndCard`) — which would play behind the still-opaque black and be wasted. With `noWait`, the call returns as soon as the DOM is in place, so the fade-in animates concurrently with `cutToScene`'s fade-from-black. The viewer sees: scene → black → next-scene-emerging-with-its-animation. + +`fadeTransition` (partial-dim, lower z-index) is no longer used by the main spec — it was unreliable for big scene jumps because the dim's transparent regions let the underlying state change show through. Kept in `overlays.ts` in case a future beat wants a softer mid-beat dim, but `cutToScene` is the default. + +## Architecture decisions / gotchas + +**Trim sidecar.** The recording necessarily includes ~14s of seed-import navigation before the choreography starts. The fixture stamps `recordingState.startMs` at context creation; the spec calls `markBeatsStart()` when ready; the delta lands in `.tmp/video/recordings/_latest-trim.json` and ffmpeg `-ss` skips past it. **Don't try to inject the seed via IndexedDB** — the addInitScript timing race vs. SP's IDB-read is genuinely unsafe and the trim sidecar handles host-speed variance correctly. + +**Page clock.** `page.clock.install({ time: SCREENSHOT_BASE_DATE })` is on by default (inherited from screenshot fixture). This freezes `Date.now`, `setTimeout`, `setInterval`, and `requestAnimationFrame` in the page until you call `clock.runFor(ms)` or `clock.resume()`. Beat 3 calls `runFor(5500)` to skip the 5s focus-mode countdown, then `resume()` so the running timer ticks naturally and end-card stat counters animate (they use rAF). + +**Material snack bars and dialogs.** Hidden via CSS in the fixture init script (`.cdk-overlay-pane:has(.mat-mdc-dialog-container)`, `.mat-mdc-snack-bar-container`, plus `mention-list`, `.mention-menu`, `.add-task-bar-panel`). With the clock installed, snack auto-dismiss timers don't fire on their own — hiding them is cleaner than waiting. focus-mode-overlay isn't a mat-dialog so it's unaffected. + +**App zoom.** `app-root { zoom: 1.4 }` in fixture init "zooms in" on the SP UI without shrinking the recording canvas. At 1.4 the inner viewport for the layout is 1024/1.4 ≈ 731px — enough for the work-view + collapsed sidenav + 240-wide right panel without clipping. Earlier 1.5 was cropping the right edge of the work view. Earlier 1.4 iterations *also* stacked `transform: scale(1.45)` on the add-task-bar, but that compounded badly with the zoom and clipped past the viewport — letting `app-root zoom` do the work alone is simpler. Overlays are siblings of `app-root` in the DOM tree (appended to `body`), so they're unaffected by this zoom — design overlay sizes against the un-zoomed viewport. + +**Schedule day-panel width.** Pre-seeded in `ONBOARDING_INIT` via `localStorage.setItem('SUP_RIGHT_PANEL_WIDTH', '250')` — that's `RIGHT_PANEL_CONFIG.MIN_WIDTH`, the smallest the panel allows before its 200px CLOSE_THRESHOLD kicks in. Pre-seeding through the panel's own persistence path means the inner schedule grid computes its column widths against 250 and the event blocks don't overflow. Earlier iterations forced `width !important` on `.side`, which sized the chrome but didn't propagate to the grid — events then spilled past the panel's right edge and required ugly `overflow-x: hidden` belt-and-braces clips. Don't go that route. + +**Add-task-bar overlays.** The fixture only hides the *overlay surfaces* that pop on top of the bar while typing (mat-autocomplete suggestions, mention-list, loading spinner) — those would otherwise read as glitchy white boxes mid-gif. The bar itself is not styled by the fixture; it uses its real `:host` rules. + +**Cursor highlight.** Soft white radial-gradient ring at `z-index: 2147483640`, follows mousemove. Toggle visibility per-beat via `body.__sp-hide-cursor-highlight` — used during the capture beat where the focused input would otherwise show the ring as a stray dot. + +**Main-text consistency.** `.__sp-video-overlay-text`, `.__sp-video-int-card-title`, `.__sp-video-end-card-title` share a single rule with `font-size: clamp(48px, 6.4vw, 96px) !important`. The `!important` flag is required because `.mat-typography h1` has specificity (0,1,1) which outranks our class-only selector. Card titles are `

` rather than `

` for belt-and-braces — even with `!important`, the typography font shorthand can sneak through. + +**Drag ghost.** Beat 2 uses `attachDragGhost(page, sourceLocator)` which clones the source via `outerHTML`, attaches a mousemove listener, and follows the cursor with a 2° tilt + drop shadow. Detaches on mouse-up. SP's cdkDrag may or may not show its own preview depending on the drop target's `cdkDropList` wiring; the ghost guarantees the act of dragging reads regardless. + +**Loop boundary.** `loopBoundary(page, 'in', ms)` shows full-black opacity 1 then fades to 0 over `ms` (lead-in). `loopBoundary(page, 'out', ms)` fades from 0 to 1 (closing). Gif seam is black-to-black, no jump cut. `z-index: 2147483647` (max safe) so it covers everything including end card. + +**Build script picks most-recent webm.** No need to clean `.tmp/video/recordings/` between runs. Old webms accumulate but only the most recent `.mtime` is built into outputs. + +**Variant filename suffix.** `build-video.ts` reads `process.env.REEL_VARIANT` and appends `-${variant}` to all output filenames when set. `npm run video:full` works because env vars propagate through `npm run`. + +## Iteration loop + +1. Edit `reel.spec.ts` (copy, beat order, durations) or `overlays.ts` (visual styling). +2. `npm run checkFile ` on every changed `.ts` file (per project CLAUDE.md). +3. `npm run video` — capture (~32s) + build (~10s). +4. Open `dist/video/reel-optimized.gif`. + +**Don't** put backticks inside CSS comments in template literals — they're parsed as nested template expressions and break TypeScript. Multiple iterations have stumbled on this. + +**Don't** use mixed `+`/`*` operators without parentheses in spec coordinates — eslint `no-mixed-operators` will fail. Use intermediate `const` or wrap in parens. + +## 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.** 16:9 (1920×1080) for landing-page hero, 9:16 (1080×1920) for mobile social. Different `VIDEO_SIZE` per matrix entry. +- **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. +- **End-card "Open the app →" CTA.** Chip-styled visual cue; not a real link. +- **Tighten further.** Current default lands at ~17s; could push to ~14s by tightening drag pause times. + +## Coordinates with project-wide CLAUDE.md + +Lives under `e2e/`, so `e2e/CLAUDE.md` rules also apply (test template, fixture conventions). Linting via `npm run checkFile` per the root project guidance. No translations affected — all overlay copy is hard-coded English in the spec for now. diff --git a/e2e/store-video/build-video.ts b/e2e/store-video/build-video.ts new file mode 100644 index 0000000000..5c8a20960e --- /dev/null +++ b/e2e/store-video/build-video.ts @@ -0,0 +1,211 @@ +/** + * Post-process the latest Playwright video capture into shippable formats: + * - reel.mp4 1920×1080, 30fps, H.264 yuv420p landing-page fallback + * - reel.webm 1920×1080, 30fps, VP9 landing-page primary + * - reel.gif 1024 wide, 24fps, two-pass palette README embed + * + * Inputs: the most recent `.webm` under `.tmp/video/recordings/` (where the + * fixture's `recordVideo` setting writes). Outputs: `dist/video/`. + * + * ffmpeg is required; gifsicle is optional (used to shrink the gif if present). + * + * Run: npm run video:build + */ + +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const REPO_ROOT = path.resolve(__dirname, '..', '..'); +const RECORDINGS_DIR = path.join(REPO_ROOT, '.tmp', 'video', 'recordings'); +const TRIM_SIDECAR_PATH = path.join(RECORDINGS_DIR, '_latest-trim.json'); +const OUT_DIR = path.join(REPO_ROOT, 'dist', 'video'); + +/** + * Variant suffix applied to output filenames. `REEL_VARIANT=full` produces + * `reel-full.{mp4,webm,gif}` so the tight default and the uncut version + * can coexist in `dist/video/`. Set the same env var for capture so both + * runs land on the same suffix. + */ +const VARIANT = process.env.REEL_VARIANT ?? ''; +const SUFFIX = VARIANT ? `-${VARIANT}` : ''; + +/** + * Read the trim offset (seconds) from the sidecar the fixture writes when + * `markBeatsStart()` is called. Falls back to 0 (no trim) if missing or + * malformed. `VIDEO_TRIM_OVERRIDE` env var wins for manual overrides. + */ +const readTrimSeconds = (): number => { + const override = process.env.VIDEO_TRIM_OVERRIDE; + if (override !== undefined) { + const n = Number(override); + if (Number.isFinite(n) && n >= 0) return n; + } + if (!fs.existsSync(TRIM_SIDECAR_PATH)) return 0; + try { + const { offsetMs } = JSON.parse(fs.readFileSync(TRIM_SIDECAR_PATH, 'utf8')); + if (typeof offsetMs === 'number' && Number.isFinite(offsetMs) && offsetMs >= 0) { + return offsetMs / 1000; + } + } catch { + /* fall through */ + } + return 0; +}; + +const findMostRecentWebm = (root: string): string => { + if (!fs.existsSync(root)) { + throw new Error(`${root} does not exist. Run \`npm run video:capture\` first.`); + } + const candidates: { file: string; mtime: number }[] = []; + const walk = (dir: string): void => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) walk(p); + else if (entry.isFile() && entry.name.endsWith('.webm')) { + candidates.push({ file: p, mtime: fs.statSync(p).mtimeMs }); + } + } + }; + walk(root); + if (candidates.length === 0) { + throw new Error(`No .webm files under ${root}. Did the capture run produce a video?`); + } + candidates.sort((a, b) => b.mtime - a.mtime); + return candidates[0].file; +}; + +const run = (cmd: string, args: string[]): void => { + const r = spawnSync(cmd, args, { stdio: 'inherit' }); + if (r.error) throw r.error; + if (r.status !== 0) throw new Error(`${cmd} exited with status ${r.status}`); +}; + +const has = (cmd: string): boolean => { + // Probe by `which` rather than running the command: ffmpeg and gifsicle + // disagree on `-version` vs `--version` flags, and either may exit non-zero + // for harmless reasons. `which` only cares whether the binary is on PATH. + const r = spawnSync('which', [cmd], { stdio: 'ignore' }); + return r.status === 0; +}; + +const main = (): void => { + if (!has('ffmpeg')) { + throw new Error('ffmpeg not found in PATH — install ffmpeg first.'); + } + fs.mkdirSync(OUT_DIR, { recursive: true }); + + const src = findMostRecentWebm(RECORDINGS_DIR); + console.log(`[video] source: ${path.relative(REPO_ROOT, src)}`); + + const trimSeconds = readTrimSeconds(); + // `-ss` BEFORE `-i` is input seek (fast, frame-accurate when re-encoding). + // Empty array when no trim, so the ffmpeg arg list is clean. + const trimArgs = trimSeconds > 0 ? ['-ss', trimSeconds.toFixed(3)] : []; + if (trimSeconds > 0) { + console.log( + `[video] trimming first ${trimSeconds.toFixed(3)}s (seed-import lead-in)`, + ); + } + + const mp4 = path.join(OUT_DIR, `reel${SUFFIX}.mp4`); + const webm = path.join(OUT_DIR, `reel${SUFFIX}.webm`); + const palette = path.join(OUT_DIR, `.palette${SUFFIX}.png`); + const gif = path.join(OUT_DIR, `reel${SUFFIX}.gif`); + + // 1. mp4 — Playwright records VFR; force CFR 30fps for predictable playback. + console.log('[video] -> mp4'); + run('ffmpeg', [ + '-y', + ...trimArgs, + '-i', + src, + '-c:v', + 'libx264', + '-preset', + 'slow', + '-crf', + '20', + '-pix_fmt', + 'yuv420p', + '-vf', + 'fps=30', + '-movflags', + '+faststart', + '-an', + mp4, + ]); + + // 2. webm — VP9 at the same quality is materially smaller than H.264. + console.log('[video] -> webm'); + run('ffmpeg', [ + '-y', + ...trimArgs, + '-i', + src, + '-c:v', + 'libvpx-vp9', + '-crf', + '32', + '-b:v', + '0', + '-pix_fmt', + 'yuv420p', + '-vf', + 'fps=30', + '-an', + webm, + ]); + + // 3. gif — two-pass palette is non-negotiable for tolerable color quality. + // `stats_mode=full` (vs `diff`) builds the palette from every pixel of + // every frame, which gives more even coverage of the intermediate + // brightness levels we hit during fade-to-black scene cuts. With `diff` + // the palette skews toward "changing pixels" and the in-fade frames + // banded visibly. `dither=sierra2_4a` is an error-diffusion dither + // that's smoother for gradients than the previous `bayer` (Bayer's + // fixed pattern reads as a static crosshatch at low contrast). + console.log('[video] -> gif (palette pass)'); + run('ffmpeg', [ + '-y', + ...trimArgs, + '-i', + src, + '-vf', + 'fps=24,scale=1024:-1:flags=lanczos,palettegen=stats_mode=full', + palette, + ]); + console.log('[video] -> gif (paletteuse)'); + run('ffmpeg', [ + '-y', + ...trimArgs, + '-i', + src, + '-i', + palette, + '-lavfi', + 'fps=24,scale=1024:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=sierra2_4a', + gif, + ]); + fs.unlinkSync(palette); + + if (has('gifsicle')) { + console.log(`[video] -> reel${SUFFIX}-optimized.gif (gifsicle -O3 --lossy=80)`); + const optimized = path.join(OUT_DIR, `reel${SUFFIX}-optimized.gif`); + run('gifsicle', ['-O3', '--lossy=80', gif, '-o', optimized]); + } else { + console.log( + '[video] gifsicle not installed; skipping optimization (install for ~30% smaller gif).', + ); + } + + console.log('[video] outputs:'); + for (const f of fs.readdirSync(OUT_DIR)) { + const full = path.join(OUT_DIR, f); + if (!fs.statSync(full).isFile()) continue; + const kb = (fs.statSync(full).size / 1024).toFixed(0); + console.log(` ${path.relative(REPO_ROOT, full)} ${kb} KB`); + } +}; + +main(); diff --git a/e2e/store-video/fixture.ts b/e2e/store-video/fixture.ts new file mode 100644 index 0000000000..88159de606 --- /dev/null +++ b/e2e/store-video/fixture.ts @@ -0,0 +1,303 @@ +/** + * Video pipeline fixture. Mirrors the web-mode setup of the screenshot fixture + * (`e2e/store-screenshots/fixture.ts`) but creates its own browser context + * with `recordVideo` enabled, since `browser.newContext()` does not inherit + * the project's `use.video` setting. + * + * Recording lands in `.tmp/video/recordings/.webm` after the page + * closes; `build-video.ts` picks the most recent one. + * + * Trim handling: the recording necessarily includes ~16s of seed-import + * navigation before the choreographed beats begin. The fixture timestamps the + * moment of context creation; the spec calls `markBeatsStart()` once seeded + * and ready. The delta (an offset in ms) is written to a sidecar JSON that + * `build-video.ts` consumes, so ffmpeg can `-ss` past the lead-in. Single- + * worker assumption — the sidecar is shared, which is fine because we run + * `workers: 1`. + */ + +import { test as base, type Page } from '@playwright/test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { ImportPage } from '../pages/import.page'; +import { writeSeedFile } from '../store-screenshots/seed/build-seed'; +import { + SCREENSHOT_BASE_DATE, + type Locale, + type Theme, +} from '../store-screenshots/matrix'; +import { waitForAppReady } from '../utils/waits'; + +const REPO_ROOT = path.resolve(__dirname, '..', '..'); +const SEED_DIR = path.join(REPO_ROOT, '.tmp', 'video-seeds'); +const RECORDING_DIR = path.join(REPO_ROOT, '.tmp', 'video', 'recordings'); +const TRIM_SIDECAR_PATH = path.join(RECORDING_DIR, '_latest-trim.json'); + +// Viewport == recording size, otherwise Playwright pads the smaller axis with +// gray. Square 1024×1024 plays well on social embeds and matches the rhythm +// of the GitHub README. deviceScaleFactor 2 renders the page at 2× physical +// pixels (2048×2048) which Playwright then downsamples into 1024×1024 — +// sharp text on the gif. Cost: ~2× CPU/memory during capture. +const VIDEO_SIZE = { width: 1024, height: 1024 } as const; +const DEVICE_SCALE_FACTOR = 2; + +type VideoFixtures = { + locale: Locale; + theme: Theme; + customTheme: string | undefined; + seedFile: string; + seededPage: Page; + /** + * Call once the app is in the desired starting state (post-seed-import, + * post-settle) and the choreographed beats are about to begin. The fixture + * writes a sidecar JSON with the offset so `build-video.ts` can trim the + * recording's lead-in. + */ + markBeatsStart: () => void; +}; + +const ONBOARDING_INIT = (): void => { + localStorage.setItem('SUP_ONBOARDING_PRESET_DONE', 'true'); + localStorage.setItem('SUP_ONBOARDING_HINTS_DONE', 'true'); + localStorage.setItem('SUP_IS_SHOW_TOUR', 'true'); + localStorage.setItem('SUP_EXAMPLE_TASKS_CREATED', 'true'); + // Collapsed icon-only sidenav for the reel — denser content, more "app + // feels alive" framing without the wider expanded sidebar. + localStorage.setItem('SUP_NAV_SIDEBAR_EXPANDED', 'false'); + // Right panel narrowed to RIGHT_PANEL_CONFIG.MIN_WIDTH (250px). This is + // the smallest width the panel allows before its close-threshold kicks + // in — pre-seeding via the panel's own persistence path means the + // schedule grid inside computes its column widths against 250px and + // doesn't overflow the panel's right edge. Earlier iterations forced + // `width !important` on .side, which sized the chrome but didn't tell + // the schedule grid, leaving event blocks spilling past the viewport. + localStorage.setItem('SUP_RIGHT_PANEL_WIDTH', '250'); +}; + +// Shared between the page fixture (writer) and markBeatsStart (reader). +// Single-worker invariant — see file header. +const recordingState: { startMs: number } = { startMs: 0 }; + +export const test = base.extend({ + locale: ['en', { option: true }] as never, + theme: ['dark', { option: true }] as never, + customTheme: [undefined, { option: true }] as never, + + seedFile: async ({ locale, customTheme }, use) => { + const file = writeSeedFile(SCREENSHOT_BASE_DATE, SEED_DIR, { + locale, + customTheme, + }); + await use(file); + }, + + // Override the default page fixture so we can pass `recordVideo` to the + // context. `use.video` from the project config only applies to Playwright's + // built-in default context. + page: async ({ browser, baseURL, theme, locale }, use, testInfo) => { + // Captured as close to context creation as possible. A ~tens-of-ms delay + // 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 context = await browser.newContext({ + baseURL: baseURL ?? 'http://localhost:4242', + userAgent: `PLAYWRIGHT-VIDEO-${testInfo.workerIndex}`, + storageState: undefined, + // Pin navigator.language so ImportPage's English text matchers work + // regardless of host locale. UI locale is switched after seed import. + locale: 'en-US', + viewport: VIDEO_SIZE, + deviceScaleFactor: DEVICE_SCALE_FACTOR, + recordVideo: { + dir: RECORDING_DIR, + size: VIDEO_SIZE, + }, + }); + const page = await context.newPage(); + + await page.clock.install({ time: SCREENSHOT_BASE_DATE }); + + await page.addInitScript(ONBOARDING_INIT); + await page.addInitScript((darkMode) => { + try { + localStorage.setItem('DARK_MODE', darkMode); + } catch { + /* noop */ + } + }, theme); + await page.addInitScript((initialLocale) => { + (window as unknown as { __spCurrentLocale?: string }).__spCurrentLocale = + initialLocale; + }, locale); + // 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 + // cards which sit higher) so it's automatically hidden during beat 4/5. + // 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 }); + }); + + // Suppress UI noise that fights with the choreographed reel: + // - Material/CDK tooltips (cursor lingering would otherwise pop one) + // - Reminder dialogs (clock.runFor in beat 3 advances time and would + // otherwise trigger the seed's task reminders mid-recording) + // - app-root zoom (1.4) — visually "zooms in" on the SP UI without + // shrinking the recording canvas. The add-task-bar uses its real + // default styles (max-width 720, width 90%); at zoom 1.4 inside a + // 1024 viewport that lands well inside the frame. Earlier 1.5 was + // cropping the right edge of the work view; 1.4 leaves enough + // inner-viewport (731px) for the layout to breathe. Overlays are + // siblings of app-root in the DOM tree, so they are unaffected by + // this zoom. + await page.addInitScript(() => { + const style = document.createElement('style'); + style.id = '__sp-video-injected-styles'; + style.textContent = ` + mat-tooltip-component, + .mat-mdc-tooltip, + .cdk-overlay-container .mat-mdc-tooltip, + .cdk-overlay-container .mat-tooltip, + .cdk-overlay-container [role="tooltip"] { + visibility: hidden !important; + opacity: 0 !important; + } + /* Hide every Material dialog that would modal over the reel — + reminders, scheduling confirmation, anything else. The reel + doesn't legitimately need any modal, and per-selector :has() + rules kept missing variants. Targeting the dialog container + class catches them all. focus-mode-overlay is its own element, + not a mat-dialog, so it's unaffected. */ + .cdk-overlay-pane:has(.mat-mdc-dialog-container), + .cdk-overlay-pane:has(mat-dialog-container) { + display: none !important; + } + /* Hide every Material snack bar — beat 1's task-add and beat 4's + focus-mode-exit both fire snacks ("Task added", "Deleted + Reminder") that would otherwise sit at the bottom of the frame + into the next beat. Clock is installed throughout most of the + reel, so the snacks' auto-dismiss timers don't fire on their + own. Hide them outright. */ + .mat-mdc-snack-bar-container, + snack-custom, + .cdk-overlay-pane:has(snack-custom), + .cdk-overlay-pane:has(.mat-mdc-snack-bar-container) { + display: none !important; + } + app-root { + zoom: 1.4; + } + /* 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 + handles sizing correctly so the schedule grid inside computes + its column widths properly. */ + /* Hide overlays that pop on top of the add-task-bar while typing + — these aren't "styles" on the bar itself, they're separate + cdk-overlay surfaces that would otherwise read as glitchy + white boxes on the gif: + - mat-autocomplete dropdown (suggestion list) + - mention-list (#tag and @due dropdowns from short syntax) + - search loading spinner */ + .mat-mdc-autocomplete-panel.add-task-bar-panel, + .cdk-overlay-pane:has(.add-task-bar-panel) { + display: none !important; + } + mention-list, + .mention-menu, + .dropdown-menu.scrollable-menu { + display: none !important; + } + add-task-bar .spinner, + add-task-bar mat-spinner { + display: none !important; + } + `; + const attach = (): void => { + if (!document.getElementById(style.id)) document.head.appendChild(style); + }; + if (document.head) attach(); + else document.addEventListener('DOMContentLoaded', attach, { once: true }); + }); + + page.on('pageerror', (err) => { + console.error('[video pageerror]', err.message); + }); + + await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await waitForAppReady(page); + + try { + await use(page); + } finally { + // Closing the context flushes the video to disk under RECORDING_DIR. + if (!page.isClosed()) await page.close(); + await context.close(); + } + }, + + seededPage: async ({ page, seedFile }, use) => { + const importPage = new ImportPage(page); + await importPage.navigateToImportPage(); + await importPage.importBackupFile(seedFile); + await waitForAppReady(page); + await use(page); + }, + + markBeatsStart: async ({}, use) => { + let beatsMs: number | null = null; + await use(() => { + beatsMs = Date.now(); + }); + if (beatsMs == null) return; + const offsetMs = beatsMs - recordingState.startMs; + try { + fs.mkdirSync(RECORDING_DIR, { recursive: true }); + fs.writeFileSync( + TRIM_SIDECAR_PATH, + JSON.stringify({ offsetMs, recordedAtMs: beatsMs }, null, 2), + ); + } catch (err) { + console.warn(`[video] failed to write trim sidecar: ${(err as Error).message}`); + } + }, +}); + +export { expect } from '@playwright/test'; diff --git a/e2e/store-video/overlays.ts b/e2e/store-video/overlays.ts new file mode 100644 index 0000000000..31fe5ef9d8 --- /dev/null +++ b/e2e/store-video/overlays.ts @@ -0,0 +1,833 @@ +/** + * Text overlays for marketing-reel beats. Injected as fixed-position DOM via + * `page.evaluate()` so they render in the app's actual fonts, layer cleanly on + * the live UI, fade in/out via CSS transitions, and stay diffable as code + * (copy changes are PRs, not Kdenlive title clips). + * + * const overlay = await showOverlay(page, 'No account. No tracking.'); + * await page.waitForTimeout(1500); + * await overlay.hide(); + * + * For the closing brand frame, use `showEndCard` instead — full-viewport, + * solid backdrop, multi-line layout. + */ + +import type { Page } from '@playwright/test'; + +export type OverlayPosition = 'center' | 'lower'; + +export type OverlayChip = { + /** Inline SVG string (use one of the `LOGOS` constants below). */ + svg?: string; + /** Optional text after the icon. */ + label?: string; +}; + +export type OverlayOptions = { + /** Vertical placement; defaults to 'lower' (lower-third title bar). */ + position?: OverlayPosition; + /** Fade transition duration in ms (applied to both show and hide). */ + fadeMs?: number; + /** + * Optional row of icon chips below the text. Used by beats that name + * external integrations (GitHub, Jira, …) — chips visually reinforce the + * copy without forcing the viewer to parse it. + */ + chips?: OverlayChip[]; + /** + * If true, return as soon as the DOM is set up (the visible class has + * been added so the fade-in is now running). The caller is responsible + * for letting it play out — useful when this call lands inside a + * `cutToScene` callback so the fade plays *during* the fade-from-black. + */ + noWait?: boolean; +}; + +/** + * Pre-built brand SVGs (Simple Icons) plus a Material "more" glyph. Inlined + * here so overlays render with no network fetch and no asset pipeline. Trust + * boundary: these strings are checked into the repo, not user input — safe + * for innerHTML. + */ +export const LOGOS = { + github: + '', + gitlab: + '', + jira: '', + calendar: + '', + trello: + '', + linear: + '', + more: '', +} as const; + +export type OverlayHandle = { + hide: () => Promise; +}; + +/** + * One line on the end card. Plain string renders as-is. The object form + * animates the `{n}` placeholder in `template` from 0 up to `to` over the + * card's reveal — gives an otherwise static frame a beat of motion. + * { template: '★ {n}K on GitHub', to: 19 } → "★ 19K on GitHub" + * { template: '{n} ★ on Google Play', to: 4.8, decimals: 1 } + * + * Animation requires the page clock to be running (call `page.clock.resume()` + * in the spec before showing the card if it was previously installed). + */ +export type EndCardStat = string | { template: string; to: number; decimals?: number }; + +export type EndCardContent = { + title: string; + subtitle?: string; + /** One line per entry. Rendered stacked under the subtitle. */ + stats?: EndCardStat[]; + /** + * Optional logo above the title. URL is served by the dev server. + * `monochrome: true` applies a brightness/invert filter so the image + * renders as a pure-white silhouette on the dark backdrop. + */ + logo?: { src: string; alt?: string; monochrome?: boolean }; +}; + +export type IntegrationsLogo = { + /** Inline SVG string from `LOGOS`. */ + svg: string; + label: string; + /** + * Optional brand color for the SVG. Maps to CSS `color`, which the SVGs + * inherit via `fill="currentColor"`. Defaults to white. + */ + color?: string; +}; + +export type IntegrationsCardContent = { + title: string; + /** Logo grid; works best with 3-6 entries. */ + logos: IntegrationsLogo[]; + /** Optional small text below the grid (e.g. "+ more integrations"). */ + subtitle?: string; +}; + +const STYLE_ID = '__sp-video-overlay-style'; +const OVERLAY_ID_PREFIX = '__sp-video-overlay-'; +const END_CARD_ID = '__sp-video-end-card'; + +let overlayCounter = 0; + +const ensureStyleInjected = async (page: Page): Promise => { + await page.evaluate((id) => { + if (document.getElementById(id)) return; + const style = document.createElement('style'); + style.id = id; + style.textContent = ` + .__sp-video-overlay { + position: fixed; + left: 0; + right: 0; + /* Above fadeTransition (2147483640) so overlay text reads through the + dim while the underlying app changes. End/integration cards are + higher still so they cover the overlay when they take over. */ + z-index: 2147483641; + pointer-events: none; + opacity: 0; + transition: opacity var(--__sp-fade-ms, 350ms) ease-out; + } + .__sp-video-overlay.lower { bottom: 0; } + .__sp-video-overlay.center { + top: 50%; + display: flex; + justify-content: center; + align-items: center; + transform: translateY(-50%); + } + .__sp-video-overlay.visible { opacity: 1; } + .__sp-video-overlay-bg { + background: #000; + padding: 32px 60px; + display: flex; + flex-direction: column; + align-items: center; + transform: translateY(24px); + transition: transform var(--__sp-fade-ms, 350ms) ease-out; + } + .__sp-video-overlay.visible .__sp-video-overlay-bg { + transform: translateY(0); + } + .__sp-video-overlay.center .__sp-video-overlay-bg { + border-radius: 14px; + max-width: 80vw; + } + .__sp-video-overlay-text, + .__sp-video-int-card-title, + .__sp-video-end-card-title { + color: #fff; + font-family: Roboto, "Inter", system-ui, sans-serif; + font-weight: 600 !important; + /* All "main text" across the reel renders at one size — single voice. + !important guards against .mat-typography h1 (specificity 0,1,1) + which would otherwise outrank our class selector. */ + font-size: clamp(48px, 6.4vw, 96px) !important; + letter-spacing: -0.02em !important; + line-height: 1.15 !important; + text-align: center; + margin: 0; + } + .__sp-video-overlay-chips { + display: flex; + justify-content: center; + gap: 16px; + margin-top: 22px; + flex-wrap: wrap; + } + .__sp-video-overlay-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 999px; + color: #fff; + font-family: Roboto, "Inter", system-ui, sans-serif; + font-weight: 500; + font-size: clamp(22px, 2vw, 32px); + } + .__sp-video-overlay-chip svg { + width: 1.15em; + height: 1.15em; + } + .__sp-video-end-card { + position: fixed; + inset: 0; + z-index: 2147483646; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: linear-gradient(135deg, #0a0a14 0%, #131626 100%); + opacity: 0; + transition: opacity var(--__sp-fade-ms, 250ms) ease-out; + pointer-events: none; + font-family: Roboto, "Inter", system-ui, sans-serif; + padding: 72px; + } + .__sp-video-end-card.visible { opacity: 1; } + .__sp-video-end-card-logo { + width: clamp(160px, 22vw, 260px); + height: clamp(160px, 22vw, 260px); + margin: 0 0 56px; + object-fit: contain; + } + .__sp-video-end-card-logo.monochrome { + /* brightness(0) flattens any rasterized PNG to black; invert(1) flips + it to a pure-white silhouette. For SVGs with no fill (sp.svg) this + also produces a clean white mark on the dark backdrop. */ + filter: brightness(0) invert(1); + } + .__sp-video-end-card-title { + margin: 0 0 40px; + } + .__sp-video-end-card-subtitle { + color: #c8cce0; + font-weight: 500; + font-size: clamp(36px, 3.5vw, 60px); + letter-spacing: -0.01em; + margin: 0; + } + .__sp-video-end-card-stats { + margin-top: 44px; + display: flex; + flex-direction: column; + gap: 14px; + align-items: center; + } + .__sp-video-end-card-stat { + color: #9da2ba; + font-weight: 500; + font-size: clamp(34px, 3vw, 52px); + margin: 0; + white-space: nowrap; + } + /* The platforms list (third stat) is much longer than the animated + "★ 19K" / "4.8 ★" lines and would wrap at the bigger size. Render + it at a step smaller so it stays on one line without forcing the + shorter stats to also shrink. */ + .__sp-video-end-card-stat:nth-child(3) { + font-size: clamp(24px, 2.2vw, 38px); + } + .__sp-video-int-card { + position: fixed; + inset: 0; + z-index: 2147483645; + /* Logos centered in the upper portion of the card. Title is + absolutely positioned at the bottom in a lower-third style, + matching the overlay text in beats 1-3. */ + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: #000; + color: #fff; + opacity: 0; + transition: opacity var(--__sp-fade-ms, 350ms) ease-out; + pointer-events: none; + font-family: Roboto, "Inter", system-ui, sans-serif; + /* Big bottom padding reserves the lower-third for the title bar, + keeping logos clear of it without absolute math. */ + padding: 88px 88px 240px; + } + .__sp-video-int-card.visible { opacity: 1; } + .__sp-video-int-card-title { + position: absolute; + /* Raised slightly off the bottom so the bar feels intentionally + placed rather than glued to the viewport edge. */ + bottom: 72px; + left: 0; + right: 0; + margin: 0; + padding: 32px 60px; + background: #000; + text-align: center; + max-width: none; + transform: translateY(24px); + transition: transform var(--__sp-fade-ms, 350ms) ease-out; + } + .__sp-video-int-card.visible .__sp-video-int-card-title { + transform: translateY(0); + } + .__sp-video-int-card-logos { + display: grid; + grid-template-columns: repeat(3, auto); + gap: 64px 80px; + justify-items: center; + } + .__sp-video-int-card-logo { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + /* Default color for the SVG (inherited via fill="currentColor"). + Per-logo brand colors are set inline via cell.style.color in + showIntegrationsCard — keep this rule on the cell, NOT on the + inner svg, otherwise the more-specific child rule would beat + the inline parent style. */ + color: #fff; + opacity: 0; + transform: translateY(16px); + transition: + opacity 400ms ease-out, + transform 400ms ease-out; + } + .__sp-video-int-card.visible .__sp-video-int-card-logo { + opacity: 1; + transform: translateY(0); + } + .__sp-video-int-card.visible .__sp-video-int-card-logo:nth-child(1) { transition-delay: 80ms; } + .__sp-video-int-card.visible .__sp-video-int-card-logo:nth-child(2) { transition-delay: 180ms; } + .__sp-video-int-card.visible .__sp-video-int-card-logo:nth-child(3) { transition-delay: 280ms; } + .__sp-video-int-card.visible .__sp-video-int-card-logo:nth-child(4) { transition-delay: 380ms; } + .__sp-video-int-card.visible .__sp-video-int-card-logo:nth-child(5) { transition-delay: 480ms; } + .__sp-video-int-card.visible .__sp-video-int-card-logo:nth-child(6) { transition-delay: 580ms; } + .__sp-video-int-card-logo svg { + width: clamp(80px, 11vw, 160px); + height: clamp(80px, 11vw, 160px); + } + .__sp-video-int-card-logo-label { + font-size: clamp(24px, 2.2vw, 36px); + color: #c8cce0; + font-weight: 500; + margin: 0; + } + .__sp-video-int-card-subtitle { + margin-top: 120px; + font-size: clamp(30px, 2.6vw, 44px); + color: #b6bbcd; + font-weight: 500; + } + `; + document.head.appendChild(style); + }, STYLE_ID); +}; + +export const showOverlay = async ( + page: Page, + text: string, + options: OverlayOptions = {}, +): Promise => { + const position: OverlayPosition = options.position ?? 'lower'; + const fadeMs = options.fadeMs ?? 420; + const chips = options.chips ?? []; + await ensureStyleInjected(page); + overlayCounter += 1; + const id = `${OVERLAY_ID_PREFIX}${overlayCounter}`; + await page.evaluate( + (args) => { + const el = document.createElement('div'); + el.id = args.id; + el.className = `__sp-video-overlay ${args.position}`; + el.style.setProperty('--__sp-fade-ms', `${args.fadeMs}ms`); + const bg = document.createElement('div'); + bg.className = '__sp-video-overlay-bg'; + const p = document.createElement('p'); + p.className = '__sp-video-overlay-text'; + p.textContent = args.text; + bg.appendChild(p); + if (args.chips.length > 0) { + const row = document.createElement('div'); + row.className = '__sp-video-overlay-chips'; + for (const chip of args.chips) { + const span = document.createElement('span'); + span.className = '__sp-video-overlay-chip'; + if (chip.svg) { + // Trusted source: chip.svg comes from the LOGOS constant in + // overlays.ts, which is checked-in code, not user input. + span.insertAdjacentHTML('afterbegin', chip.svg); + } + if (chip.label) { + const label = document.createElement('span'); + label.textContent = chip.label; + span.appendChild(label); + } + row.appendChild(span); + } + bg.appendChild(row); + } + el.appendChild(bg); + document.body.appendChild(el); + // Force a paint before flipping `visible` so the transition fires. + void el.offsetWidth; + el.classList.add('visible'); + }, + { id, text, position, fadeMs, chips }, + ); + // Wait for fade-in to finish so beat-duration timing reflects visible + // state. Skip when noWait so callers (e.g. inside cutToScene) can let + // the fade run concurrently with their own animation. + 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); + }, + }; +}; + +const INT_CARD_ID = '__sp-video-int-card'; +const TRANSITION_ID = '__sp-video-transition'; +const LOOP_BOUNDARY_ID = '__sp-video-loop-boundary'; +const DRAG_GHOST_ID = '__sp-video-drag-ghost'; + +/** + * Attaches a "ghost" element that follows the cursor during a drag — a + * clone of the source element, semi-transparent, slightly tilted, casting + * a shadow. SP's cdkDrag may or may not show its own preview depending on + * the drop target wiring; this guarantees the act of dragging reads on the + * gif regardless. Call `attach` at mouse-down time, `detach` at mouse-up. + * + * const ghost = await attachDragGhost(page, sourceLocator); + * await page.mouse.down(); + * await page.mouse.move(...); + * await page.mouse.up(); + * await ghost.detach(); + */ +export const attachDragGhost = async ( + page: Page, + sourceLocator: import('@playwright/test').Locator, +): Promise<{ detach: () => Promise }> => { + const box = await sourceLocator.boundingBox(); + const html = await sourceLocator.evaluate((el) => el.outerHTML).catch(() => null); + if (!box || !html) { + return { detach: async () => undefined }; + } + await page.evaluate( + (args) => { + const ghost = document.createElement('div'); + ghost.id = args.id; + ghost.innerHTML = args.html; + ghost.style.cssText = [ + 'position:fixed', + 'left:0', + 'top:0', + `width:${args.width}px`, + 'pointer-events:none', + // Above app UI, under the lower-third overlay. + 'z-index:999998', + 'opacity:0.88', + 'box-shadow:0 18px 48px rgba(0,0,0,0.55)', + 'border-radius:6px', + 'transform:translate3d(-9999px,-9999px,0) rotate(-2deg)', + 'transition:none', + 'will-change:transform', + ].join(';'); + // Inner wrappers might have the original element's id — null them + // out so they don't collide with anything in the live DOM. + ghost.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id')); + document.body.appendChild(ghost); + const halfW = args.width / 2; + const halfH = args.height / 2; + const onMove = (e: MouseEvent): void => { + ghost.style.transform = `translate3d(${e.clientX - halfW}px,${e.clientY - halfH}px,0) rotate(-2deg)`; + }; + // Stash on window so detach can pull it off again. + (window as unknown as { __spDragGhostMove?: typeof onMove }).__spDragGhostMove = + onMove; + document.addEventListener('mousemove', onMove, { passive: true }); + }, + { id: DRAG_GHOST_ID, html, width: box.width, height: box.height }, + ); + return { + detach: async () => { + await page.evaluate((id) => { + const ghost = document.getElementById(id); + if (ghost) ghost.remove(); + const w = window as unknown as { + __spDragGhostMove?: (e: MouseEvent) => void; + }; + if (w.__spDragGhostMove) { + document.removeEventListener('mousemove', w.__spDragGhostMove); + w.__spDragGhostMove = undefined; + } + }, DRAG_GHOST_ID); + }, + }; +}; + +/** + * Full-screen black overlay used to bookend the recording so the gif loop + * boundary doesn't read as a hard cut. Sits above everything (z-index above + * the end card). Call `mode: 'in'` first (opaque, fading to transparent over + * the lead-in) and `mode: 'out'` at the end (transparent, fading to opaque). + */ +export const loopBoundary = async ( + page: Page, + mode: 'in' | 'out', + durationMs = 350, +): Promise => { + await page.evaluate( + (args) => { + let el = document.getElementById(args.id); + if (!el) { + el = document.createElement('div'); + el.id = args.id; + el.style.cssText = [ + 'position:fixed', + 'inset:0', + 'background:#000', + 'z-index:2147483647', + 'pointer-events:none', + // Material's standard motion curve (cubic-bezier(0.4, 0, 0.2, 1) + // — slow start, fast middle, slow finish, asymmetrically biased + // toward a snappier reveal) reads smoother for scene cuts than + // a generic `ease-in-out`. At the gif's 24fps a 200ms fade is + // ~5 frames; the curve concentrates the visible opacity change + // into the middle frames so it reads as a gradient rather than + // a stepped staircase. + `transition:opacity ${args.durationMs}ms cubic-bezier(0.4, 0, 0.2, 1)`, + // Start opacity matches the mode: 'in' starts opaque (revealing + // SP underneath); 'out' starts transparent (covering it back up). + `opacity:${args.mode === 'in' ? 1 : 0}`, + ].join(';'); + document.body.appendChild(el); + // Force a paint before the opacity flip so the transition fires. + void el.offsetWidth; + } + el.style.opacity = args.mode === 'in' ? '0' : '1'; + }, + { id: LOOP_BOUNDARY_ID, mode, durationMs }, + ); + await page.waitForTimeout(durationMs); +}; + +/** + * Hard scene cut via fade-to-black. Use when the next scene's content is + * unrelated to the previous one — full-screen card replacing focus mode, + * for example. The screen fades to true black, the callback runs while + * the scene is hidden behind the black, then black fades back to reveal + * whatever the callback set up. + * + * This is more reliable than `fadeTransition` (which uses a partial dim + * and lets the underlying state-change show through). With `cutToScene`, + * intermediate states (focus-mode dismissal animations, layout reflow, + * etc.) are invisible behind the black cover. + * + * await cutToScene(page, async () => { + * await dismissFocusMode(); + * await showIntegrationsCard(page, content, { noWait: true }); + * }); + * + * The `{ noWait: true }` on `showIntegrationsCard` lets its stagger + * animation play *during* the fade-from-black rather than be hidden + * behind it. + */ +export const cutToScene = async ( + page: Page, + setupNextScene: () => Promise | void, + options: { fadeMs?: number } = {}, +): Promise => { + // 200ms × 2 (fade-to-black + fade-from-black) = 400ms per scene cut. + // At the gif's 24fps that's ~5 frames per fade. Combined with Material + // motion curve and sierra2_4a dither (see build-video.ts) this reads as + // a smooth gradient at this duration. Going shorter starts to look + // stepped; much longer drags out the reel. + const fadeMs = options.fadeMs ?? 200; + // Fade existing scene to opaque black (loopBoundary uses z 2147483647, + // higher than any beat overlay/card, so it covers everything). + await loopBoundary(page, 'out', fadeMs); + // Behind black: prepare next scene. + await setupNextScene(); + // Fade black away to reveal the next scene. + await loopBoundary(page, 'in', fadeMs); +}; + +/** + * Subtly dim-fade the screen while an underlying transition happens (state + * dispatch, navigation). Use to soften hard cuts between SP views. + * + * await fadeTransition(page, async () => { + * // do the cut here — dispatch, navigate, etc. + * }); + */ +export const fadeTransition = async ( + page: Page, + during: () => Promise | void, + options: { fadeMs?: number; opacity?: number } = {}, +): Promise => { + // Slightly longer fade with a softer dim — the lower-third overlay + // text rides above the dim layer (higher z-index), so reducing the + // dim from 70% → 55% black still hides the underlying state change + // but keeps the lower-third bar feeling continuous instead of cut. + const fadeMs = options.fadeMs ?? 260; + const opacity = options.opacity ?? 0.55; + await page.evaluate( + (args) => { + const existing = document.getElementById(args.id); + if (existing) existing.remove(); + const el = document.createElement('div'); + el.id = args.id; + el.style.cssText = [ + 'position:fixed', + 'inset:0', + 'z-index:2147483640', + 'background:#000', + 'opacity:0', + `transition:opacity ${args.fadeMs}ms ease-out`, + 'pointer-events:none', + ].join(';'); + document.body.appendChild(el); + void el.offsetWidth; + el.style.opacity = String(args.opacity); + }, + { id: TRANSITION_ID, fadeMs, opacity }, + ); + await page.waitForTimeout(fadeMs); + await during(); + await page.evaluate( + (args) => { + const el = document.getElementById(args.id); + if (!el) return; + el.style.opacity = '0'; + window.setTimeout(() => el.remove(), args.fadeMs + 50); + }, + { id: TRANSITION_ID, fadeMs }, + ); + await page.waitForTimeout(fadeMs); +}; + +export const showIntegrationsCard = async ( + page: Page, + content: IntegrationsCardContent, + options: { fadeMs?: number; noWait?: boolean } = {}, +): Promise => { + const fadeMs = options.fadeMs ?? 420; + await ensureStyleInjected(page); + await page.evaluate( + (args) => { + const el = document.createElement('div'); + el.id = args.id; + el.className = '__sp-video-int-card'; + el.style.setProperty('--__sp-fade-ms', `${args.fadeMs}ms`); + + const title = document.createElement('p'); + title.className = '__sp-video-int-card-title'; + title.textContent = args.content.title; + el.appendChild(title); + + const grid = document.createElement('div'); + grid.className = '__sp-video-int-card-logos'; + for (const logo of args.content.logos) { + const cell = document.createElement('div'); + cell.className = '__sp-video-int-card-logo'; + // CSS `color` flows into the SVG via `fill="currentColor"`. Label + // stays white (set on its own selector) so brand colors only apply + // to the icon. + if (logo.color) cell.style.color = logo.color; + // Trusted source: `logo.svg` comes from the LOGOS constant in + // overlays.ts, which is checked-in code, not user input. + cell.insertAdjacentHTML('afterbegin', logo.svg); + const label = document.createElement('p'); + label.className = '__sp-video-int-card-logo-label'; + label.textContent = logo.label; + cell.appendChild(label); + grid.appendChild(cell); + } + el.appendChild(grid); + + if (args.content.subtitle) { + const sub = document.createElement('p'); + sub.className = '__sp-video-int-card-subtitle'; + sub.textContent = args.content.subtitle; + el.appendChild(sub); + } + + document.body.appendChild(el); + void el.offsetWidth; + el.classList.add('visible'); + }, + { id: INT_CARD_ID, content, fadeMs }, + ); + // When noWait, return as soon as DOM is set up (the visible class has + // been added, so the fade-in / stagger animation is now running). The + // caller is responsible for letting it play out — useful when this + // call lands inside a `cutToScene` callback so the stagger plays + // *during* the fade-from-black rather than wasted behind it. + if (!options.noWait) { + // Fade plus the longest stagger delay (~580ms for 6th logo). + await page.waitForTimeout(fadeMs + 600); + } + 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: INT_CARD_ID, fadeMs }, + ); + await page.waitForTimeout(fadeMs); + }, + }; +}; + +export const showEndCard = async ( + page: Page, + content: EndCardContent, + options: { fadeMs?: number; noWait?: boolean } = {}, +): Promise => { + const fadeMs = options.fadeMs ?? 380; + await ensureStyleInjected(page); + await page.evaluate( + (args) => { + const el = document.createElement('div'); + el.id = args.id; + el.className = '__sp-video-end-card'; + el.style.setProperty('--__sp-fade-ms', `${args.fadeMs}ms`); + if (args.content.logo) { + const img = document.createElement('img'); + img.className = + '__sp-video-end-card-logo' + + (args.content.logo.monochrome ? ' monochrome' : ''); + img.src = args.content.logo.src; + img.alt = args.content.logo.alt ?? ''; + el.appendChild(img); + } + const title = document.createElement('p'); + title.className = '__sp-video-end-card-title'; + title.textContent = args.content.title; + el.appendChild(title); + if (args.content.subtitle) { + const sub = document.createElement('p'); + sub.className = '__sp-video-end-card-subtitle'; + sub.textContent = args.content.subtitle; + el.appendChild(sub); + } + if (args.content.stats && args.content.stats.length > 0) { + const statsBox = document.createElement('div'); + statsBox.className = '__sp-video-end-card-stats'; + const animated: HTMLElement[] = []; + for (const line of args.content.stats) { + const stat = document.createElement('p'); + stat.className = '__sp-video-end-card-stat'; + if (typeof line === 'string') { + stat.textContent = line; + } else { + stat.dataset.tpl = line.template; + stat.dataset.to = String(line.to); + stat.dataset.decimals = String(line.decimals ?? 0); + const placeholder = (line.decimals ?? 0) > 0 ? '0.0' : '0'; + stat.textContent = line.template.replace('{n}', placeholder); + animated.push(stat); + } + statsBox.appendChild(stat); + } + el.appendChild(statsBox); + if (animated.length > 0) { + // Stagger each stat's count-up by ~280ms so they read as + // sequential facts being revealed, not one undifferentiated blob + // of motion. Wait for the card's fade-in to complete before the + // first one starts so the numbers don't roll while the card is + // still appearing. + const stagger = 280; + const duration = 900; + animated.forEach((p, i) => { + const offset = i * stagger; + const delay = args.fadeMs + offset; + window.setTimeout(() => { + const start = performance.now(); + const tick = (now: number): void => { + const t = Math.min((now - start) / duration, 1); + const eased = 1 - Math.pow(1 - t, 3); + const tpl = p.dataset.tpl ?? ''; + const to = Number(p.dataset.to ?? '0'); + const dec = Number(p.dataset.decimals ?? '0'); + const val = (eased * to).toFixed(dec); + p.textContent = tpl.replace('{n}', val); + if (t < 1) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }, delay); + }); + } + } + document.body.appendChild(el); + void el.offsetWidth; + el.classList.add('visible'); + }, + { id: END_CARD_ID, content, fadeMs }, + ); + // Skip when noWait so the fade-in / stat counter-ups play concurrently + // with whatever animation the caller is running (typically a fade-from + // -black). Otherwise wait for the card to fully reveal before returning. + 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: END_CARD_ID, fadeMs }, + ); + await page.waitForTimeout(fadeMs); + }, + }; +}; diff --git a/e2e/store-video/scenarios/reel.spec.ts b/e2e/store-video/scenarios/reel.spec.ts new file mode 100644 index 0000000000..cd5c5c1dc2 --- /dev/null +++ b/e2e/store-video/scenarios/reel.spec.ts @@ -0,0 +1,316 @@ +/** + * Marketing reel — five (tight) or six (full) beats. + * + * Lead-in Black fades to SP UI with schedule panel open. + * 1 Capture in seconds. type a task with short syntax + * (`A task 1h #urgent @17`). + * 1.5 [full only] No account. No tracking. + * 2 Plan your day. drag the captured task onto the + * schedule panel; a synthetic ghost + * follows the cursor so the act of + * dragging reads regardless of SP's + * cdkDrag wiring. + * 3 Focus on what matters. focus-mode in progress on the + * captured task. clock.resume() lets + * the timer tick visibly. + * 4 Work from GitHub, Jira & more. full-screen integrations card. + * 5 Free and open source. end card with stat counter-ups + * (staggered). + * Boundary Black fades in so the gif loop seam is clean. + * + * Variant selection via `REEL_VARIANT`: + * (unset) tight default — drops "No account. No tracking." + * and tightens beat holds, lands at ~16-17s. + * full includes every beat at the original durations, + * ~21s. Run via `npm run video:full`. + * + * Tune copy or beat order in this file — every choice is one block, + * edited independently. + */ +import { test } from '../fixture'; +import type { OverlayHandle } from '../overlays'; +import { + LOGOS, + attachDragGhost, + cutToScene, + loopBoundary, + showEndCard, + showIntegrationsCard, + showOverlay, +} from '../overlays'; + +const VARIANT = process.env.REEL_VARIANT ?? ''; +const isFull = VARIANT === 'full'; + +const parkCursor = async (page: import('@playwright/test').Page): Promise => { + // Park the cursor offscreen so any matTooltip dismisses and the cursor + // doesn't sit on top of a button while overlays are visible. + try { + await page.mouse.move(0, 0); + } catch { + /* noop */ + } +}; + +/** + * Short-syntax string typed into the global add-task bar in beat 1. + * "A task" title + * "1h" time estimate + * "#urgent" tag (auto-created if missing) + * "@17" due time today at 17:00 + */ +const CAPTURED_TASK_TITLE = 'A task 1h #urgent @17'; + +test.describe('@video reel', () => { + test.use({ locale: 'en', theme: 'dark' }); + + test('marketing reel', async ({ seededPage, markBeatsStart }) => { + const page = seededPage; + + // ── Pre-roll (trimmed off the gif) ──────────────────────────────────── + 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)) { + await scheduleBtn.click(); + await page.locator('schedule-day-panel').first().waitFor({ + state: 'visible', + timeout: 5_000, + }); + // The panel's `_scrollToCurrentTime` runs on a 100ms timeout after init + // and parks the current-time marker ~50px from the top, which lands the + // simulated 09:30 close to the panel's top edge. Wait past that and + // nudge further down so the visible window covers more of the working + // day — gives beat 2's drop target more visual context (the drag lands + // on a populated mid-day stretch instead of an empty pre-9:30 area). + await page.waitForTimeout(200); + await page.evaluate(() => { + const panel = document.querySelector('schedule-day-panel'); + const container = + (panel?.closest('.side-inner') as HTMLElement | null) ?? + (document.querySelector('.side-inner') as HTMLElement | null); + if (container) { + container.scrollTop += 120; + } + }); + } + await parkCursor(page); + await page.waitForTimeout(400); + + markBeatsStart(); + + // ── Lead-in ────────────────────────────────────────────────────────── + await loopBoundary(page, 'in', isFull ? 600 : 480); + + // ── Beat 1 — Capture in seconds. ───────────────────────────────────── + const b1 = await showOverlay(page, 'Capture in seconds.'); + 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(250); + await globalInput.click(); + // Hide the cursor highlight during typing — it sits in the middle of + // the focused input and reads as a stray white dot. Restored after. + await page.evaluate(() => document.body.classList.add('__sp-hide-cursor-highlight')); + await globalInput.pressSequentially(CAPTURED_TASK_TITLE, { delay: 55 }); + await page.waitForTimeout(450); + await page.keyboard.press('Enter'); + await page.waitForTimeout(500); + + // ── Beat 1 → next: cut to black, swap state ────────────────────────── + // cutToScene fades to opaque black (z-index max), runs the callback + // behind it (state changes invisible), then fades back to reveal the + // new state. `noWait` on the next overlay lets its fade-in play + // *during* the fade-from-black instead of being wasted behind it. + let capturedTaskId: string | null = null; + let bExtra: OverlayHandle | undefined; + let b2: OverlayHandle | undefined; + await cutToScene(page, async () => { + await page.evaluate(() => { + const helper = ( + window as unknown as { + __e2eTestHelpers?: { store?: { dispatch: (a: unknown) => void } }; + } + ).__e2eTestHelpers; + helper?.store?.dispatch({ type: '[Layout] Hide AddTaskBar' }); + }); + await page + .locator('add-task-bar.global') + .first() + .waitFor({ state: 'hidden', timeout: 3_000 }) + .catch(() => undefined); + capturedTaskId = await page + .locator('task') + .first() + .getAttribute('data-task-id') + .catch(() => null); + // Restore the cursor highlight — drag in beat 2 needs it visible. + await page.evaluate(() => + document.body.classList.remove('__sp-hide-cursor-highlight'), + ); + await parkCursor(page); + void b1.hide(); + if (isFull) { + bExtra = await showOverlay(page, 'No account. No tracking.', { + noWait: true, + }); + } else { + b2 = await showOverlay(page, 'Plan your day.', { noWait: true }); + } + }); + + // ── Beat 1.5 → 2 transition (full variant only) ────────────────────── + if (isFull) { + await page.waitForTimeout(1500); + await cutToScene(page, async () => { + void bExtra!.hide(); + b2 = await showOverlay(page, 'Plan your day.', { noWait: true }); + }); + } + + // ── Beat 2 — Plan your day. (drag with ghost preview) ──────────────── + const schedulePanel = page.locator('schedule-day-panel').first(); + const dragSource = page.locator('task').first(); + const taskBox = await dragSource.boundingBox(); + const panelBox = await schedulePanel.boundingBox(); + if (taskBox && panelBox) { + const taskHalfW = taskBox.width * 0.5; + const taskHalfH = taskBox.height * 0.5; + const panelHalfW = panelBox.width * 0.5; + const panelMidH = panelBox.height * 0.5; + const startX = taskBox.x + taskHalfW; + const startY = taskBox.y + taskHalfH; + const endX = panelBox.x + panelHalfW; + const endY = panelBox.y + panelMidH; + await page.mouse.move(startX, startY); + await page.waitForTimeout(120); + await page.mouse.down(); + // Ghost attaches AFTER mouse.down so the initial cursor-arrives-on- + // task moment isn't visually competing with the ghost popping in. + const ghost = await attachDragGhost(page, dragSource); + await page.waitForTimeout(120); + await page.mouse.move(endX, endY, { steps: 25 }); + await page.waitForTimeout(180); + await page.mouse.up(); + await ghost.detach(); + await page.waitForTimeout(isFull ? 250 : 150); + await parkCursor(page); + } + await page.waitForTimeout(isFull ? 900 : 600); + + // ── Beat 2 → 3 transition: cut to black, dispatch focus mode ───────── + let b3: OverlayHandle | undefined; + await cutToScene(page, async () => { + void b2!.hide(); + if (await scheduleBtn.isVisible().catch(() => false)) { + await scheduleBtn.click(); + } + if (capturedTaskId) { + await page.evaluate((id) => { + const helper = ( + window as unknown as { + __e2eTestHelpers?: { store?: { dispatch: (a: unknown) => void } }; + } + ).__e2eTestHelpers; + if (!helper?.store) return; + helper.store.dispatch({ type: '[Task] SetCurrentTask', id }); + helper.store.dispatch({ type: '[FocusMode] Show Overlay' }); + helper.store.dispatch({ + type: '[FocusMode] Start Session', + duration: 1500000, + }); + }, capturedTaskId); + await page + .locator('focus-mode-main') + .first() + .waitFor({ state: 'visible', timeout: 10_000 }) + .catch(() => undefined); + await page.clock.runFor(5500).catch(() => undefined); + await page + .locator('focus-mode-main .bottom-controls') + .first() + .waitFor({ state: 'visible', timeout: 5_000 }) + .catch(() => undefined); + await page.clock.resume().catch(() => undefined); + } + await parkCursor(page); + b3 = await showOverlay(page, 'Focus on what matters.', { noWait: true }); + }); + await page.waitForTimeout(isFull ? 1800 : 1200); + + // ── Beat 3 → 4 transition: cut to black, swap to integrations card ── + let b4: OverlayHandle | undefined; + await cutToScene(page, async () => { + if (b3) void b3.hide(); + await page.evaluate(() => { + const helper = ( + window as unknown as { + __e2eTestHelpers?: { store?: { dispatch: (a: unknown) => void } }; + } + ).__e2eTestHelpers; + helper?.store?.dispatch({ type: '[FocusMode] Hide Overlay' }); + helper?.store?.dispatch({ type: '[FocusMode] Cancel Session' }); + }); + await page + .locator('focus-mode-main') + .first() + .waitFor({ state: 'hidden', timeout: 3_000 }) + .catch(() => undefined); + b4 = await showIntegrationsCard( + page, + { + title: 'Plays well with GitHub, Jira & many more', + logos: [ + { svg: LOGOS.github, label: 'GitHub' }, + { svg: LOGOS.gitlab, label: 'GitLab', color: '#fc6d26' }, + { svg: LOGOS.jira, label: 'Jira', color: '#2684ff' }, + { svg: LOGOS.linear, label: 'Linear', color: '#5e6ad2' }, + { svg: LOGOS.trello, label: 'Trello', color: '#0079bf' }, + { svg: LOGOS.calendar, label: 'Calendar' }, + ], + }, + { noWait: true }, + ); + }); + await page.waitForTimeout(isFull ? 2500 : 2000); + + // ── Beat 4 → 5 transition: cut to black, swap to end card ──────────── + await cutToScene(page, async () => { + if (b4) void b4.hide(); + await showEndCard( + page, + { + logo: { + src: '/assets/icons/sp.svg', + alt: 'Super Productivity', + monochrome: true, + }, + title: 'Free and open source.', + subtitle: 'superproductivity.com', + stats: [ + { template: '★ {n}K on GitHub', to: 19 }, + { template: '{n} ★ on Google Play', to: 4.8, decimals: 1 }, + 'Web · iOS · Android · macOS · Linux · Windows & many more', + ], + }, + { noWait: true }, + ); + }); + await page.waitForTimeout(isFull ? 3000 : 2500); + + // ── Loop boundary ──────────────────────────────────────────────────── + // Don't hide the end card — let it stay live. The loop boundary + // (z-index 2147483647) fades black over the still-visible card so + // the gif loop seam is end-card → black → black-fading-to-app-ui, + // with no brief flash of the underlying SP view between end-card + // hide and the boundary fade-in. + await loopBoundary(page, 'out', isFull ? 500 : 450); + }); +}); diff --git a/package-lock.json b/package-lock.json index e6392fb800..c12220b99d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -135,7 +135,6 @@ "pretty-quick": "^4.2.2", "query-string": "^7.1.3", "rxjs": "^7.8.2", - "sharp": "^0.34.5", "shepherd.js": "^11.2.0", "spark-md5": "^3.0.2", "stacktrace-js": "^2.0.2", @@ -3922,6 +3921,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -5122,496 +5122,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", @@ -24882,51 +24392,6 @@ "node": ">=8" } }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index ca7c84411d..e651a8d1f2 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,10 @@ "screenshots:build": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\",\"moduleResolution\":\"node\"}' npx ts-node --transpile-only e2e/store-screenshots/build-store-assets.ts", "screenshots:capture:electron": "npm run electron:build && SCREENSHOT_MODE=electron npx playwright test --config e2e/playwright.store-screenshots.electron.config.ts", "screenshots:electron": "npm run screenshots:capture:electron && npm run screenshots:build", + "video": "npm run video:capture && npm run video:build", + "video:capture": "npx playwright test --config e2e/playwright.store-video.config.ts", + "video:build": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\",\"moduleResolution\":\"node\"}' npx ts-node --transpile-only e2e/store-video/build-video.ts", + "video:full": "REEL_VARIANT=full npm run video", "electron": "NODE_ENV=PROD electron .", "electron:build": "node ./tools/build-wayland-idle-helper.js && tsc -p electron/tsconfig.electron.json && node electron/scripts/bundle-preload.js", "electron:verify-asar": "node tools/verify-electron-requires.js .tmp/app-builds/linux-unpacked/resources/app.asar", @@ -276,7 +280,6 @@ "pretty-quick": "^4.2.2", "query-string": "^7.1.3", "rxjs": "^7.8.2", - "sharp": "^0.34.5", "shepherd.js": "^11.2.0", "spark-md5": "^3.0.2", "stacktrace-js": "^2.0.2", diff --git a/src/app/core/theme/custom-theme.service.ts b/src/app/core/theme/custom-theme.service.ts index 512ea42c8a..875b748207 100644 --- a/src/app/core/theme/custom-theme.service.ts +++ b/src/app/core/theme/custom-theme.service.ts @@ -28,13 +28,6 @@ export const AVAILABLE_CUSTOM_THEMES: CustomTheme[] = [ url: 'assets/themes/arc.css', requiredMode: 'dark', }, - // Carbon — temporarily disabled, re-enable to expose in the theme picker. - // { - // id: 'carbon', - // name: 'Carbon', - // url: 'assets/themes/carbon.css', - // requiredMode: 'dark', - // }, { id: 'catppuccin-mocha', name: 'Catppuccin Mocha', @@ -71,12 +64,6 @@ export const AVAILABLE_CUSTOM_THEMES: CustomTheme[] = [ url: 'assets/themes/glass.css', requiredMode: 'dark', }, - { - id: 'lines', - name: 'Lines', - url: 'assets/themes/lines.css', - requiredMode: 'system', - }, { id: 'nord-polar-night', name: 'Nord Polar Night', @@ -95,12 +82,6 @@ export const AVAILABLE_CUSTOM_THEMES: CustomTheme[] = [ url: 'assets/themes/rainbow.css', requiredMode: 'system', }, - { - id: 'velvet', - name: 'Velvet', - url: 'assets/themes/velvet.css', - requiredMode: 'dark', - }, ]; @Injectable({ providedIn: 'root' }) diff --git a/src/app/features/schedule/schedule.constants.ts b/src/app/features/schedule/schedule.constants.ts index e756cad62a..d40a6d4ba2 100644 --- a/src/app/features/schedule/schedule.constants.ts +++ b/src/app/features/schedule/schedule.constants.ts @@ -18,12 +18,8 @@ export const SCHEDULE_CONSTANTS = { BREAKPOINTS: { /** Width threshold for tablet devices (768px) */ TABLET: 768, - /** Width threshold below which the schedule header switches to its compact form. Mirrors `$layout-xs` in `_media-queries.scss`. */ - XS: 600, /** Width threshold for mobile devices (480px) */ MOBILE: 480, - /** Width threshold below which the schedule header drops the date range and shows only the week number. */ - XXS: 420, }, /** diff --git a/src/app/features/schedule/schedule/schedule.component.html b/src/app/features/schedule/schedule/schedule.component.html index ae5d483d1f..ea6beb4164 100644 --- a/src/app/features/schedule/schedule/schedule.component.html +++ b/src/app/features/schedule/schedule/schedule.component.html @@ -22,44 +22,6 @@ - @if (showCalFilterBtn()) { - - - -
- @for (provider of enabledCalendarProviders(); track provider.id) { - - } -
-
-
- } -
+@if (showCalFilterBar()) { + + @for (provider of enabledCalendarProviders(); track provider.id) { + + @if (provider.color) { + + } + {{ calProviderInitials(provider) }} + + } + +} +
{ }); }); - describe('showCalFilterBtn computed', () => { + describe('showCalFilterBar computed', () => { const makeProvider = (id: string): any => ({ id, isEnabled: true, @@ -725,7 +725,7 @@ describe('ScheduleComponent', () => { store.overrideSelector(selectCalendarProviders, []); store.refreshState(); fixture.detectChanges(); - expect(component.showCalFilterBtn()).toBe(false); + expect(component.showCalFilterBar()).toBe(false); }); it('should be false with a single visible provider', () => { @@ -733,7 +733,7 @@ describe('ScheduleComponent', () => { store.overrideSelector(selectCalendarProviders, [makeProvider('only')]); store.refreshState(); fixture.detectChanges(); - expect(component.showCalFilterBtn()).toBe(false); + expect(component.showCalFilterBar()).toBe(false); }); it('should be true when the only enabled provider is hidden (C2 regression)', () => { @@ -743,7 +743,7 @@ describe('ScheduleComponent', () => { const hidden = TestBed.inject(HiddenCalendarProvidersService); hidden.setHidden(['only']); fixture.detectChanges(); - expect(component.showCalFilterBtn()).toBe(true); + expect(component.showCalFilterBar()).toBe(true); }); it('should be true with multiple enabled providers', () => { @@ -754,7 +754,7 @@ describe('ScheduleComponent', () => { ]); store.refreshState(); fixture.detectChanges(); - expect(component.showCalFilterBtn()).toBe(true); + expect(component.showCalFilterBar()).toBe(true); }); }); diff --git a/src/app/features/schedule/schedule/schedule.component.ts b/src/app/features/schedule/schedule/schedule.component.ts index 86caae5739..995dc85403 100644 --- a/src/app/features/schedule/schedule/schedule.component.ts +++ b/src/app/features/schedule/schedule/schedule.component.ts @@ -11,14 +11,16 @@ import { fromEvent } from 'rxjs'; import { select, Store } from '@ngrx/store'; import { selectCalendarProviders } from '../../issue/store/issue-provider.selectors'; import { HiddenCalendarProvidersService } from '../../calendar-integration/hidden-calendar-providers.service'; -import { getIssueProviderTooltip } from '../../issue/mapping-helper/get-issue-provider-tooltip'; +import { + getIssueProviderInitials, + getIssueProviderTooltip, +} from '../../issue/mapping-helper/get-issue-provider-tooltip'; import { IssueProvider } from '../../issue/issue.model'; import { - MatMenu, - MatMenuContent, - MatMenuItem, - MatMenuTrigger, -} from '@angular/material/menu'; + MatChipListbox, + MatChipListboxChange, + MatChipOption, +} from '@angular/material/chips'; import { debounceTime, map, startWith } from 'rxjs/operators'; import { safeFormatDate } from '../../../util/safe-format-date'; import { TaskService } from '../../tasks/task.service'; @@ -53,10 +55,8 @@ import { parseDbDateStr } from '../../../util/parse-db-date-str'; MatIcon, MatTooltip, TranslatePipe, - MatMenu, - MatMenuContent, - MatMenuItem, - MatMenuTrigger, + MatChipListbox, + MatChipOption, ], templateUrl: './schedule.component.html', styleUrls: ['./schedule.component.scss'], @@ -86,19 +86,26 @@ export class ScheduleComponent { .pipe(map((ps) => ps.filter((p) => p.isEnabled))), { initialValue: [] }, ); - // Show the button with multiple providers, OR with a single provider that - // is currently hidden — otherwise the user has no UI to re-enable the only + // Show the bar with multiple providers, OR with a single provider that is + // currently hidden — otherwise the user has no UI to re-enable the only // calendar after, e.g., deleting all but one provider in settings. - readonly showCalFilterBtn = computed(() => { + readonly showCalFilterBar = computed(() => { const providers = this.enabledCalendarProviders(); if (providers.length > 1) return true; const hidden = this.hiddenCalendarProviderIds(); return providers.some((p) => hidden.includes(p.id)); }); readonly calProviderLabel = (p: IssueProvider): string => getIssueProviderTooltip(p); + readonly calProviderInitials = (p: IssueProvider): string => + getIssueProviderInitials(p) ?? + getIssueProviderTooltip(p).substring(0, 2).toUpperCase(); - toggleCalProvider(providerId: string): void { - this._hiddenCalendarProviders.toggle(providerId); + onCalProvidersChange(ev: MatChipListboxChange): void { + const visible = new Set((ev.value as string[]) ?? []); + const hidden = this.enabledCalendarProviders() + .map((p) => p.id) + .filter((id) => !visible.has(id)); + this._hiddenCalendarProviders.setHidden(hidden); } private _currentTimeViewMode = computed(() => this.layoutService.selectedTimeView()); @@ -181,15 +188,6 @@ export class ScheduleComponent { weeksToShow = computed(() => Math.ceil(this.daysToShow().length / 7)); - // Memoized so headerTitle only re-runs at the breakpoint boundary, - // not on every debounced resize tick. - private _isCompact = computed( - () => this._windowSize().width < SCHEDULE_CONSTANTS.BREAKPOINTS.XS, - ); - private _isVeryCompact = computed( - () => this._windowSize().width < SCHEDULE_CONSTANTS.BREAKPOINTS.XXS, - ); - headerTitle = computed(() => { const days = this.daysToShow(); if (!days.length) return ''; @@ -203,17 +201,9 @@ export class ScheduleComponent { const start = parseDbDateStr(days[0]); const end = parseDbDateStr(days[days.length - 1]); const weekNr = getWeekNumber(start); // ISO — default firstDayOfWeek=1 - const sameMonth = - start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear(); const startStr = safeFormatDate(start, 'MMM d', locale); - const endStr = sameMonth - ? safeFormatDate(end, 'd', locale) - : safeFormatDate(end, 'MMM d', locale); - const labelKey = this._isCompact() - ? T.F.WORKLOG.CMP.WEEK_NR_SHORT - : T.F.WORKLOG.CMP.WEEK_NR; - const label = this._translate.instant(labelKey, { nr: weekNr }); - if (this._isVeryCompact()) return label; + const endStr = safeFormatDate(end, 'MMM d', locale); + const label = this._translate.instant(T.F.WORKLOG.CMP.WEEK_NR, { nr: weekNr }); return `${label} · ${startStr} – ${endStr}`; }); diff --git a/src/app/features/tasks/task.service.ts b/src/app/features/tasks/task.service.ts index 18f95621f3..c592a255cc 100644 --- a/src/app/features/tasks/task.service.ts +++ b/src/app/features/tasks/task.service.ts @@ -772,14 +772,7 @@ export class TaskService { focusTaskById(taskId: string, shouldStartEditing: boolean): void { requestAnimationFrame(() => { requestAnimationFrame(() => { - // Prefer the in-panel instance when both the main list and the side - // detail panel render the same task (e.g. a just-created sub-task in - // the parent's sub-task list). Focusing the panel copy preserves the - // user's current context (parent stays selected) and on mobile lands - // on a visible input rather than the main-list copy that the panel - // overlays (#7120). - const allEls = document.querySelectorAll(`#t-${CSS.escape(taskId)}`); - const taskElement = allEls[allEls.length - 1]; + const taskElement = document.getElementById(`t-${taskId}`); if (!taskElement) return; taskElement.focus(); diff --git a/src/app/t.const.ts b/src/app/t.const.ts index d9163a6116..c8269ec91c 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -1984,7 +1984,6 @@ const T = { TOTAL_TIME: 'F.WORKLOG.CMP.TOTAL_TIME', VIEW_TASK_DETAILS: 'F.WORKLOG.CMP.VIEW_TASK_DETAILS', WEEK_NR: 'F.WORKLOG.CMP.WEEK_NR', - WEEK_NR_SHORT: 'F.WORKLOG.CMP.WEEK_NR_SHORT', WORKED: 'F.WORKLOG.CMP.WORKED', }, D_CONFIRM_RESTORE: 'F.WORKLOG.D_CONFIRM_RESTORE', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 7edc94b557..7089e32323 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1940,7 +1940,6 @@ "TOTAL_TIME": "Time spent total:", "VIEW_TASK_DETAILS": "View task details", "WEEK_NR": "Week {{nr}}", - "WEEK_NR_SHORT": "W{{nr}}", "WORKED": "Worked" }, "D_CONFIRM_RESTORE": "Are you sure you want to move the task \"{{title}}\" into your Today task list?", diff --git a/src/assets/themes/carbon.css b/src/assets/themes/carbon.css deleted file mode 100644 index c8a2b3de51..0000000000 --- a/src/assets/themes/carbon.css +++ /dev/null @@ -1,272 +0,0 @@ -/** - * Carbon — flat dark theme for Super Productivity. - * Cool near-black surfaces, hairline 1px borders, no shadows, no glow. - * Inspired by Linear's clean, technical aesthetic. - */ - -* { - box-shadow: none !important; -} - -:root { - --transition-fast: 120ms ease-in-out; - --transition-normal: 200ms ease-in-out; -} - -body.isDarkTheme { - /* =============================== - * CARBON PALETTE - * ===============================*/ - - --carbon-bg: #08090a; - --carbon-surface: #101113; - --carbon-surface-2: #15171a; - --carbon-surface-3: #1c1f24; - --carbon-border: rgba(255, 255, 255, 0.06); - --carbon-border-strong: rgba(255, 255, 255, 0.1); - --carbon-fg: #e6e6e6; - --carbon-fg-muted: #8a8f98; - --carbon-indigo: #5e6ad2; - --carbon-indigo-hover: #7170ff; - - /* =============================== - * BACKGROUNDS - * ===============================*/ - - --bg: var(--carbon-bg); - --bg-darker: #050506; - --bg-slightly-lighter: #0e0f11; - --bg-lighter: var(--carbon-surface); - --bg-lightest: var(--carbon-surface-2); - --bg-super-light: var(--carbon-surface-3); - - --card-bg: var(--carbon-surface); - --sidenav-bg: #0c0d0f; - --selected-task-bg-color: var(--carbon-surface-2); - --banner-bg: var(--carbon-surface); - - --task-c-bg: var(--carbon-surface); - --task-c-selected-bg: var(--carbon-surface-2); - --sub-task-c-bg: #0d0e10; - --sub-task-c-bg-done: #0a0b0c; - --task-c-bg-done: #0a0b0c; - --task-c-current-bg: var(--carbon-surface-3); - --task-c-drag-drop-bg: var(--carbon-surface-3); - --sub-task-c-bg-in-selected: var(--carbon-surface); - - --standard-note-bg: var(--carbon-surface); - --standard-note-bg-hovered: var(--carbon-surface-2); - - /* =============================== - * TEXT - * ===============================*/ - - --text-color: var(--carbon-fg); - --text-color-less-intense: rgba(230, 230, 230, 0.85); - --text-color-muted: var(--carbon-fg-muted); - --text-color-more-intense: #f7f8f8; - --text-color-most-intense: #ffffff; - - --standard-note-fg: var(--carbon-fg); - --task-detail-value-color: rgba(230, 230, 230, 0.7); - - /* =============================== - * BORDERS — hairline - * ===============================*/ - - --extra-border-color: var(--carbon-border); - --separator-color: var(--carbon-border); - --divider-color: var(--carbon-border); - --chip-outline-color: var(--carbon-border-strong); - - /* =============================== - * ACCENT — Linear indigo - * ===============================*/ - - --palette-accent-500: var(--carbon-indigo); - --c-accent: var(--carbon-indigo); - --palette-accent-100: #d6daf5; - --palette-accent-200: #b9c1ee; - --palette-accent-300: #9ca7e6; - --palette-accent-400: #7e8dde; - --palette-accent-600: #4d57b8; - --palette-accent-700: #3d4699; - --palette-accent-800: #2e367a; - --palette-accent-900: #1f255b; - - /* =============================== - * UI - * ===============================*/ - - --scrollbar-thumb: #2a2d33; - --scrollbar-thumb-hover: #3a3e46; - --scrollbar-track: var(--carbon-bg); - - --close-btn-bg: var(--carbon-surface-3); - --close-btn-border: transparent; - - --select-hover-bg: rgba(94, 106, 210, 0.1); - --options-border-color: rgba(94, 106, 210, 0.2); - - --attachment-bg: var(--carbon-surface); - --attachment-border: var(--carbon-border); - --attachment-control-bg: rgba(0, 0, 0, 0.5); - --attachment-control-border: transparent; - --attachment-control-hover-bg: rgba(0, 0, 0, 0.8); - - --grid-color: rgba(255, 255, 255, 0.04); - --progress-bg: rgba(255, 255, 255, 0.08); - - --improvement-text: #f7f8f8; - --improvement-border: rgba(94, 106, 210, 0.3); - --improvement-button-text: #ffffff; - - /* No shadows */ - --shadow-key-umbra-opacity: 0; - --shadow-key-penumbra-opacity: 0; - --shadow-ambient-shadow-opacity: 0; - --task-current-shadow: none; - --task-selected-shadow: none; - --whiteframe-shadow-1dp: none; - --whiteframe-shadow-2dp: none; - --whiteframe-shadow-3dp: none; - --card-shadow: none; - --task-shadow: none; - --task-shadow-sub-task: none; - - --hover-controls-border: 1px solid var(--carbon-border); - --hover-controls-border-opacity: 1; - - --hover-bg-opacity: 0.04; - --focus-bg-opacity: 0.06; - --pressed-bg-opacity: 0.1; - --disabled-opacity: 0.38; - - /* Tight radii — Linear is squarer than most */ - --card-border-radius: 6px; - --task-border-radius: 6px; - --radius-xs: 3px; - --radius-sm: 4px; - --radius-md: 6px; - --radius-lg: 8px; - - /* Sidenav — subtle, dense */ - --sidenav-item-active-bg: rgba(94, 106, 210, 0.1); - --sidenav-active-text: var(--carbon-indigo-hover); - --sidenav-hover-bg: rgba(255, 255, 255, 0.04); -} - -/* Linear's signature compressed typography */ -body.isDarkTheme h1, -body.isDarkTheme h2, -body.isDarkTheme h3, -body.isDarkTheme .mat-h1, -body.isDarkTheme .mat-h2, -body.isDarkTheme .mat-h3 { - letter-spacing: -0.012em; - font-weight: 600; -} - -/* 2px indigo accent stripe on active sidenav item — Linear hallmark */ -body.isDarkTheme magic-side-nav nav-item button.active::before, -body.isDarkTheme magic-side-nav nav-item button[routerlinkactive='active']::before { - content: ''; - position: absolute; - left: 0; - top: 6px; - bottom: 6px; - width: 2px; - background: var(--carbon-indigo); - border-radius: 0 2px 2px 0; -} - -body.isDarkTheme magic-side-nav nav-item { - position: relative; -} - -/* Squarer Material inputs/buttons */ -body.isDarkTheme .mat-mdc-button, -body.isDarkTheme .mat-mdc-raised-button, -body.isDarkTheme .mat-mdc-outlined-button { - --mdc-filled-button-container-shape: 4px; - --mdc-outlined-button-container-shape: 4px; - --mdc-protected-button-container-shape: 4px; - --mdc-text-button-container-shape: 4px; -} - -/* Hairline divider on sidenav right edge */ -body.isDarkTheme magic-side-nav { - border-right: 1px solid var(--carbon-border); -} - -body::before, -body .first-line:hover .hover-controls::before { - display: none !important; -} - -body, -body.isDarkTheme { - background-color: var(--bg); - color: var(--text-color); - transition: - background-color var(--transition-normal), - color var(--transition-normal); -} - -body .page-wrapper, -body.isDarkTheme .page-wrapper { - color: var(--text-color); -} - -a, -body a[href], -body.isDarkTheme a[href] { - color: var(--c-accent); - text-decoration: none; - transition: opacity var(--transition-fast); -} - -a:hover, -body a[href]:hover, -body.isDarkTheme a[href]:hover { - color: var(--carbon-indigo-hover); - opacity: 1; -} - -/* Crisp 1px hairline on cards & dialogs (Linear's signature) */ -body.isDarkTheme .mat-mdc-card, -body.isDarkTheme .mat-mdc-dialog-surface, -body.isDarkTheme .mat-mdc-menu-panel { - background: var(--carbon-surface) !important; - border: 1px solid var(--carbon-border); -} - -body.isDarkTheme task .box, -body.isDarkTheme planner-task, -body.isDarkTheme note .note { - border: 1px solid var(--carbon-border); -} - -body.isDarkTheme task.isSelected .box, -body.isDarkTheme task.isCurrent .box { - border-color: var(--carbon-border-strong); -} - -/* Subtle hover */ -.task-c:hover, -.sub-task-c:hover { - background-color: var(--task-c-selected-bg); - transition: background-color var(--transition-fast); -} - -*:focus-visible { - outline: 1px solid var(--c-accent); - outline-offset: 2px; -} - -body.isDarkTheme { - --mat-theme-surface: var(--carbon-surface); - --mat-theme-on-surface: var(--text-color); - --mat-theme-background: var(--bg); - --mat-theme-primary: var(--carbon-indigo); -} diff --git a/src/assets/themes/lines.css b/src/assets/themes/lines.css deleted file mode 100644 index e7468020fb..0000000000 --- a/src/assets/themes/lines.css +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Lines Theme for Super Productivity - * Builds on the Zen theme: horizontal separator lines between tasks for a list-style aesthetic. - * Tasks have only top/bottom borders — no side borders, no shadows, no backgrounds. - * Works with both light and dark modes. - */ - -@import url('./zen.css'); - -/* === Horizontal separator lines between top-level tasks === */ -body task { - border-top: 1px solid var(--separator-color); -} - -body task:last-of-type { - border-bottom: 1px solid var(--separator-color); -} - -/* No separators on sub-tasks — they form a connected indented block under the parent */ -body .sub-tasks task, -body .sub-tasks task:last-of-type { - border-top: none; - border-bottom: none; -} - -/* Sharp corners — lines run edge-to-edge */ -body task .box { - border-radius: 0 !important; -} - -/* === Selection: zen uses a pill border; we use a subtle tint (corners are sharp) === */ -body task.isSelected .box { - background: var(--c-dark-10) !important; - border: none !important; - border-radius: 0 !important; -} - -body.isDarkTheme task.isSelected .box { - background: var(--c-light-05) !important; -} - -/* Don't apply selection style to subtask boxes */ -body task.isSelected .sub-tasks .box { - background: transparent !important; -} - -/* === Planner: tight list with horizontal separator lines === */ - -/* Remove the per-row gap so lines act as the only separator */ -body .normal-tasks-items > *, -body .deadline-items > *, -body .scheduled-items > * { - margin-bottom: 0 !important; -} - -/* Strip box-style chrome from every planner item host */ -body planner-task, -body planner-deadline-task, -body planner-repeat-projection, -body planner-calendar-event { - border: none !important; - border-radius: 0 !important; - background: transparent !important; - box-shadow: none !important; -} - -/* Inner wrapper divs (deadline/repeat have their own bordered pill) */ -body planner-deadline-task > div, -body planner-repeat-projection > div { - border: none !important; - border-radius: 0 !important; -} - -/* Top separator on every direct child of the normal/deadline lists */ -body .normal-tasks-items > *, -body .deadline-items > * { - border-top: 1px solid var(--separator-color) !important; -} - -body .normal-tasks-items > *:last-child, -body .deadline-items > *:last-child { - border-bottom: 1px solid var(--separator-color) !important; -} - -/* Scheduled items wrap each row in `.scheduled-item` (time bubble + content). - Put the line on the wrapper so it spans the full row width. */ -body .scheduled-items > .scheduled-item { - border-top: 1px solid var(--separator-color); -} - -body .scheduled-items > .scheduled-item:last-child { - border-bottom: 1px solid var(--separator-color); -} diff --git a/src/assets/themes/velvet.css b/src/assets/themes/velvet.css deleted file mode 100644 index cecc4e2dfc..0000000000 --- a/src/assets/themes/velvet.css +++ /dev/null @@ -1,904 +0,0 @@ -/** - * Velvet — soft dark theme for Super Productivity. - * Warm near-black surfaces, frosted-glass vibrancy, generous radii, and a - * subtle gradient tinted by the user's primary color. Inspired by Raycast's - * native-feeling aesthetic — gentle shadows, smooth transitions, no flash. - */ - -:root { - --transition-fast: 130ms cubic-bezier(0.2, 0, 0.13, 1); - --transition-normal: 220ms cubic-bezier(0.2, 0, 0.13, 1); - - /* Frosted-glass backdrop-filter tokens — single source of truth for - blur/saturate intensities. Pair with a per-surface bg color. */ - --velvet-frost: blur(20px) saturate(180%); - --velvet-frost-strong: blur(28px) saturate(180%); - --velvet-frost-deep: blur(32px) saturate(180%); - --velvet-frost-tooltip: blur(12px) saturate(160%); - --velvet-frost-tile: blur(8px); -} - -body.isDarkTheme { - /* =============================== - * VELVET PALETTE — neutral surfaces only. - * Accent comes from the user's --c-primary / --c-accent (no custom - * brand color baked in here). Surfaces are warm near-blacks, used as - * the dark base; everything tinted goes through color-mix(--c-primary). - * ===============================*/ - - --velvet-bg: #131013; - --velvet-bg-deep: #0a080a; - --velvet-surface: #1a171a; - --velvet-surface-2: #221f22; - --velvet-surface-3: #2a272a; - --velvet-surface-4: #322f32; - --velvet-border: rgba(255, 255, 255, 0.04); - --velvet-border-strong: rgba(255, 255, 255, 0.1); - --velvet-fg: #e6e6e6; - --velvet-fg-muted: #7a7779; - - /* =============================== - * BACKGROUNDS - * ===============================*/ - - --bg: var(--velvet-bg); - --bg-darker: var(--velvet-bg-deep); - --bg-slightly-lighter: var(--velvet-surface); - --bg-lighter: var(--velvet-surface-2); - --bg-lightest: var(--velvet-surface-3); - --bg-super-light: var(--velvet-surface-4); - - --card-bg: var(--velvet-surface); - --sidenav-bg: var(--velvet-bg-deep); - --selected-task-bg-color: var(--velvet-surface-2); - --banner-bg: var(--velvet-surface-2); - - --task-c-bg: var(--velvet-surface); - --task-c-selected-bg: var(--velvet-surface-2); - --sub-task-c-bg: #1c1c1c; - --sub-task-c-bg-done: #181818; - --task-c-bg-done: #181818; - --task-c-current-bg: var(--velvet-surface-3); - --task-c-drag-drop-bg: var(--velvet-surface-3); - --sub-task-c-bg-in-selected: var(--velvet-surface); - - --standard-note-bg: var(--velvet-surface); - --standard-note-bg-hovered: var(--velvet-surface-2); - - /* =============================== - * TEXT - * ===============================*/ - - --text-color: var(--velvet-fg); - --text-color-less-intense: rgba(237, 237, 237, 0.85); - --text-color-muted: var(--velvet-fg-muted); - --text-color-more-intense: #f5f5f5; - --text-color-most-intense: #ffffff; - - --standard-note-fg: var(--velvet-fg); - --task-detail-value-color: rgba(237, 237, 237, 0.7); - - /* =============================== - * BORDERS - * ===============================*/ - - --extra-border-color: var(--velvet-border); - --separator-color: var(--velvet-border); - --divider-color: rgba(255, 255, 255, 0.06); - --chip-outline-color: var(--velvet-border-strong); - - /* =============================== - * ACCENT — keep the user's chosen primary/accent untouched. - * Real Raycast doesn't have a single brand accent: it uses different - * tints (red, green, blue, purple) per category. The neutral white-alpha - * pills below provide the active/selected language; the user's accent - * still shows on focus rings, links, and primary buttons. - * ===============================*/ - - /* =============================== - * UI - * ===============================*/ - - --scrollbar-thumb: #3a3a3a; - --scrollbar-thumb-hover: #4a4a4a; - --scrollbar-track: var(--velvet-bg); - - --close-btn-bg: var(--velvet-surface-3); - --close-btn-border: transparent; - - --select-hover-bg: color-mix(in srgb, var(--c-primary) 12%, transparent); - --options-border-color: color-mix(in srgb, var(--c-primary) 22%, transparent); - - --attachment-bg: var(--velvet-surface-2); - --attachment-border: var(--velvet-border); - --attachment-control-bg: rgba(0, 0, 0, 0.45); - --attachment-control-border: transparent; - --attachment-control-hover-bg: rgba(0, 0, 0, 0.75); - - --grid-color: rgba(255, 255, 255, 0.05); - --progress-bg: rgba(255, 255, 255, 0.1); - - --improvement-text: #f5f5f5; - --improvement-border: color-mix(in srgb, var(--c-primary) 32%, transparent); - --improvement-button-text: #ffffff; - - /* Soft elevation — subtle but present */ - --shadow-key-umbra-opacity: 0.18; - --shadow-key-penumbra-opacity: 0.12; - --shadow-ambient-shadow-opacity: 0.1; - --task-current-shadow: - 0 1px 2px rgba(0, 0, 0, 0.3), 0 0 0 1px var(--velvet-border-strong); - --task-selected-shadow: - 0 2px 6px rgba(0, 0, 0, 0.35), 0 0 0 1px var(--velvet-border-strong); - --card-shadow: 0 1px 2px rgba(0, 0, 0, 0.25), 0 0 0 1px var(--velvet-border); - - --hover-controls-border: 1px solid var(--velvet-border); - --hover-controls-border-opacity: 1; - - --hover-bg-opacity: 0.06; - --focus-bg-opacity: 0.1; - --pressed-bg-opacity: 0.14; - --disabled-opacity: 0.4; - - /* Generous radii — Raycast feel */ - --card-border-radius: 10px; - --task-border-radius: 8px; - --radius-md: 10px; - --radius-lg: 14px; - - /* Sidenav — neutral pill, matching the actual Raycast aesthetic. */ - --sidenav-item-active-bg: rgba(255, 255, 255, 0.07); - --sidenav-active-text: #ffffff; - --sidenav-hover-bg: rgba(255, 255, 255, 0.035); - --sidenav-text: rgba(255, 255, 255, 0.78); - --sidenav-text-secondary: rgba(255, 255, 255, 0.55); - - /* Task detail panel surfaces — match the side panel's frosted feel */ - --task-detail-bg: rgba(255, 255, 255, 0.04); - --task-detail-bg-hover: rgba(255, 255, 255, 0.07); - --task-detail-shadow: none; - --task-detail-value-color: rgba(237, 237, 237, 0.85); - - /* Schedule events: match the translucent task-row aesthetic so they don't - read as a different visual language from planner-task. */ - --schedule-event-bg: rgba(255, 255, 255, 0.04); - --schedule-event-bg-in-side-panel: rgba(255, 255, 255, 0.04); - - /* Tighter vertical rhythm — Raycast list rows sit closer together than - SP's defaults. Cuts ~3px between every task row. */ - --task-inner-padding-top-bottom: 1px; -} - -/* Top-level task gap — boxes sit edge-to-edge for a denser list. */ -body.isDarkTheme task .inner-wrapper > .box { - top: 0 !important; - bottom: 0 !important; -} - -/* Subtasks need a bit more breathing room — they're nested and benefit - from a small visible gap to read as their own grouped sub-list. */ -body.isDarkTheme task .sub-tasks task .inner-wrapper > .box { - top: 1px !important; - bottom: 1px !important; -} - -/* Planner-task: match the regular task aesthetic. The component uses - --task-c-bg (a solid surface color) on its host, while regular tasks - use a translucent .box override. Force the same translucent styling - here so both list types share a single visual language. */ -body.isDarkTheme planner-task { - background: rgba(255, 255, 255, 0.02) !important; -} - -body.isDarkTheme planner-task:hover { - background: rgba(255, 255, 255, 0.04) !important; -} - -body.isDarkTheme planner-task.isSelected { - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.1) 0%, - rgba(255, 255, 255, 0.05) 100% - ) !important; -} - -/* Sidenav: drive everything through CSS variables that the nav-item - component already uses (--sidenav-item-active-bg, --sidenav-hover-bg). - Adding explicit per-button rules on top caused inconsistent rendering - between regular nav-items, tree headers, mat-menu items, and the - mode-toggle button. The vars are overridden up in the :root section. */ - -/* Tighter typography — Raycast is dense and precisely-tuned. Body text is - slightly smaller with a subtle negative tracking; headings are bolder - with tighter tracking. */ -body.isDarkTheme { - letter-spacing: -0.006em; - font-feature-settings: 'cv11', 'ss01', 'ss03'; -} - -body.isDarkTheme h1, -body.isDarkTheme h2, -body.isDarkTheme h3, -body.isDarkTheme h4, -body.isDarkTheme .mat-h1, -body.isDarkTheme .mat-h2, -body.isDarkTheme .mat-h3 { - letter-spacing: -0.02em !important; - font-weight: 600 !important; -} - -/* Task title — slightly tighter and more readable */ -body.isDarkTheme task .first-line .title-and-tags-wrapper, -body.isDarkTheme task .title { - font-weight: 500 !important; - letter-spacing: -0.008em !important; -} - -/* Muted text uses a quieter color overall */ -body.isDarkTheme .mat-mdc-form-field-hint, -body.isDarkTheme .secondary-text, -body.isDarkTheme [class*='muted'], -body.isDarkTheme .task-detail-hint { - color: var(--velvet-fg-muted) !important; -} - -/* Mac-keyboard-style kbd chips — Raycast shows shortcuts everywhere as - little 3D keys with a subtle inset highlight. */ -body.isDarkTheme kbd, -body.isDarkTheme .key-combo-chip, -body.isDarkTheme .keyboard-key { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 18px; - padding: 0 5px; - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.08); - border-bottom-color: rgba(255, 255, 255, 0.04); - border-radius: 4px; - font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; - font-size: 11px; - font-weight: 500; - color: var(--velvet-fg); - box-shadow: - 0 1px 0 rgba(0, 0, 0, 0.3), - inset 0 1px 0 rgba(255, 255, 255, 0.08); - text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2); -} - -/* Frosted-glass dialogs/menus/cards — macOS vibrancy feel */ -body.isDarkTheme .mat-mdc-dialog-surface, -body.isDarkTheme .mat-mdc-menu-panel, -body.isDarkTheme .cdk-overlay-pane .mat-mdc-autocomplete-panel, -body.isDarkTheme .mat-mdc-card { - background: rgba(31, 31, 31, 0.78) !important; - backdrop-filter: var(--velvet-frost); - -webkit-backdrop-filter: var(--velvet-frost); - border: 1px solid rgba(255, 255, 255, 0.06); - box-shadow: - 0 16px 48px rgba(0, 0, 0, 0.55), - 0 2px 8px rgba(0, 0, 0, 0.35), - inset 0 1px 0 rgba(255, 255, 255, 0.04); -} - -/* Pill-shaped global add-task bar — Raycast command-bar */ -body.isDarkTheme add-task-bar.global .add-task-container { - border-radius: 14px !important; - background: rgba(31, 28, 30, 0.92) !important; - backdrop-filter: var(--velvet-frost-strong); - -webkit-backdrop-filter: var(--velvet-frost-strong); - border: 1px solid rgba(255, 255, 255, 0.08); - box-shadow: - 0 24px 64px rgba(0, 0, 0, 0.65), - 0 6px 16px rgba(0, 0, 0, 0.4), - inset 0 1px 0 rgba(255, 255, 255, 0.05); -} - -/* Form fields: dark, rounded, with coral focus glow — Raycast input feel */ -body.isDarkTheme input.mat-mdc-input-element, -body.isDarkTheme textarea.mat-mdc-input-element, -body.isDarkTheme .mdc-text-field--filled:not(.mdc-text-field--disabled) { - background: rgba(255, 255, 255, 0.04) !important; - border-radius: 8px !important; - transition: - background 120ms ease-out, - box-shadow 120ms ease-out !important; -} - -body.isDarkTheme - .mat-mdc-form-field.mat-focused - .mdc-text-field--filled:not(.mdc-text-field--disabled), -body.isDarkTheme .mdc-text-field--focused:not(.mdc-text-field--disabled) { - background: color-mix(in srgb, var(--c-primary) 6%, transparent) !important; - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c-primary) 40%, transparent) !important; -} - -/* Hide the underline-style line-ripple — Raycast inputs are bordered, not - underlined. */ -body.isDarkTheme .mdc-line-ripple, -body.isDarkTheme .mdc-text-field--filled .mdc-line-ripple::before, -body.isDarkTheme .mdc-text-field--filled .mdc-line-ripple::after { - display: none !important; -} - -/* Primary buttons get a subtle coral gradient — like Raycast's CTAs */ -body.isDarkTheme .mat-mdc-raised-button.mat-primary, -body.isDarkTheme .mat-mdc-unelevated-button.mat-primary, -body.isDarkTheme button[mat-flat-button][color='primary'], -body.isDarkTheme button[mat-raised-button][color='primary'] { - background: linear-gradient( - 180deg, - var(--c-primary) 0%, - color-mix(in srgb, var(--c-primary) 70%, black) 100% - ) !important; - color: #ffffff !important; - box-shadow: - 0 1px 0 rgba(255, 255, 255, 0.12) inset, - 0 2px 6px color-mix(in srgb, var(--c-primary) 32%, transparent) !important; - border-radius: 8px !important; - font-weight: 500 !important; - letter-spacing: -0.005em !important; -} - -body.isDarkTheme .mat-mdc-raised-button.mat-primary:hover, -body.isDarkTheme .mat-mdc-unelevated-button.mat-primary:hover, -body.isDarkTheme button[mat-flat-button][color='primary']:hover { - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--c-primary) 70%, white) 0%, - var(--c-primary) 100% - ) !important; - box-shadow: - 0 1px 0 rgba(255, 255, 255, 0.16) inset, - 0 4px 12px color-mix(in srgb, var(--c-primary) 40%, transparent) !important; -} - -/* (task box bg/states defined further below — duplicates removed for cascade clarity) */ - -body.isDarkTheme { - background-color: var(--bg); - color: var(--text-color); - transition: - background-color var(--transition-normal), - color var(--transition-normal); -} - -body.isDarkTheme .page-wrapper { - color: var(--text-color); -} - -body.isDarkTheme a[href] { - color: var(--c-accent); - text-decoration: none; - transition: - color var(--transition-fast), - opacity var(--transition-fast); -} - -body.isDarkTheme a[href]:hover { - color: color-mix(in srgb, var(--c-primary) 70%, white); -} - -/* (task transitions and isCurrent border defined inline with bg rules below) */ - -.task-c:hover, -.sub-task-c:hover { - background-color: var(--task-c-selected-bg); -} - -/* Coral focus ring with offset glow — Raycast uses a soft 2px coral outline - on every focusable element. Skip elements that have their own border-based - focus state, otherwise we get a doubled-border effect (1px coral border + - 2px coral outline). */ -body.isDarkTheme *:focus-visible { - outline: 2px solid var(--c-primary); - outline-offset: 2px; - border-radius: 4px; -} - -body.isDarkTheme task-detail-item .input-item:focus-visible, -body.isDarkTheme task-detail-item .mat-expansion-panel:focus-visible, -body.isDarkTheme task-detail-item .input-item:focus-within, -body.isDarkTheme task-detail-item .mat-expansion-panel:focus-within, -body.isDarkTheme task:focus-visible, -body.isDarkTheme task:focus, -body.isDarkTheme planner-task:focus-visible, -body.isDarkTheme planner-task:focus { - outline: none !important; -} - -/* Section labels in the sidebar — Raycast uses regular case + muted color, - NOT uppercase small-caps. Looking at actual Raycast screenshots: "Today", - "Suggestions", "Commands", "Results" are regular sentence case. */ -body.isDarkTheme magic-side-nav .section-label, -body.isDarkTheme magic-side-nav .nav-section-title, -body.isDarkTheme nav-folder > .folder-title { - font-size: 12px !important; - font-weight: 500 !important; - letter-spacing: -0.005em !important; - color: var(--velvet-fg-muted) !important; -} - -/* Banner: warm-tinted, frosted */ -body.isDarkTheme banner .content-wrapper { - background: rgba(38, 35, 37, 0.85) !important; - backdrop-filter: blur(20px) saturate(160%); - -webkit-backdrop-filter: blur(20px) saturate(160%); - border-radius: 8px; - margin: 4px 8px; -} - -/* Body fallback color — near-black, matches Raycast's actual window bg. */ -body.isDarkTheme { - background-color: #0a080a !important; -} - -/* THE shared background — painted on .app-container so it covers BOTH the - side nav and the main content area as one continuous gradient. The - sidenav's translucent panel lets the gradient show through (frosted-glass - effect), main.main-content is transparent below, so a single gradient - defines the visual character across every view. - - Why .app-container and not body: with a wallpaper set, .bg-image and - .bg-overlay sit between body and .app-container, so a body-level gradient - would be hidden. .app-container sits ABOVE the wallpaper layers, matching - the original intent of layering the primary-tinted gradient on top of - the blurred wallpaper + overlay. - - var(--c-primary) tracks the user's chosen primary palette; color-mix - gives alpha control. */ -body.isDarkTheme .app-container { - background-color: transparent !important; - background-image: - radial-gradient( - ellipse 80% 60% at 10% -10%, - color-mix(in srgb, var(--c-primary) 10%, transparent) 0%, - transparent 55% - ), - linear-gradient( - 180deg, - color-mix(in srgb, var(--c-primary) 4%, transparent) 0%, - transparent 100% - ) !important; - background-attachment: fixed !important; -} - -/* Neutralize SP's body:before flat tint. That layer paints a brightness(0.1) - primary-900 wash over the body whenever the user has bg-tint ENABLED, and - it shows through the transparent regions of the gradient — making the - tint-enabled and tint-disabled states look noticeably different. With - :before hidden, the .app-container gradient is the single source of - primary tint and both states render identically. */ -body.isDarkTheme:before { - display: none !important; -} - -/* Everything below .app-container must be transparent so the gradient - painted on .app-container shows through the side nav, header, route - wrapper, and right panel equally. .app-container itself owns the - gradient and is not in this list. */ -body.isDarkTheme main.main-content, -body.isDarkTheme .header-wrapper, -body.isDarkTheme header.header-wrapper, -body.isDarkTheme right-panel, -body.isDarkTheme right-panel > div, -body.isDarkTheme main-header, -body.isDarkTheme main-header > .wrapper, -body.isDarkTheme .route-wrapper, -body.isDarkTheme banner { - background: transparent !important; - background-color: transparent !important; -} - -/* Blur the user's wallpaper image so the frosted-glass surfaces above it - read as proper macOS-style vibrancy instead of crisp desktop showing - through. Real Raycast does this — the desktop behind the window is - visibly out of focus. */ -body.isDarkTheme .bg-image { - filter: blur(24px) saturate(1.2); - /* compensate for the soft edges blur creates by scaling slightly */ - transform: scale(1.06); -} - -/* Sidenav: one consistent translucent panel. Apply blur+bg to the OUTER - host AND make every inner wrapper (.nav-sidenav, .nav-list) transparent - so the host's bg shows through uniformly — otherwise the inner solid bg - covers up the host's translucency and produces inconsistent edges. */ -body.isDarkTheme magic-side-nav { - background: rgba(15, 12, 14, 0.65) !important; - backdrop-filter: var(--velvet-frost-deep); - -webkit-backdrop-filter: var(--velvet-frost-deep); - border-right: 1px solid rgba(255, 255, 255, 0.04); -} - -/* When a wallpaper is set, .bg-image is already heavily blurred (24px), and - the bg-overlay + .app-container gradient already provide tint and contrast. - Drop both the sidenav's backdrop blur (which re-blurs already-blurred pixels - into mud) and its dark bg, so the wallpaper + overlay + gradient read as one - continuous surface across sidenav and main content. */ -body.isDarkTheme.hasBgImage magic-side-nav { - backdrop-filter: none !important; - -webkit-backdrop-filter: none !important; - background: transparent !important; -} - -body.isDarkTheme magic-side-nav .nav-sidenav, -body.isDarkTheme magic-side-nav .nav-list, -body.isDarkTheme magic-side-nav nav-list, -body.isDarkTheme magic-side-nav .side-nav { - background: transparent !important; - background-color: transparent !important; -} - -/* Thin neutral scrollbars — Raycast's are unobtrusive, not branded. */ -body.isDarkTheme ::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -body.isDarkTheme ::-webkit-scrollbar-track { - background: transparent; -} - -body.isDarkTheme ::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.1); - border-radius: 4px; - border: 2px solid transparent; - background-clip: padding-box; -} - -body.isDarkTheme ::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.18); - background-clip: padding-box; -} - -/* right-panel: the host is transparent so the gradient shows through, but - the actual sliding side panel (.side) gets the Raycast frosted-glass - treatment when open — distinct from the main content area. */ -body.isDarkTheme right-panel .side { - background: rgba(24, 21, 23, 0.72) !important; - backdrop-filter: var(--velvet-frost-strong); - -webkit-backdrop-filter: var(--velvet-frost-strong); - border-left: 1px solid rgba(255, 255, 255, 0.06) !important; - box-shadow: -16px 0 48px rgba(0, 0, 0, 0.3); -} - -/* Resize handle: coral on hover, like Raycast's resizers */ -body.isDarkTheme right-panel .resize-handle:hover, -body.isDarkTheme right-panel .resize-handle:focus, -body.isDarkTheme right-panel .resize-handle:active { - background-color: var(--c-primary) !important; - box-shadow: 0 0 12px color-mix(in srgb, var(--c-primary) 40%, transparent); -} - -/* Edge close-handle: frosted bg with coral icon on hover */ -body.isDarkTheme right-panel .edge-close-handle { - background: rgba(38, 35, 37, 0.85) !important; - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid rgba(255, 255, 255, 0.06); - border-right: none; - transition: - background-color 130ms ease-out, - color 130ms ease-out; -} - -body.isDarkTheme right-panel .edge-close-handle:hover { - background: color-mix(in srgb, var(--c-primary) 18%, transparent) !important; -} - -body.isDarkTheme right-panel .edge-close-handle:hover mat-icon { - color: var(--c-primary) !important; -} - -/* Default task: very subtle bg — Raycast list rows are mostly transparent - over the gradient, only revealing a bg on hover or selection. */ -body.isDarkTheme task .box { - background: rgba(255, 255, 255, 0.02) !important; - transition: - background-color 100ms ease-out, - border-color 100ms ease-out !important; -} - -/* Hover: subtle neutral pill, matches Raycast list rows on hover */ -body.isDarkTheme task:hover .box, -body.isDarkTheme task .box:hover { - background: rgba(255, 255, 255, 0.04) !important; -} - -/* Selected: subtle neutral white-alpha gradient pill, like Raycast's - actual selected list rows — no coral, no offset. */ -body.isDarkTheme task.isSelected .box { - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.1) 0%, - rgba(255, 255, 255, 0.05) 100% - ) !important; -} - -/* Currently-running task — leave the original component styling alone - (no leading-edge offset, no override) so its layout stays consistent - with other states. The state distinction comes through Raycast's - coral done-toggle halo, defined elsewhere. */ - -/* Symmetric padding so content sits at the visual center of the translucent box. - Need !important to beat Angular's view-encapsulation specificity. The original - has -1px on top — only noticeable once the box becomes translucent. */ -body.isDarkTheme task .inner-wrapper { - padding: var(--task-inner-padding-top-bottom) 0 var(--task-inner-padding-top-bottom) !important; -} - -/* (No row separators — testing whether the bg/hover state alone is enough - to read as a list, which is how Raycast typically renders list rows - without explicit dividers.) */ - -/* Suppress the default rectangular focus border on .box. The state cues now - come from the bg + leading-edge inset shadow defined further up; both are - declared above this rule so the cascade lands correctly for non-:focus - states. (Plain :focus still suppressed so it doesn't double-up.) */ -body.isDarkTheme task:focus .box, -body.isDarkTheme task:focus-visible .box { - border-color: transparent !important; -} - -body.isDarkTheme note .note, -body.isDarkTheme .standard-note { - background: rgba(36, 36, 38, 0.55) !important; - backdrop-filter: blur(12px) saturate(140%); - -webkit-backdrop-filter: blur(12px) saturate(140%); -} - -/* Inner top highlight — the macOS frosted-glass tell */ -body.isDarkTheme .mat-mdc-card, -body.isDarkTheme .mat-mdc-dialog-surface, -body.isDarkTheme .mat-mdc-menu-panel { - position: relative; -} - -body.isDarkTheme .mat-mdc-dialog-surface::before, -body.isDarkTheme .mat-mdc-menu-panel::before { - content: ''; - position: absolute; - inset: 0; - border-radius: inherit; - pointer-events: none; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, transparent 30%); -} - -/* Task detail panel items — frosted-glass tiles with rounded corners - and a coral accent on focus, matching the side panel's aesthetic. */ -body.isDarkTheme task-detail-item .input-item, -body.isDarkTheme task-detail-item .mat-expansion-panel { - background: rgba(255, 255, 255, 0.04) !important; - border: 1px solid rgba(255, 255, 255, 0.04) !important; - border-radius: 10px !important; - margin: 6px 8px !important; - backdrop-filter: var(--velvet-frost-tile); - -webkit-backdrop-filter: var(--velvet-frost-tile); - transition: - background 130ms ease-out, - border-color 130ms ease-out !important; -} - -body.isDarkTheme task-detail-item .input-item:hover, -body.isDarkTheme task-detail-item .mat-expansion-panel:hover, -body.isDarkTheme task-detail-item .mat-expansion-panel-header:not(.mat-expanded):hover { - background: rgba(255, 255, 255, 0.07) !important; - border-color: rgba(255, 255, 255, 0.08) !important; -} - -/* Expanded panel uses the same neutral hover bg, just held — no coral. - Real Raycast doesn't tint expanded sections with the brand color. */ -body.isDarkTheme task-detail-item .mat-expansion-panel.mat-expanded { - background: rgba(255, 255, 255, 0.05) !important; - border-color: rgba(255, 255, 255, 0.06) !important; -} - -body.isDarkTheme task-detail-item .input-item.isFocused { - background: rgba(255, 255, 255, 0.06) !important; - border-color: rgba(255, 255, 255, 0.08) !important; -} - -/* Component CSS adds an accent border on :focus via: - :host-context(.isNoTouchOnly):focus & { border-color: --palette-primary-400 !important } - Suppress entirely — no border on focused items, just the bg shift. - The body.isDarkTheme prefix already wins on specificity over the - component's :host-context rule, so the .isNoTouchOnly variants are - not needed. */ -body.isDarkTheme task-detail-item:focus .input-item, -body.isDarkTheme task-detail-item:focus .mat-expansion-panel { - border-color: transparent !important; -} - -/* Markdown notes inside task detail — same frosted treatment */ -body.isDarkTheme task-detail-item .markdown-wrapper, -body.isDarkTheme task-detail-item .markdown-parsed, -body.isDarkTheme task-detail-item .markdown-unparsed { - background: rgba(255, 255, 255, 0.04) !important; - border-radius: 10px !important; - border: 1px solid rgba(255, 255, 255, 0.05) !important; -} - -/* Material chips (tags / labels) — pill shape with subtle border, like - Raycast's category chips. */ -body.isDarkTheme mat-chip, -body.isDarkTheme .mat-mdc-chip, -body.isDarkTheme .mat-mdc-standard-chip { - background: rgba(255, 255, 255, 0.06) !important; - border: 1px solid rgba(255, 255, 255, 0.06) !important; - border-radius: 999px !important; - font-weight: 500 !important; - letter-spacing: -0.005em; - height: 22px !important; -} - -body.isDarkTheme mat-chip:hover, -body.isDarkTheme .mat-mdc-chip:hover { - background: rgba(255, 255, 255, 0.1) !important; - border-color: rgba(255, 255, 255, 0.12) !important; -} - -/* Done-toggle (the round task-completion checkbox): coral-ringed when - currently running. */ -body.isDarkTheme done-toggle button, -body.isDarkTheme done-toggle .ico-btn { - transition: all 150ms cubic-bezier(0.2, 0, 0.13, 1) !important; -} - -body.isDarkTheme task.isCurrent done-toggle button, -body.isDarkTheme task.isCurrent done-toggle .ico-btn { - box-shadow: - 0 0 0 2px color-mix(in srgb, var(--c-primary) 25%, transparent), - 0 0 12px color-mix(in srgb, var(--c-primary) 30%, transparent) !important; -} - -/* Smooth Raycast-style transitions globally on interactive elements */ -body.isDarkTheme button, -body.isDarkTheme a, -body.isDarkTheme [role='button'] { - transition: - background-color 130ms cubic-bezier(0.2, 0, 0.13, 1), - color 130ms cubic-bezier(0.2, 0, 0.13, 1), - box-shadow 130ms cubic-bezier(0.2, 0, 0.13, 1), - transform 130ms cubic-bezier(0.2, 0, 0.13, 1); -} - -/* mat-icon coloring inside main-header buttons — coral when active */ -body.isDarkTheme main-header button.isActive mat-icon, -body.isDarkTheme main-header button.isActive2 mat-icon { - color: var(--c-primary) !important; -} - -/* Work-context title (current project/tag name in the header) — bold, - tight tracking, like a Raycast page title. */ -body.isDarkTheme main-header .current-work-context-title { - font-weight: 600 !important; - letter-spacing: -0.018em !important; - font-size: 16px; -} - -/* Subtasks: indented but visually consistent — same hover/select states as - parent tasks but slightly subtler. */ -body.isDarkTheme task .sub-tasks task::after { - background: rgba(255, 255, 255, 0.04) !important; -} - -body.isDarkTheme task .sub-tasks task .box { - background: rgba(255, 255, 255, 0.015) !important; -} - -body.isDarkTheme task .sub-tasks task:hover .box { - background: rgba(255, 255, 255, 0.04) !important; -} - -body.isDarkTheme task .sub-tasks task.isSelected .box { - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.08) 0%, - rgba(255, 255, 255, 0.04) 100% - ) !important; -} - -/* Done tasks: muted but still legible. The dim opacity gives them the - subtle, calm feel Raycast uses for completed/dismissed items. */ -body.isDarkTheme task.isDone .first-line, -body.isDarkTheme task.isDone .title-and-tags-wrapper { - opacity: 0.55; - text-decoration: line-through; - text-decoration-color: rgba(237, 237, 237, 0.4); - text-decoration-thickness: 1px; -} - -/* Dialog titles: Raycast uses bold, tight, pulled-in dialog headers */ -body.isDarkTheme .mat-mdc-dialog-title, -body.isDarkTheme h2.mat-mdc-dialog-title, -body.isDarkTheme [mat-dialog-title] { - font-weight: 600 !important; - letter-spacing: -0.018em !important; - font-size: 17px !important; -} - -/* Mat-tab group (settings tabs): coral underline on active tab */ -body.isDarkTheme .mat-mdc-tab-group { - --mdc-tab-indicator-active-indicator-color: var(--c-primary); - --mat-tab-header-active-label-text-color: var(--c-primary); - --mat-tab-header-active-focus-label-text-color: var(--c-primary); - --mat-tab-header-active-hover-label-text-color: var(--c-primary); - --mat-tab-header-active-ripple-color: var(--c-primary); -} - -body.isDarkTheme .mat-mdc-tab .mdc-tab__text-label { - font-weight: 500 !important; - letter-spacing: -0.005em !important; -} - -/* Mat-select trigger: subtle bg + 8px radius */ -body.isDarkTheme .mat-mdc-select-trigger, -body.isDarkTheme .mat-mdc-form-field-flex { - border-radius: 8px; -} - -/* Mat-checkbox: coral when checked */ -body.isDarkTheme .mat-mdc-checkbox.mat-mdc-checkbox-checked { - --mdc-checkbox-selected-icon-color: var(--c-primary); - --mdc-checkbox-selected-hover-icon-color: color-mix( - in srgb, - var(--c-primary) 70%, - white - ); - --mdc-checkbox-selected-focus-icon-color: var(--c-primary); - --mdc-checkbox-selected-pressed-icon-color: color-mix( - in srgb, - var(--c-primary) 70%, - black - ); -} - -/* Mat-slide-toggle: coral when on */ -body.isDarkTheme .mat-mdc-slide-toggle.mat-mdc-slide-toggle-checked { - --mdc-switch-selected-track-color: var(--c-primary); - --mdc-switch-selected-handle-color: #fff; - --mdc-switch-selected-hover-track-color: color-mix( - in srgb, - var(--c-primary) 70%, - white - ); - --mdc-switch-selected-pressed-track-color: color-mix( - in srgb, - var(--c-primary) 70%, - black - ); - --mdc-switch-selected-focus-track-color: var(--c-primary); -} - -/* Tooltips: dark frosted, like Raycast's tooltips */ -body.isDarkTheme .mat-mdc-tooltip, -body.isDarkTheme .mdc-tooltip__surface { - background: rgba(31, 28, 30, 0.92) !important; - backdrop-filter: var(--velvet-frost-tooltip); - -webkit-backdrop-filter: var(--velvet-frost-tooltip); - color: var(--velvet-fg) !important; - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 6px !important; - font-size: 12px !important; - font-weight: 500 !important; - letter-spacing: -0.005em !important; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); -} - -/* Drag-and-drop: neutral surface bg + a soft drop shadow to indicate - "in-flight" — no accent tint. */ -body.isDarkTheme task.isDragReady:not(.cdk-drag-placeholder) .box, -body.isDarkTheme .cdk-drag-preview { - background: rgba(40, 36, 38, 0.95) !important; - box-shadow: - 0 12px 32px rgba(0, 0, 0, 0.5), - 0 0 0 1px rgba(255, 255, 255, 0.08) !important; -} - -body.isDarkTheme { - --mat-theme-surface: var(--velvet-surface); - --mat-theme-on-surface: var(--text-color); - --mat-theme-background: var(--bg); -} diff --git a/src/assets/themes/zen.css b/src/assets/themes/zen.css index 6cc6ab15da..4f5fc6145a 100644 --- a/src/assets/themes/zen.css +++ b/src/assets/themes/zen.css @@ -39,10 +39,8 @@ body.isDarkTheme { /* Use main bg instead of lighter grays */ --bg-lighter: var(--bg); --bg-lightest: var(--bg); - - /* Schedule events: solid main bg so things behind don't show through */ - --schedule-event-bg: var(--bg); - --schedule-event-bg-in-side-panel: var(--bg); + --schedule-event-bg: transparent; + --schedule-event-bg-in-side-panel: transparent; /* Selected task: flat with border */ --task-c-selected-bg: transparent;