xtablo-source/docs/superpowers/plans/2026-04-02-pwa.md
Arthur Belleville 795378e1f8
docs: add PWA implementation plan
10-task plan covering dependencies, icon generation, vite-plugin-pwa
config, meta tags, SW registration, safe areas, install prompt hook,
banner component, and end-to-end verification.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:56:41 +02:00

25 KiB

PWA 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 apps/main installable as a native-feeling PWA with fast repeat loads and mobile polish.

Architecture: Add vite-plugin-pwa to the existing Vite build. The plugin auto-generates a service worker (Workbox) and web app manifest. A custom install prompt hook + banner component handles the "Add to Home Screen" UX. Safe area CSS and viewport meta tags polish the standalone experience.

Tech Stack: vite-plugin-pwa, Workbox (auto-generated), sharp (icon generation), React hooks


File Map

Action File Responsibility
Create apps/main/scripts/generate-pwa-icons.ts One-time script to generate PWA icon set from source images
Create apps/main/public/pwa-icons/ Generated icon files (committed to repo)
Create apps/main/src/hooks/useInstallPrompt.ts React hook capturing beforeinstallprompt event
Create apps/main/src/hooks/useInstallPrompt.test.ts Tests for install prompt hook
Create apps/main/src/components/InstallBanner.tsx Dismissible install prompt banner
Create apps/main/src/components/InstallBanner.test.tsx Tests for install banner
Modify apps/main/package.json Add vite-plugin-pwa and sharp dependencies
Modify apps/main/vite.config.ts Add VitePWA plugin config
Modify apps/main/index.html Add PWA meta tags, update viewport
Modify apps/main/src/main.tsx Register service worker
Modify apps/main/src/main.css Add safe area CSS for standalone mode
Modify apps/main/src/App.tsx Add InstallBanner to app shell

Task 1: Install dependencies

Files:

  • Modify: apps/main/package.json

  • Step 1: Install vite-plugin-pwa

cd apps/main && pnpm add -D vite-plugin-pwa
  • Step 2: Install sharp for icon generation
cd apps/main && pnpm add -D sharp @types/sharp
  • Step 3: Verify installation
cd apps/main && pnpm list vite-plugin-pwa sharp

Expected: Both packages listed with versions.

  • Step 4: Commit
git add apps/main/package.json pnpm-lock.yaml
git commit -m "feat(pwa): add vite-plugin-pwa and sharp dependencies"

Task 2: Generate PWA icon set

Files:

  • Create: apps/main/scripts/generate-pwa-icons.ts

  • Create: apps/main/public/pwa-icons/*.png

  • Step 1: Create the icon generation script

Create apps/main/scripts/generate-pwa-icons.ts:

import sharp from "sharp";
import path from "node:path";
import fs from "node:fs";

const PUBLIC_DIR = path.resolve(import.meta.dirname, "../public");
const OUTPUT_DIR = path.join(PUBLIC_DIR, "pwa-icons");

const ICON_SIZES = [
  { size: 16, name: "favicon-16x16.png" },
  { size: 32, name: "favicon-32x32.png" },
  { size: 180, name: "apple-touch-icon-180x180.png" },
  { size: 192, name: "pwa-192x192.png" },
  { size: 512, name: "pwa-512x512.png" },
  { size: 512, name: "pwa-512x512-maskable.png", maskable: true },
];

// Maskable icons need 10% safe zone padding (per spec).
// We add a colored background and shrink the source to 80% to stay within the safe zone.
const MASKABLE_PADDING_RATIO = 0.1;
const MASKABLE_BG_COLOR = "#ffffff";

async function generateIcons(sourceFile: string) {
  if (!fs.existsSync(OUTPUT_DIR)) {
    fs.mkdirSync(OUTPUT_DIR, { recursive: true });
  }

  for (const icon of ICON_SIZES) {
    const outputPath = path.join(OUTPUT_DIR, icon.name);

    if (icon.maskable) {
      // Create maskable icon with padding and background
      const innerSize = Math.round(icon.size * (1 - MASKABLE_PADDING_RATIO * 2));
      const offset = Math.round(icon.size * MASKABLE_PADDING_RATIO);

      const resizedIcon = await sharp(sourceFile)
        .resize(innerSize, innerSize, { fit: "contain" })
        .toBuffer();

      await sharp({
        create: {
          width: icon.size,
          height: icon.size,
          channels: 4,
          background: MASKABLE_BG_COLOR,
        },
      })
        .composite([{ input: resizedIcon, left: offset, top: offset }])
        .png()
        .toFile(outputPath);
    } else {
      await sharp(sourceFile)
        .resize(icon.size, icon.size, { fit: "contain" })
        .png()
        .toFile(outputPath);
    }

    console.log(`Generated: ${icon.name} (${icon.size}x${icon.size})`);
  }
}

// Determine source: pass "staging" as first arg for staging icons
const variant = process.argv[2];
const sourceFile =
  variant === "staging"
    ? path.join(PUBLIC_DIR, "staging_icon.jpg")
    : path.join(PUBLIC_DIR, "icon.jpg");

if (!fs.existsSync(sourceFile)) {
  console.error(`Source icon not found: ${sourceFile}`);
  process.exit(1);
}

console.log(`Generating PWA icons from: ${sourceFile}`);
generateIcons(sourceFile).then(() => console.log("Done!"));
  • Step 2: Run the script for production icons
cd apps/main && npx tsx scripts/generate-pwa-icons.ts

Expected: 6 PNG files created in apps/main/public/pwa-icons/.

  • Step 3: Verify the generated icons exist and have reasonable sizes
ls -la apps/main/public/pwa-icons/

Expected: 6 files — favicon-16x16.png, favicon-32x32.png, apple-touch-icon-180x180.png, pwa-192x192.png, pwa-512x512.png, pwa-512x512-maskable.png.

  • Step 4: Commit
git add apps/main/scripts/generate-pwa-icons.ts apps/main/public/pwa-icons/
git commit -m "feat(pwa): add icon generation script and generated PWA icons"

Task 3: Configure vite-plugin-pwa

Files:

  • Modify: apps/main/vite.config.ts

  • Step 1: Add VitePWA plugin to vite config

In apps/main/vite.config.ts, add the import at the top:

import { VitePWA } from "vite-plugin-pwa";

Then add the plugin to the plugins array (before the cloudflare conditional). Note that mode is already available from the defineConfig callback parameter:

VitePWA({
  registerType: "autoUpdate",
  includeAssets: [
    "public/icon.jpg",
    "public/logo_dark.png",
    "public/logo_white.png",
  ],
  manifest: {
    name: mode === "staging" ? "XTablo (Staging)" : "XTablo",
    short_name: "XTablo",
    description: "Collaborative project management for construction teams",
    start_url: "/",
    display: "standalone",
    orientation: "any",
    theme_color: "#1e1b2e",
    background_color: "#1e1b2e",
    icons: [
      {
        src: "pwa-icons/pwa-192x192.png",
        sizes: "192x192",
        type: "image/png",
      },
      {
        src: "pwa-icons/pwa-512x512.png",
        sizes: "512x512",
        type: "image/png",
      },
      {
        src: "pwa-icons/pwa-512x512-maskable.png",
        sizes: "512x512",
        type: "image/png",
        purpose: "maskable",
      },
    ],
  },
  workbox: {
    globPatterns: ["**/*.{js,css,html,ico,png,jpg,svg,woff,woff2}"],
    // Do not precache source maps
    globIgnores: ["**/*.map"],
  },
}),

Note: theme_color and background_color are set to #1e1b2e which is the dark navbar background color from the CSS. This provides a consistent splash screen feel on Android. Pick dark because standalone apps typically launch in the user's last-used theme, and dark provides a cleaner splash.

  • Step 2: Build to verify the plugin works
cd apps/main && pnpm build

Expected: Build succeeds. The dist/ folder should now contain manifest.webmanifest, sw.js, and workbox-*.js.

  • Step 3: Verify manifest was generated
cat apps/main/dist/manifest.webmanifest | head -30

Expected: JSON with name "XTablo", icons array, display "standalone".

  • Step 4: Commit
git add apps/main/vite.config.ts
git commit -m "feat(pwa): configure vite-plugin-pwa with manifest and workbox precaching"

Task 4: Update HTML meta tags and viewport

Files:

  • Modify: apps/main/index.html

  • Step 1: Update index.html with PWA meta tags

Replace the current <head> content in apps/main/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/png" sizes="32x32" href="/pwa-icons/favicon-32x32.png" />
    <link rel="icon" type="image/png" sizes="16x16" href="/pwa-icons/favicon-16x16.png" />
    <link rel="apple-touch-icon" sizes="180x180" href="/pwa-icons/apple-touch-icon-180x180.png" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
    <meta name="theme-color" content="#1e1b2e" />
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <meta name="apple-mobile-web-app-status-bar-style" content="default" />
    <title>XTablo</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Changes from original:

  • Favicon now points to generated PNG icons instead of icon.jpg

  • Added viewport-fit=cover for notched devices

  • Added theme-color meta tag

  • Added Apple PWA meta tags

  • The <link rel="manifest"> tag is injected automatically by vite-plugin-pwa — do NOT add it manually

  • Step 2: Build and verify meta tags in output

cd apps/main && pnpm build && head -20 dist/index.html

Expected: The built HTML includes all meta tags plus an auto-injected <link rel="manifest">.

  • Step 3: Commit
git add apps/main/index.html
git commit -m "feat(pwa): add PWA meta tags and update viewport for standalone mode"

Task 5: Register service worker in main.tsx

Files:

  • Modify: apps/main/src/main.tsx

  • Step 1: Add SW registration

Add the following import and call at the end of apps/main/src/main.tsx, after the createRoot().render() call:

import { registerSW } from "virtual:pwa-register";

// Auto-update service worker — checks for updates on page load
registerSW({ immediate: true });
  • Step 2: Add type declaration for the virtual module

The virtual:pwa-register module needs a type declaration. vite-plugin-pwa ships its own types. Add to apps/main/tsconfig.json (or tsconfig.app.json, whichever controls the app source) in compilerOptions.types:

"types": ["vite-plugin-pwa/client"]

If there's no types array yet, check the existing tsconfig structure and add it appropriately.

  • Step 3: Verify typecheck passes
cd apps/main && pnpm typecheck

Expected: No errors related to virtual:pwa-register.

  • Step 4: Commit
git add apps/main/src/main.tsx apps/main/tsconfig.json apps/main/tsconfig.app.json
git commit -m "feat(pwa): register service worker with auto-update"

Task 6: Add safe area CSS for standalone mode

Files:

  • Modify: apps/main/src/main.css

  • Step 1: Add safe area styles

Add the following at the end of the @layer base block in apps/main/src/main.css (after the existing body rule, still inside @layer base):

@media (display-mode: standalone) {
  body {
    padding-top: env(safe-area-inset-top);
    padding-bottom: env(safe-area-inset-bottom);
    padding-left: env(safe-area-inset-left);
    padding-right: env(safe-area-inset-right);
  }
}

This only applies when the app is running in standalone (installed PWA) mode, not in a regular browser tab.

  • Step 2: Build to verify no CSS errors
cd apps/main && pnpm build

Expected: Build succeeds with no CSS errors.

  • Step 3: Commit
git add apps/main/src/main.css
git commit -m "feat(pwa): add safe area insets for standalone mode"

Task 7: Create useInstallPrompt hook

Files:

  • Create: apps/main/src/hooks/useInstallPrompt.ts

  • Create: apps/main/src/hooks/useInstallPrompt.test.ts

  • Step 1: Write the test file

Create apps/main/src/hooks/useInstallPrompt.test.ts:

import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useInstallPrompt } from "./useInstallPrompt";

const DISMISSED_KEY = "pwa-install-dismissed";

describe("useInstallPrompt", () => {
  beforeEach(() => {
    localStorage.clear();
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  it("starts with canInstall false and isStandalone false", () => {
    const { result } = renderHook(() => useInstallPrompt());
    expect(result.current.canInstall).toBe(false);
    expect(result.current.isStandalone).toBe(false);
    expect(result.current.isDismissed).toBe(false);
  });

  it("captures beforeinstallprompt event and sets canInstall to true", () => {
    const { result } = renderHook(() => useInstallPrompt());

    const event = new Event("beforeinstallprompt");
    Object.assign(event, { preventDefault: vi.fn(), prompt: vi.fn() });

    act(() => {
      window.dispatchEvent(event);
    });

    expect(result.current.canInstall).toBe(true);
  });

  it("dismiss persists to localStorage and sets isDismissed", () => {
    const { result } = renderHook(() => useInstallPrompt());

    act(() => {
      result.current.dismiss();
    });

    expect(result.current.isDismissed).toBe(true);
    expect(localStorage.getItem(DISMISSED_KEY)).toBe("true");
  });

  it("reads dismissed state from localStorage on mount", () => {
    localStorage.setItem(DISMISSED_KEY, "true");
    const { result } = renderHook(() => useInstallPrompt());
    expect(result.current.isDismissed).toBe(true);
  });

  it("detects iOS Safari", () => {
    // Mock iOS Safari user agent
    vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
      "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
    );
    const { result } = renderHook(() => useInstallPrompt());
    expect(result.current.isIOS).toBe(true);
  });
});
  • Step 2: Run the tests to verify they fail
cd apps/main && pnpm vitest run src/hooks/useInstallPrompt.test.ts

Expected: FAIL — module ./useInstallPrompt not found.

  • Step 3: Implement the hook

Create apps/main/src/hooks/useInstallPrompt.ts:

import { useCallback, useEffect, useRef, useState } from "react";

const DISMISSED_KEY = "pwa-install-dismissed";

interface BeforeInstallPromptEvent extends Event {
  prompt(): Promise<{ outcome: "accepted" | "dismissed" }>;
}

export function useInstallPrompt() {
  const deferredPrompt = useRef<BeforeInstallPromptEvent | null>(null);
  const [canInstall, setCanInstall] = useState(false);
  const [isDismissed, setIsDismissed] = useState(
    () => localStorage.getItem(DISMISSED_KEY) === "true"
  );

  const isStandalone =
    typeof window !== "undefined" &&
    window.matchMedia("(display-mode: standalone)").matches;

  const isIOS =
    typeof navigator !== "undefined" &&
    /iPad|iPhone|iPod/.test(navigator.userAgent);

  useEffect(() => {
    const handler = (e: Event) => {
      e.preventDefault();
      deferredPrompt.current = e as BeforeInstallPromptEvent;
      setCanInstall(true);
    };
    window.addEventListener("beforeinstallprompt", handler);
    return () => window.removeEventListener("beforeinstallprompt", handler);
  }, []);

  const promptInstall = useCallback(async () => {
    if (!deferredPrompt.current) return;
    const result = await deferredPrompt.current.prompt();
    if (result.outcome === "accepted") {
      deferredPrompt.current = null;
      setCanInstall(false);
    }
  }, []);

  const dismiss = useCallback(() => {
    setIsDismissed(true);
    localStorage.setItem(DISMISSED_KEY, "true");
  }, []);

  return { canInstall, isStandalone, isIOS, isDismissed, promptInstall, dismiss };
}
  • Step 4: Run the tests to verify they pass
cd apps/main && pnpm vitest run src/hooks/useInstallPrompt.test.ts

Expected: All 5 tests pass.

  • Step 5: Commit
git add apps/main/src/hooks/useInstallPrompt.ts apps/main/src/hooks/useInstallPrompt.test.ts
git commit -m "feat(pwa): add useInstallPrompt hook with tests"

Task 8: Create InstallBanner component

Files:

  • Create: apps/main/src/components/InstallBanner.tsx

  • Create: apps/main/src/components/InstallBanner.test.tsx

  • Step 1: Write the test file

Create apps/main/src/components/InstallBanner.test.tsx:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// Mock the hook so we can control its return values
const mockPromptInstall = vi.fn();
const mockDismiss = vi.fn();

vi.mock("../hooks/useInstallPrompt", () => ({
  useInstallPrompt: vi.fn(() => ({
    canInstall: false,
    isStandalone: false,
    isIOS: false,
    isDismissed: false,
    promptInstall: mockPromptInstall,
    dismiss: mockDismiss,
  })),
}));

import { useInstallPrompt } from "../hooks/useInstallPrompt";
import { InstallBanner } from "./InstallBanner";

const mockUseInstallPrompt = vi.mocked(useInstallPrompt);

describe("InstallBanner", () => {
  beforeEach(() => {
    mockPromptInstall.mockClear();
    mockDismiss.mockClear();
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  it("renders nothing when canInstall is false and not iOS", () => {
    mockUseInstallPrompt.mockReturnValue({
      canInstall: false,
      isStandalone: false,
      isIOS: false,
      isDismissed: false,
      promptInstall: mockPromptInstall,
      dismiss: mockDismiss,
    });
    const { container } = render(<InstallBanner />);
    expect(container.firstChild).toBeNull();
  });

  it("renders nothing when already in standalone mode", () => {
    mockUseInstallPrompt.mockReturnValue({
      canInstall: true,
      isStandalone: true,
      isIOS: false,
      isDismissed: false,
      promptInstall: mockPromptInstall,
      dismiss: mockDismiss,
    });
    const { container } = render(<InstallBanner />);
    expect(container.firstChild).toBeNull();
  });

  it("renders nothing when dismissed", () => {
    mockUseInstallPrompt.mockReturnValue({
      canInstall: true,
      isStandalone: false,
      isIOS: false,
      isDismissed: true,
      promptInstall: mockPromptInstall,
      dismiss: mockDismiss,
    });
    const { container } = render(<InstallBanner />);
    expect(container.firstChild).toBeNull();
  });

  it("renders install banner when canInstall is true", () => {
    mockUseInstallPrompt.mockReturnValue({
      canInstall: true,
      isStandalone: false,
      isIOS: false,
      isDismissed: false,
      promptInstall: mockPromptInstall,
      dismiss: mockDismiss,
    });
    render(<InstallBanner />);
    expect(screen.getByText(/install/i)).toBeInTheDocument();
  });

  it("calls promptInstall when install button is clicked", async () => {
    mockUseInstallPrompt.mockReturnValue({
      canInstall: true,
      isStandalone: false,
      isIOS: false,
      isDismissed: false,
      promptInstall: mockPromptInstall,
      dismiss: mockDismiss,
    });
    render(<InstallBanner />);
    await userEvent.click(screen.getByRole("button", { name: /install/i }));
    expect(mockPromptInstall).toHaveBeenCalledOnce();
  });

  it("calls dismiss when close button is clicked", async () => {
    mockUseInstallPrompt.mockReturnValue({
      canInstall: true,
      isStandalone: false,
      isIOS: false,
      isDismissed: false,
      promptInstall: mockPromptInstall,
      dismiss: mockDismiss,
    });
    render(<InstallBanner />);
    await userEvent.click(screen.getByRole("button", { name: /dismiss|close/i }));
    expect(mockDismiss).toHaveBeenCalledOnce();
  });

  it("renders iOS instructions when isIOS is true and not dismissed", () => {
    mockUseInstallPrompt.mockReturnValue({
      canInstall: false,
      isStandalone: false,
      isIOS: true,
      isDismissed: false,
      promptInstall: mockPromptInstall,
      dismiss: mockDismiss,
    });
    render(<InstallBanner />);
    expect(screen.getByText(/share/i)).toBeInTheDocument();
    expect(screen.getByText(/add to home screen/i)).toBeInTheDocument();
  });
});
  • Step 2: Run the tests to verify they fail
cd apps/main && pnpm vitest run src/components/InstallBanner.test.tsx

Expected: FAIL — module ./InstallBanner not found.

  • Step 3: Implement the component

Create apps/main/src/components/InstallBanner.tsx:

import { X, Download, Share } from "lucide-react";
import { useInstallPrompt } from "../hooks/useInstallPrompt";

export function InstallBanner() {
  const { canInstall, isStandalone, isIOS, isDismissed, promptInstall, dismiss } =
    useInstallPrompt();

  // Don't show if already installed, dismissed, or no install option available
  if (isStandalone || isDismissed) return null;
  if (!canInstall && !isIOS) return null;

  return (
    <div className="flex items-center gap-3 border-b border-border bg-card px-4 py-2.5 text-sm">
      {isIOS ? (
        <>
          <Share className="size-4 shrink-0 text-muted-foreground" />
          <p className="flex-1 text-foreground">
            Install XTablo: tap{" "}
            <span className="font-medium">Share</span> then{" "}
            <span className="font-medium">Add to Home Screen</span>
          </p>
        </>
      ) : (
        <>
          <Download className="size-4 shrink-0 text-muted-foreground" />
          <p className="flex-1 text-foreground">
            Install XTablo for a faster, native experience
          </p>
          <button
            type="button"
            onClick={promptInstall}
            className="shrink-0 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90"
            aria-label="Install app"
          >
            Install
          </button>
        </>
      )}
      <button
        type="button"
        onClick={dismiss}
        className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
        aria-label="Dismiss"
      >
        <X className="size-4" />
      </button>
    </div>
  );
}

The banner renders as a slim top bar using existing design tokens (bg-card, border-border, text-foreground, etc.) so it matches the app's theme automatically.

  • Step 4: Run the tests to verify they pass
cd apps/main && pnpm vitest run src/components/InstallBanner.test.tsx

Expected: All 7 tests pass.

  • Step 5: Commit
git add apps/main/src/components/InstallBanner.tsx apps/main/src/components/InstallBanner.test.tsx
git commit -m "feat(pwa): add InstallBanner component with tests"

Task 9: Wire InstallBanner into the app shell

Files:

  • Modify: apps/main/src/App.tsx

  • Step 1: Add InstallBanner to the App component

In apps/main/src/App.tsx, import the component:

import { InstallBanner } from "./components/InstallBanner";

Then add <InstallBanner /> as the first child inside the main <div className="min-h-screen bg-background">, before <Routes />:

<div className="min-h-screen bg-background">
  <InstallBanner />
  <Routes />
  {showBanner && (
    <CookieBanner
      onAcceptAll={acceptAll}
      onRejectAll={rejectAll}
      onSavePreferences={saveConsent}
    />
  )}
</div>

The banner appears at the very top of the page, above all content including the sidebar and top bar. It self-hides based on the hook state (dismissed, standalone, no install available).

  • Step 2: Build and typecheck
cd apps/main && pnpm typecheck && pnpm build

Expected: Both pass with no errors.

  • Step 3: Commit
git add apps/main/src/App.tsx
git commit -m "feat(pwa): wire InstallBanner into app shell"

Task 10: Verify full PWA build end-to-end

Files: None (verification only)

  • Step 1: Clean build
cd apps/main && pnpm clean && pnpm build

Expected: Build succeeds.

  • Step 2: Verify all PWA assets in dist/
ls apps/main/dist/manifest.webmanifest apps/main/dist/sw.js apps/main/dist/pwa-icons/

Expected: manifest.webmanifest and sw.js exist. pwa-icons/ folder contains all 6 generated icons.

  • Step 3: Verify manifest content
cat apps/main/dist/manifest.webmanifest

Expected: JSON with name: "XTablo", display: "standalone", theme_color: "#1e1b2e", 3 icons (192, 512, 512 maskable).

  • Step 4: Run all tests
cd apps/main && pnpm test

Expected: All tests pass, including the new useInstallPrompt and InstallBanner tests.

  • Step 5: Run linter
cd apps/main && pnpm lint

Expected: No new linting errors. Fix any issues before proceeding.

  • Step 6: Preview locally (manual verification)
cd apps/main && pnpm preview

Open http://localhost:4173 in Chrome. Open DevTools → Application tab:

  • Manifest section should show "XTablo" with correct icons
  • Service Workers section should show an active SW
  • Lighthouse PWA audit should pass core checks (installable, has manifest, has SW)

This step is manual — verify visually, then stop the preview server.

  • Step 7: Final commit if any lint fixes were needed
git add -A
git commit -m "fix(pwa): lint fixes"

Only run this if Step 5 required fixes. Skip if lint was clean.