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

25 KiB

B2+B3: UI Port + 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: Port the legacy Flask cm_web_view.py UI to Next.js (RSC reads + Server Actions for inline edits, two URL-based tabs) and ship the PWA manifest + icons so operators can install on their phones.

Architecture: Hand-write the glue (types, fetch helpers, Server Actions, manifest, page wrappers, auto-refresh) and invoke frontend-design three times for the UI code (data tables, frame, icons). No Docker/compose changes — COPY web/ ./ already bundles the new files.

Tech Stack: Next.js 15 (App Router) + React 19 (Server Components, Server Actions, useOptimistic) + Tailwind v4. next/og's ImageResponse renders the PWA icons at build time — no external SVG-to-PNG tooling.

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


File Map

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

Three frontend-design invocations, scoped:

  1. Data primitiveseditable-cell.tsx, accounts-table.tsx, users-table.tsx (Task 5).
  2. Framelayout.tsx, nav.tsx, error.tsx (Task 7).
  3. Iconsicon.tsx, apple-icon.tsx (Task 9).

Each invocation gets a focused brief so the output stays coherent.


Task 1: Hand-write types

Files:

  • Create: web/lib/types.ts

  • Step 1: Create the directory

mkdir -p /home/yiekheng/projects/cm_bot_v2/web/lib
  • Step 2: Write web/lib/types.ts
// Mirrors the SQL schema in docker/mysql/init.d/01-schema.sql and the
// JSON projection from app/cm_api.py's get_account / get_user routes.

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;
};

export type AccUpdate = Acc;

export type UserUpdate = Pick<
  User,
  "f_username" | "f_password" | "t_username" | "t_password"
>;
  • Step 3: Commit
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/lib/types.ts && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
  commit -m "feat(web): add TypeScript types for Acc and User"

Task 2: Hand-write the api-server fetch helper

Files:

  • Create: web/lib/api.ts

  • Step 1: Write web/lib/api.ts

import type { Acc, User } from "./types";

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

type FetchInit = {
  method?: "GET" | "POST";
  body?: unknown;
  cache?: RequestCache;
};

export async function fetchApi(path: string, options: FetchInit = {}): 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(): Promise<Acc[]> {
  const data = await fetchApi("/acc/");
  return data as Acc[];
}

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

cache: "no-store" is critical — RSC's default caching would stale the dashboard between page loads.

  • Step 2: Commit
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/lib/api.ts && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
  commit -m "feat(web): add server-side api-server fetch helper"

Task 3: Hand-write Server Actions

Files:

  • Create: web/app/actions.ts

  • Step 1: Write web/app/actions.ts

"use server";

import { revalidatePath } from "next/cache";
import { fetchApi } from "@/lib/api";
import type { AccUpdate, UserUpdate } from "@/lib/types";

export type ActionResult = { ok: true } | { ok: false; error: string };

export async function updateAccount(data: AccUpdate): Promise<ActionResult> {
  try {
    await fetchApi("/update-acc-data", { method: "POST", body: data });
    revalidatePath("/");
    return { ok: true };
  } catch (err) {
    return { ok: false, error: err instanceof Error ? err.message : String(err) };
  }
}

export async function updateUser(data: UserUpdate): Promise<ActionResult> {
  try {
    await fetchApi("/update-user-data", { method: "POST", body: data });
    revalidatePath("/users");
    return { ok: true };
  } catch (err) {
    return { ok: false, error: err instanceof Error ? err.message : String(err) };
  }
}

The "use server" directive at the top makes these functions invocable from client components via Next.js's Server Actions wire format. revalidatePath re-runs the matching page's RSC fetch and patches the result in.

  • Step 2: Commit
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/app/actions.ts && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
  commit -m "feat(web): add updateAccount and updateUser Server Actions"

Task 4: Hand-write the auto-refresh client component

Files:

  • Create: web/components/auto-refresh.tsx

  • Step 1: Create the directory and file

mkdir -p /home/yiekheng/projects/cm_bot_v2/web/components

Create web/components/auto-refresh.tsx:

"use client";

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

/**
 * Mounts a setInterval that calls router.refresh() every `intervalMs`.
 * router.refresh() re-runs the matching Server Component fetch and
 * patches the rendered output in — no full page reload, no flicker.
 *
 * Renders nothing.
 */
export default function AutoRefresh({
  intervalMs = 30_000,
}: {
  intervalMs?: number;
}) {
  const router = useRouter();
  useEffect(() => {
    const id = setInterval(() => router.refresh(), intervalMs);
    return () => clearInterval(id);
  }, [router, intervalMs]);
  return null;
}
  • Step 2: Commit
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/components/auto-refresh.tsx && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
  commit -m "feat(web): add 30s auto-refresh client component"

Task 5: Invoke frontend-design for the data primitives

Files:

  • Create: web/components/editable-cell.tsx

  • Create: web/components/accounts-table.tsx

  • Create: web/components/users-table.tsx

  • Step 1: Invoke the frontend-design skill

Use the Skill tool with skill="frontend-design:frontend-design" and the following brief:

Generate three React Client Components for a Next.js 15 + Tailwind v4
internal CRUD dashboard. The dashboard lives at `web/components/` and
ports a legacy Flask UI (gradient purple, FontAwesome, inline cell
editing) to a polished modern look that reads as a real production
dashboard. The previous scaffold used a brutalist hazard-tape aesthetic;
THIS UI is the production replacement and should look distinct from the
scaffold while staying in the same design family if possible (or pivot
fully to a new direction if that serves the data better).

Constraints — every file:
- "use client" at the top.
- Tailwind v4 utility classes only — no external font, image, or icon deps.
- TypeScript with strict types from `@/lib/types`.
- Mobile-first responsive: tables collapse to card stacks below 640px.
- Server Actions imported from `@/app/actions` for mutations.
- React 19 useOptimistic for inline-edit feedback (instant local update,
  reverts on Server Action failure).
- No external state libraries — useState / useOptimistic / useTransition
  only.

File 1: `web/components/editable-cell.tsx`

Generic primitive: a span/td that turns into a text input on click,
saves on Enter / Save button, cancels on Escape / Cancel button.
Props:

  type EditableCellProps = {
    value: string;
    onSave: (next: string) => Promise<{ ok: boolean; error?: string }>;
    label?: string; // accessibility label, e.g. "edit password"
    isCurrentlyEditing?: boolean;
    onEditStart?: () => void;
    onEditEnd?: () => void;
  };

Behavior:
- Click → input, focus + select all.
- Enter or Save button → calls onSave, optimistically updates display
  immediately, reverts on { ok: false } and shows the error inline for
  a few seconds.
- Escape or Cancel button → restores original value.
- Hover affordance (subtle visual cue that the cell is editable).
- isCurrentlyEditing/onEditStart/onEditEnd let a parent table track
  which cell is in edit mode (so auto-refresh can pause).

File 2: `web/components/accounts-table.tsx`

Renders the Accounts dashboard tab content. Receives initial data from
the server component:

  type Props = { initial: Acc[]; prefixPattern: string };

Renders:
- A stats header (count of total accounts).
- A refresh button (calls router.refresh() from next/navigation).
- A sortable table:
  - Username column: sortable. Sort logic: rows whose username starts
    with `prefixPattern` always sort to the top; within each group,
    sort descending by username string.
  - Password, Status, Link: editable via EditableCell, calling the
    `updateAccount` Server Action with the full Acc object on save.
  - Status column shows a colored badge for empty/wait/done states
    (three visually distinct variants).
- Below 640px: each row collapses to a card stack with labeled rows.
- Empty state when initial.length === 0: a clear "no accounts" message.

File 3: `web/components/users-table.tsx`

Renders the Users dashboard tab content. Receives:

  type Props = { initial: User[]; prefixPattern: string };

Same shape as accounts-table but for the User type:
- Sortable username column with the prefix-priority logic on
  user.f_username.
- Then sorted by last_update_time descending (newest first) within
  each group.
- f_password, t_username, t_password are editable cells that call
  updateUser Server Action.
- last_update_time is read-only.
- f_username is read-only (it's the primary key).

All three files: return TypeScript source ready to drop into the
listed paths. Imports use `@/lib/types`, `@/app/actions`, and
`next/navigation` as appropriate.
  • Step 2: Save the returned files

The skill returns three TSX files. Save them to:

  • web/components/editable-cell.tsx

  • web/components/accounts-table.tsx

  • web/components/users-table.tsx

  • Step 3: Commit

cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/components/editable-cell.tsx web/components/accounts-table.tsx web/components/users-table.tsx && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
  commit -m "feat(web): add data tables and editable-cell primitive (frontend-design)"

Task 6: Hand-write page wrappers (RSC entry points)

Files:

  • Rewrite: web/app/page.tsx

  • Create: web/app/users/page.tsx

  • Step 1: Replace web/app/page.tsx

The current file is the B1 brutalist scaffold. Replace it entirely with the accounts entry-point wrapper:

import { getAccounts } from "@/lib/api";
import AccountsTable from "@/components/accounts-table";

const PREFIX_PATTERN = process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN ?? "13c";

export default async function AccountsPage() {
  const accounts = await getAccounts();
  return <AccountsTable initial={accounts} prefixPattern={PREFIX_PATTERN} />;
}

PREFIX_PATTERN reads from NEXT_PUBLIC_CM_PREFIX_PATTERN (which gets baked into the client bundle since NEXT_PUBLIC_ prefixed env vars are exposed to the browser). The default 13c matches envs/dev/.env.example's CM_PREFIX_PATTERN.

  • Step 2: Create web/app/users/page.tsx
mkdir -p /home/yiekheng/projects/cm_bot_v2/web/app/users

Then:

import { getUsers } from "@/lib/api";
import UsersTable from "@/components/users-table";

const PREFIX_PATTERN = process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN ?? "13c";

export default async function UsersPage() {
  const users = await getUsers();
  return <UsersTable initial={users} prefixPattern={PREFIX_PATTERN} />;
}
  • Step 3: Commit
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/app/page.tsx web/app/users/page.tsx && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
  commit -m "feat(web): add Server Component entry pages for / and /users"

Task 7: Invoke frontend-design for the frame (layout, nav, error)

Files:

  • Rewrite: web/app/layout.tsx

  • Create: web/components/nav.tsx

  • Create: web/app/error.tsx

  • Step 1: Invoke frontend-design

Use the Skill tool with skill="frontend-design:frontend-design" and:

Generate three TSX files for a Next.js 15 + Tailwind v4 internal
dashboard. The dashboard has two URL-based tabs (`/` for Accounts,
`/users` for Users). The visual identity should match the data-table
components already designed (accounts-table.tsx, users-table.tsx) —
they use a polished production-dashboard aesthetic, distinct from the
brutalist hazard-tape scaffold that preceded them.

File 1: `web/app/layout.tsx` — root layout (Server Component).

Required:
- Imports `./globals.css`.
- `export const metadata` includes `title: "CM Bot V2"` and a
  `themeColor` matching the chosen aesthetic (the value also goes into
  `web/app/manifest.ts` separately — pick a single hex and document it
  so I can mirror it).
- Renders <html lang="en"> > <body> > <Nav /> > <main>{children}</main>
  + a single <AutoRefresh /> imported from "@/components/auto-refresh".
- Standard root layout structure for App Router; no per-page metadata
  here.

File 2: `web/components/nav.tsx` — top nav (Client Component, "use client",
because it uses usePathname() to highlight the active tab).

Required:
- Two links: "Accounts" → "/", "Users" → "/users".
- Uses next/link for client-side navigation.
- Active tab gets a clear visual highlight (matching the dashboard
  aesthetic).
- Mobile-friendly: two equal-width links on narrow screens.
- A small product mark on the left ("CM Bot V2" or similar) — purely
  decorative, no navigation action.

File 3: `web/app/error.tsx` — top-level error boundary (Client Component,
"use client"; Next.js requires this).

Required:
- Receives { error, reset } props per Next.js's error boundary contract.
- Visual: clear message ("Couldn't reach the API"), explanation that
  api-server may be unreachable, a prominent "Retry" button that calls
  reset(). Optional: small monospace block showing error.message and
  error.digest for the operator's eye.
- Matches the dashboard aesthetic. Keep tone: this is an operator tool
  — apologetic copy is wrong; informative is right.

All three files: TypeScript source ready to drop into the paths listed.
Use `@/components/auto-refresh` and `@/components/nav` as imports where
relevant.
  • Step 2: Save the returned files

Save to:

  • web/app/layout.tsx (overwrite the B1 scaffold version)
  • web/components/nav.tsx
  • web/app/error.tsx

Note the chosen themeColor hex — Task 8 hardcodes the same value into manifest.ts.

  • Step 3: Commit
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/app/layout.tsx web/components/nav.tsx web/app/error.tsx && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
  commit -m "feat(web): add layout, nav, and error boundary (frontend-design)"

Task 8: Hand-write the PWA manifest

Files:

  • Create: web/app/manifest.ts

  • Step 1: Write web/app/manifest.ts

Use the themeColor hex chosen by frontend-design in Task 7. Replace #000000 below with the actual value.

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" },
    ],
  };
}

Next.js auto-serves this at /manifest.webmanifest. The /icon and /apple-icon URLs are auto-generated from app/icon.tsx and app/apple-icon.tsx (created in Task 9).

  • Step 2: Commit
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/app/manifest.ts && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
  commit -m "feat(web): add PWA manifest config"

Task 9: Invoke frontend-design for the PWA icons

Files:

  • Create: web/app/icon.tsx

  • Create: web/app/apple-icon.tsx

  • Step 1: Invoke frontend-design

Use the Skill tool with skill="frontend-design:frontend-design" and:

Generate two TSX files that produce PWA icons via Next.js 15's
ImageResponse API (from "next/og"). The icons should match the visual
identity of the dashboard frame designed earlier (theme color hex was
<paste the hex chosen in Task 7>; layout is a polished production
dashboard, not the brutalist scaffold).

File 1: `web/app/icon.tsx` — Android home-screen / desktop favicon.

```typescript
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 here: a single <div> styled with inline `style={{...}}`
      // that contains a brand mark on the chosen theme color
      // background. ImageResponse uses Satori, which only supports
      // a tiny subset of CSS via inline `style` — no Tailwind, no
      // CSS classes, no <main>/<section>; just <div> + <span>.
    ),
    size,
  );
}

Brand mark requirements:

  • The literal text "CM" is the safest bet (the product is "CM Bot V2"; initials read as a logo at small sizes).
  • Single color, high contrast against the background.
  • Centered, generous padding.
  • No font dep — Satori falls back to a system font if none is supplied.

File 2: web/app/apple-icon.tsx — iOS home-screen icon.

Same shape as icon.tsx but with size = { width: 180, height: 180 }. iOS does NOT honor the manifest icon list reliably; this file is what shows up when a user does "Add to Home Screen" on Safari.

Constraints (both files):

  • Use ONLY inline style (Satori does not support Tailwind classes or external stylesheets — this is a hard constraint of ImageResponse).
  • No imports beyond next/og's ImageResponse.
  • Keep the JSX tree shallow (a wrapper div + the brand mark).

- [ ] **Step 2: Save the returned files**

Save to:
- `web/app/icon.tsx`
- `web/app/apple-icon.tsx`

- [ ] **Step 3: Commit**

```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/app/icon.tsx web/app/apple-icon.tsx && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
  commit -m "feat(web): add PWA icons via Next.js ImageResponse (frontend-design)"

Task 10: Build + smoke verification

Files: none modified.

This task is the integration verification. Requires docker compose v2 on the deploy host.

  • Step 1: Rebuild the cm-web-next image
cd /home/yiekheng/projects/cm_bot_v2 && \
sudo docker compose -f docker-compose.yml -f docker-compose.override.yml down --remove-orphans && \
sudo docker compose -f docker-compose.yml -f docker-compose.override.yml build --no-cache web-next 2>&1 | tail -30

Expected: a successful build, ending with a "FINISHED" line. If npm run build fails inside the container, the error message points to the offending TSX. Common causes:

  • frontend-design's TSX uses a dependency we didn't install. Add the dep to web/package.json, rebuild.

  • Type mismatch between lib/types.ts and what a component expects. Reconcile.

  • Step 2: Bring the dev stack up

bash scripts/dev.sh up

Wait ~15s for the healthcheck.

bash scripts/dev.sh status

Expected: OK.

  • Step 3: Smoke — / renders the accounts table
curl -sf http://localhost:8010/ | grep -E "13c1000|13c1011"

Expected: hits — the seed accounts from docker/mysql/init.d/02-seed.sql are visible in the rendered HTML.

  • Step 4: Smoke — /users/ renders the users table
curl -sf http://localhost:8010/users/ | grep -E "player_one_seed|player_two_seed"

Expected: hits — the seeded user pairings show up.

  • Step 5: Smoke — manifest is served
curl -sf http://localhost:8010/manifest.webmanifest | python3 -c "import json,sys; m=json.load(sys.stdin); print(m['name'], m['display'])"

Expected: CM Bot V2 standalone.

  • Step 6: Smoke — icons render to PNG
curl -sIf http://localhost:8010/icon | grep -i "content-type"
curl -sIf http://localhost:8010/apple-icon | grep -i "content-type"

Expected: both return Content-Type: image/png.

  • Step 7: Smoke — /api/anything is still 404
curl -sf -o /dev/null -w "code=%{http_code}\n" http://localhost:8010/api/anything/

Expected: code=404. Confirms the architecture pivot stuck — no public API surface.

  • Step 8: Manual browser check

Open http://localhost:8010/ in a browser:

  • Accounts table renders with the 4 available + 2 done seed rows.

  • Click an editable cell on row 13c1010. Change a value. Save. The cell updates instantly. After ~1 second, the page revalidates and the new value persists.

  • mysql -h 127.0.0.1 -u cm -pdevpassword cm -e "SELECT * FROM acc WHERE username='13c1010'" shows the new value.

  • Click "Users" in the nav. Page navigates to /users/, shows the two seeded user pairings.

  • Wait 30 seconds with the page open. Browser devtools network tab shows an automatic fetch for the RSC payload (the auto-refresh.tsx component firing).

  • sudo docker stop dev-cm-api-server. Refresh the page in the browser. The error boundary renders. Click Retry. Still errors. sudo docker start dev-cm-api-server. Retry. Page recovers.

  • Step 9: PWA install check (browser)

In Chrome on desktop or Android, the address bar shows an "Install" affordance. Click it; the app opens chromeless with the icon from app/icon.tsx. On Safari iOS, "Add to Home Screen" — the home-screen icon shows the apple-icon.

  • Step 10: Tear down
bash scripts/dev.sh down

Spec Coverage Check (self-review)

Spec requirement Task
URL-based tabs (/, /users/) Task 6
Server Components fetch from api-server Task 2 (helpers), Task 6 (page wrappers)
Server Actions for mutations + revalidatePath Task 3
useOptimistic on inline edits Task 5 (editable-cell)
Sortable username with prefix-priority Task 5 (accounts-table, users-table)
Status badge for empty/wait/done Task 5 (accounts-table)
Refresh button Task 5 (accounts-table, users-table)
Auto-refresh every 30s Task 4 (component), Task 7 (mounted in layout)
Mobile-first responsive (collapse below 640px) Task 5
Error boundary for api-server unreachable Task 7 (error.tsx)
Two-tab nav with active highlight Task 7 (nav.tsx)
app/manifest.ts PWA manifest Task 8
app/icon.tsx and app/apple-icon.tsx via ImageResponse Task 9
Theme color consistent between layout metadata and manifest Tasks 7 & 8 (frontend-design picks color in 7; Task 8 mirrors it)
Seeded data renders (acc + user) Task 10 steps 3-4
No public /api/* route Task 10 step 7
Manifest served at /manifest.webmanifest Task 10 step 5
Icons served as image/png Task 10 step 6
Inline edit round-trips through Server Action and persists in mysql Task 10 step 8
Error boundary works when api-server stopped Task 10 step 8
Auto-refresh observable in network tab Task 10 step 8
PWA installable in Chrome / Safari Task 10 step 9

No gaps. No placeholders. Function names (updateAccount, updateUser, getAccounts, getUsers, fetchApi, AutoRefresh, EditableCell) consistent across tasks. Type names (Acc, User, AccUpdate, UserUpdate, ActionResult) consistent across tasks.