From 2594fa97c10a67fbf28272952be57c8ce28ddc86 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:24:58 +0700 Subject: [PATCH 1/5] docs(pwa): design spec + implementation plan for per-tool PWA install --- .../plans/2026-09-11-per-tool-pwa-install.md | 429 ++++++++++++++++++ .../2026-09-11-per-tool-pwa-install-design.md | 210 +++++++++ 2 files changed, 639 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-11-per-tool-pwa-install.md create mode 100644 docs/superpowers/specs/2026-09-11-per-tool-pwa-install-design.md diff --git a/docs/superpowers/plans/2026-09-11-per-tool-pwa-install.md b/docs/superpowers/plans/2026-09-11-per-tool-pwa-install.md new file mode 100644 index 0000000..cca8bac --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-per-tool-pwa-install.md @@ -0,0 +1,429 @@ +# Per-tool PWA Install Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every GoodWebTools tool installable as its own focused PWA — tapping the installed icon opens straight into that one tool — with a dismissible "Install this tool" button and a distinct per-tool icon. + +**Architecture:** A build-time Astro endpoint emits one web manifest per registry tool (`/manifests/.webmanifest`, `scope`/`start_url` = the tool route, unique `id`). A prebuild script rasterizes a distinct icon per tool from its lucide glyph. `Base.astro` links the per-tool manifest + apple-touch icon on tool pages. A React island shows the install button (native `beforeinstallprompt` on Android/desktop, iOS Add-to-Home-Screen hint). The existing Workbox service worker is untouched and keeps serving offline. + +**Tech Stack:** Astro endpoints (`getStaticPaths`), `@vite-pwa/astro` (SW only), `lucide-static` (glyph SVGs), `sharp` (SVG→PNG, already a dep), React island + hook. + +**Spec:** `docs/superpowers/specs/2026-09-11-per-tool-pwa-install-design.md` + +## Global Constraints + +- Client-side only; no server. Nothing uploaded. +- Commit identity `Kresna <13603341+slaveofcode@users.noreply.github.com>`; NO AI-attribution trailers; no `/Users/…` or employer strings in committed files. +- New tools/features default `status: 'beta'` (N/A here — no new registry tool). +- App scope decision: `scope` = `start_url` = `/tools/` (focused single-tool). +- Icons: distinct per tool (192 + 512 maskable, 180 apple-touch), category-colored tile + white glyph. +- Bahasa rule: never render "tool" as "alat"; keep the loanword. +- Verify loop before ship: `npx vitest run` + `npm run test:e2e` + `npm run lint` + `npm run build` all green. + +--- + +### Task 1: Pure manifest + icon-name lib + +**Files:** +- Create: `src/tools/pwa/manifest.lib.ts` +- Test: `src/tools/pwa/manifest.lib.test.ts` + +**Interfaces:** +- Produces: `buildToolManifest(t: { id: string; name: string; summary: string }): Record`; `pascalToKebab(name: string): string`. + +- [ ] **Step 1: Write failing tests** + +```ts +import { describe, it, expect } from 'vitest'; +import { buildToolManifest, pascalToKebab } from './manifest.lib'; + +describe('pascalToKebab', () => { + it.each([['FileText', 'file-text'], ['Clock', 'clock'], ['QrCode', 'qr-code'], ['Wand2', 'wand-2']])( + '%s → %s', (a, b) => expect(pascalToKebab(a)).toBe(b)); +}); + +describe('buildToolManifest', () => { + const m = buildToolManifest({ id: 'markdown', name: 'Markdown Preview', summary: 'View Markdown' }); + it('scopes and starts at the tool route with a unique id', () => { + expect(m.start_url).toBe('/tools/markdown'); + expect(m.scope).toBe('/tools/markdown'); + expect(m.id).toBe('/tools/markdown'); + expect(m.display).toBe('standalone'); + }); + it('names the app and references per-tool icons', () => { + expect(m.name).toBe('Markdown Preview — GoodWebTools'); + expect(m.short_name).toBe('Markdown Preview'); + const icons = m.icons as Array<{ src: string; sizes: string }>; + expect(icons.map(i => i.src)).toEqual(['/manifests/icons/markdown-192.png', '/manifests/icons/markdown-512.png']); + }); +}); +``` + +- [ ] **Step 2: Run to verify fail** — `npx vitest run src/tools/pwa/manifest.lib.test.ts` → FAIL (module missing). + +- [ ] **Step 3: Implement** + +```ts +export function pascalToKebab(name: string): string { + return name + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/([A-Za-z])([0-9])/g, '$1-$2') + .toLowerCase(); +} + +export function buildToolManifest(t: { id: string; name: string; summary: string }): Record { + return { + id: `/tools/${t.id}`, + name: `${t.name} — GoodWebTools`, + short_name: t.name, + description: t.summary, + start_url: `/tools/${t.id}`, + scope: `/tools/${t.id}`, + display: 'standalone', + theme_color: '#0a0a0a', + background_color: '#fffdf5', + icons: [ + { src: `/manifests/icons/${t.id}-192.png`, sizes: '192x192', type: 'image/png', purpose: 'any maskable' }, + { src: `/manifests/icons/${t.id}-512.png`, sizes: '512x512', type: 'image/png', purpose: 'any maskable' }, + ], + }; +} +``` + +- [ ] **Step 4: Run** — `npx vitest run src/tools/pwa/manifest.lib.test.ts` → PASS. +- [ ] **Step 5: Commit** — `feat(pwa): pure per-tool manifest builder`. + +--- + +### Task 2: Per-tool icon generator (prebuild script) + +**Files:** +- Create: `scripts/make-tool-icons.mjs` +- Modify: `package.json` (add `lucide-static` devDep + wire prebuild) + +**Interfaces:** +- Produces: `public/manifests/icons/-{192,512,180}.png` for every registry tool. + +- [ ] **Step 1: Add dependency** — `npm i -D lucide-static` (ships `node_modules/lucide-static/icons/.svg`). + +- [ ] **Step 2: Write the script** (mirrors `scripts/generate-og.mjs`; reuses its registry regex + `CAT_COLOR`, adds an `icon:` capture) + +```js +import sharp from 'sharp'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; + +const OUT = 'public/manifests/icons'; +mkdirSync(OUT, { recursive: true }); +const CAT_COLOR = { Dev:'#3b82f6', PDF:'#ef4444', Image:'#22c55e', Files:'#eab308', Documents:'#14b8a6', Draw:'#a855f7', Media:'#ec4899', Network:'#06b6d4', Maps:'#10b981', Legacy:'#6366f1', Playground:'#f97316', Calculators:'#8b5cf6', Testers:'#0ea5e9' }; + +function pascalToKebab(n){ return n.replace(/([a-z0-9])([A-Z])/g,'$1-$2').replace(/([A-Za-z])([0-9])/g,'$1-$2').toLowerCase(); } + +function readTools(){ + const src = readFileSync('src/registry/tools.ts','utf8'); + const re = /\{\s*id:\s*'([^']+)'[\s\S]*?load:\s*\(\)\s*=>\s*import\('[^']+'\)[^}]*\}/g; + const tools=[]; let m; + while((m=re.exec(src))){ const e=m[0]; const g=rx=>(e.match(rx)||[])[1]||''; + tools.push({ id:m[1], category:g(/category:\s*'([^']*)'/), icon:g(/icon:\s*([A-Za-z0-9]+)/) }); } + return tools; +} + +// lucide-static . Pull the inner glyph, force white stroke. +function glyphInner(iconName){ + const p = `node_modules/lucide-static/icons/${pascalToKebab(iconName)}.svg`; + if(!existsSync(p)) return null; + const svg = readFileSync(p,'utf8'); + const inner = svg.replace(/^[\s\S]*?]*>/,'').replace(/<\/svg>\s*$/,''); + return inner; +} + +function tile(size, glyph, color, pad){ + const gs = size - pad*2; // glyph box + return ` + + ${glyph} +`; +} + +const only = process.argv[2]; +const tools = readTools().filter(t=>!only||t.id===only); +let n=0, missing=[]; +for(const t of tools){ + const glyph = glyphInner(t.icon); + const color = CAT_COLOR[t.category] || '#7c3aed'; + if(!glyph){ missing.push(`${t.id}:${t.icon}`); } + const g = glyph || ''; + for(const [size,pad] of [[192,44],[512,120],[180,30]]){ + const png = await sharp(Buffer.from(tile(size,g,color,pad))).png().toBuffer(); + writeFileSync(`${OUT}/${t.id}-${size}.png`, png); + } + n++; +} +if(missing.length) console.warn(`[make-tool-icons] ${missing.length} tools fell back to a generic glyph:`, missing.join(', ')); +console.log(`Generated icons for ${n} tools → ${OUT}/`); +``` + +- [ ] **Step 3: Wire prebuild** — in `package.json` add `"icons:tools": "node scripts/make-tool-icons.mjs"` and change `prebuild` to `npm run copy:wasm && npm run og && npm run icons:tools`. Add `public/manifests/icons/` to `.gitignore` (generated, like `public/og/`). + +- [ ] **Step 4: Run** — `node scripts/make-tool-icons.mjs markdown` → three PNGs written for `markdown`; then full run, confirm no unexpected `missing` glyphs (a few fallbacks are acceptable; log lists them). + +- [ ] **Step 5: Commit** — `feat(pwa): generate distinct per-tool app icons at build`. + +--- + +### Task 3: Per-tool manifest endpoint + +**Files:** +- Create: `src/pages/manifests/[tool].webmanifest.ts` + +**Interfaces:** +- Consumes: `buildToolManifest` (Task 1), the `tools` registry. +- Produces: static `/manifests/.webmanifest` per tool. + +- [ ] **Step 1: Implement endpoint** + +```ts +import type { APIRoute } from 'astro'; +import { tools } from '@/registry/tools'; +import { buildToolManifest } from '@/tools/pwa/manifest.lib'; + +export function getStaticPaths() { + return tools.map(t => ({ params: { tool: t.id }, props: { name: t.name, summary: t.summary } })); +} + +export const GET: APIRoute = ({ params, props }) => { + const body = JSON.stringify(buildToolManifest({ id: params.tool!, name: props.name, summary: props.summary })); + return new Response(body, { headers: { 'content-type': 'application/manifest+json; charset=utf-8' } }); +}; +``` + +- [ ] **Step 2: Build check** — `npm run build`, then confirm `dist/manifests/markdown.webmanifest` exists and its JSON has `start_url:"/tools/markdown"`. Expected: PASS. +- [ ] **Step 3: Commit** — `feat(pwa): emit a web manifest per tool`. + +--- + +### Task 4: SW-only plugin + global manifest + Base.astro props + +**Files:** +- Modify: `astro.config.mjs` (AstroPWA `manifest: false`) +- Verify: `public/manifest.webmanifest` (already exists — becomes authoritative) +- Modify: `src/layouts/Base.astro` + +**Interfaces:** +- Produces: `Base.astro` props `manifestHref?: string` (default `/manifest.webmanifest`), `appleTouchIcon?: string` (default `/apple-touch-icon.png`). + +- [ ] **Step 1:** In `astro.config.mjs`, set `AstroPWA({ …, manifest: false })` (keep `registerType`, `workbox`, `includeAssets`). Removes the plugin-generated manifest so ours is the only one. + +- [ ] **Step 2:** In `src/layouts/Base.astro`: add to `Props` and destructure `manifestHref = '/manifest.webmanifest'` and `appleTouchIcon = '/apple-touch-icon.png'`. Change the head links to `` and the apple-touch icon link to `href={appleTouchIcon}`. + +- [ ] **Step 3: Build check** — `npm run build`; confirm (a) SW still emitted (`dist/sw.js` or `dist/registerSW.js` present as before), (b) `dist/manifest.webmanifest` is the static 869B one, (c) a normal page (`dist/index.html`) links exactly one manifest → `/manifest.webmanifest`. +- [ ] **Step 4: Commit** — `feat(pwa): own the web manifest (plugin builds SW only); per-page manifest link`. + +--- + +### Task 5: Wire per-tool manifest + apple icon + install island slot in the tool route + +**Files:** +- Modify: `src/pages/[...locale]/tools/[tool].astro` + +**Interfaces:** +- Consumes: `Base.astro` props (Task 4); `InstallTool` island (Task 7 — import path `@/islands/shell/InstallTool`). + +- [ ] **Step 1:** In `[tool].astro`, pass to `Base`: `manifestHref={`/manifests/${tool.id}.webmanifest`}` and `appleTouchIcon={`/manifests/icons/${tool.id}-180.png`}` (use the resolved tool id variable already in scope). +- [ ] **Step 2:** In the tool header (near the H1/`BETA` badge), render `` (add the import). If `InstallTool` isn't built yet, stub the import with a comment and complete in Task 7 before build. +- [ ] **Step 3: Build check** — `npm run build`; confirm `dist/tools/markdown/index.html` links `/manifests/markdown.webmanifest` (exactly one manifest link) and an apple-touch icon `…markdown-180.png`. +- [ ] **Step 4: Commit** — `feat(pwa): link per-tool manifest + icon on tool pages`. + +--- + +### Task 6: useInstallPrompt hook + +**Files:** +- Create: `src/hooks/useInstallPrompt.ts` +- Test: `src/hooks/useInstallPrompt.test.ts` +- Modify: `src/layouts/Base.astro` (early-capture inline script) + +**Interfaces:** +- Produces: `useInstallPrompt(): { canPrompt: boolean; isIOS: boolean; isStandalone: boolean; installed: boolean; promptInstall: () => Promise }`. + +- [ ] **Step 1: Early-capture script** — in `Base.astro` head (inline, runs before hydration): + +```html + +``` + +- [ ] **Step 2: Write failing test** (jsdom; simulate the global + events) + +```ts +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { useInstallPrompt } from './useInstallPrompt'; + +beforeEach(() => { (window as any).__gwtInstall = { evt: null }; }); + +it('reports canPrompt once a beforeinstallprompt event is captured', () => { + const { result } = renderHook(() => useInstallPrompt()); + expect(result.current.canPrompt).toBe(false); + act(() => { + (window as any).__gwtInstall.evt = { prompt: async () => {}, userChoice: Promise.resolve({ outcome: 'accepted' }) }; + window.dispatchEvent(new Event('gwt-installable')); + }); + expect(result.current.canPrompt).toBe(true); +}); +``` + +- [ ] **Step 3: Run to fail** — `npx vitest run src/hooks/useInstallPrompt.test.ts` → FAIL. + +- [ ] **Step 4: Implement** + +```ts +import { useCallback, useEffect, useState } from 'react'; + +interface BIPEvent extends Event { prompt: () => Promise; userChoice: Promise<{ outcome: string }> } + +export function useInstallPrompt() { + const [canPrompt, setCanPrompt] = useState(false); + const [installed, setInstalled] = useState(false); + const [isStandalone, setStandalone] = useState(false); + const [isIOS, setIOS] = useState(false); + + useEffect(() => { + const w = window as unknown as { __gwtInstall?: { evt: BIPEvent | null } }; + setCanPrompt(!!w.__gwtInstall?.evt); + const ua = navigator.userAgent || ''; + setIOS(/iP(hone|ad|od)/.test(ua) && /Safari/.test(ua) && !/CriOS|FxiOS/.test(ua)); + const mm = window.matchMedia('(display-mode: standalone)'); + const nav = navigator as unknown as { standalone?: boolean }; + setStandalone(mm.matches || nav.standalone === true); + const onInstallable = () => setCanPrompt(true); + const onInstalled = () => { setInstalled(true); setCanPrompt(false); }; + const onMM = (e: MediaQueryListEvent) => setStandalone(e.matches); + window.addEventListener('gwt-installable', onInstallable); + window.addEventListener('gwt-installed', onInstalled); + mm.addEventListener?.('change', onMM); + return () => { + window.removeEventListener('gwt-installable', onInstallable); + window.removeEventListener('gwt-installed', onInstalled); + mm.removeEventListener?.('change', onMM); + }; + }, []); + + const promptInstall = useCallback(async () => { + const w = window as unknown as { __gwtInstall?: { evt: BIPEvent | null } }; + const evt = w.__gwtInstall?.evt; + if (!evt) return; + await evt.prompt(); + await evt.userChoice.catch(() => undefined); + w.__gwtInstall!.evt = null; + setCanPrompt(false); + }, []); + + return { canPrompt, isIOS, isStandalone, installed, promptInstall }; +} +``` + +- [ ] **Step 5: Run** — PASS. +- [ ] **Step 6: Commit** — `feat(pwa): useInstallPrompt hook + early beforeinstallprompt capture`. + +--- + +### Task 7: InstallTool island + +**Files:** +- Create: `src/islands/shell/InstallTool.tsx` + +**Interfaces:** +- Consumes: `useInstallPrompt` (Task 6). Props `{ toolId: string; name: string; lang?: Lang }`. + +- [ ] **Step 1: Implement** (dismiss persisted per-tool; hidden when standalone/installed/dismissed; iOS shows an instructions popover) + +```tsx +import { useState } from 'react'; +import { Download, Share, X } from 'lucide-react'; +import { useInstallPrompt } from '@/hooks/useInstallPrompt'; +import type { Lang } from '@/i18n/config'; + +const TR: Record = { + en: { install: 'Install this tool', ios: 'Tap the Share button, then “Add to Home Screen”.', dismiss: 'Dismiss' }, + id: { install: 'Instal tool ini', ios: 'Tap tombol Share, lalu “Add to Home Screen”.', dismiss: 'Tutup' }, +}; + +export default function InstallTool({ toolId, name, lang = 'en' }: { toolId: string; name: string; lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const { canPrompt, isIOS, isStandalone, installed, promptInstall } = useInstallPrompt(); + const key = `gwt-install-dismissed:${toolId}`; + const [dismissed, setDismissed] = useState(() => { try { return localStorage.getItem(key) === '1'; } catch { return false; } }); + const [showIOS, setShowIOS] = useState(false); + + if (isStandalone || installed || dismissed || (!canPrompt && !isIOS)) return null; + const dismiss = () => { try { localStorage.setItem(key, '1'); } catch { /* ignore */ } setDismissed(true); }; + + return ( + + + + {showIOS && {t.ios}} + + ); +} +``` + +- [ ] **Step 2:** Remove the Task-5 stub note; ensure `[tool].astro` imports and renders it. +- [ ] **Step 3: Build check** — `npm run build` succeeds; tool page ships the island. +- [ ] **Step 4: Commit** — `feat(pwa): "Install this tool" button island`. + +--- + +### Task 8: E2E — install button surfaces on a synthetic prompt + +**Files:** +- Create: `e2e/tools/install-tool.spec.ts` + +- [ ] **Step 1: Write the spec** + +```ts +import { test, expect } from '@playwright/test'; + +test('shows the install button when a beforeinstallprompt is available', async ({ page }) => { + await page.goto('/tools/markdown'); + await page.waitForLoadState('networkidle').catch(() => {}); + await page.evaluate(() => { + (window as any).__gwtInstall = { evt: { prompt: async () => {}, userChoice: Promise.resolve({ outcome: 'dismissed' }) } }; + window.dispatchEvent(new Event('gwt-installable')); + }); + await expect(page.getByRole('button', { name: /Install this tool/i })).toBeVisible({ timeout: 30_000 }); +}); + +test('tool page links its own manifest', async ({ page }) => { + const res = await page.goto('/tools/markdown'); + expect(res?.status()).toBe(200); + const href = await page.locator('link[rel="manifest"]').getAttribute('href'); + expect(href).toBe('/manifests/markdown.webmanifest'); +}); +``` + +- [ ] **Step 2: Run** — `npm run test:e2e --grep "install"` (kill stale dev server first). Expected: PASS. +- [ ] **Step 3: Commit** — `test(pwa): install button + per-tool manifest E2E`. + +--- + +### Task 9: Verify loop + ship + +- [ ] **Step 1:** `npx vitest run` (all green) · `npm run lint` (0 errors) · `npm run build` (green; spot-check `dist/manifests/*.webmanifest` count ≈ tool count; icons present; a normal page links global manifest, a tool page links its own) · `npm run test:e2e --grep "install"`. +- [ ] **Step 2: Hand review** — no leaked streams/listeners in the hook; SSR-safe (all `window`/`navigator` in effects or `is:inline` script); the early-capture script is inert on non-installable browsers; `manifest:false` didn't break the SW. +- [ ] **Step 3: Ship** — branch `feat/per-tool-pwa` (spec already committed there), PR → develop, CI, merge, promote develop→main, confirm Cloudflare prod build, verify a live tool page links its own manifest and (on a real Android/desktop Chrome) the install prompt appears. Tell the user to hard-refresh (SW). + +--- + +## Self-Review + +- **Spec coverage:** manifests (T1,T3), icons (T2), plugin/manifest wiring (T4), per-page link + island slot (T5), hook (T6), button/iOS/dismiss (T7), tests (T1,T6,T8), risks (early-capture in T6, `manifest:false` verified in T4, iOS in T7). Locale limitation (EN start_url) accepted in spec. +- **Placeholder scan:** none — all code inline. +- **Type consistency:** `buildToolManifest`/`pascalToKebab` names match across T1/T2/T3; `useInstallPrompt` return shape matches T6↔T7; island props `{toolId,name,lang}` match T5↔T7. diff --git a/docs/superpowers/specs/2026-09-11-per-tool-pwa-install-design.md b/docs/superpowers/specs/2026-09-11-per-tool-pwa-install-design.md new file mode 100644 index 0000000..3d7212d --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-per-tool-pwa-install-design.md @@ -0,0 +1,210 @@ +# Per-tool PWA install — design + +**Date:** 2026-09-11 +**Status:** design (approved in brainstorm; pending user review of this spec) + +## Goal + +Let a user install **any single GoodWebTools tool** as its own home-screen / +desktop app, so tapping the icon opens **straight into that one tool** (e.g. the +Markdown viewer) — a fast, app-like way to reach the tools they use often on a +phone, without installing a real app. Every tool is installable. Each installed +app has its **own distinct icon** and launches focused on its own tool. + +Non-goals: an app store presence, push notifications, background sync, or any +server component. Everything stays client-side and offline-capable via the +existing service worker. + +## Decisions (locked in brainstorm) + +- **Scope:** every tool is installable (~190). +- **Install UI:** a dismissible inline **"Install this tool"** button in the tool + header. Native prompt on Android/desktop; iOS shows Add-to-Home-Screen steps; + hidden when already installed or dismissed. +- **Icons:** distinct **per-tool** icons generated at build from the tool's + lucide glyph on a branded tile (192 + 512 maskable, 180 apple-touch). +- **App scope:** focused single-tool — `scope: /tools/`; site-chrome links + open in the normal browser. + +## Background: what exists today + +- **Service worker / PWA:** `@vite-pwa/astro` (`AstroPWA` in `astro.config.mjs`) + with `registerType: 'autoUpdate'` and a `manifest: {...}` block for the global + app. Workbox precaches the built assets (with `globIgnores` for heavy chunks). +- **Manifest link is hand-authored** in `src/layouts/Base.astro:114` + (``), plus + `apple-mobile-web-app-*` metas and `apple-touch-icon`. This is the key enabler: + we can make the linked manifest **per-page** without fighting the plugin. +- **Dynamic tool route:** `src/pages/[...locale]/tools/[tool].astro` renders every + registry tool through `ToolHost`. It already knows the tool `id`, `name`, + `category`, and `lang`. +- **Registry:** `src/registry/tools.ts` — each `ToolDef` has + `{ id, name, category, route, icon (lucide component), summary, status }`. +- **Build-time asset scripts (the pattern to mirror):** + - `scripts/generate-og.mjs` — satori (HTML→SVG) + `@resvg/resvg-js` (SVG→PNG), + parses `tools.ts`, writes `public/og/.png` per tool at prebuild. + - `scripts/make-icons.mjs` — `sharp` (SVG→PNG), writes the global app icons. + - Both `@resvg/resvg-js`, `satori`, and `sharp` are already dependencies. + +## Architecture + +Four independent units: + +### 1. Per-tool manifests (build endpoint) + +New Astro endpoint **`src/pages/manifests/[tool].webmanifest.ts`** with +`getStaticPaths()` over the registry, emitting `/manifests/.webmanifest` for +every tool. It imports the registry TS directly (no regex parsing needed — +endpoints run in the Astro/Vite graph) and delegates the object shape to a pure, +tested builder: + +```ts +// src/tools/pwa/manifest.lib.ts (pure, unit-tested) +export interface ToolManifestInput { id: string; name: string; summary: string; category: string; } +export function buildToolManifest(t: ToolManifestInput): Record { + return { + id: `/tools/${t.id}`, + name: `${t.name} — GoodWebTools`, + short_name: t.name, + description: t.summary, + start_url: `/tools/${t.id}`, + scope: `/tools/${t.id}`, + display: 'standalone', + theme_color: '#0a0a0a', + background_color: '#fffdf5', + icons: [ + { src: `/manifests/icons/${t.id}-192.png`, sizes: '192x192', type: 'image/png', purpose: 'any maskable' }, + { src: `/manifests/icons/${t.id}-512.png`, sizes: '512x512', type: 'image/png', purpose: 'any maskable' }, + ], + }; +} +``` + +Endpoint returns it as `application/manifest+json`. + +**Note (locale):** `start_url`/`scope` use the English root (`/tools/`). The +`/id/tools/` Bahasa variant is out of scope for v1 (documented limitation); +the installed app opens the EN page. A later enhancement can emit a second +manifest per locale. + +### 2. Per-tool icons (prebuild script) + +New **`scripts/make-tool-icons.mjs`** (mirrors `generate-og.mjs`), wired into the +existing prebuild chain in `package.json` next to `generate-og`. For each tool: + +- Resolve the tool's lucide glyph SVG. The registry stores the icon as a + component whose `displayName` is the PascalCase name (verified: `FileText`). + We map name → kebab-case (`file-text`) and read the raw SVG from + **`lucide-static`** (new devDependency; ships `icons/.svg`). +- Compose the glyph (white stroke) centered on a maskable-safe tile filled with + the tool's **category color** (reuse the `CAT_COLOR` map already in + `generate-og.mjs`), matching the brand. +- Rasterize with `sharp` (or resvg) to `public/manifests/icons/-192.png`, + `-512.png` (maskable safe zone) and `-180.png` (apple-touch, larger glyph). +- Generated at prebuild, **not committed** (like `public/og/`). + +Registry parsing reuses `generate-og.mjs`'s regex approach **plus** an +`icon:\s*(\w+)` capture to get the glyph name. A tiny unit test covers the +name→kebab mapping and the CAT_COLOR fallback. + +Build cost: ~190 tools × 3 PNGs. OG generation already renders ~190 images at +build, so this is a known, acceptable cost; `make-tool-icons.mjs` accepts a tool +id argv for single-tool local runs (same as `generate-og.mjs`). + +### 3. Per-page manifest + apple-touch wiring + +- `AstroPWA({ manifest: false, ... })` — the plugin keeps generating the + **service worker** but stops owning the web manifest, so there is exactly one + manifest link (ours) and no collision. The global site manifest becomes a + static **`public/manifest.webmanifest`** (moved out of the plugin config). +- `Base.astro` gains two optional props: + `manifestHref?: string` (default `/manifest.webmanifest`) and + `appleTouchIcon?: string` (default `/apple-touch-icon.png`), used in the + existing `` and ``. +- `[...locale]/tools/[tool].astro` passes + `manifestHref={`/manifests/${id}.webmanifest`}` and + `appleTouchIcon={`/manifests/icons/${id}-180.png`}`. + +**Build assertion:** every `dist/tools//index.html` links exactly ONE +manifest, pointing at `/manifests/.webmanifest`, and each +`/manifests/.webmanifest` + its icons exist. + +### 4. Install UI (React island + hook) + +New **`src/hooks/useInstallPrompt.ts`**: + +- Early-captures `beforeinstallprompt` (a tiny inline script in `Base.astro` + head stashes the event on `window.__gwtInstallEvent` and `preventDefault()`s, + so it isn't missed before the island hydrates; the hook reads/subscribes). +- Exposes `{ canPrompt, isIOS, isStandalone, promptInstall(), installed }`. +- `isStandalone` via `matchMedia('(display-mode: standalone)')` or + `navigator.standalone` (iOS). Listens for `appinstalled`. + +New island **`src/islands/shell/InstallTool.tsx`** (`client:idle`), rendered by +`[tool].astro` in the tool header with `toolId`, `name`, `lang`: + +- Renders nothing when `isStandalone` or previously dismissed + (`localStorage: gwt-install-dismissed:`) or `installed`. +- Android/desktop (`canPrompt`): a **"📲 Install this tool"** button → + `promptInstall()`. +- iOS (`isIOS`, no `canPrompt`): the button opens a small popover with the + "Share → Add to Home Screen" steps. +- A subtle dismiss control writes the localStorage flag. +- Bilingual (EN/ID) copy via the established `TR` pattern. + +The button lives in the tool page header near the H1/`BETA` badge (in +`[tool].astro`, or `ToolHost` if that's where the header renders — confirmed +during planning). + +## Data flow + +Build: registry → (endpoint) per-tool manifests + (script) per-tool icons → +`dist`. Page: `[tool].astro` → `Base.astro` links that tool's manifest + apple +icon → browser sees an installable single-tool manifest. Runtime: browser fires +`beforeinstallprompt` (captured) → `InstallTool` shows the button → user installs +→ home-screen icon `start_url:/tools/` launches the tool; the existing SW +serves it offline. + +## Testing + +- **Unit:** `buildToolManifest` (field shape, id/scope/start_url, icon paths); + icon name→kebab + CAT_COLOR fallback helper. +- **Build assertions** (script or a lightweight test): all + `/manifests/.webmanifest` and icon PNGs exist; each tool page links its own + manifest exactly once. +- **E2E** (`e2e/tools/install-tool.spec.ts`): on a tool page, dispatch a + synthetic `beforeinstallprompt`; assert the "Install this tool" button appears + and clicking it calls the event's `prompt()` (spy). Assert it's hidden when + `matchMedia('(display-mode: standalone)')` is forced true. (Real OS install + can't be automated.) + +## Risks & mitigations + +- **Multiple installs per origin:** Chrome/Edge/Android distinguish installed + apps by manifest `id` — we set a unique `id` per tool. Verify manually on + Android/desktop Chrome during rollout. +- **`beforeinstallprompt` timing:** captured by an inline head script so it's not + lost before hydration. +- **iOS:** no programmatic prompt; the button is an instructions affordance. Each + Add-to-Home-Screen uses the page's `apple-touch-icon` (now per-tool) and the + existing `apple-mobile-web-app-*` metas → focused launch. +- **`manifest: false` regression:** confirm the SW still builds and the global + `public/manifest.webmanifest` serves the site install unchanged. +- **Precache size:** per-tool icons are small PNGs; manifests are tiny. Confirm + no precache-size warning; if needed, `globIgnores` the `manifests/**` glob so + they're runtime-fetched (still offline via runtime caching). +- **Build time:** ~570 PNGs at prebuild; parallelize/accept as with OG images. + +## Rollout + +Single PR to `develop` → promote to `main` (standard flow). Because it touches +the PWA/manifest wiring, verify on a real phone (Android Chrome install + +iOS Add-to-Home-Screen) before promoting, and confirm the existing site-wide +install still works. PWA/SW change → users hard-refresh to pick it up. + +## Open questions (for plan stage) + +- Exact header insertion point for `InstallTool` (`[tool].astro` vs `ToolHost`). +- Whether to precache or runtime-cache the per-tool icons/manifests. +- Confirm `lucide-static` kebab names cover every icon the registry uses (a build + check can fail loudly on a missing glyph and fall back to the GWT tile). From 8a0e5adf7908343e001db4d91ee68c9ef9350770 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:25:27 +0700 Subject: [PATCH 2/5] feat(pwa): pure per-tool manifest builder --- src/tools/pwa/manifest.lib.test.ts | 36 ++++++++++++++++++++++++++++++ src/tools/pwa/manifest.lib.ts | 32 ++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 src/tools/pwa/manifest.lib.test.ts create mode 100644 src/tools/pwa/manifest.lib.ts diff --git a/src/tools/pwa/manifest.lib.test.ts b/src/tools/pwa/manifest.lib.test.ts new file mode 100644 index 0000000..607c7b4 --- /dev/null +++ b/src/tools/pwa/manifest.lib.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { buildToolManifest, pascalToKebab } from './manifest.lib'; + +describe('pascalToKebab', () => { + it.each([ + ['FileText', 'file-text'], + ['Clock', 'clock'], + ['QrCode', 'qr-code'], + ['Wand2', 'wand-2'], + ['MonitorPlay', 'monitor-play'], + ['Video', 'video'], + ])('%s → %s', (a, b) => expect(pascalToKebab(a)).toBe(b)); +}); + +describe('buildToolManifest', () => { + const m = buildToolManifest({ id: 'markdown', name: 'Markdown Preview', summary: 'View Markdown' }); + + it('scopes and starts at the tool route with a unique id', () => { + expect(m.start_url).toBe('/tools/markdown'); + expect(m.scope).toBe('/tools/markdown'); + expect(m.id).toBe('/tools/markdown'); + expect(m.display).toBe('standalone'); + }); + + it('names the app and references per-tool icons', () => { + expect(m.name).toBe('Markdown Preview — GoodWebTools'); + expect(m.short_name).toBe('Markdown Preview'); + expect(m.description).toBe('View Markdown'); + const icons = m.icons as Array<{ src: string; sizes: string; purpose: string }>; + expect(icons.map(i => i.src)).toEqual([ + '/manifests/icons/markdown-192.png', + '/manifests/icons/markdown-512.png', + ]); + expect(icons.every(i => i.purpose === 'any maskable')).toBe(true); + }); +}); diff --git a/src/tools/pwa/manifest.lib.ts b/src/tools/pwa/manifest.lib.ts new file mode 100644 index 0000000..553c8d9 --- /dev/null +++ b/src/tools/pwa/manifest.lib.ts @@ -0,0 +1,32 @@ +/** + * Pure helpers for per-tool PWA manifests. Each tool gets its own manifest so a + * user can install a single tool as a focused app that launches straight into + * `/tools/`. Framework-free and unit-tested. + */ + +/** 'FileText' → 'file-text', 'Wand2' → 'wand-2' (matches lucide-static file names). */ +export function pascalToKebab(name: string): string { + return name + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/([A-Za-z])([0-9])/g, '$1-$2') + .toLowerCase(); +} + +/** A focused, installable web manifest for one tool. */ +export function buildToolManifest(t: { id: string; name: string; summary: string }): Record { + return { + id: `/tools/${t.id}`, + name: `${t.name} — GoodWebTools`, + short_name: t.name, + description: t.summary, + start_url: `/tools/${t.id}`, + scope: `/tools/${t.id}`, + display: 'standalone', + theme_color: '#0a0a0a', + background_color: '#fffdf5', + icons: [ + { src: `/manifests/icons/${t.id}-192.png`, sizes: '192x192', type: 'image/png', purpose: 'any maskable' }, + { src: `/manifests/icons/${t.id}-512.png`, sizes: '512x512', type: 'image/png', purpose: 'any maskable' }, + ], + }; +} From e8c6219c11bde5c134f96e9d20c8f6d5c9866daa Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:26:42 +0700 Subject: [PATCH 3/5] feat(pwa): generate distinct per-tool app icons at build --- .gitignore | 1 + package-lock.json | 8 +++++ package.json | 6 ++-- scripts/make-tool-icons.mjs | 70 +++++++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 scripts/make-tool-icons.mjs diff --git a/.gitignore b/.gitignore index aeac4a2..7d61e1d 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ models-r2/ # Excalidraw fonts (copied by copy:wasm) public/excalidraw/ public/og/ +public/manifests/icons/ public/sqlite/ # Impeccable design-lint local config (per-machine tooling, not repo state) diff --git a/package-lock.json b/package-lock.json index 63e8603..10ee02c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,6 +103,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "fake-indexeddb": "^6.2.5", "jsdom": "^23.2.0", + "lucide-static": "^1.45.0", "prettier": "^3.9.5", "prettier-plugin-astro": "^0.12.3", "prettier-plugin-tailwindcss": "^0.5.14", @@ -14694,6 +14695,13 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, + "node_modules/lucide-static": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.45.0.tgz", + "integrity": "sha512-MaMkcTVobsAlkat8/AsLRgZHeni3/UWHolsAZRazod6/nnBTlhHaS6xlSi+Xh1Y0I1Gf68YHUVXuJe9Up3lZww==", + "dev": true, + "license": "ISC" + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", diff --git a/package.json b/package.json index 3a5a073..4ad807a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "og": "node scripts/generate-og.mjs", "predev": "npm run copy:wasm", "dev": "astro dev", - "prebuild": "npm run copy:wasm && npm run og", + "prebuild": "npm run copy:wasm && npm run og && npm run icons:tools", "build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 astro build", "preview": "astro preview", "test": "vitest", @@ -37,7 +37,8 @@ "tauri:bundle": "npm run build && tauri build", "download:ffmpeg": "node scripts/download-ffmpeg-binaries.mjs", "bundle:check": "node scripts/bundle-tauri-assets.mjs", - "pretauri:build": "npm run bundle:check" + "pretauri:build": "npm run bundle:check", + "icons:tools": "node scripts/make-tool-icons.mjs" }, "repository": { "type": "git", @@ -145,6 +146,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "fake-indexeddb": "^6.2.5", "jsdom": "^23.2.0", + "lucide-static": "^1.45.0", "prettier": "^3.9.5", "prettier-plugin-astro": "^0.12.3", "prettier-plugin-tailwindcss": "^0.5.14", diff --git a/scripts/make-tool-icons.mjs b/scripts/make-tool-icons.mjs new file mode 100644 index 0000000..39ad4a8 --- /dev/null +++ b/scripts/make-tool-icons.mjs @@ -0,0 +1,70 @@ +/** + * Build-time per-tool PWA icons. For each registry tool, render its lucide glyph + * (white stroke) on a category-colored maskable tile → PNG at 192/512 (maskable + * safe zone) and 180 (apple-touch). Output: public/manifests/icons/-.png, + * generated at prebuild (not committed, like public/og/). Mirrors generate-og.mjs. + * Pass a tool id as argv[2] to render just one (local testing). + */ +import sharp from 'sharp'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; + +const OUT = 'public/manifests/icons'; +mkdirSync(OUT, { recursive: true }); + +const CAT_COLOR = { + Dev: '#3b82f6', PDF: '#ef4444', Image: '#22c55e', Files: '#eab308', Documents: '#14b8a6', + Draw: '#a855f7', Media: '#ec4899', Network: '#06b6d4', Maps: '#10b981', Legacy: '#6366f1', + Playground: '#f97316', Calculators: '#8b5cf6', Testers: '#0ea5e9', +}; + +function pascalToKebab(n) { + return n.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/([A-Za-z])([0-9])/g, '$1-$2').toLowerCase(); +} + +function readTools() { + const src = readFileSync('src/registry/tools.ts', 'utf8'); + const re = /\{\s*id:\s*'([^']+)'[\s\S]*?load:\s*\(\)\s*=>\s*import\('[^']+'\)[^}]*\}/g; + const tools = []; + let m; + while ((m = re.exec(src))) { + const e = m[0]; + const g = rx => (e.match(rx) || [])[1] || ''; + tools.push({ id: m[1], category: g(/category:\s*'([^']*)'/), icon: g(/icon:\s*([A-Za-z0-9]+)/) }); + } + return tools; +} + +// lucide-static file: a license comment + …glyph…. Extract the inner glyph. +function glyphInner(iconName) { + if (!iconName) return null; + const p = `node_modules/lucide-static/icons/${pascalToKebab(iconName)}.svg`; + if (!existsSync(p)) return null; + const svg = readFileSync(p, 'utf8'); + return svg.replace(/^[\s\S]*?]*>/, '').replace(/<\/svg>\s*$/, '').trim(); +} + +function tile(size, glyph, color, pad) { + const gs = size - pad * 2; + return ` + + ${glyph} +`; +} + +const only = process.argv[2]; +const tools = readTools().filter(t => !only || t.id === only); +let n = 0; +const missing = []; +for (const t of tools) { + const glyph = glyphInner(t.icon); + if (!glyph) missing.push(`${t.id}:${t.icon || '?'}`); + const g = glyph || ''; + const color = CAT_COLOR[t.category] || '#7c3aed'; + for (const [size, pad] of [[192, 44], [512, 120], [180, 30]]) { + const png = await sharp(Buffer.from(tile(size, g, color, pad))).png().toBuffer(); + writeFileSync(`${OUT}/${t.id}-${size}.png`, png); + } + n++; +} +if (missing.length) console.warn(`[make-tool-icons] ${missing.length} tools fell back to a generic glyph: ${missing.join(', ')}`); +console.log(`Generated icons for ${n} tools → ${OUT}/`); From 8cf90cebbd9fab696f2e91a8e66d59e6a6ea4c5c Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:33:39 +0700 Subject: [PATCH 4/5] feat(pwa): per-tool manifest link, install button island + hook Emit a web manifest per tool (endpoint), own the manifest (plugin builds the SW only), link each tool page to its own manifest + generated apple-touch icon, and add an 'Install this tool' button (useInstallPrompt hook + early beforeinstallprompt capture; iOS Add-to-Home-Screen hint; per-tool dismissal). --- astro.config.mjs | 26 ++-------- src/hooks/useInstallPrompt.test.ts | 39 +++++++++++++++ src/hooks/useInstallPrompt.ts | 59 +++++++++++++++++++++++ src/islands/shell/InstallTool.tsx | 50 +++++++++++++++++++ src/layouts/Base.astro | 24 ++++++++- src/pages/[...locale]/tools/[tool].astro | 8 ++- src/pages/manifests/[tool].webmanifest.ts | 15 ++++++ 7 files changed, 197 insertions(+), 24 deletions(-) create mode 100644 src/hooks/useInstallPrompt.test.ts create mode 100644 src/hooks/useInstallPrompt.ts create mode 100644 src/islands/shell/InstallTool.tsx create mode 100644 src/pages/manifests/[tool].webmanifest.ts diff --git a/astro.config.mjs b/astro.config.mjs index ae3c67a..affb891 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -156,27 +156,11 @@ export default defineConfig({ scope: '/', includeAssets: ['icon-192.png', 'icon-512.png'], registerType: 'autoUpdate', - manifest: { - name: 'GoodWebTools', - short_name: 'GWT', - description: 'Privacy-first client-side utilities', - theme_color: '#0a0a0a', - background_color: '#0a0a0a', - icons: [ - { - src: '/icon-192.png', - sizes: '192x192', - type: 'image/png', - purpose: 'any maskable' - }, - { - src: '/icon-512.png', - sizes: '512x512', - type: 'image/png', - purpose: 'any maskable' - } - ] - }, + // The web manifest is owned by the app, not the plugin, so tool pages can + // link their own per-tool manifest (see public/manifest.webmanifest and + // src/pages/manifests/[tool].webmanifest.ts). The plugin still builds the + // service worker for offline support. + manifest: false, workbox: { navigateFallback: '/404', globPatterns: ['**/*.{css,js,html,svg,png,ico,txt,woff2}'], diff --git a/src/hooks/useInstallPrompt.test.ts b/src/hooks/useInstallPrompt.test.ts new file mode 100644 index 0000000..9d8bb36 --- /dev/null +++ b/src/hooks/useInstallPrompt.test.ts @@ -0,0 +1,39 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { useInstallPrompt } from './useInstallPrompt'; + +beforeEach(() => { + (window as unknown as { __gwtInstall?: unknown }).__gwtInstall = { evt: null }; +}); + +describe('useInstallPrompt', () => { + it('is not promptable with no captured event', () => { + const { result } = renderHook(() => useInstallPrompt()); + expect(result.current.canPrompt).toBe(false); + expect(result.current.installed).toBe(false); + }); + + it('reports canPrompt once a beforeinstallprompt is captured', () => { + const { result } = renderHook(() => useInstallPrompt()); + act(() => { + (window as unknown as { __gwtInstall: { evt: unknown } }).__gwtInstall.evt = { + prompt: async () => {}, + userChoice: Promise.resolve({ outcome: 'accepted' }), + }; + window.dispatchEvent(new Event('gwt-installable')); + }); + expect(result.current.canPrompt).toBe(true); + }); + + it('clears promptability once installed', () => { + const { result } = renderHook(() => useInstallPrompt()); + act(() => { + (window as unknown as { __gwtInstall: { evt: unknown } }).__gwtInstall.evt = { prompt: async () => {}, userChoice: Promise.resolve({ outcome: 'accepted' }) }; + window.dispatchEvent(new Event('gwt-installable')); + }); + expect(result.current.canPrompt).toBe(true); + act(() => { window.dispatchEvent(new Event('gwt-installed')); }); + expect(result.current.installed).toBe(true); + expect(result.current.canPrompt).toBe(false); + }); +}); diff --git a/src/hooks/useInstallPrompt.ts b/src/hooks/useInstallPrompt.ts new file mode 100644 index 0000000..fd6a367 --- /dev/null +++ b/src/hooks/useInstallPrompt.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useState } from 'react'; + +interface BIPEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: string }>; +} + +interface InstallGlobal { + __gwtInstall?: { evt: BIPEvent | null }; +} + +/** + * Surfaces the browser's install capability for the "Install this tool" button. + * The `beforeinstallprompt` event is captured early by an inline script in + * Base.astro (into `window.__gwtInstall`) so it is never missed before this + * hook mounts. iOS Safari has no such event — callers show manual instructions. + */ +export function useInstallPrompt() { + const [canPrompt, setCanPrompt] = useState(false); + const [installed, setInstalled] = useState(false); + const [isStandalone, setStandalone] = useState(false); + const [isIOS, setIOS] = useState(false); + + useEffect(() => { + const w = window as unknown as InstallGlobal; + setCanPrompt(!!w.__gwtInstall?.evt); + + const ua = navigator.userAgent || ''; + setIOS(/iP(hone|ad|od)/.test(ua) && /Safari/.test(ua) && !/CriOS|FxiOS/.test(ua)); + + const mm = typeof window.matchMedia === 'function' ? window.matchMedia('(display-mode: standalone)') : null; + const nav = navigator as unknown as { standalone?: boolean }; + setStandalone((mm?.matches ?? false) || nav.standalone === true); + + const onInstallable = () => setCanPrompt(true); + const onInstalled = () => { setInstalled(true); setCanPrompt(false); }; + const onMM = (e: MediaQueryListEvent) => setStandalone(e.matches); + window.addEventListener('gwt-installable', onInstallable); + window.addEventListener('gwt-installed', onInstalled); + mm?.addEventListener?.('change', onMM); + return () => { + window.removeEventListener('gwt-installable', onInstallable); + window.removeEventListener('gwt-installed', onInstalled); + mm?.removeEventListener?.('change', onMM); + }; + }, []); + + const promptInstall = useCallback(async () => { + const w = window as unknown as InstallGlobal; + const evt = w.__gwtInstall?.evt; + if (!evt) return; + await evt.prompt(); + await evt.userChoice.catch(() => undefined); + if (w.__gwtInstall) w.__gwtInstall.evt = null; + setCanPrompt(false); + }, []); + + return { canPrompt, isIOS, isStandalone, installed, promptInstall }; +} diff --git a/src/islands/shell/InstallTool.tsx b/src/islands/shell/InstallTool.tsx new file mode 100644 index 0000000..a0a0c59 --- /dev/null +++ b/src/islands/shell/InstallTool.tsx @@ -0,0 +1,50 @@ +import { useState } from 'react'; +import { Download, Share, X } from 'lucide-react'; +import { useInstallPrompt } from '@/hooks/useInstallPrompt'; +import type { Lang } from '@/i18n/config'; + +const TR: Record = { + en: { install: 'Install this tool', ios: 'Tap the Share button, then “Add to Home Screen”.', dismiss: 'Dismiss' }, + id: { install: 'Instal tool ini', ios: 'Tap tombol Share, lalu “Add to Home Screen”.', dismiss: 'Tutup' }, +}; + +export default function InstallTool({ toolId, name, lang = 'en' }: { toolId: string; name: string; lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const { canPrompt, isIOS, isStandalone, installed, promptInstall } = useInstallPrompt(); + const key = `gwt-install-dismissed:${toolId}`; + const [dismissed, setDismissed] = useState(() => { + try { return localStorage.getItem(key) === '1'; } catch { return false; } + }); + const [showIOS, setShowIOS] = useState(false); + + // Nothing to offer: already installed / running standalone / dismissed, or the + // browser can neither prompt nor (iOS) show manual steps. + if (isStandalone || installed || dismissed || (!canPrompt && !isIOS)) return null; + + const dismiss = () => { + try { localStorage.setItem(key, '1'); } catch { /* ignore */ } + setDismissed(true); + }; + + return ( + + + + {showIOS && ( + + {t.ios} + + )} + + ); +} diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 1749331..77afbd7 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -17,6 +17,10 @@ export interface Props { canonical?: string; /** Set for pages that should not be indexed (e.g. 404). */ noindex?: boolean; + /** Per-page web manifest (tool pages link their own installable manifest). */ + manifestHref?: string; + /** Per-page apple-touch icon (tool pages use their own generated icon). */ + appleTouchIcon?: string; /** Replace the " | GoodWebTools" pattern entirely. */ fullTitle?: string; /** JSON-LD structured data object(s) to embed. */ @@ -38,6 +42,8 @@ const { jsonLd, lang = DEFAULT_LOCALE, localized = false, + manifestHref = '/manifest.webmanifest', + appleTouchIcon = '/apple-touch-icon.png', } = Astro.props; const canonicalURL = canonical ?? new URL(Astro.url.pathname, SITE_URL).href; @@ -111,13 +117,27 @@ const jsonLdBlocks = [siteJsonLd, ...(Array.isArray(jsonLd) ? jsonLd : jsonLd ? /> <!-- PWA --> - <link rel="manifest" href="/manifest.webmanifest" /> + <link rel="manifest" href={manifestHref} /> <meta name="theme-color" content="#fffdf5" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="any" /> <link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" /> <link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png" /> - <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> + <link rel="apple-touch-icon" href={appleTouchIcon} /> + <script is:inline> + // Capture the install prompt before any island hydrates so the + // "Install this tool" button can fire it later. Inert where unsupported. + window.__gwtInstall = window.__gwtInstall || { evt: null }; + window.addEventListener('beforeinstallprompt', (e) => { + e.preventDefault(); + window.__gwtInstall.evt = e; + window.dispatchEvent(new Event('gwt-installable')); + }); + window.addEventListener('appinstalled', () => { + window.__gwtInstall.evt = null; + window.dispatchEvent(new Event('gwt-installed')); + }); + </script> <meta name="mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> diff --git a/src/pages/[...locale]/tools/[tool].astro b/src/pages/[...locale]/tools/[tool].astro index 9c60ad6..e97deae 100644 --- a/src/pages/[...locale]/tools/[tool].astro +++ b/src/pages/[...locale]/tools/[tool].astro @@ -3,6 +3,7 @@ import { ChevronRight } from 'lucide-react'; import Base from '@/layouts/Base.astro'; import ToolHost from '@/islands/ToolHost'; import ShareButton from '@/islands/share/ShareButton'; +import InstallTool from '@/islands/shell/InstallTool'; import { tools, getToolById } from '@/registry/tools'; import { localizedTool } from '@/registry/tool-i18n'; import { getToolSeo } from '@/registry/tool-seo'; @@ -89,6 +90,8 @@ if (seo?.faqs && seo.faqs.length > 0) { jsonLd={jsonLd} lang={lang} localized + manifestHref={`/manifests/${tool.id}.webmanifest`} + appleTouchIcon={`/manifests/icons/${tool.id}-180.png`} > <main class="page-container py-8"> <nav aria-label={t(lang, 'a11y.breadcrumb')} class="mb-4"> @@ -115,7 +118,10 @@ if (seo?.faqs && seo.faqs.length > 0) { </span> )} </div> - <ShareButton client:idle url={toolUrl} title={`${label.name} — ${SITE_NAME}`} text={label.summary} /> + <div class="flex flex-wrap items-center gap-2"> + <InstallTool client:idle toolId={tool.id} name={label.name} lang={lang} /> + <ShareButton client:idle url={toolUrl} title={`${label.name} — ${SITE_NAME}`} text={label.summary} /> + </div> </div> <p class="max-w-3xl text-muted-foreground">{lead}</p> </div> diff --git a/src/pages/manifests/[tool].webmanifest.ts b/src/pages/manifests/[tool].webmanifest.ts new file mode 100644 index 0000000..417db6f --- /dev/null +++ b/src/pages/manifests/[tool].webmanifest.ts @@ -0,0 +1,15 @@ +import type { APIRoute } from 'astro'; +import { tools } from '@/registry/tools'; +import { buildToolManifest } from '@/tools/pwa/manifest.lib'; + +export function getStaticPaths() { + return tools.map(t => ({ params: { tool: t.id }, props: { name: t.name, summary: t.summary } })); +} + +export const GET: APIRoute = ({ params, props }) => { + const { name, summary } = props as { name: string; summary: string }; + const manifest = buildToolManifest({ id: params.tool as string, name, summary }); + return new Response(JSON.stringify(manifest), { + headers: { 'content-type': 'application/manifest+json; charset=utf-8' }, + }); +}; From 852f80235ab8520ee6fb4d2a98fe15913ee71b51 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:34:14 +0700 Subject: [PATCH 5/5] test(pwa): per-tool manifest link + install button E2E --- e2e/tools/install-tool.spec.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 e2e/tools/install-tool.spec.ts diff --git a/e2e/tools/install-tool.spec.ts b/e2e/tools/install-tool.spec.ts new file mode 100644 index 0000000..eae9360 --- /dev/null +++ b/e2e/tools/install-tool.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from '@playwright/test'; + +test('tool page links its own per-tool manifest', async ({ page }) => { + const res = await page.goto('/tools/markdown'); + expect(res?.status()).toBe(200); + const href = await page.locator('link[rel="manifest"]').getAttribute('href'); + expect(href).toBe('/manifests/markdown.webmanifest'); + const apple = await page.locator('link[rel="apple-touch-icon"]').getAttribute('href'); + expect(apple).toBe('/manifests/icons/markdown-180.png'); +}); + +test('shows the install button when a beforeinstallprompt is available', async ({ page }) => { + await page.goto('/tools/markdown'); + await page.waitForLoadState('networkidle').catch(() => {}); + + // Simulate an installable browser (headless Chromium never fires this itself). + const install = page.getByRole('button', { name: 'Install this tool' }); + await expect(async () => { + await page.evaluate(() => { + const w = window as unknown as { __gwtInstall: { evt: unknown } }; + w.__gwtInstall = { evt: { prompt: async () => {}, userChoice: Promise.resolve({ outcome: 'dismissed' }) } }; + window.dispatchEvent(new Event('gwt-installable')); + }); + await expect(install).toBeVisible({ timeout: 2000 }); + }).toPass({ timeout: 30_000 }); +});