diff --git a/docs/superpowers/plans/2026-07-27-pwa-installability.md b/docs/superpowers/plans/2026-07-27-pwa-installability.md
new file mode 100644
index 0000000..511270a
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-27-pwa-installability.md
@@ -0,0 +1,503 @@
+# AgendaMax PWA Installability 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 AgendaMax installable as a web app on Android, iOS, and compatible desktop browsers without adding offline writes or changing the existing API/authentication model.
+
+**Architecture:** Add a hand-maintained web manifest and service worker under `public/`, register the worker only in production, and provide a reusable React install prompt. Chromium uses `beforeinstallprompt`; iOS Safari receives explicit Share -> Add to Home Screen instructions. The worker caches only the app shell/static assets and bypasses all API and non-GET requests.
+
+**Tech Stack:** React 18, TypeScript, Vite 5, Tailwind CSS, Express static hosting, Playwright, Node.js 22.5+.
+
+## Global Constraints
+
+- Use a manually maintained manifest and service worker; do not add `vite-plugin-pwa`.
+- Keep the data model online-first; never cache `/api` responses or queue offline writes.
+- Keep AgendaMax's existing Spanish visual language and make installation a secondary action.
+- Production installation requires HTTPS; `localhost` is valid for local testing.
+- Do not change authentication, tenant isolation, API routes, or database schema.
+- Preserve the existing `public/favicon.svg` and brand color `#3b66ff`.
+- Keep service-worker registration failure non-fatal and log it only in development.
+
+## File Map
+
+- Create `public/manifest.webmanifest`: browser install metadata and icon declarations.
+- Create `public/sw.js`: versioned shell/static-asset cache with API bypass.
+- Create `public/icon-192.png` and `public/icon-512.png`: install icons derived from the existing favicon mark.
+- Create `scripts/generate-pwa-icons.mjs`: reproducible local icon generation using the existing Playwright dependency.
+- Create `src/lib/useInstallPrompt.ts`: browser capability detection and deferred install event lifecycle.
+- Create `src/components/InstallAppPrompt.tsx`: install button and iOS instruction modal.
+- Create `pwa-e2e.mjs`: production-server PWA endpoint and browser-behavior checks.
+- Modify `index.html`: manifest link, iOS metadata, and safe-area viewport setting.
+- Modify `src/main.tsx`: production service-worker registration.
+- Modify `src/index.css`: safe-area utility for the install dialog.
+- Modify `src/pages/LoginPage.tsx`, `src/components/AppShell.tsx`, and `src/components/AdminShell.tsx`: render the reusable install action in the existing UI surfaces.
+- Modify `package.json`: icon-generation and PWA test scripts.
+- Modify `README.md`: document install behavior, HTTPS, and validation commands.
+
+---
+
+### Task 1: Add Static PWA Metadata and Icons
+
+**Files:**
+- Create: `scripts/generate-pwa-icons.mjs`
+- Create: `public/icon-192.png`
+- Create: `public/icon-512.png`
+- Create: `public/manifest.webmanifest`
+- Modify: `index.html:5-12`
+- Modify: `package.json:7-25`
+
+**Interfaces:**
+- Produces `/manifest.webmanifest`, `/icon-192.png`, and `/icon-512.png` in the Vite output.
+- Keeps `/favicon.svg` as the browser-tab icon.
+
+- [ ] **Step 1: Add a reproducible icon generator.**
+
+Create `scripts/generate-pwa-icons.mjs` using the already-installed Playwright package. It must load `public/favicon.svg` as a data URL, render it on a white 1:1 page, and screenshot exactly 192x192 and 512x512 PNG files:
+
+```js
+import { chromium } from "playwright";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const svg = await fs.readFile(path.join(root, "public", "favicon.svg"), "utf8");
+const browser = await chromium.launch({ headless: true });
+
+try {
+ for (const size of [192, 512]) {
+ const page = await browser.newPage({ viewport: { width: size, height: size }, deviceScaleFactor: 1 });
+ await page.setContent(`${svg}`);
+ await page.screenshot({ path: path.join(root, "public", `icon-${size}.png`), type: "png" });
+ await page.close();
+ }
+} finally {
+ await browser.close();
+}
+```
+
+- [ ] **Step 2: Run the generator and verify PNG dimensions.**
+
+Run:
+
+```text
+node scripts/generate-pwa-icons.mjs
+```
+
+Expected: `public/icon-192.png` and `public/icon-512.png` exist. Verify their PNG signature and dimensions with a short Node check before continuing; do not substitute SVG files for the required PNG icons.
+
+- [ ] **Step 3: Add the manifest.**
+
+Create `public/manifest.webmanifest` with this exact contract:
+
+```json
+{
+ "name": "AgendaMax",
+ "short_name": "AgendaMax",
+ "description": "Gestión visual de citas, empleados e ingresos para tu negocio.",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "portrait-primary",
+ "background_color": "#f6f7fb",
+ "theme_color": "#3b66ff",
+ "icons": [
+ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
+ ]
+}
+```
+
+- [ ] **Step 4: Update HTML metadata and package scripts.**
+
+In `index.html`, keep the existing favicon/theme/description and add:
+
+```html
+
+
+
+
+
+```
+
+Change the viewport content to include `viewport-fit=cover` while preserving the current scale values. Add these scripts to `package.json`:
+
+```json
+"generate:pwa-icons": "node scripts/generate-pwa-icons.mjs",
+"test:pwa": "node pwa-e2e.mjs"
+```
+
+- [ ] **Step 5: Build and inspect static output.**
+
+Run:
+
+```text
+npm.cmd run build
+```
+
+Expected: exit code 0 and `dist/manifest.webmanifest`, `dist/icon-192.png`, and `dist/icon-512.png` exist. The worker is added in Task 2, so do not treat its absence as a failure in this task.
+
+- [ ] **Step 6: Commit the static PWA contract.**
+
+```text
+git add package.json index.html public/manifest.webmanifest public/icon-192.png public/icon-512.png scripts/generate-pwa-icons.mjs
+git commit -m "feat: add AgendaMax PWA metadata and icons"
+```
+
+---
+
+### Task 2: Add the Production Service Worker
+
+**Files:**
+- Create: `public/sw.js`
+- Modify: `src/main.tsx:18-25`
+
+**Interfaces:**
+- Browser loads `/sw.js` from the same origin in production.
+- The worker owns only shell/static caching; API requests remain network-only.
+
+- [ ] **Step 1: Add the service worker with explicit routing.**
+
+Create `public/sw.js` with a versioned cache and these rules:
+
+```js
+const CACHE_NAME = "agendamax-shell-v1";
+const SHELL_URLS = ["/", "/manifest.webmanifest", "/favicon.svg", "/icon-192.png", "/icon-512.png"];
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS)).then(() => self.skipWaiting())
+ );
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches.keys()
+ .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
+ .then(() => self.clients.claim())
+ );
+});
+
+self.addEventListener("fetch", (event) => {
+ const { request } = event;
+ const url = new URL(request.url);
+ if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return;
+
+ if (request.mode === "navigate") {
+ event.respondWith(
+ fetch(request)
+ .then((response) => {
+ if (response.ok) {
+ const copy = response.clone();
+ void caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
+ }
+ return response;
+ })
+ .catch(() => caches.match("/"))
+ );
+ return;
+ }
+
+ const cacheableDestination = new Set(["script", "style", "image", "font", "manifest", "worker"]);
+ if (!cacheableDestination.has(request.destination)) return;
+
+ event.respondWith(
+ caches.match(request).then((cached) => {
+ if (cached) return cached;
+ return fetch(request).then((response) => {
+ if (response.ok) {
+ const copy = response.clone();
+ void caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
+ }
+ return response;
+ });
+ })
+ );
+});
+```
+
+The worker must not add a catch-all cache path, intercept non-GET requests, or cache requests whose path starts with `/api/`.
+
+- [ ] **Step 2: Register the worker only in production.**
+
+Append this registration after the React root render in `src/main.tsx`:
+
+```ts
+if (import.meta.env.PROD && "serviceWorker" in navigator) {
+ window.addEventListener("load", () => {
+ void navigator.serviceWorker
+ .register("/sw.js", { updateViaCache: "none" })
+ .catch((error: unknown) => {
+ if (import.meta.env.DEV) console.warn("AgendaMax service worker registration failed", error);
+ });
+ });
+}
+```
+
+The registration failure is non-fatal and must never reject or delay React startup; the development-only warning is retained for local diagnostics if the environment condition is changed during debugging.
+
+- [ ] **Step 3: Validate worker behavior statically.**
+
+Run:
+
+```text
+npm.cmd run typecheck
+npm.cmd run build
+```
+
+Expected: both exit 0, and `dist/sw.js` exists because Vite copies `public/sw.js`.
+
+- [ ] **Step 4: Commit the worker.**
+
+```text
+git add public/sw.js src/main.tsx
+git commit -m "feat: add production PWA service worker"
+```
+
+---
+
+### Task 3: Add Install Detection and UI
+
+**Files:**
+- Create: `src/lib/useInstallPrompt.ts`
+- Create: `src/components/InstallAppPrompt.tsx`
+- Modify: `src/index.css:228-322`
+- Modify: `src/pages/LoginPage.tsx:1-6` and rendered layout
+- Modify: `src/components/AppShell.tsx:1-18` and mobile header
+- Modify: `src/components/AdminShell.tsx:1-14` and mobile header
+
+**Interfaces:**
+- `useInstallPrompt(): { state: InstallPromptState; install: () => Promise; dismiss: () => void }`.
+- `InstallPromptState` is exactly `"unsupported" | "available" | "ios-instructions" | "installed"`.
+- `` renders nothing for `unsupported` or `installed` and owns the iOS `Modal` lifecycle.
+
+- [ ] **Step 1: Define the deferred prompt type and write the hook contract.**
+
+Create `src/lib/useInstallPrompt.ts` with a local event type instead of adding an unsafe global declaration:
+
+```ts
+import { useEffect, useState } from "react";
+
+export type InstallPromptState = "unsupported" | "available" | "ios-instructions" | "installed";
+
+interface BeforeInstallPromptEvent extends Event {
+ prompt: () => Promise;
+ userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
+}
+
+function isStandalone() {
+ return window.matchMedia("(display-mode: standalone)").matches ||
+ ("standalone" in navigator && Boolean((navigator as Navigator & { standalone?: boolean }).standalone));
+}
+
+function isIOSSafari() {
+ const ua = navigator.userAgent;
+ const ios = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
+ return ios && /Safari/i.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS/i.test(ua);
+}
+
+export function useInstallPrompt() {
+ const [state, setState] = useState(() => {
+ if (typeof window === "undefined") return "unsupported";
+ if (isStandalone()) return "installed";
+ return isIOSSafari() ? "ios-instructions" : "unsupported";
+ });
+ const [deferred, setDeferred] = useState(null);
+ const [dismissed, setDismissed] = useState(false);
+
+ useEffect(() => {
+ if (isStandalone()) {
+ setState("installed");
+ return;
+ }
+ const onBeforeInstallPrompt = (event: Event) => {
+ event.preventDefault();
+ setDeferred(event as BeforeInstallPromptEvent);
+ setState("available");
+ };
+ const onInstalled = () => {
+ setDeferred(null);
+ setState("installed");
+ };
+ window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
+ window.addEventListener("appinstalled", onInstalled);
+ return () => {
+ window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
+ window.removeEventListener("appinstalled", onInstalled);
+ };
+ }, []);
+
+ const install = async () => {
+ if (!deferred) return;
+ const event = deferred;
+ setDeferred(null);
+ await event.prompt();
+ const choice = await event.userChoice;
+ if (choice.outcome === "accepted") setState("installed");
+ else setDismissed(true);
+ };
+
+ return { state: dismissed ? "unsupported" : state, install, dismiss: () => setDismissed(true) };
+}
+```
+
+Keep the hook browser-only and ensure event listeners are removed on unmount. The iOS state is intentionally limited to Safari; unsupported browsers remain normal web pages.
+
+- [ ] **Step 2: Build the reusable prompt component.**
+
+Create `src/components/InstallAppPrompt.tsx` using `useInstallPrompt`, `Modal`, and `Download`, `Share`, and `PlusSquare` icons from `lucide-react`. The component must:
+
+```tsx
+export function InstallAppPrompt() {
+ const { state, install } = useInstallPrompt();
+ const [iosOpen, setIosOpen] = useState(false);
+
+ if (state === "unsupported" || state === "installed") return null;
+ if (state === "ios-instructions") {
+ return (
+ <>
+
+ setIosOpen(false)} title="Instalar AgendaMax" subtitle="Safari lo añade a tu pantalla de inicio.">
+
+
+
Toca Compartir en la barra de Safari.
+
Elige Añadir a pantalla de inicio y confirma.
+
+
+
+ >
+ );
+ }
+ return ;
+}
+```
+
+The final component may use `aria-label`/`aria-labelledby` as needed, but must keep the exact accessible action name `/Instalar AgendaMax/`, close the iOS modal on backdrop/Escape through the existing `Modal`, and never show an install CTA after standalone detection.
+
+- [ ] **Step 3: Add safe-area styling.**
+
+Append a focused utility to `src/index.css`:
+
+```css
+.safe-area-bottom {
+ padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
+}
+```
+
+Apply it to the iOS modal content/footer surface only; do not change the global page height or reserve a permanent bottom band.
+
+- [ ] **Step 4: Integrate one visible action per page shell.**
+
+Import `InstallAppPrompt` and render it once in each of these existing surfaces:
+
+- `LoginPage`: immediately below the login card's submit area, using a compact centered container so it does not affect the desktop brand panel.
+- `AppShell`: in the existing mobile header beside the AgendaMax wordmark/menu controls; keep it hidden on large screens with the same `lg:hidden` responsive convention.
+- `AdminShell`: in the existing mobile header beside the Admin wordmark/menu controls; keep it hidden on large screens with the same `lg:hidden` convention.
+
+Do not put the component inside `SidebarContent`, because that JSX is rendered once for desktop and again when the mobile drawer opens. One instance per shell avoids duplicate deferred prompt listeners.
+
+- [ ] **Step 5: Verify TypeScript and responsive rendering.**
+
+Run:
+
+```text
+npm.cmd run typecheck
+npm.cmd run build
+```
+
+Expected: exit code 0 for both. Confirm the app still loads the login page and both authenticated shells without console errors.
+
+- [ ] **Step 6: Commit the install UI.**
+
+```text
+git add src/lib/useInstallPrompt.ts src/components/InstallAppPrompt.tsx src/index.css src/pages/LoginPage.tsx src/components/AppShell.tsx src/components/AdminShell.tsx
+git commit -m "feat: add cross-platform PWA install prompt"
+```
+
+---
+
+### Task 4: Add PWA Verification and Production Documentation
+
+**Files:**
+- Create: `pwa-e2e.mjs`
+- Modify: `README.md:47-64`
+
+**Interfaces:**
+- `npm run test:pwa` checks a built production server at `PWA_BASE_URL` or `http://localhost:3000`.
+- The check exits non-zero on missing assets, invalid manifest fields, unexpected API interception, or UI behavior failures.
+
+- [ ] **Step 1: Add endpoint and manifest assertions.**
+
+In `pwa-e2e.mjs`, fetch the base URL and assert status 200 for `/manifest.webmanifest`, `/sw.js`, `/icon-192.png`, and `/icon-512.png`. Parse the manifest and assert `display === "standalone"`, `start_url === "/"`, `scope === "/"`, and both declared icon sizes. Fetch a known SPA route such as `/calendar` and assert it returns the built HTML rather than a 404. Fetch `/api/health` and assert it remains a JSON API response.
+
+- [ ] **Step 2: Add browser install-event checks.**
+
+Use Playwright Chromium with `BASE = process.env.PWA_BASE_URL || "http://localhost:3000"`. Add an init script that dispatches a cancelable `beforeinstallprompt` event after load and records `prompt()` calls:
+
+```js
+await page.addInitScript(() => {
+ window.__installPromptCalls = 0;
+ setTimeout(() => {
+ const event = new Event("beforeinstallprompt", { cancelable: true });
+ event.prompt = async () => { window.__installPromptCalls += 1; };
+ event.userChoice = Promise.resolve({ outcome: "accepted", platform: "web" });
+ window.dispatchEvent(event);
+ }, 100);
+});
+```
+
+On `/`, assert the accessible `Instalar AgendaMax` action becomes visible, click it, and assert `window.__installPromptCalls === 1`.
+
+- [ ] **Step 3: Add iOS and standalone checks.**
+
+Create a second context with an iPhone Safari user agent. Before page scripts run, define `Navigator.prototype.standalone` as `false`; assert the install action is visible, click it, and assert the modal contains `Compartir` and `Añadir a pantalla de inicio`. Create a third context whose `matchMedia` returns `matches: true` for `display-mode: standalone`; assert no `Instalar AgendaMax` action is visible. Create a fourth normal context without an install event and assert no install action is visible.
+
+- [ ] **Step 4: Add service-worker/API safety checks.**
+
+After loading the production page, wait for `navigator.serviceWorker.ready` with a bounded timeout. Inspect the worker source returned by `/sw.js` and assert it contains the `/api/` bypass and `request.method !== "GET"` guard. Use Playwright request listeners to confirm normal page/API loading still reaches `/api/health`; do not use a cached fixture as a substitute for the real API response.
+
+- [ ] **Step 5: Document local and production validation.**
+
+Add a `## Instalar AgendaMax como app` section to `README.md` after the production commands:
+
+- Android/Chrome: open the HTTPS URL and select the install action shown by AgendaMax or the browser address bar.
+- iPhone/iPad Safari: use Share -> Add to Home Screen; iOS does not expose Android's in-page install prompt.
+- The installed shell can be reopened without browser chrome, but business data still requires an internet connection.
+- Local validation: run `npm.cmd run build`, start production with `npm.cmd start`, then run `npm.cmd run test:pwa`.
+- Production validation requires the Coolify HTTPS domain, not an HTTP IP address.
+
+- [ ] **Step 6: Run the complete verification set.**
+
+With a built production server running on port 3000, run:
+
+```text
+npm.cmd run typecheck
+npm.cmd run build
+npm.cmd run test:pwa
+npm.cmd run audit:visual
+git diff --check
+```
+
+Expected: typecheck, build, PWA checks, and visual audit exit 0; `git diff --check` reports no whitespace errors. If the pre-existing scheduling data causes API booking assertions in unrelated suites, record that limitation without weakening PWA checks.
+
+- [ ] **Step 7: Commit verification and docs.**
+
+```text
+git add pwa-e2e.mjs README.md
+git commit -m "test: verify AgendaMax PWA installation"
+```
+
+---
+
+## Final Review Checklist
+
+- [ ] `manifest.webmanifest` has valid name, scope, start URL, standalone display, theme/background colors, and 192/512 PNG icons.
+- [ ] `index.html` includes manifest, iOS metadata, `apple-touch-icon`, and `viewport-fit=cover`.
+- [ ] `sw.js` is copied into `dist`, updates by version, falls back only for navigation, and bypasses `/api` and non-GET requests.
+- [ ] Service-worker registration runs only in production and cannot block React startup.
+- [ ] Android/Chromium install prompt, iOS instructions, unsupported browser, dismissed prompt, and standalone states are covered.
+- [ ] Login, business mobile shell, and admin mobile shell each have one install action instance.
+- [ ] No authentication, tenant data, API routes, database schema, or offline writes changed.
+- [ ] `npm run typecheck`, `npm run build`, `npm run test:pwa`, and the visual audit have evidence before claiming completion.