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.
This commit is contained in:
Johannes Millan 2026-05-07 23:08:24 +02:00
parent 4267a86d73
commit 1d52843cd7
34 changed files with 2017 additions and 2360 deletions

View file

@ -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

View file

@ -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.

View file

@ -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,

View file

@ -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 },
});

View file

@ -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/<store>/<locale>/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/<store>/<locale>/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/`)

View file

@ -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<void> => {
);
}
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<string, PreviewEntry[]>();
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 '&amp;';
case '<':
return '&lt;';
case '>':
return '&gt;';
case '"':
return '&quot;';
default:
return '&#39;';
}
});
const renderCard = (it: PreviewEntry): string => {
const href = escape(it.relPath);
const label = escape(it.label);
return [
`<a class="card" href="${href}" target="_blank" rel="noopener">`,
`<img loading="lazy" src="${href}" alt="${label}">`,
`<div class="label">${label}</div>`,
`</a>`,
].join('');
};
const sections = Array.from(grouped.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, items]) => {
const cards = items.map(renderCard).join('');
const heading =
`<h2>${escape(group)} ` + `<span class="count">${items.length}</span></h2>`;
return `<section>${heading}<div class="grid">${cards}</div></section>`;
})
.join('');
const html = `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<title>Screenshots preview</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; background: #111; color: #eee; margin: 0; padding: 24px; }
h1 { margin: 0 0 4px; font-size: 22px; }
.summary { color: #888; font-size: 13px; margin-bottom: 24px; }
h2 {
font-size: 14px; font-weight: 600; color: #ccc; margin: 28px 0 12px;
letter-spacing: 0.02em; text-transform: uppercase;
border-bottom: 1px solid #2a2a2a; padding-bottom: 6px;
}
h2 .count { color: #666; font-weight: 400; margin-left: 8px; }
.grid {
display: grid; gap: 14px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.card {
display: block; background: #1a1a1a; border: 1px solid #262626;
border-radius: 8px; overflow: hidden; text-decoration: none; color: inherit;
transition: transform 0.12s, border-color 0.12s;
}
.card:hover { transform: translateY(-2px); border-color: #444; }
.card img { display: block; width: 100%; height: 280px; object-fit: contain; background: #000; }
.label { padding: 8px 10px; font-size: 12px; color: #aaa; word-break: break-all; }
</style>
</head><body>
<h1>Store screenshots preview</h1>
<div class="summary">${entries.length} captures across ${grouped.size} groups · ${OUT_DIR}</div>
${sections}
</body></html>
`;
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) => {

View file

@ -124,132 +124,22 @@ export const applyLocale = async (page: Page, lng: string): Promise<void> => {
};
/**
* 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<void> => {
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<void> => {
export const applyCustomTheme = async (page: Page, themeId: string): Promise<void> => {
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<void> => {
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<void> => {
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);
};

View file

@ -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.';

View file

@ -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/<viewport>/`.
*/
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<string, ViewportSpec>;
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[];

View file

@ -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;

View file

@ -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<void>,
): Promise<void> => {
// 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<void>,
): Promise<void> => {
// 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');
});
});

View file

@ -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<void>,
): Promise<void> => {
// 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 × ~1520s 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);

View file

@ -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);

View file

@ -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": {

120
e2e/store-video/CLAUDE.md Normal file
View file

@ -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=<name>` switches the spec branch and adds a filename suffix so multiple variants coexist in `dist/video/`. `full` is the only one wired up so far; add more by branching on `isFull`-style flags in the spec.
`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 `<p>` rather than `<h1>` for belt-and-braces — even with `!important`, the typography font shorthand can sneak through.
**Drag ghost.** Beat 2 uses `attachDragGhost(page, sourceLocator)` which clones the source via `outerHTML`, attaches a mousemove listener, and follows the cursor with a 2° tilt + drop shadow. Detaches on mouse-up. SP's cdkDrag may or may not show its own preview depending on the drop target's `cdkDropList` wiring; the ghost guarantees the act of dragging reads regardless.
**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 <path>` 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.

View file

@ -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();

303
e2e/store-video/fixture.ts Normal file
View file

@ -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/<random>.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<VideoFixtures>({
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';

833
e2e/store-video/overlays.ts Normal file
View file

@ -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:
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.4 3-.405 1.02.005 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>',
gitlab:
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"/></svg>',
jira: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.571 11.513H0a5.218 5.218 0 0 0 5.232 5.215h2.13v2.057A5.215 5.215 0 0 0 12.575 24V12.518a1.005 1.005 0 0 0-1.005-1.005zm5.723-5.756H5.736a5.215 5.215 0 0 0 5.215 5.214h2.129V13.03a5.218 5.218 0 0 0 5.215 5.214V6.761a1.001 1.001 0 0 0-1.001-1.004zM23.013 0H11.455a5.215 5.215 0 0 0 5.215 5.215h2.129v2.057A5.215 5.215 0 0 0 24 12.483V1.005A1.001 1.001 0 0 0 23.013 0z"/></svg>',
calendar:
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 4h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20a2 2 0 002 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zM9 14H7v-2h2v2zm4 0h-2v-2h2v2zm4 0h-2v-2h2v2z"/></svg>',
trello:
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21.147 0H2.853A2.86 2.86 0 0 0 0 2.853v18.294A2.86 2.86 0 0 0 2.853 24h18.294A2.86 2.86 0 0 0 24 21.147V2.853A2.86 2.86 0 0 0 21.147 0M10.34 18.66a.94.94 0 0 1-.94.94H4.51a.94.94 0 0 1-.94-.94V4.46a.94.94 0 0 1 .94-.94h4.89a.94.94 0 0 1 .94.94zm10.09-6.7a.94.94 0 0 1-.94.94H14.6a.94.94 0 0 1-.94-.94V4.46a.94.94 0 0 1 .94-.94h4.89a.94.94 0 0 1 .94.94z"/></svg>',
linear:
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M.403 13.795A12 12 0 0 0 10.205 23.597zM.014 11.166a12 12 0 0 1 13.156-12.971l1.236 1.235L1.286 12.557 0 11.272a12 12 0 0 1 .014-.106m1.638-3.32a12 12 0 0 1 6.094-6.094l8.68 8.68-6.095 6.095zm5.79 13.998l9.992-9.992a12 12 0 0 0 5.93-2.876c.044-.04.087-.08.13-.121q.063-.06.124-.123c.04-.043.082-.087.122-.13a12 12 0 0 0 2.876-5.93z"/></svg>',
more: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="6" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="18" cy="12" r="2"/></svg>',
} as const;
export type OverlayHandle = {
hide: () => Promise<void>;
};
/**
* One line on the end card. Plain string renders as-is. The object form
* animates the `{n}` placeholder in `template` from 0 up to `to` over the
* 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<void> => {
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<OverlayHandle> => {
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<void> => {
await page.evaluate(
(args) => {
const el = document.getElementById(args.id);
if (!el) return;
el.classList.remove('visible');
window.setTimeout(() => el.remove(), args.fadeMs + 50);
},
{ id, fadeMs },
);
await page.waitForTimeout(fadeMs);
},
};
};
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<void> }> => {
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<void> => {
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> | void,
options: { fadeMs?: number } = {},
): Promise<void> => {
// 200ms × 2 (fade-to-black + fade-from-black) = 400ms per scene cut.
// At the gif's 24fps that's ~5 frames per fade. Combined with Material
// 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> | void,
options: { fadeMs?: number; opacity?: number } = {},
): Promise<void> => {
// Slightly longer fade with a softer dim — the lower-third overlay
// text rides above the dim layer (higher z-index), so reducing the
// 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<OverlayHandle> => {
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<void> => {
await page.evaluate(
(args) => {
const el = document.getElementById(args.id);
if (!el) return;
el.classList.remove('visible');
window.setTimeout(() => el.remove(), args.fadeMs + 50);
},
{ id: INT_CARD_ID, fadeMs },
);
await page.waitForTimeout(fadeMs);
},
};
};
export const showEndCard = async (
page: Page,
content: EndCardContent,
options: { fadeMs?: number; noWait?: boolean } = {},
): Promise<OverlayHandle> => {
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<void> => {
await page.evaluate(
(args) => {
const el = document.getElementById(args.id);
if (!el) return;
el.classList.remove('visible');
window.setTimeout(() => el.remove(), args.fadeMs + 50);
},
{ id: END_CARD_ID, fadeMs },
);
await page.waitForTimeout(fadeMs);
},
};
};

View file

@ -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<void> => {
// 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);
});
});

537
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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' })

View file

@ -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,
},
/**

View file

@ -22,44 +22,6 @@
</button>
</div>
@if (showCalFilterBtn()) {
<button
mat-icon-button
[matMenuTriggerFor]="calFilterMenu"
[matTooltip]="T.F.SCHEDULE.CALENDAR_VISIBILITY | translate"
[attr.aria-label]="T.F.SCHEDULE.CALENDAR_VISIBILITY | translate"
>
<mat-icon>event_note</mat-icon>
</button>
<mat-menu #calFilterMenu="matMenu">
<ng-template matMenuContent>
<div (click)="$event.stopPropagation()">
@for (provider of enabledCalendarProviders(); track provider.id) {
<button
mat-menu-item
role="menuitemcheckbox"
[attr.aria-checked]="!hiddenCalendarProviderIds().includes(provider.id)"
(click)="toggleCalProvider(provider.id)"
>
@if (hiddenCalendarProviderIds().includes(provider.id)) {
<mat-icon>check_box_outline_blank</mat-icon>
} @else {
<mat-icon>check_box</mat-icon>
}
@if (provider.color) {
<span
class="cal-color-dot"
[style.background]="provider.color"
></span>
}
{{ calProviderLabel(provider) }}
</button>
}
</div>
</ng-template>
</mat-menu>
}
<div class="date-nav-group">
<button
mat-icon-button
@ -105,6 +67,31 @@
<div class="title">{{ headerTitle() }}</div>
</div>
@if (showCalFilterBar()) {
<mat-chip-listbox
class="cal-filter-bar"
[multiple]="true"
[attr.aria-label]="T.F.SCHEDULE.CALENDAR_VISIBILITY | translate"
(change)="onCalProvidersChange($event)"
>
@for (provider of enabledCalendarProviders(); track provider.id) {
<mat-chip-option
[value]="provider.id"
[selected]="!hiddenCalendarProviderIds().includes(provider.id)"
[matTooltip]="calProviderLabel(provider)"
>
@if (provider.color) {
<span
class="cal-color-dot"
[style.background]="provider.color"
></span>
}
{{ calProviderInitials(provider) }}
</mat-chip-option>
}
</mat-chip-listbox>
}
<div
class="scroll-wrapper"
[attr.data-horizontal-scroll]="shouldEnableHorizontalScroll() || null"

View file

@ -117,14 +117,19 @@
}
}
.cal-color-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
vertical-align: middle;
flex-shrink: 0;
.cal-filter-bar {
padding: var(--s-half) var(--s);
@include extraBorder('-bottom');
.cal-color-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
vertical-align: middle;
flex-shrink: 0;
}
}
.main-controls {

View file

@ -700,7 +700,7 @@ describe('ScheduleComponent', () => {
});
});
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);
});
});

View file

@ -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<string>((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}`;
});

View file

@ -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<HTMLElement>(`#t-${CSS.escape(taskId)}`);
const taskElement = allEls[allEls.length - 1];
const taskElement = document.getElementById(`t-${taskId}`);
if (!taskElement) return;
taskElement.focus();

View file

@ -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',

View file

@ -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 <strong>\"{{title}}\"</strong> into your Today task list?",

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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;