mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
4267a86d73
commit
1d52843cd7
34 changed files with 2017 additions and 2360 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
67
e2e/playwright.store-video.config.ts
Normal file
67
e2e/playwright.store-video.config.ts
Normal 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 },
|
||||
});
|
||||
|
|
@ -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/`)
|
||||
|
|
|
|||
|
|
@ -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 '&';
|
||||
case '<':
|
||||
return '<';
|
||||
case '>':
|
||||
return '>';
|
||||
case '"':
|
||||
return '"';
|
||||
default:
|
||||
return ''';
|
||||
}
|
||||
});
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
|
@ -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[];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 × ~15–20s each on a mobile viewport overruns the 180s default.
|
||||
test.setTimeout(8 * 60 * 1000);
|
||||
const page = seededPage;
|
||||
|
||||
// Mobile rows look cleaner without the per-task play button column.
|
||||
await applyTimeTrackingEnabled(page, false);
|
||||
|
||||
// 00 — Cover/hero. Today list with a marketing caption strip overlaid.
|
||||
await applyTheme(page, 'dark');
|
||||
await gotoAndSettle(page, '/#/tag/TODAY/tasks');
|
||||
await page.locator('task').first().waitFor({ state: 'visible' });
|
||||
await showMarketingOverlay(page, MARKETING_HEADLINE, MARKETING_SUBLINE);
|
||||
await screenshotMaster('mobile-00-hero', 'hero');
|
||||
await resetView(page);
|
||||
|
||||
for (let i = 0; i < LOCALES.length; i += 1) {
|
||||
const lng = LOCALES[i] as Locale;
|
||||
if (i > 0) await applyLocale(page, lng);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
120
e2e/store-video/CLAUDE.md
Normal 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.
|
||||
211
e2e/store-video/build-video.ts
Normal file
211
e2e/store-video/build-video.ts
Normal 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
303
e2e/store-video/fixture.ts
Normal 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
833
e2e/store-video/overlays.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
};
|
||||
316
e2e/store-video/scenarios/reel.spec.ts
Normal file
316
e2e/store-video/scenarios/reel.spec.ts
Normal 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
537
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue