feat(pwa): add rich install presentation
- commit
- ba1f47011a1cee5fdf14b3f33388c184389a2fd3
- parent
- adbae75db3476675cec34e57e40ef6095f7dbe9a
- parent
- bd5a682310679c1faa4dc3a70f0ae4897bf6aaa6
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | apps/ | 16 | +++++ |
| A | apps/ | bin | |
| A | apps/ | bin | |
| M | docs/ | 43 | +++++++++++++- |
| M | docs/ | 2 | + |
| M | package/ | 8 | ++- |
| M | package/ | 36 | ++++++++++++ |
| M | package/ | 2 | + |
| M | package/ | 4 | +- |
| M | package/ | 7 | ++- |
| M | package/ | 3 | +- |
| M | package/ | 92 | +++++++++++++++++++++++++++++- |
| M | package/ | 15 | +++++- |
| M | tools/ | 28 | +++++++++ |
@@ -37,6 +37,22 @@
3737 "purpose": "monochrome"3838 }3939 ],40+ "screenshots": [41+ {42+ "src": "./screenshot-narrow.png",43+ "sizes": "540x720",44+ "type": "image/png",45+ "form_factor": "narrow",46+ "label": "The local-first task list on a phone-sized screen"47+ },48+ {49+ "src": "./screenshot-wide.png",50+ "sizes": "1280x720",51+ "type": "image/png",52+ "form_factor": "wide",53+ "label": "The local-first task list on a desktop-sized screen"54+ }55+ ],4056 "shortcuts": [4157 {4258 "name": "Open tasks",Binary file.
Binary file.
@@ -84,9 +84,46 @@ direction differs from the manifest default should declare their own `dir`.
8484 8585 Storefront metadata is optional and product-specific. Add lowercase `categories` only when they8686 truthfully describe the finished app. Add `iarc_rating_id` only after obtaining a real IARC87-certification code; never copy a placeholder rating. Rich screenshots are covered separately.88-Experimental capabilities such as file, protocol, share-target, and launch handlers are deliberately89-absent from the starter and should be added only with matching product behavior and tests.87+certification code; never copy a placeholder rating. Experimental capabilities such as file,88+protocol, share-target, and launch handlers are deliberately absent from the starter and should be89+added only with matching product behavior and tests.90+91+### Replace or remove install presentation92+93+The starter manifest includes one labeled `540x720` narrow screenshot, one labeled `1280x720` wide94+screenshot, and one **Open tasks** shortcut. They are examples of the real generated app, not95+generic promotional art. Before launch:96+97+- replace both screenshots with current product captures and update each `sizes`, `label`, and98+ `form_factor`, or remove the entire `screenshots` member and both files;99+- replace the shortcut name, description, route, and icon with one useful product entry point, or100+ remove the entire `shortcuts` member;101+- keep shortcut routes inside manifest scope and backed by a prerendered route so they cold-start102+ offline; and103+- keep shortcut icons inside scope with truthful MIME types and dimensions.104+105+Build validation checks every referenced asset, requires labeled narrow and wide variants when the106+`screenshots` member is present, and rejects shortcut routes that were not emitted. Screenshots are107+deliberately excluded from the required shell precache; deleting promotional captures cannot break108+offline startup.109+110+The starter does not claim a Web Share Target. If a product opts in, first add a same-scope,111+prerendered action route, then add a manifest member such as:112+113+```json114+{115+ "share_target": {116+ "action": "./share/",117+ "method": "GET",118+ "enctype": "application/x-www-form-urlencoded",119+ "params": { "title": "title", "text": "text", "url": "url" }120+ }121+}122+```123+124+The receiving island must ignore unknown parameters, cap title/text lengths, and accept a shared URL125+only after parsing it and allow-listing the intended protocols (normally `https:` and `http:`). Test126+direct, malformed, oversized, and offline launches before shipping the opt-in route.90127 91128 ## Deno Deploy92129 @@ -38,6 +38,8 @@ project. Adding or removing a generated file requires updating this map in the s
3838 | `public/icon-512.png` | Product asset | Supplies the regular 512x512 install icon. |3939 | `public/icon-maskable-512.png` | Product asset | Supplies a padded 512x512 maskable icon with a safe-zone-aware mark. |4040 | `public/icon-monochrome.svg` | Product asset | Supplies the transparent silhouette used where the OS applies a solid icon color. |41+| `public/screenshot-narrow.png` | Product asset | Supplies a replaceable labeled phone-sized install screenshot. |42+| `public/screenshot-wide.png` | Product asset | Supplies a replaceable labeled desktop-sized install screenshot. |4143 | `public/manifest.webmanifest` | Product asset | Declares stable install identity, locale, scope, orientation, icons, and shortcuts. |4244 | `src/app.ts` | Author-owned source | Composes the app name, storage, sync, credential origins, and repository link. |4345 | `src/env.d.ts` | Generated configuration | Adds Astro and lofi build-time environment types. |@@ -13,7 +13,7 @@ import { LOFI_VERSION } from "../version.ts";
1313 import { prepareLofiAstroConfig } from "../astro/mod.ts";1414 import { loadEnvironment, serverEnvironmentNames } from "../tooling/environment.ts";1515 import { precacheUrls, scanSecrets, sourceFingerprint, walkFiles } from "../tooling/project.ts";16-import { productionPwaIssues } from "../tooling/pwa-validation.ts";16+import { productionPwaIssues, screenshotAssetPaths } from "../tooling/pwa-validation.ts";1717 import { runDeno } from "../tooling/process.ts";1818 import { exitOnFailure, validatedCommandEnvironment } from "./shared.ts";1919 @@ -59,7 +59,11 @@ await Deno.writeTextFile(
5959 serviceWorkerPath,6060 serviceWorker.replace("__LOFI_BUILD_REVISION__", sourceHash),6161 );62-const precache = precacheUrls(await walkFiles("dist", { includeDist: true }));62+const productionManifest = JSON.parse(await Deno.readTextFile("dist/manifest.webmanifest"));63+const precache = precacheUrls(64+ await walkFiles("dist", { includeDist: true }),65+ screenshotAssetPaths(productionManifest, environment.LOFI_BASE_PATH),66+);6367 await Deno.writeTextFile("dist/lofi-precache.json", `${JSON.stringify(precache.sort())}\n`);6468 6569 const pwaIssues = await productionPwaIssues(Deno.cwd(), environment.LOFI_BASE_PATH);@@ -8,6 +8,13 @@ type ManifestShortcut = {
88 url: string;99 icons?: Array<Omit<ManifestIcon, "purpose">>;1010 };11+type ManifestScreenshot = {12+ src: string;13+ sizes: string;14+ type: string;15+ form_factor: "narrow" | "wide";16+ label: string;17+};1118 1219 type Manifest = {1320 id: string;@@ -17,6 +24,7 @@ type Manifest = {
1724 scope: string;1825 orientation: string;1926 icons: ManifestIcon[];27+ screenshots: ManifestScreenshot[];2028 shortcuts: ManifestShortcut[];2129 [member: string]: unknown;2230 };@@ -149,3 +157,31 @@ Deno.test("starter shortcut and its icons stay inside the application scope", as
149157 );150158 assert(taskIsland.includes('id="tasks"'), "starter shortcut has no matching task anchor");151159 });160+161+Deno.test("starter install screenshots are labeled narrow and wide captures", async () => {162+ const publicRoot = new URL("../apps/reference/public/", import.meta.url);163+ const manifest = JSON.parse(164+ await Deno.readTextFile(new URL("manifest.webmanifest", publicRoot)),165+ ) as Manifest;166+ const expected = new Map([167+ ["narrow", { file: "screenshot-narrow.png", width: 540, height: 720 }],168+ ["wide", { file: "screenshot-wide.png", width: 1280, height: 720 }],169+ ]);170+ assert(manifest.screenshots.length === expected.size, "starter screenshot set must stay small");171+ for (const screenshot of manifest.screenshots) {172+ const contract = expected.get(screenshot.form_factor);173+ assert(contract, `unexpected screenshot form factor ${screenshot.form_factor}`);174+ assert(basename(screenshot.src) === contract.file, `${screenshot.form_factor} file drifted`);175+ assert(screenshot.type === "image/png", `${contract.file} must declare image/png`);176+ assert(screenshot.label.trim().length > 0, `${contract.file} needs an accessible label`);177+ assert(178+ screenshot.sizes === `${contract.width}x${contract.height}`,179+ `${contract.file} has the wrong declared size`,180+ );181+ const dimensions = pngDimensions(await Deno.readFile(new URL(contract.file, publicRoot)));182+ assert(183+ dimensions.width === contract.width && dimensions.height === contract.height,184+ `${contract.file} is ${dimensions.width}x${dimensions.height}`,185+ );186+ }187+});@@ -13,6 +13,8 @@ export const STARTER_FILES: readonly string[] = [
1313 "public/icon-512.png",1414 "public/icon-maskable-512.png",1515 "public/icon-monochrome.svg",16+ "public/screenshot-narrow.png",17+ "public/screenshot-wide.png",1618 "public/manifest.webmanifest",1719 "src/app.ts",1820 "src/env.d.ts",@@ -8,7 +8,9 @@
88 "public/icon-512.png": "b943095ef43892d24974dabb7fe68221f365af749e6fd455a0d0296f08ecd854",99 "public/icon-maskable-512.png": "c61dfca524605cfc073f1725af358599ce0aa38d4637fe5d3c5c59230ecbd3e2",1010 "public/icon-monochrome.svg": "db5bdb4b62cd1f4dcd72494b2f94e5aac0246cf4318e03468f121cf9339d2df9",11- "public/manifest.webmanifest": "813dd5fc80db1f10fffefc5ff72c48bd07aba7424036ac08d5f39d96ddabbb47",11+ "public/manifest.webmanifest": "c76d1d487e86fca4ce4f8c71a5c3b22a15a2d0e3370583ae0fb40aafd4513560",12+ "public/screenshot-narrow.png": "8d11e3779e2e7437ef9e44d07bd382d161570846663338b3ad3f1cdb065b018f",13+ "public/screenshot-wide.png": "812786db28f6803d12adc8bd3af5fffefa8e4e20851b61de8a256fcc538c114e",1214 "README.md": "4d4f37ec8f4cfc7dc127d7c3b0c74fd22658dd6ba9f59229316367095ebcdfd7",1315 "src/app.ts": "4c4c6c3f6ad4123f6cffc7a480965c4ea38442676c2af662d1bbfe082d40194f",1416 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",@@ -143,8 +143,11 @@ export async function sourceFingerprint(root = Deno.cwd()): Promise<string> {
143143 return Array.from(digest).map((byte) => byte.toString(16).padStart(2, "0")).join("");144144 }145145 146-export function precacheUrls(paths: readonly string[]): string[] {147- return expectedPrecacheUrls(paths);146+export function precacheUrls(147+ paths: readonly string[],148+ presentationPaths: readonly string[] = [],149+): string[] {150+ return expectedPrecacheUrls(paths, presentationPaths);148151 }149152 150153 function contains(haystack: Uint8Array, needle: Uint8Array): boolean {@@ -55,7 +55,8 @@ Deno.test("precache URLs are portable app-shell URLs", () => {
5555 "lofi-precache.json",5656 "assets\\client.js",5757 "manifest.webmanifest",58- ]);58+ "screenshot-wide.png",59+ ], ["screenshot-wide.png"]);5960 assert(6061 JSON.stringify(urls) === JSON.stringify([6162 "./",@@ -401,6 +401,34 @@ async function parseAndValidateManifest(
401401 remediation,402402 );403403 issues.push(...screenshots.issues);404+ const validSources = new Set(screenshots.images.map((image) => image.src));405+ const formFactors = new Set<string>();406+ if (Array.isArray(manifest.screenshots)) {407+ for (const [index, raw] of manifest.screenshots.entries()) {408+ if (!isObject(raw)) continue;409+ const label = `${manifestFile}: screenshots[${index}]`;410+ if (!nonEmptyString(raw.label)) {411+ issues.push(issue(`${label}.label must describe the screenshot`, remediation));412+ }413+ if (raw.form_factor !== "narrow" && raw.form_factor !== "wide") {414+ issues.push(415+ issue(`${label}.form_factor must be narrow or wide`, remediation),416+ );417+ } else if (nonEmptyString(raw.src) && validSources.has(raw.src)) {418+ formFactors.add(raw.form_factor);419+ }420+ }421+ }422+ for (const formFactor of ["narrow", "wide"]) {423+ if (!formFactors.has(formFactor)) {424+ issues.push(425+ issue(426+ `${manifestFile}: screenshots need a valid labeled ${formFactor} image`,427+ remediation,428+ ),429+ );430+ }431+ }404432 }405433 }406434 return { issues, manifest, scope };@@ -425,6 +453,20 @@ function links(html: string): Array<{ rel: string; href: string }> {
425453 });426454 }427455 456+function emittedRoutePath(url: URL, basePath: string): string | undefined {457+ if (url.origin !== syntheticOrigin || !url.pathname.startsWith(basePath)) return undefined;458+ let path: string;459+ try {460+ path = decodeURIComponent(url.pathname.slice(basePath.length));461+ } catch {462+ return undefined;463+ }464+ if (path.split("/").some((part) => part === "..")) return undefined;465+ if (!path) return "index.html";466+ if (path.endsWith("/")) return `${path}index.html`;467+ return path.endsWith(".html") ? path : `${path}/index.html`;468+}469+428470 async function filesUnder(root: string): Promise<string[]> {429471 const files: string[] = [];430472 async function visit(path: string) {@@ -440,11 +482,33 @@ async function filesUnder(root: string): Promise<string[]> {
440482 441483 const precacheExcludedPaths = new Set(["lofi-build.json", "lofi-precache.json", "sw.js"]);442484 485+/** Returns manifest screenshot paths, which are install presentation rather than shell resources. */486+export function screenshotAssetPaths(manifest: unknown, deploymentBase = "/"): string[] {487+ if (!isObject(manifest) || !Array.isArray(manifest.screenshots)) return [];488+ const basePath = normalizeDeploymentBase(deploymentBase);489+ const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);490+ return [491+ ...new Set(manifest.screenshots.flatMap((raw) => {492+ if (!isObject(raw) || !nonEmptyString(raw.src)) return [];493+ try {494+ const path = assetPath(new URL(raw.src, manifestUrl), basePath);495+ return path ? [path] : [];496+ } catch {497+ return [];498+ }499+ })),500+ ].sort();501+}502+443503 /** Map build output paths to portable URLs expected in the service-worker precache. */444-export function expectedPrecacheUrls(paths: readonly string[]): string[] {504+export function expectedPrecacheUrls(505+ paths: readonly string[],506+ presentationPaths: readonly string[] = [],507+): string[] {508+ const presentation = new Set(presentationPaths.map((path) => path.replaceAll("\\", "/")));445509 return paths446510 .map((path) => path.replaceAll("\\", "/"))447- .filter((path) => !precacheExcludedPaths.has(path))511+ .filter((path) => !precacheExcludedPaths.has(path) && !presentation.has(path))448512 .map((path) => path === "index.html" ? "./" : `./${path}`)449513 .sort();450514 }@@ -503,6 +567,25 @@ export async function productionPwaIssues(root: string, deploymentBase = "/") {
503567 if (htmlFiles.length === 0) {504568 issues.push(issue("dist/: no prerendered HTML routes were emitted", remediation));505569 }570+ if (Array.isArray(result.manifest?.shortcuts)) {571+ const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);572+ for (const [index, raw] of result.manifest.shortcuts.entries()) {573+ if (!isObject(raw) || !nonEmptyString(raw.url)) continue;574+ try {575+ const route = emittedRoutePath(new URL(raw.url, manifestUrl), basePath);576+ if (!route || !htmlFiles.includes(route)) {577+ issues.push(578+ issue(579+ `dist/${manifestFile}: shortcuts[${index}].url has no emitted offline route`,580+ remediation,581+ ),582+ );583+ }584+ } catch {585+ // Source validation already reports malformed shortcut URLs.586+ }587+ }588+ }506589 const expectedManifest = new URL(`${syntheticOrigin}${basePath}${manifestFile}`).href;507590 const appleIcons = new Set<string>();508591 for (const path of htmlFiles) {@@ -566,7 +649,10 @@ export async function productionPwaIssues(root: string, deploymentBase = "/") {
566649 issue("dist/lofi-precache.json: missing or malformed precache manifest", remediation),567650 );568651 }569- const expectedPrecache = expectedPrecacheUrls(files);652+ const expectedPrecache = expectedPrecacheUrls(653+ files,654+ screenshotAssetPaths(result.manifest, basePath),655+ );570656 if (571657 !Array.isArray(precache) || !precache.every((entry) => typeof entry === "string") ||572658 JSON.stringify([...precache].sort()) !== JSON.stringify(expectedPrecache)@@ -4,6 +4,7 @@ import {
44 expectedPrecacheUrls,55 productionContentType,66 productionPwaIssues,7+ screenshotAssetPaths,78 sourcePwaIssues,89 } from "./pwa-validation.ts";910 @@ -132,6 +133,8 @@ Deno.test("source PWA validation checks optional shortcuts and screenshots", asy
132133 const issues = await sourcePwaIssues(project, "/field-notes/");133134 assert(hasIssue(issues, "shortcuts[0].url must stay inside"), "escaped shortcut was accepted");134135 assert(hasIssue(issues, "missing asset missing-shot.png"), "missing screenshot was accepted");136+ assert(hasIssue(issues, "label must describe"), "unlabeled screenshot was accepted");137+ assert(hasIssue(issues, "form_factor must be narrow or wide"), "missing form factor passed");135138 } finally {136139 await Deno.remove(root, { recursive: true });137140 }@@ -198,9 +201,12 @@ async function makeProductionFixture(): Promise<string> {
198201 `const revision = "fixture-hash"; new URL("./lofi-precache.json", self.registration.scope);\n`,199202 );200203 files.push("index.html", "settings/index.html", "lofi-build.json", "sw.js");204+ const manifest = JSON.parse(await Deno.readTextFile(join(dist, "manifest.webmanifest")));201205 await Deno.writeTextFile(202206 join(dist, "lofi-precache.json"),203- `${JSON.stringify(expectedPrecacheUrls(files))}\n`,207+ `${208+ JSON.stringify(expectedPrecacheUrls(files, screenshotAssetPaths(manifest, "/field-notes/")))209+ }\n`,204210 );205211 return root;206212 }@@ -228,10 +234,17 @@ Deno.test("production PWA validation catches nested links, precache drift, and w
228234 join(dist, "sw.js"),229235 `new URL("./lofi-precache.json", self.registration.scope);\n`,230236 );237+ const manifest = JSON.parse(await Deno.readTextFile(join(dist, "manifest.webmanifest")));238+ manifest.shortcuts[0].url = "./missing/";239+ await Deno.writeTextFile(240+ join(dist, "manifest.webmanifest"),241+ `${JSON.stringify(manifest, null, 2)}\n`,242+ );231243 const issues = await productionPwaIssues(root, "/field-notes/");232244 assert(hasIssue(issues, "settings/index.html: must link"), "nested manifest link drift passed");233245 assert(hasIssue(issues, "entries do not match"), "precache drift passed");234246 assert(hasIssue(issues, "build revision does not match"), "worker revision drift passed");247+ assert(hasIssue(issues, "has no emitted offline route"), "missing shortcut route passed");235248 } finally {236249 await Deno.remove(root, { recursive: true });237250 }@@ -430,11 +430,20 @@ async function assertProductionOutput(projectRoot: string) {
430430 "icon-192.png",431431 "icon-512.png",432432 "icon-maskable-512.png",433+ "screenshot-narrow.png",434+ "screenshot-wide.png",433435 ]434436 ) assert(files.includes(required), `production output is missing ${required}`);435437 436438 const manifest = JSON.parse(await Deno.readTextFile(join(dist, "manifest.webmanifest"))) as {437439 icons: Array<{ src: string; sizes: string; type: string; purpose: string }>;440+ screenshots: Array<{441+ src: string;442+ sizes: string;443+ type: string;444+ form_factor: string;445+ label: string;446+ }>;438447 };439448 const expected = new Map([440449 [@@ -471,6 +480,22 @@ async function assertProductionOutput(projectRoot: string) {
471480 }472481 }473482 assert(manifest.icons.length === expected.size, "manifest does not contain the exact icon set");483+ const screenshots = new Map([484+ ["narrow", ["screenshot-narrow.png", 540, 720] as const],485+ ["wide", ["screenshot-wide.png", 1280, 720] as const],486+ ]);487+ for (const screenshot of manifest.screenshots) {488+ const contract = screenshots.get(screenshot.form_factor);489+ assert(contract, `manifest references unexpected ${screenshot.form_factor} screenshot`);490+ const file = basename(screenshot.src);491+ assert(file === contract[0], `${screenshot.form_factor} screenshot file drifted`);492+ assert(screenshot.type === "image/png", `${file} has incorrect manifest type`);493+ assert(screenshot.sizes === `${contract[1]}x${contract[2]}`, `${file} size drifted`);494+ assert(screenshot.label.trim().length > 0, `${file} has no label`);495+ const [width, height] = pngDimensions(await Deno.readFile(join(dist, file)));496+ assert(width === contract[1] && height === contract[2], `${file} dimensions are incorrect`);497+ }498+ assert(manifest.screenshots.length === screenshots.size, "manifest screenshot set is incomplete");474499 const [appleWidth, appleHeight] = pngDimensions(475500 await Deno.readFile(join(dist, "apple-touch-icon.png")),476501 );@@ -489,6 +514,9 @@ async function assertProductionOutput(projectRoot: string) {
489514 "./icon-maskable-512.png",490515 ]491516 ) assert(precache.includes(shellAsset), `precache is missing ${shellAsset}`);517+ for (const screenshot of ["screenshot-narrow.png", "screenshot-wide.png"]) {518+ assert(!precache.includes(`./${screenshot}`), `${screenshot} leaked into the required shell`);519+ }492520 const worker = await Deno.readTextFile(join(dist, "sw.js"));493521 assert(494522 !worker.includes("__LOFI_BUILD_REVISION__"),