cm_bot_v2/docs/superpowers/specs/2026-05-02-b2-b3-ui-port-pwa-design.md

18 KiB

B2+B3: Next.js UI Port + PWA Design

Date: 2026-05-02 Status: Approved (design) Sequel to: 2026-05-02-b1-nextjs-scaffold-design.md Followed by: B4 (cutover — delete app/cm_web_view.py, retire cm-web Flask service, rename cm-web-nextcm-web).

Problem

B1 stood up the Next.js scaffold with no UI and no public /api/*. We now need to port the Flask cm_web_view.py UI to Next.js and ship a Progressive Web App so operators can install it on their phones.

The legacy Flask UI:

  • Two tabs: Accounts (4 columns: username, password, status, link) and Users (5 columns: f_username, f_password, t_username, t_password, last_update_time).
  • Inline cell editing — click → input → save POST → refresh row.
  • Sortable username column with prefix-priority logic (configured prefix on top, then descending).
  • Auto-refresh every 30s.
  • Stats cards (count of each table).
  • Refresh button.

The new Next.js implementation must preserve every operator-facing capability without exposing the four /api/* endpoints publicly (per the B1 architecture: RSC for reads, Server Actions for writes).

Goal

Bundle B2 (UI port) and B3 (PWA) into one cycle that ships a fully functional cm-web-next capable of replacing cm-web (Flask) entirely. The legacy service remains running in parallel until B4 cuts over.

Non-Goals

  • B4 cutoverapp/cm_web_view.py and the cm-web service stay untouched.
  • Service worker / offline mode — manifest + installability is the PWA scope. An internal CRUD tool that needs the api-server to function offers no value offline; adding a service worker for a "feels native" sake is YAGNI for now.
  • Authentication — aaPanel C3 (basic auth) handles auth at the proxy. No app-level login flow.
  • New features beyond Flask parity — no bulk edit, no search, no filtering. Same surface as today, just on a better stack.
  • Tests — vitest setup is captured as an out-of-scope follow-up. The UI is exercised end-to-end via the dev stack; logic-heavy parts (Server Actions) are simple wrappers around fetch.

Architecture

Routing

URL-based tabs via App Router parallel routes:

URL Server Component Renders
/ app/page.tsx Accounts table
/users/ app/users/page.tsx Users table

Each page is a Server Component that fetches data from api-server:3000 over the docker network (no client-side fetch, no public JSON). The shared layout (app/layout.tsx) renders nav + the active page.

URL-based tabs (vs single-page state-based tabs) means:

  • Refresh works (you stay on the right tab).
  • Shareable URLs.
  • No 'use client' wrapper needed for the page itself — the page is a Server Component.
  • Loading and error states get clean Suspense/error boundaries via loading.tsx and error.tsx.

Data flow

Browser ─GET─▶ web-next (Next.js) ─server-side fetch─▶ api-server:3000/acc/
   ▲                                                        │
   │  rendered HTML / RSC payload                           │ JSON
   └────────────────────────────────────────────────────────┘

Mutations:

Browser ─POST (Server Action)─▶ web-next ─fetch─▶ api-server:3000/update-acc-data
                                       │
                              revalidatePath('/')
                                       │
                                  (re-renders with fresh data)

The useOptimistic hook (React 19) gives instant cell update on save — server action runs in the background; UI rolls back on failure.

File structure

web/
├── app/
│   ├── layout.tsx                    ← rewrite (frontend-design): nav, theme-color metadata, manifest link
│   ├── globals.css                   ← unchanged from B1
│   ├── page.tsx                      ← rewrite (frontend-design): accounts dashboard
│   ├── users/
│   │   └── page.tsx                  ← new (frontend-design): users dashboard
│   ├── actions.ts                    ← new (hand-written): updateAcc, updateUser Server Actions
│   ├── error.tsx                     ← new (frontend-design): top-level error boundary UI
│   ├── icon.tsx                      ← new (frontend-design): Next.js auto-generates favicon PNG
│   ├── apple-icon.tsx                ← new (frontend-design): Next.js auto-generates apple-touch-icon
│   └── manifest.ts                   ← new (hand-written): PWA manifest config
├── components/
│   ├── accounts-table.tsx            ← new (frontend-design): client component, inline editing
│   ├── users-table.tsx               ← new (frontend-design): client component, inline editing
│   ├── editable-cell.tsx             ← new (frontend-design): generic inline-edit primitive
│   ├── nav.tsx                       ← new (frontend-design): top nav with two tabs
│   └── auto-refresh.tsx              ← new (frontend-design): client component that triggers revalidation every 30s
├── lib/
│   ├── api.ts                        ← new (hand-written): server-side fetch helpers
│   └── types.ts                      ← new (hand-written): TypeScript types matching api-server JSON
└── public/                           ← unchanged from B1 (.gitkeep only)

web/lib/api-paths.ts and web/app/api/ are not introduced — the architecture pivot in B1 stands.

frontend-design brief structure

frontend-design generates all TSX in this cycle. The brief that gets handed to it covers (in one invocation, returning many files):

  • A coherent visual identity replacing the brutalist construction-tape scaffold. Whatever frontend-design picks must read as a real production dashboard, not a placeholder. The scaffold's job was "obviously temporary"; B2's job is "obviously the real UI" — clear visual distinction so anyone who sees both knows which is which.
  • Two-page layout (Accounts and Users) with a shared nav.
  • Inline cell editing UX: hover affordance → click → text input → save (button or Enter) / cancel (button or Escape) → optimistic update.
  • Sortable column on username with prefix-priority logic baked in. The prefix is read from process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN (or hardcoded 13c if absent) — passed through lib/api.ts to the table component as a prop.
  • Status badges for acc.status: empty/wait/done = three visually distinct states.
  • Refresh button (forces revalidation; equivalent to router.refresh()).
  • Error boundary visual: catches the api-server-unreachable case, shows a clear "API unavailable" message with retry.
  • Manifest icon design (icon.tsx, apple-icon.tsx) — uses Next.js's ImageResponse API to render an SVG-style icon to PNG. Should match the dashboard's visual identity, not the scaffold's hazard tape.
  • Mobile-first responsive: tables collapse to card stacks below 640px breakpoint.

Server Actions (app/actions.ts)

Hand-written:

"use server";

import { revalidatePath } from "next/cache";
import { fetchApi } from "@/lib/api";

export async function updateAccount(data: {
  username: string;
  password: string;
  status: string;
  link: string;
}): Promise<{ ok: boolean; error?: string }> {
  try {
    await fetchApi("/update-acc-data", { method: "POST", body: data });
    revalidatePath("/");
    return { ok: true };
  } catch (err) {
    return { ok: false, error: String(err) };
  }
}

export async function updateUser(data: {
  f_username: string;
  f_password: string;
  t_username: string;
  t_password: string;
}): Promise<{ ok: boolean; error?: string }> {
  try {
    await fetchApi("/update-user-data", { method: "POST", body: data });
    revalidatePath("/users");
    return { ok: true };
  } catch (err) {
    return { ok: false, error: String(err) };
  }
}

Each action returns a discriminated { ok, error? } so the client component can show a toast/error indicator without throwing.

lib/api.ts (server-side fetch helper)

const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000";

export async function fetchApi(
  path: string,
  options: { method?: "GET" | "POST"; body?: unknown; cache?: RequestCache } = {},
): Promise<unknown> {
  const url = `${API_BASE_URL}${path}`;
  const init: RequestInit = {
    method: options.method ?? "GET",
    cache: options.cache ?? "no-store",
    headers: options.body ? { "content-type": "application/json" } : undefined,
    body: options.body ? JSON.stringify(options.body) : undefined,
  };
  const res = await fetch(url, init);
  if (!res.ok) {
    throw new Error(`API ${options.method ?? "GET"} ${path} failed: ${res.status}`);
  }
  return res.json();
}

export async function getAccounts() {
  const data = await fetchApi("/acc/");
  return data as Acc[];
}

export async function getUsers() {
  const data = await fetchApi("/user/");
  return data as User[];
}

cache: "no-store" ensures every request hits api-server fresh — RSC caching would stale the dashboard. Combined with revalidatePath on mutations, this gives correct re-render behavior.

lib/types.ts

export type Acc = {
  username: string;
  password: string;
  status: string;
  link: string;
};

export type User = {
  f_username: string;
  f_password: string;
  t_username: string;
  t_password: string;
  last_update_time: string | null;
};

Mirrors the SQL schema from the dev seed and the api-server's column projection.

PWA — app/manifest.ts

Next.js 15 supports app/manifest.ts exporting a function that returns the manifest JSON. Next renders it at /manifest.webmanifest automatically.

import type { MetadataRoute } from "next";

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "CM Bot V2",
    short_name: "CM Bot",
    description: "CM Bot account and user dashboard",
    start_url: "/",
    display: "standalone",
    orientation: "portrait",
    background_color: "#ffffff",
    theme_color: "#000000",
    icons: [
      { src: "/icon", sizes: "any", type: "image/png" },
      { src: "/apple-icon", sizes: "180x180", type: "image/png" },
    ],
  };
}

The theme_color value is finalized by frontend-design (matching the chosen aesthetic) — #000000 here is a placeholder the implementation will overwrite once the design lands.

Icons via app/icon.tsx and app/apple-icon.tsx

Next.js 15 generates /icon and /apple-icon as PNGs at build time from any TSX component returning JSX (rendered via next/og's ImageResponse). frontend-design designs the icon JSX directly — no external SVG-to-PNG tooling, no checked-in PNG binaries, no manual asset pipeline.

Example shape (frontend-design provides the actual JSX):

import { ImageResponse } from "next/og";

export const size = { width: 512, height: 512 };
export const contentType = "image/png";

export default function Icon() {
  return new ImageResponse(
    (/* JSX returning a styled <div> with brand mark */),
    size,
  );
}

apple-icon.tsx follows the same pattern with size = { width: 180, height: 180 }.

Auto-refresh

The legacy Flask UI auto-refreshes every 30s via setInterval. We preserve that behavior with a tiny client component:

"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";

export default function AutoRefresh({ intervalMs = 30000 }: { intervalMs?: number }) {
  const router = useRouter();
  useEffect(() => {
    const id = setInterval(() => router.refresh(), intervalMs);
    return () => clearInterval(id);
  }, [router, intervalMs]);
  return null;
}

router.refresh() re-runs the Server Component fetch and patches the result in — no full page reload, no flicker. Mounted in the layout so it covers both pages.

Error boundary (app/error.tsx)

"use client";
export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    /* frontend-design owns visuals: clear "API unavailable" message,
       prominent retry button, mention of contacting the operator */
  );
}

Catches every render-time and Server-Action-time error in the route subtree. The most likely failure mode is api-server unreachable; the error boundary turns a stack trace into a usable retry screen.

Compose / Dockerfile / scripts — no changes

docker-compose.yml, docker-compose.override.yml, docker/web-next/Dockerfile, scripts/dev.sh, scripts/publish.sh are untouched. The build step picks up the new files automatically because COPY web/ ./ in the Dockerfile copies everything under web/.

The npm install layer (no lockfile yet) re-resolves, but package.json doesn't change in this cycle — no new deps. (Next.js 15's ImageResponse for icons is in next/og, already shipped with Next.js.)

Files Created / Modified

File Operation Owner
web/app/layout.tsx Rewrite frontend-design
web/app/page.tsx Rewrite frontend-design
web/app/users/page.tsx Create frontend-design
web/app/error.tsx Create frontend-design
web/app/icon.tsx Create frontend-design
web/app/apple-icon.tsx Create frontend-design
web/app/actions.ts Create hand-written
web/app/manifest.ts Create hand-written
web/components/accounts-table.tsx Create frontend-design
web/components/users-table.tsx Create frontend-design
web/components/editable-cell.tsx Create frontend-design
web/components/nav.tsx Create frontend-design
web/components/auto-refresh.tsx Create hand-written
web/lib/api.ts Create hand-written
web/lib/types.ts Create hand-written

No file deletions. No changes outside web/.

Verification

  1. Build succeeds. bash scripts/dev.sh up brings up the stack; dev-cm-web-next starts without webpack errors.
  2. Accounts table renders. curl -sf http://localhost:8010/ | grep -E "13c1000|13c1011" returns hits — the seed accounts from the dev MySQL are visible. (Dev DB has 4 available + 2 'done' accounts seeded by docker/mysql/init.d/02-seed.sql.)
  3. Users table renders. curl -sf http://localhost:8010/users/ | grep -E "player_one_seed|player_two_seed" returns hits — the seeded user pairings show up.
  4. Inline edit (acc). Open http://localhost:8010/ in a browser. Click an editable cell on row 13c1010. Change a value. Save. The cell updates instantly (optimistic), then the page revalidates. Verify in mysql: mysql -h 127.0.0.1 -u cm -pdevpassword cm -e "SELECT * FROM acc WHERE username='13c1010'" shows the new value.
  5. Inline edit (user). Same flow on /users/.
  6. Sort. Click the username header (or whatever the sortable affordance is). Order flips between ascending and descending; prefix-priority is preserved (rows starting with 13c stay on top).
  7. Refresh button. Click it; the data round-trips api-server. Look for the network HTML/RSC re-fetch in browser devtools.
  8. Auto-refresh. Wait 30 seconds with the page open; observe the network tab fire a fetch automatically.
  9. Error boundary. With dev stack up, sudo docker stop dev-cm-api-server, then refresh http://localhost:8010/. The error boundary renders. sudo docker start dev-cm-api-server, click retry → page recovers.
  10. PWA install. On Chrome (desktop or Android), the address bar shows an "Install" affordance. Install. The app opens chromeless. The icon shows the frontend-design icon, not a generic letter glyph.
  11. iOS Add-to-Home. On Safari iOS, "Add to Home Screen" — the home-screen icon shows the apple-touch-icon (180x180), title is "CM Bot".
  12. Legacy Flask still works. curl -sf http://localhost:8000/ returns the Flask HTML page unchanged.
  13. No public API. curl -i http://localhost:8010/api/anything/ returns 404 (or 308 → 404, per B1).

Risk

Medium.

  • Server Action call sites are sprinkled across client components. A mismatch between the action's argument shape and what the client passes silently fails until you trigger the action. Mitigated by importing the action's TypeScript signature into the client component (compile-time check).
  • useOptimistic rollback on error. If the Server Action returns { ok: false }, the client must reverse the optimistic update. Easy to forget. The editable-cell.tsx primitive owns this so each table doesn't have to.
  • revalidatePath cache semantics. Mutating /api/acc revalidates /; mutating /api/user revalidates /users. If a future feature crosses that boundary (e.g., editing an acc that's also referenced by a user row), we'd need to revalidate both. Out of scope for now.
  • iOS "Add to Home Screen" doesn't honor manifest.webmanifest. iOS uses apple-icon.tsx and the <title> from app/layout.tsx; the manifest is mostly a hint. The two icon files (icon.tsx, apple-icon.tsx) cover both Android (manifest-driven) and iOS.
  • First Docker build still slow. No lockfile means npm install re-resolves every cache miss. Acceptable for B2; revisit if iteration speed becomes painful.
  • Auto-refresh + inline edit collision. If a user is mid-edit when the 30s auto-refresh fires, router.refresh() re-renders the table. The editable cell needs to detect "currently editing" and skip the rerender for that cell — handled in editable-cell.tsx by anchoring edit state outside the cell's key and only refreshing when no cell is in editing mode (or by debouncing the refresh during edits).

Out-of-Scope Follow-Ups

  • B4 cutover — separate cycle: delete app/cm_web_view.py, retire cm-web service, rename cm-web-nextcm-web (image, container name, compose service name).
  • Vitest setup + tests for lib/api.ts and the Server Actions. The current verification is end-to-end; component-level tests are valuable but not yet structured.
  • Service worker for offline fallback. Internal CRUD doesn't need it. If we ever want operators to "see the last-known data" while offline, a Workbox-generated SW is a small lift.
  • Bulk edit / search / filter in the tables — not in current Flask UI either.
  • i18n / dark mode / theme toggle — punt until someone asks.
  • Real-time updates via SSE or WebSocket — current 30s polling is fine for the operator workflow; rebuilding to push-based is its own design problem.