diff --git a/e2e/store-screenshots/README.md b/e2e/store-screenshots/README.md index 9958d8a216..a63ab698ff 100644 --- a/e2e/store-screenshots/README.md +++ b/e2e/store-screenshots/README.md @@ -5,7 +5,7 @@ Reproducible app-store screenshots driven by Playwright + a single seed dataset. ## Quick start ```bash -# Capture everything (all viewports × all scenarios that apply to that platform) +# Capture the web-store matrix (all web viewports × matching scenarios) npm run screenshots # Or split: @@ -14,6 +14,8 @@ npm run screenshots:capture:desktop # desktopMaster only npm run screenshots:capture:mobile # iPhone/iPad/Android viewports only npm run screenshots:capture:electron # Electron build → dist/screenshots/_master_electron/ npm run screenshots:electron # capture:electron + build (lands in dist/) +npm run screenshots:build:flathub # rebuild only dist/screenshots/flathub/ +npm run screenshots:flathub # Linux Electron capture + Flathub-ready build npm run screenshots:build # rebuild dist/ layout from existing masters # One group while iterating @@ -28,8 +30,9 @@ npx playwright test --config e2e/playwright.store-screenshots.config.ts \ | `SCREENSHOT_MODE=electron` | Switches the fixture to the Electron pipeline (set by `screenshots:capture:electron`). | | `SCREENSHOT_BASE_DATE=2026-05-06T09:30:00` | Pin the "today" anchor used by the seed builder. Default is a Wednesday well clear of midnight in CI timezones. | | `SP_SCREENSHOT_BG_DARK_URL` / `SP_SCREENSHOT_BG_LIGHT_URL` | Override the default Unsplash backgrounds (e.g. point to a vendored asset for offline / privacy-sensitive runs). | -| `SP_SCREENSHOT_BG_DISABLE=1` | Drop background images entirely. | +| `SP_SCREENSHOT_BG_DISABLE=1` | Drop background images entirely (set by `screenshots:flathub`). | | `SP_SCREENSHOT_BG_OVERLAY_OPACITY=80` | Drives the per-context "Darken/lighten background image for better contrast" slider (0–99). Default 80 for screenshots vs. 20 in the app. | +| `SP_SCREENSHOTS_STORE=flathub` | Restrict post-processing to one store rule. Used by `screenshots:build:flathub` so Linux captures do not regenerate Mac App Store output. | Master captures land in `dist/screenshots/_master/////.png`. Per-store assets land in `dist/screenshots///NN-name.png` (and the F-Droid `fastlane/...` layout). @@ -74,7 +77,7 @@ Specs are platform-grouped: `scenarios/desktop/all.spec.ts` and `scenarios/mobil | `scenarios/desktop/all.spec.ts` | 12 desktop captures: hero + 11 scenes / light variants | | `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 shared/per-store layouts; JPEG re-encode for `maxBytes`-capped stores (Snap); emits `_preview.html` contact sheet | +| `build-store-assets.ts` | Renames + copies masters into shared/per-store layouts; filters/frames Flathub; 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 @@ -113,7 +116,7 @@ for (const locale of LOCALES) { - **Snap** uses the same desktop master content but stays separate because it caps at 5 items, ≤2 MB each, single global gallery (no per-locale). Pipeline emits all desktops re-encoded as JPEG (mozjpeg, q90→q60 step-down) so each fits the cap; **trim manually to 5 before Snap upload**. - **Play / Apple** explicitly forbid / discourage device frames. - **Apple** requires only iPhone 6.9" (1290×2796) and iPad 13" (2064×2752); smaller sizes auto-derive. -- **Flathub** requires native window chrome and forbids overlays — sourced from the Electron capture pipeline (single global gallery; run on a Linux X11/Wayland host via `npm run screenshots:capture:electron`). +- **Flathub** requires native window chrome and forbids overlays — sourced from the Electron capture pipeline (single global gallery). Run on a Linux X11/Wayland host via `npm run screenshots:flathub`; it disables decorative backgrounds, drops the marketing hero/duplicate variants, and frames the final PNGs with transparent rounded corners + shadow. ## Electron pipeline (Mac App Store, Flathub) @@ -126,6 +129,10 @@ npm run screenshots:capture:electron # Capture + build — masters and deliverables under dist/screenshots/ # (macappstore/, flathub/). Mirrors `npm run screenshots` for the web pipeline. npm run screenshots:electron + +# Flathub-ready Linux capture + targeted build. Disables decorative backgrounds +# and emits the filtered/framed dist/screenshots/flathub/ gallery. +npm run screenshots:flathub ``` Same scenarios, same fixture file — `store-screenshots/fixture.ts` branches on the `SCREENSHOT_MODE` env var (the npm script sets it). Each desktop spec runs unchanged in either mode. @@ -140,7 +147,7 @@ Per-OS tooling (must be on PATH): - **Linux X11** — ImageMagick (`apt install imagemagick`, ships `import`) - **Linux Wayland** — `grim` (`apt install grim`, wlroots-based compositors only) -The Mac App Store store rule has `masterDir: 'electron'` in `STORE_RULES`, so the post-processor pulls those captures into `dist/screenshots/macappstore/`. All other stores still come from the web pipeline. +The Mac App Store and Flathub store rules have `masterDir: 'electron'` in `STORE_RULES`, so the post-processor pulls those captures into `dist/screenshots/macappstore/` and `dist/screenshots/flathub/`. Flathub additionally pins its gallery order and applies rounded transparent window framing in `build-store-assets.ts`. All other stores still come from the web pipeline. ## Status @@ -149,7 +156,7 @@ The Mac App Store store rule has `masterDir: 'electron'` in `STORE_RULES`, so th - ✅ Per-build `_preview.html` contact sheet under `dist/screenshots/` for one-click QA - ✅ Electron-mode pipeline with macOS traffic-light compositing and Linux OS chrome capture (`grim` / `import`) - ✅ Mac App Store wired to source from `_master_electron/` -- ✅ Flathub STORE_RULE (single-gallery, sourced from `_master_electron/`) +- ✅ Flathub STORE_RULE (single-gallery, sourced from `_master_electron/`, filtered/framed for Flathub) - ✅ Snap JPEG re-encode under 2 MB cap (mozjpeg, automatic per-file) - ✅ Tooltip suppression + cursor parking so leftover Material tooltips don't bleed into captures - ⏳ Smoke-test the Electron pipeline on a real Mac (or Linux X11 for plumbing) diff --git a/e2e/store-screenshots/build-store-assets.ts b/e2e/store-screenshots/build-store-assets.ts index cd121be3f6..73d27e55fe 100644 --- a/e2e/store-screenshots/build-store-assets.ts +++ b/e2e/store-screenshots/build-store-assets.ts @@ -7,7 +7,8 @@ * Captures are made at each store's native pixel size (see playwright.store-screenshots.config.ts), * so this is mostly rename + copy — no resizing. Stores with a per-file byte * cap (`maxBytes` in STORE_RULES; currently Snap @ 2 MB) get re-encoded as - * JPEG until they fit; everything else stays lossless PNG. + * JPEG until they fit; everything else stays lossless PNG. Store rules can also + * filter/reorder scenarios and apply store-specific framing (Flathub). * * Run: npm run screenshots:build */ @@ -31,11 +32,70 @@ const masterDirFor = (which: 'web' | 'electron'): string => which === 'electron' ? MASTER_DIR_ELECTRON : MASTER_DIR_WEB; type ScenarioFile = { scenario: string; file: string; theme: string; absPath: string }; +type WriteOptions = { + maxBytes?: number; + frame?: StoreRule['frame']; +}; + +const FLATHUB_WINDOW_FRAME = { + padding: 48, + radius: 16, + shadowBlur: 24, + shadowOffsetY: 12, + shadowOpacity: 0.35, +} as const; /** Strip leading `(mobile|desktop)-NN-` so output names don't double up on the index. */ const cleanScenarioLabel = (scenario: string): string => scenario.replace(/^(?:(?:mobile|desktop)-)?\d+-/, ''); +const labelForScenario = (rule: StoreRule, scenario: string): string => + rule.scenarioLabels?.find((entry) => entry.scenario === scenario)?.label ?? + cleanScenarioLabel(scenario); + +const applyScenarioOrder = ( + scenarios: ScenarioFile[], + order?: readonly string[], + store?: string, +): ScenarioFile[] => { + if (!order) return scenarios; + + const byScenario = new Map(); + for (const scenario of scenarios) byScenario.set(scenario.scenario, scenario); + + return order.map((scenario) => { + const match = byScenario.get(scenario); + if (!match) { + const tag = store ? `${store}: ` : ''; + throw new Error(`${tag}missing ordered screenshot scenario "${scenario}"`); + } + return match; + }); +}; + +const storeFilter = (): Set | null => { + const raw = process.env.SP_SCREENSHOTS_STORE; + if (!raw) return null; + const stores = raw + .split(',') + .map((store) => store.trim()) + .filter(Boolean); + return stores.length ? new Set(stores) : null; +}; + +const filterStoreRules = (rules: readonly StoreRule[]): StoreRule[] => { + const filter = storeFilter(); + if (!filter) return [...rules]; + + const out = rules.filter((rule) => filter.has(rule.store)); + if (out.length === 0) { + throw new Error( + `SP_SCREENSHOTS_STORE matched no store rules: ${[...filter].join(', ')}`, + ); + } + return out; +}; + /** * Each scenario locks one theme. List all (theme × scenario) captures present * for a given viewport+locale, sorted by scenario name (which encodes slot order @@ -74,8 +134,73 @@ const listScenariosForVariant = ( return out; }; +const transparentBg = { r: 0, g: 0, b: 0, alpha: 0 } as const; + +const roundedRectSvg = ( + width: number, + height: number, + radius: number, + fill: string, + extraAttrs = '', +): Buffer => + Buffer.from( + ``, + ); + +const frameFlathubWindow = async (src: string): Promise => { + const metadata = await sharp(src).metadata(); + const width = metadata.width; + const height = metadata.height; + if (!width || !height) { + throw new Error(`Could not read screenshot dimensions for ${src}`); + } + + const { padding, radius, shadowBlur, shadowOffsetY, shadowOpacity } = + FLATHUB_WINDOW_FRAME; + const framePadding = padding * 2; + const frameWidth = width + framePadding; + const frameHeight = height + framePadding; + const windowMask = roundedRectSvg(width, height, radius, '#fff'); + const shadowShape = roundedRectSvg( + width, + height, + radius, + '#000', + `fill-opacity="${shadowOpacity}"`, + ); + + const roundedWindow = await sharp(src) + .ensureAlpha() + .composite([{ input: windowMask, blend: 'dest-in' }]) + .png() + .toBuffer(); + + const shadow = await sharp({ + create: { + width: frameWidth, + height: frameHeight, + channels: 4, + background: transparentBg, + }, + }) + .composite([{ input: shadowShape, left: padding, top: padding + shadowOffsetY }]) + .blur(shadowBlur) + .png() + .toBuffer(); + + return sharp(shadow) + .composite([{ input: roundedWindow, left: padding, top: padding }]) + .png() + .toBuffer(); +}; + +const renderSource = async (src: string, frame?: StoreRule['frame']): Promise => { + if (frame === 'flathub-window') return frameFlathubWindow(src); + return fs.readFileSync(src); +}; + /** - * Copy `src` to `target`, then enforce `maxBytes` if set. PNGs that already + * Render `src` to `target`, then enforce `maxBytes` if set. PNGs that already * fit go through unchanged. PNGs that exceed the cap are re-encoded as JPEG * at stepped-down quality until they fit; the .png target is replaced with a * .jpg sibling. Returns the actual path written. @@ -83,29 +208,30 @@ const listScenariosForVariant = ( const writeWithCap = async ( src: string, target: string, - maxBytes?: number, + opts: WriteOptions = {}, ): Promise => { - fs.copyFileSync(src, target); - if (!maxBytes) return target; - const initialSize = fs.statSync(target).size; - if (initialSize <= maxBytes) return target; + const buf = await renderSource(src, opts.frame); + fs.writeFileSync(target, buf); + if (!opts.maxBytes) return target; + if (buf.byteLength <= opts.maxBytes) return target; const jpgTarget = target.replace(/\.png$/i, '.jpg'); - const buf = fs.readFileSync(src); // Step down quality 90 → 60. If even q60 exceeds the cap (vanishingly rare // on screenshots that are mostly UI), bail loudly so the operator notices. for (const quality of [90, 85, 80, 75, 70, 65, 60]) { const out = await sharp(buf) .jpeg({ quality, mozjpeg: true, progressive: true, chromaSubsampling: '4:2:0' }) .toBuffer(); - if (out.byteLength <= maxBytes) { + if (out.byteLength <= opts.maxBytes) { fs.writeFileSync(jpgTarget, out); if (jpgTarget !== target) fs.rmSync(target); return jpgTarget; } } throw new Error( - `${path.basename(target)} still > ${maxBytes} bytes at JPEG quality 60 — capture too large`, + `${path.basename(target)} still > ${ + opts.maxBytes + } bytes at JPEG quality 60 — capture too large`, ); }; @@ -114,7 +240,7 @@ const writeFastlanePath = async ( bucket: NonNullable, index: number, src: string, - maxBytes?: number, + opts: WriteOptions = {}, ): Promise => { const bucketDir = `${bucket}Screenshots`; const dir = path.join( @@ -129,36 +255,36 @@ const writeFastlanePath = async ( ); fs.mkdirSync(dir, { recursive: true }); const target = path.join(dir, `${index + 1}.png`); - return writeWithCap(src, target, maxBytes); + return writeWithCap(src, target, opts); }; const writePerLocale = async ( store: string, locale: string, index: number, - scenario: string, + label: string, src: string, - maxBytes?: number, + opts: WriteOptions = {}, ): Promise => { const dir = path.join(OUT_DIR, store, locale); fs.mkdirSync(dir, { recursive: true }); const num = `${index + 1}`.padStart(2, '0'); - const target = path.join(dir, `${num}-${cleanScenarioLabel(scenario)}.png`); - return writeWithCap(src, target, maxBytes); + const target = path.join(dir, `${num}-${label}.png`); + return writeWithCap(src, target, opts); }; const writeGlobal = async ( store: string, index: number, - scenario: string, + label: string, src: string, - maxBytes?: number, + opts: WriteOptions = {}, ): Promise => { const dir = path.join(OUT_DIR, store); fs.mkdirSync(dir, { recursive: true }); const num = `${index + 1}`.padStart(2, '0'); - const target = path.join(dir, `${num}-${cleanScenarioLabel(scenario)}.png`); - return writeWithCap(src, target, maxBytes); + const target = path.join(dir, `${num}-${label}.png`); + return writeWithCap(src, target, opts); }; const buildOneRule = async ( @@ -169,21 +295,29 @@ const buildOneRule = async ( const masterRoot = masterDirFor(rule.masterDir ?? 'web'); for (const locale of LOCALES) { - const scenarios = listScenariosForVariant(rule.source, locale, masterRoot); + const scenarios = applyScenarioOrder( + listScenariosForVariant(rule.source, locale, masterRoot), + rule.scenarioOrder, + rule.store, + ); if (scenarios.length === 0) { skipped += 1; continue; } const limited = rule.maxCount ? scenarios.slice(0, rule.maxCount) : scenarios; + const writeOptions: WriteOptions = {}; + if (rule.maxBytes) writeOptions.maxBytes = rule.maxBytes; + if (rule.frame) writeOptions.frame = rule.frame; for (let i = 0; i < limited.length; i += 1) { const s = limited[i]; + const label = labelForScenario(rule, s.scenario); if (rule.localeLayout === 'fastlane' && rule.fastlaneBucket) { - await writeFastlanePath(locale, rule.fastlaneBucket, i, s.absPath, rule.maxBytes); + await writeFastlanePath(locale, rule.fastlaneBucket, i, s.absPath, writeOptions); } else if (rule.localeLayout === 'global') { if (locale !== LOCALES[0]) continue; // Snap / Flathub are single-gallery - await writeGlobal(rule.store, i, s.scenario, s.absPath, rule.maxBytes); + await writeGlobal(rule.store, i, label, s.absPath, writeOptions); } else { - await writePerLocale(rule.store, locale, i, s.scenario, s.absPath, rule.maxBytes); + await writePerLocale(rule.store, locale, i, label, s.absPath, writeOptions); } written += 1; } @@ -203,6 +337,17 @@ const cleanDerivedOutput = (): void => { } }; +const cleanSelectedOutput = (rules: readonly StoreRule[]): void => { + fs.mkdirSync(OUT_DIR, { recursive: true }); + const targets = new Set(); + for (const rule of rules) { + targets.add(rule.localeLayout === 'fastlane' ? 'fdroid' : rule.store); + } + for (const target of targets) { + fs.rmSync(path.join(OUT_DIR, target), { recursive: true, force: true }); + } +}; + const main = async (): Promise => { if (!fs.existsSync(MASTER_DIR_WEB) && !fs.existsSync(MASTER_DIR_ELECTRON)) { console.error('No master captures found.'); @@ -212,10 +357,12 @@ const main = async (): Promise => { process.exit(1); } - cleanDerivedOutput(); + const rules = filterStoreRules(STORE_RULES); + if (storeFilter()) cleanSelectedOutput(rules); + else cleanDerivedOutput(); let total = 0; - for (const rule of STORE_RULES) { + for (const rule of rules) { const res = await buildOneRule(rule); total += res.written; const tag = rule.fastlaneBucket ? `${rule.store}/${rule.fastlaneBucket}` : rule.store; diff --git a/e2e/store-screenshots/matrix.ts b/e2e/store-screenshots/matrix.ts index fb9d5b991f..8811078854 100644 --- a/e2e/store-screenshots/matrix.ts +++ b/e2e/store-screenshots/matrix.ts @@ -133,6 +133,12 @@ export type StoreRule = { * each entry at 2 MB; everything else keeps the lossless PNG. */ maxBytes?: number; + /** Optional explicit gallery order/filter for stores with tighter curation. */ + scenarioOrder?: readonly string[]; + /** Optional output filename labels for raw scenario ids. */ + scenarioLabels?: readonly { scenario: string; label: string }[]; + /** Optional post-processing frame applied before writing the store output. */ + frame?: 'flathub-window'; }; export const STORE_RULES: readonly StoreRule[] = [ @@ -158,13 +164,28 @@ export const STORE_RULES: readonly StoreRule[] = [ maxBytes: 2 * 1024 * 1024, }, // Flathub: metainfo.xml `` is single-gallery and sourced from the - // Electron pipeline so the captures include native GTK chrome (Linux X11 - // host required — see README "Electron pipeline" section). + // Electron pipeline so the captures include native GTK chrome. Keep the + // gallery concise: no marketing hero, no duplicate light/dark variants. { store: 'flathub', source: 'desktopMaster', localeLayout: 'global', masterDir: 'electron', + scenarioOrder: [ + 'desktop-01-list-with-schedule', + 'desktop-08-planner', + 'desktop-02-eisenhower', + 'desktop-03-schedule-dark', + 'desktop-04-list-with-notes', + 'desktop-05-focus-mode', + 'desktop-07-project-dark', + 'desktop-10-task-detail-panel', + ], + scenarioLabels: [ + { scenario: 'desktop-03-schedule-dark', label: 'schedule' }, + { scenario: 'desktop-07-project-dark', label: 'project' }, + ], + frame: 'flathub-window', }, { store: 'ios/iphone-69', diff --git a/package.json b/package.json index c4a771b1e7..322c745d8f 100644 --- a/package.json +++ b/package.json @@ -75,8 +75,11 @@ "screenshots:capture:desktop": "npx playwright test --config e2e/playwright.store-screenshots.config.ts --project=desktopMaster", "screenshots:capture:mobile": "npx playwright test --config e2e/playwright.store-screenshots.config.ts --project=iphone69 --project=ipad13 --project=androidPhone --project=android7Tablet --project=android10Tablet", "screenshots:build": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\",\"moduleResolution\":\"node\"}' npx ts-node --transpile-only e2e/store-screenshots/build-store-assets.ts", + "screenshots:build:flathub": "SP_SCREENSHOTS_STORE=flathub npm run screenshots:build", "screenshots:capture:electron": "npm run electron:build && SCREENSHOT_MODE=electron npx playwright test --config e2e/playwright.store-screenshots.electron.config.ts", + "screenshots:capture:flathub": "SP_SCREENSHOT_BG_DISABLE=1 npm run screenshots:capture:electron", "screenshots:electron": "npm run screenshots:capture:electron && npm run screenshots:build", + "screenshots:flathub": "npm run screenshots:capture:flathub && npm run screenshots:build:flathub", "video": "npm run video:capture && npm run video:build && npm run video:open", "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",