# Deployment Page Polish — 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:** Deliver five targeted UX improvements on the unified app deployment page: JAR-upload progress inside the primary button, sort + refresh on startup logs, a collapsible checkpoints table (default collapsed), a DS-styled replica dropdown, and reversed drawer tab order (Config first).

**Architecture:** All changes are UI-only, confined to `ui/src/pages/AppsTab/AppDeploymentPage/*`, `ui/src/components/StartupLogPanel.*`, `ui/src/api/queries/admin/apps.ts` (`useUploadJar` switches to `XMLHttpRequest`), and `ui/src/api/queries/logs.ts` (`useStartupLogs` accepts a sort parameter). No backend or schema changes.

**Tech Stack:** React 18, TypeScript, Vitest + React Testing Library, `@cameleer/design-system` components (Button, Select, Tabs, SectionHeader), `lucide-react` (`RefreshCw`), TanStack Query.

**Spec:** `docs/superpowers/specs/2026-04-23-deployment-page-polish-design.md`

---

## Files touched (summary)

| Path | Change |
|------|--------|
| `ui/src/api/queries/admin/apps.ts` | `useUploadJar` rewrites the mutation function to use `XMLHttpRequest` and exposes an `onProgress` callback |
| `ui/src/api/queries/logs.ts` | `useStartupLogs` gains a 5th `sort` parameter |
| `ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx` | Adds `'uploading'` mode with a progress-overlay visual |
| `ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css` | Adds `.uploadBtnWrap`, `.uploadBtnFill`, `.checkpointsHeader` styles |
| `ui/src/pages/AppsTab/AppDeploymentPage/index.tsx` | Wires `uploadPct` state and feeds it into both upload call sites + the primary button |
| `ui/src/components/StartupLogPanel.tsx` | New header layout (sort ↑/↓ + refresh icon), local sort state, scroll-ref |
| `ui/src/components/StartupLogPanel.module.css` | Extends with `.headerRight`, `.scrollWrap`, `.ghostIconBtn` |
| `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx` | Collapsible header + `useState(false)` default |
| `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx` | Tests updated for collapsed-by-default behavior; stale empty-state assertion removed |
| `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx` | Native `<select>` → DS `Select` |
| `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx` | Tab order reversed; default tab `'config'` |
| `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx` | Updated test for default tab |

---

## Commands — run all from `ui/` unless stated otherwise

- **Test one file**: `npx vitest run <relative-path>`
- **Test all**: `npx vitest run`
- **Typecheck**: `npm run typecheck`

---

## Task 1: Extend `useUploadJar` to emit upload progress via XHR

**Why:** `fetch()` has no upload-progress event. Switching the mutation function to `XMLHttpRequest` surfaces `upload.onprogress` to the caller as a percentage.

**Files:**
- Modify: `ui/src/api/queries/admin/apps.ts:140-162`

- [ ] **Step 1.1: Replace the `useUploadJar` mutation implementation**

Open `ui/src/api/queries/admin/apps.ts`. Replace the existing `useUploadJar` export (lines 140-162) with the version below.

```ts
export function useUploadJar() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ envSlug, appSlug, file, onProgress }: {
      envSlug: string;
      appSlug: string;
      file: File;
      onProgress?: (pct: number) => void;
    }) => {
      const token = useAuthStore.getState().accessToken;
      const form = new FormData();
      form.append('file', file);

      return new Promise<AppVersion>((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open(
          'POST',
          `${config.apiBaseUrl}${envBase(envSlug)}/${encodeURIComponent(appSlug)}/versions`,
        );
        if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
        xhr.setRequestHeader('X-Cameleer-Protocol-Version', '1');

        xhr.upload.onprogress = (e) => {
          if (!onProgress || !e.lengthComputable) return;
          onProgress(Math.round((e.loaded / e.total) * 100));
        };

        xhr.onload = () => {
          if (xhr.status < 200 || xhr.status >= 300) {
            reject(new Error(`Upload failed: ${xhr.status}`));
            return;
          }
          try {
            resolve(JSON.parse(xhr.responseText) as AppVersion);
          } catch (err) {
            reject(err instanceof Error ? err : new Error('Invalid response'));
          }
        };

        xhr.onerror = () => reject(new Error('Upload network error'));
        xhr.onabort = () => reject(new Error('Upload aborted'));

        xhr.send(form);
      });
    },
    onSuccess: (_data, { envSlug, appSlug }) =>
      qc.invalidateQueries({ queryKey: ['apps', envSlug, appSlug, 'versions'] }),
  });
}
```

- [ ] **Step 1.2: Typecheck**

Run: `npm run typecheck`
Expected: PASS (no type errors; the mutation arg type changed but no call sites use the `onProgress` field yet — field is optional.)

- [ ] **Step 1.3: Commit**

```bash
git add ui/src/api/queries/admin/apps.ts
git commit -m "feat(ui): useUploadJar uses XHR and exposes onProgress"
```

---

## Task 2: Add `'uploading'` mode to `PrimaryActionButton`

**Why:** Button is the single locus for the action lifecycle. Adding `'uploading'` with a progress-fill overlay lets the user see upload progress without looking elsewhere.

**Files:**
- Modify: `ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx`
- Modify: `ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css`
- Create: `ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.test.tsx`

- [ ] **Step 2.1: Write failing component tests**

Create `ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.test.tsx`:

```tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '@cameleer/design-system';
import { PrimaryActionButton, computeMode } from './PrimaryActionButton';

function wrap(ui: React.ReactElement) {
  return render(<ThemeProvider>{ui}</ThemeProvider>);
}

describe('PrimaryActionButton', () => {
  it('renders Save in save mode', () => {
    wrap(<PrimaryActionButton mode="save" enabled onClick={() => {}} />);
    expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
  });

  it('renders Redeploy in redeploy mode', () => {
    wrap(<PrimaryActionButton mode="redeploy" enabled onClick={() => {}} />);
    expect(screen.getByRole('button', { name: /redeploy/i })).toBeInTheDocument();
  });

  it('renders Deploying… disabled in deploying mode', () => {
    wrap(<PrimaryActionButton mode="deploying" enabled={false} onClick={() => {}} />);
    const btn = screen.getByRole('button', { name: /deploying/i });
    expect(btn).toBeDisabled();
  });

  it('renders Uploading… NN% with progress overlay in uploading mode', () => {
    wrap(<PrimaryActionButton mode="uploading" enabled={false} progress={42} onClick={() => {}} />);
    const btn = screen.getByRole('button', { name: /uploading/i });
    expect(btn).toBeDisabled();
    expect(btn).toHaveTextContent('42%');
    const fill = btn.querySelector('[data-upload-fill]') as HTMLElement | null;
    expect(fill).not.toBeNull();
    expect(fill!.style.width).toBe('42%');
  });
});

describe('computeMode', () => {
  it('returns uploading when uploading flag set, even if deploymentInProgress is false', () => {
    expect(computeMode({ deploymentInProgress: false, uploading: true, hasLocalEdits: true, serverDirtyAgainstDeploy: false })).toBe('uploading');
  });

  it('returns deploying when deploymentInProgress even if uploading flag set', () => {
    expect(computeMode({ deploymentInProgress: true, uploading: true, hasLocalEdits: false, serverDirtyAgainstDeploy: false })).toBe('deploying');
  });

  it('returns save when local edits without upload or deploy', () => {
    expect(computeMode({ deploymentInProgress: false, uploading: false, hasLocalEdits: true, serverDirtyAgainstDeploy: false })).toBe('save');
  });

  it('returns redeploy when server dirty and no local edits', () => {
    expect(computeMode({ deploymentInProgress: false, uploading: false, hasLocalEdits: false, serverDirtyAgainstDeploy: true })).toBe('redeploy');
  });
});
```

- [ ] **Step 2.2: Run tests — expect failures**

Run: `npx vitest run src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.test.tsx`
Expected: FAILS with `'uploading'` mode not handled and `uploading` input missing from `computeMode`.

- [ ] **Step 2.3: Implement `PrimaryActionButton` updates**

Replace the full contents of `ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx`:

```tsx
import { Button } from '@cameleer/design-system';
import styles from './AppDeploymentPage.module.css';

export type PrimaryActionMode = 'save' | 'redeploy' | 'uploading' | 'deploying';

interface Props {
  mode: PrimaryActionMode;
  enabled: boolean;
  onClick: () => void;
  /** Upload percentage 0–100. Only meaningful when mode === 'uploading'. */
  progress?: number;
}

export function PrimaryActionButton({ mode, enabled, onClick, progress }: Props) {
  if (mode === 'uploading') {
    const pct = Math.max(0, Math.min(100, Math.round(progress ?? 0)));
    return (
      <span className={styles.uploadBtnWrap}>
        <Button size="sm" variant="primary" disabled>
          <span
            data-upload-fill
            className={styles.uploadBtnFill}
            style={{ width: `${pct}%` }}
          />
          <span className={styles.uploadBtnLabel}>Uploading… {pct}%</span>
        </Button>
      </span>
    );
  }
  if (mode === 'deploying') {
    return <Button size="sm" variant="primary" loading disabled>Deploying…</Button>;
  }
  if (mode === 'redeploy') {
    return <Button size="sm" variant="primary" disabled={!enabled} onClick={onClick}>Redeploy</Button>;
  }
  return <Button size="sm" variant="primary" disabled={!enabled} onClick={onClick}>Save</Button>;
}

export function computeMode({
  deploymentInProgress,
  uploading,
  hasLocalEdits,
  serverDirtyAgainstDeploy,
}: {
  deploymentInProgress: boolean;
  uploading: boolean;
  hasLocalEdits: boolean;
  serverDirtyAgainstDeploy: boolean;
}): PrimaryActionMode {
  if (deploymentInProgress) return 'deploying';
  if (uploading) return 'uploading';
  if (hasLocalEdits) return 'save';
  if (serverDirtyAgainstDeploy) return 'redeploy';
  return 'save';
}
```

- [ ] **Step 2.4: Add CSS for the upload fill + label stacking**

Open `ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css`. Add the following at the end of the file (before any existing trailing whitespace):

```css
/* Primary-button upload progress overlay
 * Wraps the DS Button so the inner Button can retain its own background
 * while we overlay a tinted progress fill and label on top. */
.uploadBtnWrap {
  display: inline-block;
}

.uploadBtnWrap button {
  position: relative;
  overflow: hidden;
}

.uploadBtnFill {
  position: absolute;
  inset: 0 auto 0 0;
  background: color-mix(in srgb, var(--primary) 35%, transparent);
  transition: width 120ms linear;
  pointer-events: none;
  z-index: 0;
}

.uploadBtnLabel {
  position: relative;
  z-index: 1;
}
```

If the file does not already end with a trailing newline, add one.

- [ ] **Step 2.5: Run tests — expect pass**

Run: `npx vitest run src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.test.tsx`
Expected: PASS (all 7 tests).

- [ ] **Step 2.6: Typecheck**

Run: `npm run typecheck`
Expected: PASS. (This will fail at the `AppDeploymentPage` callsite because `computeMode` now requires an `uploading` input — fixed in Task 3.)

If typecheck fails only with the missing `uploading` field in `index.tsx`, that is expected; proceed.

- [ ] **Step 2.7: Commit**

```bash
git add ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx \
        ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css \
        ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.test.tsx
git commit -m "feat(ui): PrimaryActionButton gains uploading mode + progress overlay"
```

---

## Task 3: Wire upload progress into `AppDeploymentPage`

**Why:** Parent holds the `uploadPct` state, passes `onProgress` into both upload call sites (Save and Redeploy), and forwards the pct into `PrimaryActionButton`.

**Files:**
- Modify: `ui/src/pages/AppsTab/AppDeploymentPage/index.tsx`

- [ ] **Step 3.1: Add `uploadPct` state**

Find the local UI state block near `index.tsx:88-95`:

```tsx
  // Local UI state
  const [name, setName] = useState('');
  const [stagedJar, setStagedJar] = useState<File | null>(null);
```

Insert immediately after the `stagedJar` line:

```tsx
  const [uploadPct, setUploadPct] = useState<number | null>(null);
```

- [ ] **Step 3.2: Update `computeMode` call**

Find (around `index.tsx:128-132`):

```tsx
  const primaryMode = computeMode({
    deploymentInProgress,
    hasLocalEdits: dirty.anyLocalEdit,
    serverDirtyAgainstDeploy,
  });
```

Replace with:

```tsx
  const uploading = uploadPct !== null;
  const primaryMode = computeMode({
    deploymentInProgress,
    uploading,
    hasLocalEdits: dirty.anyLocalEdit,
    serverDirtyAgainstDeploy,
  });
```

- [ ] **Step 3.3: Pass `onProgress` into the Save-path upload**

Find in `handleSave` (around `index.tsx:179-181`):

```tsx
      if (stagedJar) {
        await uploadJar.mutateAsync({ envSlug, appSlug: targetApp.slug, file: stagedJar });
      }
```

Replace with:

```tsx
      if (stagedJar) {
        setUploadPct(0);
        try {
          await uploadJar.mutateAsync({
            envSlug,
            appSlug: targetApp.slug,
            file: stagedJar,
            onProgress: setUploadPct,
          });
        } finally {
          setUploadPct(null);
        }
      }
```

- [ ] **Step 3.4: Pass `onProgress` into the Redeploy-path upload**

Find in `handleRedeploy` (around `index.tsx:262-264`):

```tsx
      if (stagedJar) {
        const newVersion = await uploadJar.mutateAsync({ envSlug, appSlug: app.slug, file: stagedJar });
        versionId = newVersion.id;
      } else {
```

Replace with:

```tsx
      if (stagedJar) {
        setUploadPct(0);
        try {
          const newVersion = await uploadJar.mutateAsync({
            envSlug,
            appSlug: app.slug,
            file: stagedJar,
            onProgress: setUploadPct,
          });
          versionId = newVersion.id;
        } finally {
          setUploadPct(null);
        }
      } else {
```

- [ ] **Step 3.5: Forward `uploadPct` to `PrimaryActionButton`**

Find the `<PrimaryActionButton ... />` render (around `index.tsx:389-393`):

```tsx
          <PrimaryActionButton
            mode={primaryMode}
            enabled={primaryEnabled}
            onClick={primaryMode === 'redeploy' ? handleRedeploy : handleSave}
          />
```

Replace with:

```tsx
          <PrimaryActionButton
            mode={primaryMode}
            enabled={primaryEnabled}
            progress={uploadPct ?? undefined}
            onClick={primaryMode === 'redeploy' ? handleRedeploy : handleSave}
          />
```

- [ ] **Step 3.6: Also disable primary button during upload**

Find the `primaryEnabled` computation (around `index.tsx:347-351`):

```tsx
  const primaryEnabled = (() => {
    if (primaryMode === 'deploying') return false;
    if (primaryMode === 'save') return !!name.trim() && (isNetNew || dirty.anyLocalEdit);
    return true; // redeploy always enabled
  })();
```

Replace with:

```tsx
  const primaryEnabled = (() => {
    if (primaryMode === 'deploying') return false;
    if (primaryMode === 'uploading') return false;
    if (primaryMode === 'save') return !!name.trim() && (isNetNew || dirty.anyLocalEdit);
    return true; // redeploy always enabled
  })();
```

- [ ] **Step 3.7: Typecheck**

Run: `npm run typecheck`
Expected: PASS.

- [ ] **Step 3.8: Run all deployment page tests**

Run: `npx vitest run src/pages/AppsTab/AppDeploymentPage`
Expected: Existing tests still pass (CheckpointsTable still has 1 pre-existing failure to fix in Task 5; nothing else should break).

- [ ] **Step 3.9: Commit**

```bash
git add ui/src/pages/AppsTab/AppDeploymentPage/index.tsx
git commit -m "feat(ui): wire JAR upload progress into the primary action button"
```

---

## Task 4: `useStartupLogs` accepts `sort` parameter

**Why:** Sort direction must drive the backend fetch, not just client-side reordering — so the 500-row limit returns the lines closest to the user's interest.

**Files:**
- Modify: `ui/src/api/queries/logs.ts:141-160`

- [ ] **Step 4.1: Change the `useStartupLogs` signature**

Open `ui/src/api/queries/logs.ts`. Replace the `useStartupLogs` function (lines 141-160) with:

```ts
export function useStartupLogs(
  application: string | undefined,
  environment: string | undefined,
  deployCreatedAt: string | undefined,
  isStarting: boolean,
  sort: 'asc' | 'desc' = 'desc',
) {
  const params: LogSearchParams = {
    application: application || undefined,
    environment: environment ?? '',
    source: 'container',
    from: deployCreatedAt || undefined,
    sort,
    limit: 500,
  };

  return useLogs(params, {
    enabled: !!application && !!deployCreatedAt && !!environment,
    refetchInterval: isStarting ? 3_000 : false,
  });
}
```

Key changes: add 5th parameter `sort` defaulting to `'desc'`, pass it through to `params.sort`.

- [ ] **Step 4.2: Typecheck**

Run: `npm run typecheck`
Expected: PASS. `StartupLogPanel` calls `useStartupLogs` without a 5th arg — safe because `sort` defaults.

- [ ] **Step 4.3: Commit**

```bash
git add ui/src/api/queries/logs.ts
git commit -m "feat(ui): useStartupLogs accepts sort parameter (default desc)"
```

---

## Task 5: `StartupLogPanel` — sort toggle + refresh button + new header

**Why:** Users need to see the newest line on demand without waiting for the 3s poll. The visual must match the Application Log panel in Runtime.

**Files:**
- Modify: `ui/src/components/StartupLogPanel.tsx`
- Modify: `ui/src/components/StartupLogPanel.module.css`
- Create: `ui/src/components/StartupLogPanel.test.tsx`

- [ ] **Step 5.1: Write failing tests**

Create `ui/src/components/StartupLogPanel.test.tsx`:

```tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@cameleer/design-system';
import type { ReactNode } from 'react';
import { StartupLogPanel } from './StartupLogPanel';
import type { Deployment } from '../api/queries/admin/apps';
import * as logsModule from '../api/queries/logs';

function wrap(ui: ReactNode) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return render(
    <ThemeProvider>
      <QueryClientProvider client={qc}>{ui}</QueryClientProvider>
    </ThemeProvider>,
  );
}

const baseDep: Deployment = {
  id: 'dep1', appId: 'a', appVersionId: 'v', environmentId: 'e',
  status: 'STARTING', targetState: 'RUNNING', deploymentStrategy: 'BLUE_GREEN',
  replicaStates: [], deployStage: 'START_REPLICAS',
  containerId: null, containerName: null, errorMessage: null,
  deployedAt: null, stoppedAt: null,
  createdAt: '2026-04-23T10:00:00Z', createdBy: null,
};

const entries = [
  { timestamp: '2026-04-23T10:00:01Z', level: 'INFO' as const, message: 'hello',
    loggerName: null, threadName: null, stackTrace: null, exchangeId: null,
    instanceId: null, application: null, mdc: null, source: 'container' as const },
];

describe('StartupLogPanel', () => {
  beforeEach(() => { vi.restoreAllMocks(); });

  it('passes default sort=desc to useStartupLogs', () => {
    const spy = vi.spyOn(logsModule, 'useStartupLogs').mockReturnValue({
      data: { data: entries, nextCursor: null, hasMore: false, levelCounts: {} },
    } as ReturnType<typeof logsModule.useStartupLogs>);
    wrap(<StartupLogPanel deployment={baseDep} appSlug="app" envSlug="dev" />);
    expect(spy).toHaveBeenCalledWith('app', 'dev', '2026-04-23T10:00:00Z', true, 'desc');
  });

  it('toggle flips sort to asc', () => {
    const spy = vi.spyOn(logsModule, 'useStartupLogs').mockReturnValue({
      data: { data: entries, nextCursor: null, hasMore: false, levelCounts: {} },
    } as ReturnType<typeof logsModule.useStartupLogs>);
    wrap(<StartupLogPanel deployment={baseDep} appSlug="app" envSlug="dev" />);
    fireEvent.click(screen.getByRole('button', { name: /oldest first|newest first/i }));
    expect(spy).toHaveBeenLastCalledWith('app', 'dev', '2026-04-23T10:00:00Z', true, 'asc');
  });

  it('refresh button calls refetch', () => {
    const refetch = vi.fn();
    vi.spyOn(logsModule, 'useStartupLogs').mockReturnValue({
      data: { data: entries, nextCursor: null, hasMore: false, levelCounts: {} },
      refetch,
    } as unknown as ReturnType<typeof logsModule.useStartupLogs>);
    wrap(<StartupLogPanel deployment={baseDep} appSlug="app" envSlug="dev" />);
    fireEvent.click(screen.getByRole('button', { name: /refresh/i }));
    expect(refetch).toHaveBeenCalled();
  });

  it('scrolls to top after refresh when sort=desc', async () => {
    const refetch = vi.fn().mockResolvedValue({});
    vi.spyOn(logsModule, 'useStartupLogs').mockReturnValue({
      data: { data: entries, nextCursor: null, hasMore: false, levelCounts: {} },
      refetch,
    } as unknown as ReturnType<typeof logsModule.useStartupLogs>);
    const scrollTo = vi.fn();
    const origScrollTo = Element.prototype.scrollTo;
    Element.prototype.scrollTo = scrollTo as unknown as typeof Element.prototype.scrollTo;
    try {
      wrap(<StartupLogPanel deployment={baseDep} appSlug="app" envSlug="dev" />);
      await act(async () => {
        fireEvent.click(screen.getByRole('button', { name: /refresh/i }));
        await Promise.resolve();
      });
      // default sort is desc → scroll to top (top === 0)
      expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 0 }));
    } finally {
      Element.prototype.scrollTo = origScrollTo;
    }
  });
});
```

- [ ] **Step 5.2: Run tests — expect failures**

Run: `npx vitest run src/components/StartupLogPanel.test.tsx`
Expected: FAIL — current signature doesn't pass sort; no refresh button; no role mapping.

- [ ] **Step 5.3: Rewrite `StartupLogPanel.tsx`**

Replace the full contents of `ui/src/components/StartupLogPanel.tsx`:

```tsx
import { useRef, useState } from 'react';
import { LogViewer, Button } from '@cameleer/design-system';
import type { LogEntry } from '@cameleer/design-system';
import { RefreshCw } from 'lucide-react';
import { useStartupLogs } from '../api/queries/logs';
import type { Deployment } from '../api/queries/admin/apps';
import styles from './StartupLogPanel.module.css';

interface StartupLogPanelProps {
  deployment: Deployment;
  appSlug: string;
  envSlug: string;
  className?: string;
}

export function StartupLogPanel({ deployment, appSlug, envSlug, className }: StartupLogPanelProps) {
  const isStarting = deployment.status === 'STARTING';
  const isFailed = deployment.status === 'FAILED';
  const [sort, setSort] = useState<'asc' | 'desc'>('desc');
  const scrollRef = useRef<HTMLDivElement>(null);

  const query = useStartupLogs(appSlug, envSlug, deployment.createdAt, isStarting, sort);
  const entries = query.data?.data ?? [];

  const scrollToLatest = () => {
    const el = scrollRef.current;
    if (!el) return;
    // asc → latest at bottom; desc → latest at top
    const top = sort === 'asc' ? el.scrollHeight : 0;
    el.scrollTo({ top, behavior: 'smooth' });
  };

  const handleRefresh = async () => {
    await query.refetch?.();
    scrollToLatest();
  };

  if (entries.length === 0 && !isStarting) return null;

  return (
    <div className={`${styles.panel}${className ? ` ${className}` : ''}`}>
      <div className={styles.header}>
        <div className={styles.headerLeft}>
          <span className={styles.title}>Startup Logs</span>
          {isStarting && (
            <>
              <span className={`${styles.badge} ${styles.badgeLive}`}>● live</span>
              <span className={styles.pollingHint}>polling every 3s</span>
            </>
          )}
          {isFailed && (
            <span className={`${styles.badge} ${styles.badgeStopped}`}>stopped</span>
          )}
        </div>
        <div className={styles.headerRight}>
          <span className={styles.lineCount}>{entries.length} entries</span>
          <Button
            variant="ghost"
            size="sm"
            onClick={() => setSort((s) => (s === 'asc' ? 'desc' : 'asc'))}
            title={sort === 'asc' ? 'Oldest first' : 'Newest first'}
            aria-label={sort === 'asc' ? 'Oldest first' : 'Newest first'}
          >
            {sort === 'asc' ? '\u2191' : '\u2193'}
          </Button>
          <Button
            variant="ghost"
            size="sm"
            onClick={handleRefresh}
            title="Refresh"
            aria-label="Refresh"
          >
            <RefreshCw size={14} />
          </Button>
        </div>
      </div>
      <div ref={scrollRef} className={styles.scrollWrap}>
        {entries.length > 0 ? (
          <LogViewer entries={entries as unknown as LogEntry[]} />
        ) : (
          <div className={styles.empty}>Waiting for container output...</div>
        )}
      </div>
    </div>
  );
}
```

- [ ] **Step 5.4: Extend `StartupLogPanel.module.css`**

Open `ui/src/components/StartupLogPanel.module.css`. Append the following at the end of the file:

```css
.headerRight {
  display: flex;
  align-items: center;
  gap: 8px;
}

.scrollWrap {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
}
```

Also modify the existing `.panel` block — its `overflow: hidden` remains correct; no change needed. Leave `.panel { ... flex-direction: column; min-height: 0; }` intact so the new `.scrollWrap` owns overflow.

- [ ] **Step 5.5: Run tests — expect pass**

Run: `npx vitest run src/components/StartupLogPanel.test.tsx`
Expected: PASS (4 tests).

- [ ] **Step 5.6: Typecheck**

Run: `npm run typecheck`
Expected: PASS.

- [ ] **Step 5.7: Commit**

```bash
git add ui/src/components/StartupLogPanel.tsx \
        ui/src/components/StartupLogPanel.module.css \
        ui/src/components/StartupLogPanel.test.tsx
git commit -m "feat(ui): startup logs — sort toggle + refresh button + desc default"
```

---

## Task 6: Collapsible `CheckpointsTable` (default collapsed) + fix broken test

**Why:** History is rarely the user's focus on page load; collapsing reclaims vertical space. Also, a pre-existing test asserts on the removed "No past deployments yet" text — fix it.

**Files:**
- Modify: `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx`
- Modify: `ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css`
- Modify: `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx`

- [ ] **Step 6.1: Update the broken test and add collapse tests**

Replace the contents of `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx` with:

```tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '@cameleer/design-system';
import type { ReactNode } from 'react';
import { CheckpointsTable } from './CheckpointsTable';
import type { Deployment, AppVersion } from '../../../api/queries/admin/apps';

function wrap(ui: ReactNode) {
  return render(<ThemeProvider>{ui}</ThemeProvider>);
}

const v6: AppVersion = {
  id: 'v6id', appId: 'a', version: 6, jarPath: '/j', jarChecksum: 'c',
  jarFilename: 'my-app-1.2.3.jar', jarSizeBytes: 1, detectedRuntimeType: null,
  detectedMainClass: null, uploadedAt: '2026-04-23T10:00:00Z',
};

const stoppedDep: Deployment = {
  id: 'd1', appId: 'a', appVersionId: 'v6id', environmentId: 'e',
  status: 'STOPPED', targetState: 'STOPPED', deploymentStrategy: 'BLUE_GREEN',
  replicaStates: [{ index: 0, containerId: 'c', containerName: 'n', status: 'STOPPED' }],
  deployStage: null, containerId: null, containerName: null, errorMessage: null,
  deployedAt: '2026-04-23T10:35:00Z', stoppedAt: '2026-04-23T10:52:00Z',
  createdAt: '2026-04-23T10:35:00Z', createdBy: 'alice',
  deployedConfigSnapshot: { jarVersionId: 'v6id', agentConfig: null, containerConfig: {}, sensitiveKeys: null },
};

function expand() {
  fireEvent.click(screen.getByRole('button', { name: /checkpoints/i }));
}

describe('CheckpointsTable', () => {
  it('defaults to collapsed — header visible, rows hidden', () => {
    wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
    expect(screen.getByRole('button', { name: /checkpoints \(1\)/i })).toBeInTheDocument();
    expect(screen.queryByText('v6')).toBeNull();
    expect(screen.queryByText('my-app-1.2.3.jar')).toBeNull();
  });

  it('clicking header expands to show rows', () => {
    wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
    expand();
    expect(screen.getByText('v6')).toBeInTheDocument();
    expect(screen.getByText('my-app-1.2.3.jar')).toBeInTheDocument();
    expect(screen.getByText('alice')).toBeInTheDocument();
  });

  it('row click invokes onSelect with deploymentId', () => {
    const onSelect = vi.fn();
    wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={5} onSelect={onSelect} />);
    expand();
    fireEvent.click(screen.getByText('v6').closest('tr')!);
    expect(onSelect).toHaveBeenCalledWith('d1');
  });

  it('renders em-dash for null createdBy', () => {
    const noActor = { ...stoppedDep, createdBy: null };
    wrap(<CheckpointsTable deployments={[noActor]} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
    expand();
    expect(screen.getByText('—')).toBeInTheDocument();
  });

  it('marks pruned-JAR rows as archived', () => {
    const pruned = { ...stoppedDep, appVersionId: 'unknown' };
    wrap(<CheckpointsTable deployments={[pruned]} versions={[]}
      currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
    expand();
    expect(screen.getByText(/archived/i)).toBeInTheDocument();
  });

  it('renders nothing when there are no checkpoints', () => {
    const { container } = wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
      currentDeploymentId="d1" jarRetentionCount={5} onSelect={() => {}} />);
    expect(container).toBeEmptyDOMElement();
  });

  it('caps visible rows at jarRetentionCount and shows expander', () => {
    const many: Deployment[] = Array.from({ length: 10 }, (_, i) => ({
      ...stoppedDep, id: `d${i}`, deployedAt: `2026-04-23T10:${String(10 + i).padStart(2, '0')}:00Z`,
    }));
    wrap(<CheckpointsTable deployments={many} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={3} onSelect={() => {}} />);
    expand();
    // 3 visible rows + 1 header row = 4 rows max in the table
    expect(screen.getAllByRole('row').length).toBeLessThanOrEqual(4);
    expect(screen.getByText(/show older \(7\)/i)).toBeInTheDocument();
  });

  it('shows all rows when jarRetentionCount >= total', () => {
    wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={10} onSelect={() => {}} />);
    expand();
    expect(screen.queryByText(/show older/i)).toBeNull();
  });

  it('falls back to default cap of 10 when jarRetentionCount is 0 or null', () => {
    const fifteen: Deployment[] = Array.from({ length: 15 }, (_, i) => ({
      ...stoppedDep, id: `d${i}`, deployedAt: `2026-04-23T10:${String(10 + i).padStart(2, '0')}:00Z`,
    }));
    wrap(<CheckpointsTable deployments={fifteen} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={null} onSelect={() => {}} />);
    expand();
    expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument();
  });
});
```

- [ ] **Step 6.2: Run tests — expect failures**

Run: `npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx`
Expected: FAIL — no collapse header button exists yet.

- [ ] **Step 6.3: Rewrite `CheckpointsTable.tsx` with collapse**

Replace the full contents of `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx`:

```tsx
import { useState } from 'react';
import { Badge } from '@cameleer/design-system';
import type { Deployment, AppVersion } from '../../../api/queries/admin/apps';
import { timeAgo } from '../../../utils/format-utils';
import styles from './AppDeploymentPage.module.css';

const FALLBACK_CAP = 10;

interface CheckpointsTableProps {
  deployments: Deployment[];
  versions: AppVersion[];
  currentDeploymentId: string | null;
  jarRetentionCount: number | null;
  onSelect: (deploymentId: string) => void;
}

export function CheckpointsTable({
  deployments,
  versions,
  currentDeploymentId,
  jarRetentionCount,
  onSelect,
}: CheckpointsTableProps) {
  const [expanded, setExpanded] = useState(false);
  const [open, setOpen] = useState(false);
  const versionMap = new Map(versions.map((v) => [v.id, v]));

  const checkpoints = deployments
    .filter((d) => d.deployedConfigSnapshot && d.id !== currentDeploymentId)
    .sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? ''));

  if (checkpoints.length === 0) {
    return null;
  }

  const cap = jarRetentionCount && jarRetentionCount > 0 ? jarRetentionCount : FALLBACK_CAP;
  const visible = expanded ? checkpoints : checkpoints.slice(0, cap);
  const hidden = checkpoints.length - visible.length;

  return (
    <div className={styles.checkpointsSection}>
      <button
        type="button"
        className={styles.checkpointsHeader}
        onClick={() => setOpen((v) => !v)}
        aria-expanded={open}
      >
        <span className={styles.checkpointsChevron}>{open ? '\u25BE' : '\u25B8'}</span>
        <span>Checkpoints</span>
        <span className={styles.checkpointsCount}>({checkpoints.length})</span>
      </button>
      {open && (
        <div className={styles.checkpointsTable}>
          <table>
            <thead>
              <tr>
                <th>Version</th>
                <th>JAR</th>
                <th>Deployed by</th>
                <th>Deployed</th>
                <th>Strategy</th>
                <th>Outcome</th>
                <th aria-label="open"></th>
              </tr>
            </thead>
            <tbody>
              {visible.map((d) => {
                const v = versionMap.get(d.appVersionId);
                const archived = !v;
                const strategyLabel =
                  d.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling';
                return (
                  <tr
                    key={d.id}
                    className={archived ? styles.checkpointArchived : undefined}
                    onClick={() => onSelect(d.id)}
                  >
                    <td>
                      <Badge label={v ? `v${v.version}` : '?'} color="auto" />
                    </td>
                    <td className={styles.jarCell}>
                      {v ? (
                        <span className={styles.jarName}>{v.jarFilename}</span>
                      ) : (
                        <>
                          <span className={styles.jarStrike}>JAR pruned</span>
                          <div className={styles.archivedHint}>archived — JAR pruned</div>
                        </>
                      )}
                    </td>
                    <td>
                      {d.createdBy ?? <span className={styles.muted}>—</span>}
                    </td>
                    <td>
                      {d.deployedAt && timeAgo(d.deployedAt)}
                      <div className={styles.isoSubline}>{d.deployedAt}</div>
                    </td>
                    <td>
                      <span className={styles.strategyPill}>{strategyLabel}</span>
                    </td>
                    <td>
                      <span
                        className={`${styles.outcomePill} ${styles[`outcome-${d.status}` as keyof typeof styles] || ''}`}
                      >
                        {d.status}
                      </span>
                    </td>
                    <td className={styles.chevron}>›</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
          {hidden > 0 && !expanded && (
            <button
              type="button"
              className={styles.showOlderBtn}
              onClick={() => setExpanded(true)}
            >
              Show older ({hidden}) — archived, postmortem only
            </button>
          )}
        </div>
      )}
    </div>
  );
}
```

- [ ] **Step 6.4: Add CSS for the collapsible header**

Open `ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css`. Append:

```css
/* Collapsible Checkpoints header */
.checkpointsSection {
  display: flex;
  flex-direction: column;
}

.checkpointsHeader {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: none;
  border: none;
  padding: 8px 0;
  font-size: 13px;
  font-weight: 600;
  color: var(--text-primary);
  cursor: pointer;
  text-align: left;
}

.checkpointsHeader:hover {
  color: var(--amber);
}

.checkpointsChevron {
  color: var(--text-muted);
  font-size: 11px;
  width: 12px;
  text-align: center;
}

.checkpointsCount {
  color: var(--text-muted);
  font-weight: 400;
}
```

- [ ] **Step 6.5: Run tests — expect pass**

Run: `npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx`
Expected: PASS (9 tests).

- [ ] **Step 6.6: Commit**

```bash
git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx \
        ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx \
        ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css
git commit -m "feat(ui): checkpoints table collapsible, default collapsed"
```

---

## Task 7: Replica dropdown uses DS `Select`

**Why:** Native `<select>` clashes with the rest of the drawer's DS styling.

**Files:**
- Modify: `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx`

- [ ] **Step 7.1: Replace native select with DS `Select`**

Open `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx`. Replace the whole file contents with:

```tsx
import { useMemo, useState } from 'react';
import { Select } from '@cameleer/design-system';
import { useInfiniteApplicationLogs } from '../../../../api/queries/logs';
import type { Deployment } from '../../../../api/queries/admin/apps';
import { instanceIdsFor } from './instance-id';
import styles from './CheckpointDetailDrawer.module.css';

interface Props {
  deployment: Deployment;
  appSlug: string;
  envSlug: string;
}

export function LogsPanel({ deployment, appSlug, envSlug }: Props) {
  const allInstanceIds = useMemo(
    () => instanceIdsFor(deployment, envSlug, appSlug),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [deployment.id, deployment.replicaStates, envSlug, appSlug]
  );

  const [replicaFilter, setReplicaFilter] = useState<'all' | number>('all');
  const filteredInstanceIds = replicaFilter === 'all'
    ? allInstanceIds
    : allInstanceIds.filter((_, i) => i === replicaFilter);

  const logs = useInfiniteApplicationLogs({
    application: appSlug,
    instanceIds: filteredInstanceIds,
    isAtTop: true,
  });

  const replicaOptions = [
    { value: 'all', label: `all (${deployment.replicaStates.length})` },
    ...deployment.replicaStates.map((_, i) => ({ value: String(i), label: String(i) })),
  ];

  return (
    <div>
      <div className={styles.filterBar}>
        <label className={styles.replicaLabel}>
          <span>Replica:</span>
          <Select
            value={String(replicaFilter)}
            onChange={(e) => {
              const v = e.target.value;
              setReplicaFilter(v === 'all' ? 'all' : Number(v));
            }}
            options={replicaOptions}
          />
        </label>
      </div>
      {logs.items.length === 0 && !logs.isLoading && (
        <div className={styles.emptyState}>No logs for this deployment.</div>
      )}
      {logs.items.map((entry, i) => (
        <div key={i} className={styles.logRow}>
          <span className={styles.logTimestamp}>{entry.timestamp}</span>{' '}
          <span>[{entry.level}]</span>{' '}
          <span>{entry.message}</span>
        </div>
      ))}
    </div>
  );
}
```

- [ ] **Step 7.2: Ensure `.replicaLabel` has adequate spacing**

Check `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.module.css`. If it does not already contain a `.replicaLabel` rule, append:

```css
.replicaLabel {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}
```

- [ ] **Step 7.3: Typecheck**

Run: `npm run typecheck`
Expected: PASS.

- [ ] **Step 7.4: Run existing drawer tests to confirm nothing regressed**

Run: `npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer`
Expected: PASS.

- [ ] **Step 7.5: Commit**

```bash
git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx \
        ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.module.css
git commit -m "refactor(ui): drawer replica filter uses DS Select"
```

---

## Task 8: Drawer tab order (Config first, default Config)

**Why:** Config is the restore-decision content. Logs is post-mortem supporting material.

**Files:**
- Modify: `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx`
- Modify (likely): `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx`

- [ ] **Step 8.1: Inspect the drawer test**

Run: `npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx`
Note any test that relies on "logs" being the default tab — these will need updates after the code change in 8.2.

- [ ] **Step 8.2: Change default tab + reorder tab array**

Open `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx`. Change line 27:

```tsx
  const [tab, setTab] = useState<TabId>('logs');
```

to:

```tsx
  const [tab, setTab] = useState<TabId>('config');
```

And the `tabs` array (lines 69-72):

```tsx
        tabs={[
          { value: 'logs', label: 'Logs' },
          { value: 'config', label: 'Config' },
        ]}
```

to:

```tsx
        tabs={[
          { value: 'config', label: 'Config' },
          { value: 'logs', label: 'Logs' },
        ]}
```

- [ ] **Step 8.3: Fix any failing drawer tests**

Re-run: `npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx`

If a test fails because it expects the Logs tab content to be visible on open, update that test to either:
1. Click the Logs tab first, then assert on log content; or
2. Assert on Config content for the default-open case.

The exact edit depends on what the existing tests assert — make the narrowest change that preserves the original test intent given the new default.

- [ ] **Step 8.4: Typecheck**

Run: `npm run typecheck`
Expected: PASS.

- [ ] **Step 8.5: Commit**

```bash
git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx \
        ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx
git commit -m "ui(drawer): reorder tabs Config first, default to Config"
```

---

## Task 9: Final verification + rules update

**Why:** Confirm the full test suite is green, and update `.claude/rules/ui.md` to reflect the new behavior a future session will need to know.

**Files:**
- Modify: `.claude/rules/ui.md`

- [ ] **Step 9.1: Run the entire UI test suite**

Run: `npx vitest run`
Expected: PASS (all tests).

- [ ] **Step 9.2: Typecheck once more**

Run: `npm run typecheck`
Expected: PASS.

- [ ] **Step 9.3: Update `.claude/rules/ui.md` deployment-page section**

Open `.claude/rules/ui.md`. Locate the Deployments bullet (currently starts with "**Deployments** — unified app deployment page"). Update the sub-bullet for CheckpointsTable to mention:

- "Checkpoints render as a collapsible `CheckpointsTable`, default collapsed; header row `Checkpoints (N) ▸/▾` toggles."

Update or add sub-bullet for `PrimaryActionButton`:

- "Primary button state machine: `save` → `uploading` (during JAR upload, shows `Uploading… N%` with tinted fill overlay) → `deploying` during active deploy. Upload progress sourced from `useUploadJar` (XHR `upload.onprogress`)."

Update or add sub-bullet for `StartupLogPanel`:

- "`StartupLogPanel` header mirrors the Runtime Application Log: `N entries` + sort toggle (↑/↓, default desc) + refresh icon (`RefreshCw`). Sort drives the backend fetch via `useStartupLogs(…, sort)` so the 500-line limit returns the window closest to the user's interest. Refresh scrolls to the latest line (top for desc, bottom for asc)."

Update or add the CheckpointDetailDrawer sub-bullet:

- "`CheckpointDetailDrawer` tabs: `Config | Logs`. Default tab `Config`. Replica filter uses DS `Select`."

Exact wording may be adapted to fit the file's existing voice and structure — but each of the four facts above must appear.

- [ ] **Step 9.4: Commit rules update**

```bash
git add .claude/rules/ui.md
git commit -m "docs(rules): reflect deployment page polish changes"
```

- [ ] **Step 9.5: Run GitNexus re-index**

Run (from repo root): `npx gitnexus analyze --embeddings`
Expected: Indexed successfully.

- [ ] **Step 9.6: Manual smoke (human-run, document in PR)**

Start the dev server (`npm run dev` in `ui/` — backend must be running on :8081). Exercise:
- **Save with staged JAR**: upload progress fills the Save button, returns to post-save state.
- **Redeploy with staged JAR**: upload progress fills → Deploying… → back to Redeploy.
- **Redeploy without staged JAR**: button goes straight to `Deploying…`.
- **Upload failure**: kill backend mid-upload → error toast; button returns to pre-click state.
- **Startup logs**: flip sort; latest entry visible in both directions; click refresh.
- **Checkpoints**: collapsed on load; open → rows visible; empty app shows nothing.
- **Drawer**: row-click opens on Config; Replica filter is DS-styled.

---

## Self-review

**Spec coverage (against `docs/superpowers/specs/2026-04-23-deployment-page-polish-design.md`):**

- §2.1 Upload progress in button → Tasks 1, 2, 3 ✓
- §2.2 Startup log sort + refresh → Tasks 4, 5 ✓
- §2.3 Checkpoints collapsible default collapsed → Task 6 ✓
- §2.4 Replica dropdown uses DS Select → Task 7 ✓
- §2.5 Drawer tabs Config first, default Config → Task 8 ✓
- §5 Non-goals (backend untouched; empty-checkpoints already shipped) — respected ✓

**Placeholder scan:** No TBDs/TODOs/"implement later" placeholders. Step 8.3 is deliberately conditional because the test content is unknown at plan-write time; the instruction provides concrete branching guidance rather than a placeholder.

**Type consistency:**
- `PrimaryActionMode` spelled the same in every task.
- `computeMode` signature: both the test (Task 2) and the call site (Task 3) use `{ deploymentInProgress, uploading, hasLocalEdits, serverDirtyAgainstDeploy }`.
- `useUploadJar` mutation arg type: `onProgress?: (pct: number) => void` — used identically in Tasks 1 and 3.
- `useStartupLogs` signature: 5th param named `sort`, defaults to `'desc'` — consistent between Tasks 4 and 5.
- CSS class names (`uploadBtnWrap`, `uploadBtnFill`, `uploadBtnLabel`, `checkpointsSection`, `checkpointsHeader`, `checkpointsChevron`, `checkpointsCount`, `scrollWrap`, `headerRight`, `replicaLabel`) — each defined once and referenced from the matching TSX.

No gaps or inconsistencies.
