Files
cameleer-server/docs/superpowers/plans/2026-04-23-deployment-page-polish.md

1360 lines
47 KiB
Markdown
Raw Normal View History

# 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 0100. 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.