- commit
- 84b0276146beeb13bc5e53b236df9b7b989b0996
- parent
- e8f63e21006ac73b406ffcf677cd12eab80d088e
- author
- Dami <dami.etoile@gmail.com>
| A | apps/ | 7 | ++ |
| M | apps/ | 25 | +++++++ |
| M | apps/ | 2 | +- |
| M | docs/ | 30 | ++++++++- |
| M | docs/ | 3 | +- |
| M | package/ | 106 | ++++++++++++++++++++++++++++-- |
| M | package/ | 1 | + |
| M | package/ | 5 | +- |
@@ -0,0 +1,7 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title">2+ <title id="title">lofi leaf silhouette</title>3+ <path4+ d="M415 56C253 58 112 112 67 224c-32 80-21 173 24 231l47-49c57 29 135 21 197-20 52-34 80-93 80-160Z"5+ fill="#000"6+ />7+</svg>@@ -2,9 +2,13 @@
22 "name": "lofi",33 "short_name": "lofi",44 "description": "A local-first application",5+ "id": "./lofi-app",6+ "lang": "en",7+ "dir": "ltr",58 "start_url": "./",69 "scope": "./",710 "display": "standalone",11+ "orientation": "any",812 "background_color": "#18231f",913 "theme_color": "#18231f",1014 "icons": [@@ -25,6 +29,27 @@
2529 "sizes": "512x512",2630 "type": "image/png",2731 "purpose": "maskable"32+ },33+ {34+ "src": "./icon-monochrome.svg",35+ "sizes": "any",36+ "type": "image/svg+xml",37+ "purpose": "monochrome"38+ }39+ ],40+ "shortcuts": [41+ {42+ "name": "Open tasks",43+ "short_name": "Tasks",44+ "description": "Open the starter task list",45+ "url": "./#tasks",46+ "icons": [47+ {48+ "src": "./icon-192.png",49+ "sizes": "192x192",50+ "type": "image/png"51+ }52+ ]2853 }2954 ]3055 }@@ -12,7 +12,7 @@ export default function TaskList() {
1212 const [text, setText] = useState("");1313 1414 return (15- <section class="island" data-island="tasks">15+ <section id="tasks" class="island" data-island="tasks">1616 <header>1717 <p class="eyebrow">Local-first</p>1818 <h2>Example Island: Tasks</h2>@@ -39,7 +39,8 @@ deno task preview --port 4173
3939 Before deploying, review:4040 4141 - `src/app.ts` — name, database namespace, stable credential origins, and repository URL;42-- `public/manifest.webmanifest` — installed name, icons, colors, start URL, and display mode;42+- `public/manifest.webmanifest` — stable install identity, locale, icons, colors, launch behavior,43+ and shortcuts;4344 - `public/favicon.svg` and any added icon files;4445 - page titles, descriptions, and starter copy;4546 - `.env` — either no public Jazz pair for local-only mode or a complete pair for optional sync.@@ -58,6 +59,33 @@ and a subpath build are different deployment artifacts; rebuild after changing `
5859 5960 Run `deno task doctor` and `deno task test` before the production build.6061 62+### Customize the web manifest before launch63+64+Treat `public/manifest.webmanifest` as product source, not generated build metadata. Before users65+install the app:66+67+- replace `name`, `short_name`, `description`, `id`, `lang`, and `dir` with product values;68+- choose an `id` URL token that will remain stable for the lifetime of the installed app; unlike69+ `start_url`, it identifies the app and does not need to be a navigable page;70+- keep `scope`, `start_url`, every shortcut URL, and every shortcut icon aligned with the deployed71+ base path;72+- replace the regular, maskable, Apple touch, and transparent monochrome icon assets while keeping73+ their purposes intact;74+- keep `orientation: "any"` unless the product genuinely needs a screen-orientation lock; and75+- replace or remove the starter `Open tasks` shortcut when replacing the task example.76+77+After launch, changing the name or start page is routine; changing `id` can make a browser treat the78+manifest as a different installed application. Choose it once. If the product ships in multiple79+languages, keep the default strings plus `lang` and `dir`, then add language maps such as80+`name_localized`, `short_name_localized`, and `description_localized`. Localized values whose text81+direction differs from the manifest default should declare their own `dir`.82+83+Storefront metadata is optional and product-specific. Add lowercase `categories` only when they84+truthfully describe the finished app. Add `iarc_rating_id` only after obtaining a real IARC85+certification code; never copy a placeholder rating. Rich screenshots are covered separately.86+Experimental capabilities such as file, protocol, share-target, and launch handlers are deliberately87+absent from the starter and should be added only with matching product behavior and tests.88+6189 ## Deno Deploy6290 6391 Create the static application once:@@ -37,7 +37,8 @@ project. Adding or removing a generated file requires updating this map in the s
3737 | `public/icon-192.png` | Product asset | Supplies the regular 192x192 install icon. |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. |40-| `public/manifest.webmanifest` | Product asset | Declares install metadata, theme colors, scope, and raster icons. |40+| `public/icon-monochrome.svg` | Product asset | Supplies the transparent silhouette used where the OS applies a solid icon color. |41+| `public/manifest.webmanifest` | Product asset | Declares stable install identity, locale, scope, orientation, icons, and shortcuts. |4142 | `src/app.ts` | Author-owned source | Composes the app name, storage, sync, credential origins, and repository link. |4243 | `src/env.d.ts` | Generated configuration | Adds Astro and lofi build-time environment types. |4344 | `src/islands/AccountGate.tsx` | Author-owned source | Presents opt-in account backup, sync, and recovery controls. |@@ -1,6 +1,25 @@
11 import { basename } from "node:path";22 33 type ManifestIcon = { src: string; sizes: string; type: string; purpose: string };4+type ManifestShortcut = {5+ name: string;6+ short_name?: string;7+ description?: string;8+ url: string;9+ icons?: Array<Omit<ManifestIcon, "purpose">>;10+};11+12+type Manifest = {13+ id: string;14+ lang: string;15+ dir: string;16+ start_url: string;17+ scope: string;18+ orientation: string;19+ icons: ManifestIcon[];20+ shortcuts: ManifestShortcut[];21+ [member: string]: unknown;22+};423 524 function assert(condition: unknown, message: string): asserts condition {625 if (!condition) throw new Error(message);@@ -14,20 +33,54 @@ function pngDimensions(bytes: Uint8Array): { width: number; height: number } {
1433 return { width: view.getUint32(16), height: view.getUint32(20) };1534 }1635 17-Deno.test("starter manifest references install-grade raster icons with exact dimensions", async () => {36+Deno.test("starter manifest declares stable identity, locale, and orientation defaults", async () => {37+ const publicRoot = new URL("../apps/reference/public/", import.meta.url);38+ const manifest = JSON.parse(39+ await Deno.readTextFile(new URL("manifest.webmanifest", publicRoot)),40+ ) as Manifest;41+ assert(typeof manifest.id === "string" && manifest.id.length > 0, "manifest id is missing");42+ assert(manifest.id !== manifest.start_url, "manifest id must not be derived from start_url");43+ assert(manifest.lang === "en", "starter manifest language must be explicit");44+ assert(manifest.dir === "ltr", "starter manifest direction must be explicit");45+ assert(manifest.orientation === "any", "starter manifest must explicitly allow any orientation");46+47+ const manifestUrl = new URL("https://example.test/field-notes/manifest.webmanifest");48+ const identity = new URL(manifest.id, manifestUrl);49+ const startUrl = new URL(manifest.start_url, manifestUrl);50+ assert(identity.origin === startUrl.origin, "manifest id must remain same-origin");51+52+ for (53+ const member of [54+ "capture_links",55+ "display_override",56+ "edge_side_panel",57+ "file_handlers",58+ "handle_links",59+ "launch_handler",60+ "protocol_handlers",61+ "scope_extensions",62+ "share_target",63+ "tab_strip",64+ ]65+ ) {66+ assert(!(member in manifest), `experimental member ${member} must be opt-in`);67+ }68+ assert(!("iarc_rating_id" in manifest), "starter manifest must not claim an IARC rating");69+ assert(!("categories" in manifest), "product categories must be chosen by the app author");70+});71+72+Deno.test("starter manifest covers regular, maskable, monochrome, and Apple icons", async () => {1873 const publicRoot = new URL("../apps/reference/public/", import.meta.url);1974 const manifest = JSON.parse(2075 await Deno.readTextFile(new URL("manifest.webmanifest", publicRoot)),21- ) as {22- icons: ManifestIcon[];23- };76+ ) as Manifest;2477 const expected = new Map([2578 ["icon-192.png", { size: 192, purpose: "any" }],2679 ["icon-512.png", { size: 512, purpose: "any" }],2780 ["icon-maskable-512.png", { size: 512, purpose: "maskable" }],2881 ]);29- assert(manifest.icons.length === expected.size, "manifest icon set changed unexpectedly");30- for (const icon of manifest.icons) {82+ const rasterIcons = manifest.icons.filter((icon) => icon.type === "image/png");83+ for (const icon of rasterIcons) {3184 const file = basename(icon.src);3285 const contract = expected.get(file);3386 assert(contract, `manifest references unexpected icon ${file}`);@@ -43,6 +96,19 @@ Deno.test("starter manifest references install-grade raster icons with exact dim
4396 `${file} is ${dimensions.width}x${dimensions.height}`,4497 );4598 }99+ assert(rasterIcons.length === expected.size, "starter raster icon roles are incomplete");100+101+ const monochrome = manifest.icons.find((icon) => icon.purpose === "monochrome");102+ assert(monochrome, "manifest has no monochrome icon");103+ assert(monochrome.type === "image/svg+xml", "monochrome icon must be a vector silhouette");104+ assert(monochrome.sizes === "any", "vector monochrome icon must declare any size");105+ const monochromeSource = await Deno.readTextFile(new URL(monochrome.src, publicRoot));106+ assert(monochromeSource.includes("<svg"), "monochrome icon is not SVG");107+ assert(108+ !monochromeSource.includes("<rect"),109+ "monochrome icon must keep its background transparent",110+ );111+46112 const apple = pngDimensions(await Deno.readFile(new URL("apple-touch-icon.png", publicRoot)));47113 assert(apple.width === 180 && apple.height === 180, "apple-touch-icon.png must be 180x180");48114 const shell = await Deno.readTextFile(@@ -55,3 +121,31 @@ Deno.test("starter manifest references install-grade raster icons with exact dim
55121 "install assets are not linked through the deployment base",56122 );57123 });124+125+Deno.test("starter shortcut and its icons stay inside the application scope", async () => {126+ const publicRoot = new URL("../apps/reference/public/", import.meta.url);127+ const manifest = JSON.parse(128+ await Deno.readTextFile(new URL("manifest.webmanifest", publicRoot)),129+ ) as Manifest;130+ assert(manifest.shortcuts.length === 1, "starter must carry one clearly replaceable shortcut");131+132+ const manifestUrl = new URL("https://example.test/field-notes/manifest.webmanifest");133+ const scope = new URL(manifest.scope, manifestUrl);134+ const shortcut = manifest.shortcuts[0];135+ const shortcutUrl = new URL(shortcut.url, manifestUrl);136+ assert(shortcutUrl.href.startsWith(scope.href), "starter shortcut escapes manifest scope");137+ assert(shortcut.icons?.length, "starter shortcut has no icon");138+ for (const icon of shortcut.icons) {139+ const iconUrl = new URL(icon.src, manifestUrl);140+ assert(iconUrl.href.startsWith(scope.href), "starter shortcut icon escapes manifest scope");141+ assert(142+ manifest.icons.some((candidate) => candidate.src === icon.src),143+ "starter shortcut icon is not part of the install icon set",144+ );145+ }146+147+ const taskIsland = await Deno.readTextFile(148+ new URL("../apps/reference/src/islands/TaskList.tsx", import.meta.url),149+ );150+ assert(taskIsland.includes('id="tasks"'), "starter shortcut has no matching task anchor");151+});@@ -12,6 +12,7 @@ export const STARTER_FILES: readonly string[] = [
1212 "public/icon-192.png",1313 "public/icon-512.png",1414 "public/icon-maskable-512.png",15+ "public/icon-monochrome.svg",1516 "public/manifest.webmanifest",1617 "src/app.ts",1718 "src/env.d.ts",@@ -7,12 +7,13 @@
77 "public/icon-192.png": "6ee711f9ec990ec55f789d7943813c5193973bf2ca9199078ee85d285317e07c",88 "public/icon-512.png": "b943095ef43892d24974dabb7fe68221f365af749e6fd455a0d0296f08ecd854",99 "public/icon-maskable-512.png": "c61dfca524605cfc073f1725af358599ce0aa38d4637fe5d3c5c59230ecbd3e2",10- "public/manifest.webmanifest": "abbbf47b9f016b621ac14c9d71867bad79be944a5224482d1add3d137fd823d5",10+ "public/icon-monochrome.svg": "db5bdb4b62cd1f4dcd72494b2f94e5aac0246cf4318e03468f121cf9339d2df9",11+ "public/manifest.webmanifest": "813dd5fc80db1f10fffefc5ff72c48bd07aba7424036ac08d5f39d96ddabbb47",1112 "README.md": "4d4f37ec8f4cfc7dc127d7c3b0c74fd22658dd6ba9f59229316367095ebcdfd7",1213 "src/app.ts": "4c4c6c3f6ad4123f6cffc7a480965c4ea38442676c2af662d1bbfe082d40194f",1314 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",1415 "src/islands/AccountGate.tsx": "ef541886720e984f989c2054266be8d2272b6c43fea722bde8ce615352cc0210",15- "src/islands/TaskList.tsx": "b38d32ecc08c60e1bef3c70e3e96914df1a2037419253324c4ac48443531f7e7",16+ "src/islands/TaskList.tsx": "0bd514b26318afcb91d18c00349b1c8b2610bf16f72b8fdc66795d8b6ed766d2",1617 "src/islands/use-tasks.ts": "2ad1649b64f677bb042861d988f54736e5442bae8f4e335fc0e50f51b2a260f1",1718 "src/layouts/Shell.astro": "12144355def66c7bf6ed5b0d611ba3a5454cf7499b667936aa6c04494807653a",1819 "src/pages/index.astro": "de9ad86f9b25f7aff1e293931a785dbe393e4d0a35464ffbb649a0ebb4518281",