feat(screenshots): polish app-store capture pipeline

Iterates the store-screenshot pipeline to ship-ready quality. Squashed
from a series of capture/build/UX changes that all live in
e2e/store-screenshots and form a coherent set.

Pipeline UX
- Print master capture path via Playwright globalTeardown.
- Declare sharp as devDep (was imported by build-store-assets but
  never listed) so a fresh install runs the build cleanly.
- Open dist/ folder when build finishes; opt out via
  SP_SCREENSHOTS_NO_OPEN=1.
- Emit dist/screenshots/_preview.html contact sheet for one-click QA
  across every per-store layout.

Capture content
- New slot 00 hero across desktop / mobile / tablet specs:
  showMarketingOverlay paints a gradient caption strip on top of the
  live app. Position is orientation-aware (bottom for landscape, top
  for portrait). Copy lives in marketing-copy.ts as a single source
  of truth.
- Desktop slot 05 captures the running focus timer instead of the
  duration picker (skip the rocket countdown via clock.runFor).
- Desktop slot 07 = plain dark project view (no wallpaper) instead
  of catppuccin; slot 08 = desktop planner.
- Mobile slots 02 and 04 use signal-based planner expansion (the
  component is gesture-only, so flip isExpanded via ng.getComponent).
- Side nav collapsed for desktop slots 01 (schedule day-panel) and
  04 (notes panel) so the right panel can breathe.
- Mobile hides the per-task play column via
  appFeatures.isTimeTrackingEnabled.
- Seed: drop Morning yoga (the only top-level done task on today),
  flip the remaining done subtask back to undone, trim the PR
  review tag chips down to the two EM tags that drive Eisenhower.

Viewports
- desktopMaster: 1280x800 CSS at 2x -> 2560x1600 (smaller MAS Retina
  size = more apparent zoom than the prior 1440x900 baseline).
- androidPhone: 412x915 CSS at 3x -> 1236x2745, matching modern
  flagships (Pixel 7/8, Galaxy S22+).
- Tablet rotations: 7" -> landscape, 10" -> portrait. android7Tablet
  moves from PHONE_VIEWPORTS to TABLET_VIEWPORTS.

Test infra
- Bump per-test timeout to 8 minutes for multi-locale single-session
  specs (12+ captures per locale overran the 180s default on slow
  viewports).
- Scope LOCALES to ['en'] for now until German strings catch up.
- ImportPage race tolerates slow imports without an encryption
  dialog: catch the 15s waitFor rejection so the 60s import-complete
  promise is the real ceiling. A cold dev server would otherwise
  abort here at 15s.
This commit is contained in:
Johannes Millan 2026-05-07 18:25:25 +02:00
parent 8689197571
commit 7157ea62df
15 changed files with 969 additions and 93 deletions

View file

@ -141,13 +141,16 @@ 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),
.then(() => 'dialog' as const)
.catch(() => 'no_dialog' as const),
importCompletePromise.then(() => 'import_complete' as const),
]);
@ -158,6 +161,10 @@ export class ImportPage extends BasePage {
await encryptionWarning.waitFor({ state: 'hidden', timeout: 10000 });
// Now wait for the import to actually complete
await importCompletePromise;
} else if (dialogOrImport === 'no_dialog') {
// Dialog didn't appear within 15s — import is slow but may still complete.
// Wait for the import-complete signal (its own 60s timeout will catch a true hang).
await importCompletePromise;
}
// If 'import_complete', the import finished without showing the dialog

View file

@ -19,6 +19,7 @@ import {
*/
export default defineConfig({
testDir: path.join(__dirname, 'store-screenshots', 'scenarios'),
globalTeardown: path.join(__dirname, 'store-screenshots', 'print-output-path.ts'),
fullyParallel: false,
forbidOnly: !!process.env.CI,
// Screenshots aren't flaky like e2e tests — failures should be investigated, not retried.

View file

@ -16,6 +16,7 @@ import { VIEWPORTS, type ViewportSpec } from './store-screenshots/matrix';
*/
export default defineConfig({
testDir: path.join(__dirname, 'store-screenshots', 'scenarios', 'desktop'),
globalTeardown: path.join(__dirname, 'store-screenshots', 'print-output-path.ts'),
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,

View file

@ -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 (pattern matches `desktop dark|light|catppuccin` etc.)
# One group while iterating
npx playwright test --config e2e/playwright.store-screenshots.config.ts \
--project=desktopMaster --grep "desktop dark \(en\)"
--project=desktopMaster --grep "desktop all"
```
## Environment overrides
@ -38,6 +38,9 @@ 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 |
@ -50,9 +53,10 @@ 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 | catppuccin-mocha | Today list with custom theme |
| desktop-07 | desktop | dark | Project (Work) view, no wallpaper — regular palette reads cleanly |
| desktop-08 | desktop | dark | Planner |
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).
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).
## Files
@ -62,11 +66,12 @@ 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` |
| `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) |
| `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 |
| `../playwright.store-screenshots.config.ts` | Separate Playwright config; one project per viewport |
## How it works
@ -134,7 +139,8 @@ 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
- ✅ 13 scenarios (6 mobile + 7 desktop) covering planner, boards, schedule, focus, notes, custom theme
- ✅ 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
- ✅ Electron-mode pipeline with OS-level chrome capture (`screencapture` / `grim` / `import`)
- ✅ Mac App Store wired to source from `_master_electron/`
- ✅ Flathub STORE_RULE (single-gallery, sourced from `_master_electron/`)

View file

@ -12,6 +12,7 @@
* Run: npm run screenshots:build
*/
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import sharp from 'sharp';
@ -214,7 +215,142 @@ const main = async (): Promise<void> => {
);
}
console.log(`\nTotal files written: ${total}`);
const previewPath = writePreviewSheet();
console.log(`Output: ${OUT_DIR}`);
console.log(`Preview: ${previewPath}`);
openFolder(OUT_DIR);
};
type PreviewEntry = { relPath: string; group: string; label: string };
/** Walk OUT_DIR and collect every emitted png/jpg as a preview entry. */
const collectPreviewEntries = (root: string, base = ''): PreviewEntry[] => {
const out: PreviewEntry[] = [];
if (!fs.existsSync(root)) return out;
for (const entry of fs.readdirSync(root)) {
const absPath = path.join(root, entry);
const relPath = base ? `${base}/${entry}` : entry;
const stat = fs.statSync(absPath);
if (stat.isDirectory()) {
out.push(...collectPreviewEntries(absPath, relPath));
continue;
}
if (!/\.(png|jpe?g)$/i.test(entry)) continue;
// Group is everything up to the last directory separator; label is filename.
const lastSlash = relPath.lastIndexOf('/');
const group = lastSlash === -1 ? '(root)' : relPath.slice(0, lastSlash);
out.push({ relPath, group, label: entry });
}
return out;
};
/**
* Emit `_preview.html` a single-page contact sheet of every capture in
* `dist/screenshots/`, grouped by store + locale. Open it in a browser to
* eyeball the whole batch instead of drilling through ~14 subfolders.
*/
const writePreviewSheet = (): string => {
const entries = collectPreviewEntries(OUT_DIR).sort((a, b) =>
a.relPath.localeCompare(b.relPath),
);
const grouped = new Map<string, PreviewEntry[]>();
for (const e of entries) {
const list = grouped.get(e.group) ?? [];
list.push(e);
grouped.set(e.group, list);
}
const escape = (s: string): string =>
s.replace(/[&<>"']/g, (c) => {
switch (c) {
case '&':
return '&amp;';
case '<':
return '&lt;';
case '>':
return '&gt;';
case '"':
return '&quot;';
default:
return '&#39;';
}
});
const renderCard = (it: PreviewEntry): string => {
const href = escape(it.relPath);
const label = escape(it.label);
return [
`<a class="card" href="${href}" target="_blank" rel="noopener">`,
`<img loading="lazy" src="${href}" alt="${label}">`,
`<div class="label">${label}</div>`,
`</a>`,
].join('');
};
const sections = Array.from(grouped.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, items]) => {
const cards = items.map(renderCard).join('');
const heading =
`<h2>${escape(group)} ` + `<span class="count">${items.length}</span></h2>`;
return `<section>${heading}<div class="grid">${cards}</div></section>`;
})
.join('');
const html = `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<title>Screenshots preview</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; background: #111; color: #eee; margin: 0; padding: 24px; }
h1 { margin: 0 0 4px; font-size: 22px; }
.summary { color: #888; font-size: 13px; margin-bottom: 24px; }
h2 {
font-size: 14px; font-weight: 600; color: #ccc; margin: 28px 0 12px;
letter-spacing: 0.02em; text-transform: uppercase;
border-bottom: 1px solid #2a2a2a; padding-bottom: 6px;
}
h2 .count { color: #666; font-weight: 400; margin-left: 8px; }
.grid {
display: grid; gap: 14px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.card {
display: block; background: #1a1a1a; border: 1px solid #262626;
border-radius: 8px; overflow: hidden; text-decoration: none; color: inherit;
transition: transform 0.12s, border-color 0.12s;
}
.card:hover { transform: translateY(-2px); border-color: #444; }
.card img { display: block; width: 100%; height: 280px; object-fit: contain; background: #000; }
.label { padding: 8px 10px; font-size: 12px; color: #aaa; word-break: break-all; }
</style>
</head><body>
<h1>Store screenshots preview</h1>
<div class="summary">${entries.length} captures across ${grouped.size} groups · ${OUT_DIR}</div>
${sections}
</body></html>
`;
const target = path.join(OUT_DIR, '_preview.html');
fs.writeFileSync(target, html, 'utf-8');
return target;
};
/**
* Reveal `dir` in the OS file manager. Detached + unref'd so the build script
* exits immediately. Silently no-ops on headless / CI environments where the
* platform opener isn't available. Opt out with `SP_SCREENSHOTS_NO_OPEN=1`.
*/
const openFolder = (dir: string): void => {
if (process.env.SP_SCREENSHOTS_NO_OPEN) return;
const isWin = process.platform === 'win32';
const isMac = process.platform === 'darwin';
const cmd = isWin ? 'cmd' : isMac ? 'open' : 'xdg-open';
const args = isWin ? ['/c', 'start', '', dir] : [dir];
try {
const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
child.on('error', () => {
/* opener not available on this system */
});
child.unref();
} catch {
/* ignore */
}
};
main().catch((err) => {

View file

@ -124,22 +124,132 @@ export const applyLocale = async (page: Page, lng: string): Promise<void> => {
};
/**
* 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.
* 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.
*/
export const applyCustomTheme = async (page: Page, themeId: string): Promise<void> => {
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> => {
await dispatchInPage(page, {
type: '[Global Config] Update Global Config Section',
sectionKey: 'misc',
sectionCfg: { customTheme: themeId },
sectionKey: 'appFeatures',
sectionCfg: { isTimeTrackingEnabled: enabled },
isSkipSnack: true,
meta: {
isPersistent: true,
entityType: 'GLOBAL_CONFIG',
entityId: 'misc',
entityId: 'appFeatures',
opType: 'UPDATE',
},
});
await page.waitForTimeout(800);
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);
};

View file

@ -0,0 +1,10 @@
/**
* Single source of truth for the cover/hero slot caption shown on the lead
* app-store screenshot. Keep it punchy large type at the top of the
* capture, with the actual app visible below as proof.
*
* If you want to A/B different copy, change here and re-run capture; all
* spec classes (desktop / mobile / tablet) read these constants.
*/
export const MARKETING_HEADLINE = 'Plan it. Do it. Done.';
export const MARKETING_SUBLINE = 'Tasks, time, focus all in one place.';

View file

@ -12,7 +12,8 @@
export type Theme = 'light' | 'dark';
export type Locale = 'en' | 'de';
export const LOCALES: readonly Locale[] = ['en', 'de'] as const;
// 'de' temporarily disabled — re-add once translations are caught up.
export const LOCALES: readonly Locale[] = ['en'] as const;
export const THEMES: readonly Theme[] = ['light', 'dark'] as const;
/**
@ -32,20 +33,27 @@ export type ViewportSpec = {
* The key is also the on-disk directory name under `_master/<viewport>/`.
*/
export const VIEWPORTS = {
/** 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 },
/** 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 },
/** 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 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 },
/** 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 },
} as const satisfies Record<string, ViewportSpec>;
export type ViewportName = keyof typeof VIEWPORTS;
@ -69,25 +77,22 @@ 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 portrait)
* or narrower than the desktopMaster (Android 10" landscape). Routing these
* layout (sidenav, panels) but the canvas is taller-than-wide (iPad / 10"
* portrait) or narrower than the desktopMaster (7" landscape). Routing these
* to the desktop spec would push side panels off-screen; routing them to the
* mobile spec wastes the canvas. They run `scenarios/tablet/` instead.
*/
export const TABLET_VIEWPORTS = [
'ipad13',
'android7Tablet',
'android10Tablet',
] as const satisfies readonly ViewportName[];

View file

@ -0,0 +1,13 @@
import * as fs from 'fs';
import { MASTER_DIR_ELECTRON, MASTER_DIR_WEB } from './matrix';
const printOutputPath = (): void => {
const dir =
process.env.SCREENSHOT_MODE === 'electron' ? MASTER_DIR_ELECTRON : MASTER_DIR_WEB;
const exists = fs.existsSync(dir);
console.log(
`\nMaster captures: ${dir}${exists ? '' : ' (not created — no scenarios ran)'}`,
);
};
export default printOutputPath;

View file

@ -1,40 +1,45 @@
/**
* 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.
* 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.
*
* 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 × catppuccin-mocha (slot 07)
* en × dark (slot 07 project view, no wallpaper)
*/
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
// 01 — Today + schedule day-panel open. Side nav collapsed so the right
// panel doesn't squeeze the task list.
await applySideNavCollapsed(page, true);
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
@ -53,7 +58,7 @@ const captureDarkScenes = async (
await shoot('desktop-03-schedule-dark', 'schedule-dark');
await resetView(page);
// 05 — Focus mode (set current task first so the title renders).
// 05 — Focus mode, running timer (not duration-selection).
await gotoAndSettle(page, '/#/tag/TODAY/tasks');
const firstTask = page.locator('task').first();
await firstTask.waitFor({ state: 'visible' });
@ -67,20 +72,41 @@ const captureDarkScenes = async (
.locator('focus-mode-overlay, focus-mode-main')
.first()
.waitFor({ state: 'visible', timeout: 10_000 });
await page.waitForTimeout(500);
// 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 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
// 04 — Project (Work) + notes panel. Side nav collapsed so the notes
// panel can breathe.
await applySideNavCollapsed(page, true);
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)
@ -97,8 +123,20 @@ 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
@ -112,13 +150,14 @@ test.describe('@screenshot desktop all', () => {
await captureLightScenes(page, screenshotMaster);
}
// 07 — Catppuccin Mocha. Routed through a project view so the global
// background image is suppressed and the catppuccin palette reads clearly.
// 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.
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-catppuccin', 'project-catppuccin');
await screenshotMaster('desktop-07-project-dark', 'project-dark');
});
});

View file

@ -10,7 +10,16 @@
*/
import { test } from '../../fixture';
import { LOCALES, type Locale } from '../../matrix';
import { applyLocale, applyTheme, gotoAndSettle, resetView } from '../../helpers';
import {
applyLocale,
applyTheme,
applyTimeTrackingEnabled,
gotoAndSettle,
resetView,
setPlannerCalendarExpanded,
showMarketingOverlay,
} from '../../helpers';
import { MARKETING_HEADLINE, MARKETING_SUBLINE } from '../../marketing-copy';
import type { Page } from '@playwright/test';
const captureDarkScenes = async (
@ -23,14 +32,11 @@ const captureDarkScenes = async (
await shoot('mobile-01-planner', 'planner');
await resetView(page);
// 02 — Planner expanded (calendar nav header click)
// 02 — Planner with calendar nav expanded (multi-week day picker)
await gotoAndSettle(page, '/#/planner');
await page.locator('planner, planner-day').first().waitFor({ state: 'visible' });
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 page.locator('planner-calendar-nav').waitFor({ state: 'visible' });
await setPlannerCalendarExpanded(page, true);
await shoot('mobile-02-planner-expanded', 'planner-expanded');
await resetView(page);
@ -60,14 +66,11 @@ const captureLightScenes = async (
page: Page,
shoot: (scenario: string, name: string) => Promise<void>,
): Promise<void> => {
// 04 — Planner expanded (light variant)
// 04 — Planner with calendar nav expanded (light variant)
await gotoAndSettle(page, '/#/planner');
await page.locator('planner, planner-day').first().waitFor({ state: 'visible' });
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 page.locator('planner-calendar-nav').waitFor({ state: 'visible' });
await setPlannerCalendarExpanded(page, true);
await shoot('mobile-04-planner-expanded-light', 'planner-expanded-light');
};
@ -78,8 +81,21 @@ test.describe('@screenshot mobile all', () => {
seededPage,
screenshotMaster,
}) => {
// 12 captures × ~1520s each on a mobile viewport overruns the 180s default.
test.setTimeout(8 * 60 * 1000);
const page = seededPage;
// Mobile rows look cleaner without the per-task play button column.
await applyTimeTrackingEnabled(page, false);
// 00 — Cover/hero. Today list with a marketing caption strip overlaid.
await applyTheme(page, 'dark');
await gotoAndSettle(page, '/#/tag/TODAY/tasks');
await page.locator('task').first().waitFor({ state: 'visible' });
await showMarketingOverlay(page, MARKETING_HEADLINE, MARKETING_SUBLINE);
await screenshotMaster('mobile-00-hero', 'hero');
await resetView(page);
for (let i = 0; i < LOCALES.length; i += 1) {
const lng = LOCALES[i] as Locale;
if (i > 0) await applyLocale(page, lng);

View file

@ -1,9 +1,9 @@
/**
* Tablet capture spec runs against ipad13 (1032×1376 CSS portrait) 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.
* 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.
*
* Touch is emulated (`hasTouch: true` because the matrix marks these as
* `isMobile`), so any hover-revealed action (e.g. desktop's
@ -14,7 +14,13 @@
* localized task content.
*/
import { test } from '../../fixture';
import { applyTheme, gotoAndSettle, resetView } from '../../helpers';
import {
applyTheme,
gotoAndSettle,
resetView,
showMarketingOverlay,
} from '../../helpers';
import { MARKETING_HEADLINE, MARKETING_SUBLINE } from '../../marketing-copy';
import type { Page } from '@playwright/test';
const captureDarkScenes = async (
@ -69,6 +75,14 @@ test.describe('@screenshot tablet all', () => {
}) => {
const page = seededPage;
await applyTheme(page, 'dark');
// 00 — Cover/hero. Today list with a marketing caption strip overlaid.
await gotoAndSettle(page, '/#/tag/TODAY/tasks');
await page.locator('task').first().waitFor({ state: 'visible' });
await showMarketingOverlay(page, MARKETING_HEADLINE, MARKETING_SUBLINE);
await screenshotMaster('tablet-00-hero', 'hero');
await resetView(page);
await captureDarkScenes(page, screenshotMaster);
await applyTheme(page, 'light');
await captureLightScenes(page, screenshotMaster);

View file

@ -46,7 +46,7 @@
"id": "personal",
"title": "Personal",
"isHiddenFromMenu": false,
"taskIds": ["t-yoga", "t-call-mom"],
"taskIds": ["t-call-mom"],
"backlogTaskIds": ["t-book-flight", "t-renew-passport"],
"noteIds": ["n-ideas", "n-week-goals"],
"theme": {
@ -269,7 +269,6 @@
"t-quarterly-plan",
"t-quarterly-plan-sub-1",
"t-quarterly-plan-sub-2",
"t-yoga",
"t-call-mom",
"t-book-flight",
"t-renew-passport",
@ -304,7 +303,7 @@
"timeEstimate": 2700000,
"isDone": false,
"notes": "",
"tagIds": ["tag-deep-work", "EM_URGENT", "EM_IMPORTANT"],
"tagIds": ["EM_URGENT", "EM_IMPORTANT"],
"created": 0,
"attachments": [],
"dueDayOffset": 0,
@ -332,7 +331,7 @@
"timeSpentOnDay": {},
"timeSpent": 0,
"timeEstimate": 1800000,
"isDone": true,
"isDone": false,
"notes": "",
"tagIds": [],
"parentId": "t-quarterly-plan",
@ -355,22 +354,6 @@
"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",
@ -508,7 +491,6 @@
"id": "TODAY",
"title": "Today",
"taskIds": [
"t-yoga",
"t-pr-review",
"t-call-mom",
"t-design-review",
@ -608,7 +590,7 @@
"tag-home": {
"id": "tag-home",
"title": "home",
"taskIds": ["t-yoga"],
"taskIds": [],
"icon": "home",
"created": 0,
"theme": {

537
package-lock.json generated
View file

@ -135,6 +135,7 @@
"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",
@ -3921,7 +3922,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@ -5122,6 +5122,496 @@
"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",
@ -24392,6 +24882,51 @@
"node": ">=8"
}
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View file

@ -276,6 +276,7 @@
"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",