);
diff --git a/client/src/pages/AgentsPage.jsx b/client/src/pages/AgentsPage.jsx
index 9e9b1c9bd1..736902da84 100644
--- a/client/src/pages/AgentsPage.jsx
+++ b/client/src/pages/AgentsPage.jsx
@@ -3,6 +3,7 @@ import { RefreshCw, Activity, XCircle, Cpu, MemoryStick, Terminal } from 'lucide
import * as api from '../services/api';
import { useAutoRefetch } from '../hooks/useAutoRefetch';
import { formatDateTime } from '../utils/formatters';
+import PageSkeleton from '../components/ui/PageSkeleton';
export function AgentsPage() {
const [killing, setKilling] = useState({});
@@ -32,7 +33,15 @@ export function AgentsPage() {
const totalMemory = agents.reduce((sum, a) => sum + (a.memory || 0), 0);
if (loading) {
- return
Scanning for AI agents...
+ );
}
return (
diff --git a/client/src/pages/BrainScanReport.jsx b/client/src/pages/BrainScanReport.jsx
index 03fa3327d6..b0f34a920c 100644
--- a/client/src/pages/BrainScanReport.jsx
+++ b/client/src/pages/BrainScanReport.jsx
@@ -3,7 +3,7 @@ import { Link, useParams } from 'react-router';
import { ArrowLeft, FileText, RefreshCw, ShieldAlert, ShieldCheck, Skull } from 'lucide-react';
import * as api from '../services/api';
import MarkdownOutput from '../components/cos/MarkdownOutput';
-import BrailleSpinner from '../components/BrailleSpinner';
+import PageSkeleton from '../components/ui/PageSkeleton';
import { useAutoRefetch } from '../hooks/useAutoRefetch';
const VERDICT_STYLES = {
@@ -36,7 +36,13 @@ export default function BrainScanReport() {
if (loading) {
return (
);
}
diff --git a/client/src/pages/CatalogIngredient.jsx b/client/src/pages/CatalogIngredient.jsx
index 533eae119f..4190bd5344 100644
--- a/client/src/pages/CatalogIngredient.jsx
+++ b/client/src/pages/CatalogIngredient.jsx
@@ -14,6 +14,7 @@ import Modal from '../components/ui/Modal.jsx';
import ConfirmButtonPair from '../components/ui/ConfirmButtonPair.jsx';
import UnsavedChangesConfirm from '../components/ui/UnsavedChangesConfirm.jsx';
import AutoSizeTextarea from '../components/ui/AutoSizeTextarea';
+import PageSkeleton from '../components/ui/PageSkeleton';
import {
getCatalogIngredientDetails,
updateCatalogIngredient,
@@ -420,9 +421,14 @@ export default function CatalogIngredient() {
if (loading || !record) {
return (
-
);
}
diff --git a/client/src/pages/CreativeDirector.jsx b/client/src/pages/CreativeDirector.jsx
index b627f9ebee..d1b8fd555c 100644
--- a/client/src/pages/CreativeDirector.jsx
+++ b/client/src/pages/CreativeDirector.jsx
@@ -18,6 +18,7 @@ import { listUniverses } from '../services/apiUniverseBuilder.js';
import { listPipelineSeries } from '../services/apiPipeline.js';
import ModelSelect from '../components/ModelSelect';
import PageHeader from '../components/PageHeader';
+import PageSkeleton from '../components/ui/PageSkeleton';
import Drawer from '../components/Drawer';
import DirectiveComposer from '../components/creative-director/DirectiveComposer.jsx';
import CreativeDirectorModelsDrawer from '../components/creative-director/CreativeDirectorModelsDrawer.jsx';
@@ -259,7 +260,20 @@ export default function CreativeDirector() {
};
if (loading) {
- return
+ );
}
return (
diff --git a/client/src/pages/Game.jsx b/client/src/pages/Game.jsx
index 03f88f2186..a74f20cfec 100644
--- a/client/src/pages/Game.jsx
+++ b/client/src/pages/Game.jsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ArrowLeft, Boxes, Gamepad2, Images, MessageSquare, Plus } from 'lucide-react';
import { Link, useNavigate, useParams } from 'react-router';
+import PageSkeleton from '../components/ui/PageSkeleton';
import toast from '../components/ui/Toast';
import AppContextPicker from '../components/AppContextPicker.jsx';
import GameBindings from '../components/games/GameBindings.jsx';
@@ -259,7 +260,32 @@ export default function Game() {
};
if (loading) {
- return
;
+ // The detail workspace is a full-bleed h-full shell with its own bordered
+ // bar; the index is a plain padded page. `id` is known before the fetch
+ // settles, so each reserves the chrome its own loaded state renders.
+ return id ? (
+
+ );
}
if (id && !game) {
diff --git a/client/src/pages/Insights.jsx b/client/src/pages/Insights.jsx
index 32c9bfa5b2..bedb7fbd32 100644
--- a/client/src/pages/Insights.jsx
+++ b/client/src/pages/Insights.jsx
@@ -17,6 +17,7 @@ import GoalScorecardTab from '../components/insights/GoalScorecardTab';
import ConfidenceBadge from '../components/insights/ConfidenceBadge';
import PageHeader from '../components/PageHeader';
import TabPills from '../components/ui/TabPills';
+import PageSkeleton from '../components/ui/PageSkeleton';
import { timeAgo } from '../utils/formatters';
// Exported for the nav-manifest tab-coverage guard (server/lib/navManifest.test.js).
@@ -28,17 +29,6 @@ export const TABS = [
{ id: 'goal-scorecard', label: 'Goal Scorecard', icon: Target }
];
-function SummaryCardSkeleton() {
- return (
-
- );
-}
-
export function OverviewTab() {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
@@ -72,11 +62,13 @@ export function OverviewTab() {
if (loading) {
return (
-
);
}
diff --git a/client/src/pages/Instances.jsx b/client/src/pages/Instances.jsx
index dc9a7e4635..31f7543e43 100644
--- a/client/src/pages/Instances.jsx
+++ b/client/src/pages/Instances.jsx
@@ -30,6 +30,7 @@ import BrainParitySchedule from '../components/instances/BrainParitySchedule';
import TailnetHelpBanner from '../components/instances/TailnetHelpBanner';
import { timeAgo, timeUntil } from '../utils/formatters';
import { directionalCounts, describeDirectional } from '../lib/syncCounts';
+import PageSkeleton from '../components/ui/PageSkeleton';
const STATUS_COLORS = {
online: 'text-port-success',
@@ -1295,9 +1296,15 @@ export default function Instances() {
if (loading) {
return (
-
);
}
diff --git a/client/src/pages/Media3DDetail.jsx b/client/src/pages/Media3DDetail.jsx
index 773ca299fd..0b08c0cea2 100644
--- a/client/src/pages/Media3DDetail.jsx
+++ b/client/src/pages/Media3DDetail.jsx
@@ -12,6 +12,7 @@ import ImageTo3dRenderOptions from '../components/media/ImageTo3dRenderOptions';
import { fieldsFromRun, renderOptionsBody, runWantsTransparency } from '../lib/imageTo3dRenderOptions';
import { imageTo3dStatusMeta } from '../components/media/imageTo3dStatus';
import toast from '../components/ui/Toast';
+import PageSkeleton from '../components/ui/PageSkeleton';
// Poll cadence while a render is in flight (a real TRELLIS.2 render is multi-minute).
const POLL_INTERVAL_MS = 2500;
@@ -107,8 +108,14 @@ export default function Media3DDetail() {
if (loading) {
return (
-
-
Loading 3D model…
+
);
}
diff --git a/client/src/pages/Models.jsx b/client/src/pages/Models.jsx
index 56b3db8a76..6a3ae19cd1 100644
--- a/client/src/pages/Models.jsx
+++ b/client/src/pages/Models.jsx
@@ -96,7 +96,7 @@ export default function Models() {
{/* Local boundary rather than the App-level one: a lazy tab must not blank
out the section header and tab bar while its chunk loads. */}
- }>
+ }>
{DetailContent ? : }
diff --git a/client/src/pages/PipelineContinuityBible.jsx b/client/src/pages/PipelineContinuityBible.jsx
index 350361d1d1..6dfee6980d 100644
--- a/client/src/pages/PipelineContinuityBible.jsx
+++ b/client/src/pages/PipelineContinuityBible.jsx
@@ -14,6 +14,7 @@ import { useEffect, useRef, useState } from 'react';
import { Link, useParams, useNavigate } from 'react-router';
import { Loader2, RefreshCw, X, ArrowLeft, BookOpen, AlertTriangle, BookMarked, Lock } from 'lucide-react';
import toast from '../components/ui/Toast';
+import PageSkeleton from '../components/ui/PageSkeleton';
import {
getPipelineSeries,
getContinuityBible,
@@ -112,9 +113,16 @@ export default function PipelineContinuityBible() {
if (loading) {
return (
-
-
-
+
);
}
diff --git a/client/src/pages/PipelineExport.jsx b/client/src/pages/PipelineExport.jsx
index 75440ccc64..ccf7f5e5e0 100644
--- a/client/src/pages/PipelineExport.jsx
+++ b/client/src/pages/PipelineExport.jsx
@@ -14,6 +14,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Link, useParams, useNavigate } from 'react-router';
import { Loader2, ArrowLeft, Download, Save, BookText, FileText, FileType } from 'lucide-react';
import toast from '../components/ui/Toast';
+import PageSkeleton from '../components/ui/PageSkeleton';
import {
getPipelineSeries,
updatePipelineSeries,
@@ -109,9 +110,17 @@ export default function PipelineExport() {
if (loading) {
return (
-
-
-
+
);
}
diff --git a/client/src/pages/PipelineIssue.jsx b/client/src/pages/PipelineIssue.jsx
index d5a9ec3fa8..a3f83feb58 100644
--- a/client/src/pages/PipelineIssue.jsx
+++ b/client/src/pages/PipelineIssue.jsx
@@ -13,6 +13,7 @@ import {
LayoutGrid, Image as ImageIcon, Clapperboard, Users, Settings, Mic, Lock, Unlock,
} from 'lucide-react';
import toast from '../components/ui/Toast';
+import PageSkeleton from '../components/ui/PageSkeleton';
import Modal from '../components/ui/Modal';
import TabPills from '../components/ui/TabPills';
import {
@@ -287,7 +288,23 @@ export default function PipelineIssue() {
? `${PIPELINE_STAGE_LABELS[stageId]} stage is locked — unlock it to regenerate`
: ambientLockHint;
- if (loading) return
Loading issue…
;
+ if (loading) {
+ return (
+
+ );
+ }
if (!issue) return null;
const StageComponent = STAGE_COMPONENTS[stageId];
diff --git a/client/src/pages/PipelineManuscriptEditor.jsx b/client/src/pages/PipelineManuscriptEditor.jsx
index 07ad386d07..55d3bd8531 100644
--- a/client/src/pages/PipelineManuscriptEditor.jsx
+++ b/client/src/pages/PipelineManuscriptEditor.jsx
@@ -34,6 +34,7 @@ import {
} from 'lucide-react';
import { formatManuscript } from '../lib/manuscriptFormat';
import toast from '../components/ui/Toast';
+import PageSkeleton from '../components/ui/PageSkeleton';
import UnsavedChangesConfirm from '../components/ui/UnsavedChangesConfirm';
import { useAsyncAction } from '../hooks/useAsyncAction';
import { usePipelineProgress } from '../hooks/usePipelineProgress';
@@ -733,7 +734,19 @@ export default function PipelineManuscriptEditor() {
else sectionRefs.current.delete(number);
};
- if (loading) return
Loading manuscript…
;
+ if (loading) {
+ return (
+
+ );
+ }
return (
// The full-bleed route removes Layout's default scroll container. The
diff --git a/client/src/pages/PipelineReverseOutline.jsx b/client/src/pages/PipelineReverseOutline.jsx
index 98f53ec0ca..8234a68c85 100644
--- a/client/src/pages/PipelineReverseOutline.jsx
+++ b/client/src/pages/PipelineReverseOutline.jsx
@@ -12,6 +12,7 @@ import { useEffect, useRef, useState } from 'react';
import { Link, useParams, useNavigate } from 'react-router';
import { Loader2, RefreshCw, X, ArrowLeft, BookOpen, AlertTriangle, Compass } from 'lucide-react';
import toast from '../components/ui/Toast';
+import PageSkeleton from '../components/ui/PageSkeleton';
import {
getPipelineSeries,
getReverseOutline,
@@ -104,9 +105,16 @@ export default function PipelineReverseOutline() {
if (loading) {
return (
-
-
-
+
);
}
diff --git a/client/src/pages/PipelineSeries.jsx b/client/src/pages/PipelineSeries.jsx
index d7633958ef..249087b9fe 100644
--- a/client/src/pages/PipelineSeries.jsx
+++ b/client/src/pages/PipelineSeries.jsx
@@ -18,6 +18,7 @@ import {
Fingerprint, Plus, Trash2, Wand2, Check, X, Download,
} from 'lucide-react';
import toast from '../components/ui/Toast';
+import PageSkeleton from '../components/ui/PageSkeleton';
import ArcCanvas from '../components/pipeline/ArcCanvas';
import AutopilotPanel from '../components/pipeline/AutopilotPanel';
import SeriesReviewPanel from '../components/pipeline/SeriesReviewPanel';
@@ -165,7 +166,21 @@ export default function PipelineSeries() {
if (didSave) toast.success('Series saved');
};
- if (loading) return
Loading series…
;
+ if (loading) {
+ return (
+
+ );
+ }
if (!series) return null;
// Mobile = flex column (grid template ignored); lg+ = grid where the inline
diff --git a/client/src/pages/PipelineSeriesRoadmap.jsx b/client/src/pages/PipelineSeriesRoadmap.jsx
index eeeaf5c753..32f3fba54a 100644
--- a/client/src/pages/PipelineSeriesRoadmap.jsx
+++ b/client/src/pages/PipelineSeriesRoadmap.jsx
@@ -22,6 +22,7 @@ import { ArcRoadmapChart } from '../components/pipeline/ArcCanvas';
import ReaderPanelView from '../components/pipeline/ReaderPanelView';
import ComparativeRankView from '../components/pipeline/ComparativeRankView';
import TabPills from '../components/ui/TabPills';
+import PageSkeleton from '../components/ui/PageSkeleton';
import {
getPipelineSeries, getIssueEditorial, analyzeIssueEditorial, getSeriesJudge,
} from '../services/api';
@@ -270,9 +271,18 @@ export default function PipelineSeriesRoadmap() {
if (loading) {
return (
-
- Loading reader map…
-
+
);
}
diff --git a/client/src/pages/PipelineVoiceFingerprint.jsx b/client/src/pages/PipelineVoiceFingerprint.jsx
index c46755d808..96c326dbca 100644
--- a/client/src/pages/PipelineVoiceFingerprint.jsx
+++ b/client/src/pages/PipelineVoiceFingerprint.jsx
@@ -18,8 +18,9 @@
import { useEffect, useState } from 'react';
import { Link, useParams, useNavigate } from 'react-router';
-import { Loader2, ArrowLeft, Fingerprint, BookOpen, Info } from 'lucide-react';
+import { ArrowLeft, Fingerprint, BookOpen, Info } from 'lucide-react';
import toast from '../components/ui/Toast';
+import PageSkeleton from '../components/ui/PageSkeleton';
import { getPipelineSeries, getVoiceFingerprint } from '../services/api';
// A cell is an outlier when the (issue, metricKey) pair is in the drift set.
@@ -59,9 +60,16 @@ export default function PipelineVoiceFingerprint() {
if (loading) {
return (
-
-
-
+
);
}
diff --git a/client/src/pages/PromptManager.jsx b/client/src/pages/PromptManager.jsx
index 0ff644c384..8ec698bb24 100644
--- a/client/src/pages/PromptManager.jsx
+++ b/client/src/pages/PromptManager.jsx
@@ -2,7 +2,6 @@ import { useState, useEffect, useRef, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { FileText, Variable, RefreshCw, Save, Plus, Trash2, Eye, Briefcase, Search, X, ChevronRight, ChevronDown } from 'lucide-react';
import toast from '../components/ui/Toast';
-import BrailleSpinner from '../components/BrailleSpinner';
import ProviderModelSelector from '../components/ProviderModelSelector';
import { filterSelectableModels, getProviderTimeout } from '../utils/providers';
import {
@@ -14,6 +13,7 @@ import {
} from '../utils/formatters';
import useFieldDraft from '../hooks/useFieldDraft';
import SettingsTabsHeader from '../components/settings/SettingsTabsHeader';
+import PageSkeleton from '../components/ui/PageSkeleton';
import PageHeader from '../components/PageHeader';
import { FormField } from '../components/ui/FormField';
import Modal from '../components/ui/Modal';
@@ -462,8 +462,8 @@ export default function PromptManager() {
-
);
diff --git a/client/src/pages/QuotaBurn.jsx b/client/src/pages/QuotaBurn.jsx
index c805c22727..25f98db20b 100644
--- a/client/src/pages/QuotaBurn.jsx
+++ b/client/src/pages/QuotaBurn.jsx
@@ -18,7 +18,7 @@ import { useNavigate, useParams } from 'react-router';
import { AlertTriangle, Flame, RefreshCw } from 'lucide-react';
import toast from '../components/ui/Toast';
import Banner from '../components/ui/Banner';
-import BrailleSpinner from '../components/BrailleSpinner';
+import PageSkeleton from '../components/ui/PageSkeleton';
import FamilyCard from '../components/quotaBurn/FamilyCard';
import { NumberField } from '../components/quotaBurn/fields';
import * as api from '../services/api';
@@ -376,7 +376,18 @@ export default function QuotaBurn() {
// fades while a poll that keeps failing deserves a standing indicator.
};
- if (loading) return
Loading burn plan…
;
+ if (loading) {
+ return (
+
+ );
+ }
// A failed first read used to land here with no cause and no way out but a
// browser reload — the header (and its "Refresh quota") returns above.
if (!config) {
diff --git a/client/src/pages/QuotaBurn.test.jsx b/client/src/pages/QuotaBurn.test.jsx
index e94d75be1a..961f3e10dd 100644
--- a/client/src/pages/QuotaBurn.test.jsx
+++ b/client/src/pages/QuotaBurn.test.jsx
@@ -490,7 +490,9 @@ describe('QuotaBurn catalog failure', () => {
// and a retry can put it back — so nothing announces it without a live region.
api.getQuotaBurnCatalog.mockRejectedValueOnce(new Error('Catalog request failed'));
renderPage('/devtools/quota-burn/grok');
- const banner = await screen.findByRole('status');
+ // Scope to the banner's own text: the first-paint PageSkeleton is also a
+ // `status` region, so a bare role query would race it.
+ const banner = (await screen.findByText('Job choices could not be loaded')).closest('[role="status"]');
expect(banner).toHaveAttribute('aria-live', 'polite');
expect(banner).toHaveTextContent('Job choices could not be loaded');
});
diff --git a/client/src/pages/RoundEditor.jsx b/client/src/pages/RoundEditor.jsx
index fd3fa3ada7..4f6655fa3d 100644
--- a/client/src/pages/RoundEditor.jsx
+++ b/client/src/pages/RoundEditor.jsx
@@ -32,6 +32,7 @@ import ReferenceAnalysis from '../components/songs/ReferenceAnalysis';
import RoundEditForm from '../components/songs/RoundEditForm';
import RoundReadView from '../components/songs/RoundReadView';
import { TEMP_ID_RE } from '../lib/roundDraft.js';
+import PageSkeleton from '../components/ui/PageSkeleton';
export default function RoundEditor() {
const { id } = useParams();
@@ -45,7 +46,20 @@ export default function RoundEditor() {
const { partnerSongs, otherSongs, togglePartner } = useRoundPartners({ id, song, setSong });
if (loading) {
- return
Loading round…
;
+ return (
+
+ );
}
if (!song) {
return (
diff --git a/client/src/pages/RoundEditor.test.jsx b/client/src/pages/RoundEditor.test.jsx
index 72f507f8f2..5a3ef77f61 100644
--- a/client/src/pages/RoundEditor.test.jsx
+++ b/client/src/pages/RoundEditor.test.jsx
@@ -55,8 +55,9 @@ describe('RoundEditor round→round navigation', () => {
fireEvent.click(screen.getByText('go-b'));
// The old draft must be gone (so a Save can't write Song A into Song B) and
- // the loading state shown until the new song arrives.
- await waitFor(() => expect(screen.getByText(/Loading round/)).toBeTruthy());
+ // the loading state shown until the new song arrives. That state is the
+ // shared PageSkeleton, which announces itself by aria-label, not by text.
+ await waitFor(() => expect(screen.getByRole('status', { name: 'Loading round' })).toBeTruthy());
expect(screen.queryByText('Song A')).toBeNull();
expect(screen.queryByRole('button', { name: /^Save$/ })).toBeNull();
diff --git a/client/src/pages/SongBookViewer.jsx b/client/src/pages/SongBookViewer.jsx
index 34769eae8b..0ccf07b208 100644
--- a/client/src/pages/SongBookViewer.jsx
+++ b/client/src/pages/SongBookViewer.jsx
@@ -84,6 +84,7 @@ import { safeReadStorage, safeWriteStorage } from '../lib/safeStorage.js';
import { formatBytes, formatDurationSec } from '../utils/formatters';
import { isHttpUrl } from '../utils/urlNormalize';
import { readFileAsBase64, JSON_UPLOAD_MAX_FILE_SIZE } from '../utils/fileUpload';
+import PageSkeleton from '../components/ui/PageSkeleton';
import {
getSong, updateSong, deleteSong,
listSongAttachments, uploadSongAttachment, deleteSongAttachment, songAttachmentUrl,
@@ -593,7 +594,19 @@ export default function SongBookViewer() {
}
if (loading || !song) {
- return
Loading song…
;
+ return (
+
+ );
}
const stageClass = SONG_STAGE_COLORS[song.stage] || SONG_STAGE_COLORS.new;
diff --git a/client/src/pages/Templates.jsx b/client/src/pages/Templates.jsx
index aff4e75187..fd775bf2d9 100644
--- a/client/src/pages/Templates.jsx
+++ b/client/src/pages/Templates.jsx
@@ -4,6 +4,7 @@ import { Layers, Code, Server, Globe, Smartphone, MonitorSmartphone, Plus } from
import * as api from '../services/api';
import FolderPicker from '../components/FolderPicker';
import { FormField } from '../components/ui/FormField';
+import PageSkeleton from '../components/ui/PageSkeleton';
const ICONS = {
layers: Layers,
@@ -68,9 +69,15 @@ export default function Templates() {
if (loading) {
return (
-
+
);
}
diff --git a/client/src/pages/VideoTimelineEditor.jsx b/client/src/pages/VideoTimelineEditor.jsx
index 103d89be19..c0dc6b7818 100644
--- a/client/src/pages/VideoTimelineEditor.jsx
+++ b/client/src/pages/VideoTimelineEditor.jsx
@@ -24,6 +24,7 @@ import {
TimelineBlock, FloatingLane, LibraryTile, StillTile, AudioRow, BedAudio,
} from '../components/media/VideoTimelineLanes';
import { NumberField, FadeFields, RemoveButton } from '../components/media/VideoTimelineInspector';
+import PageSkeleton from '../components/ui/PageSkeleton';
import {
assetUrl,
segmentDuration,
@@ -647,7 +648,17 @@ export default function VideoTimelineEditor() {
const overlayLabel = useCallback((ov) => ov.assetFile, []);
const bedLabel = useCallback((tr) => tr.assetFile, []);
- if (loading) return
Loading project…
;
+ if (loading) {
+ return (
+
+ );
+ }
if (error || !project) {
return (
diff --git a/client/src/pages/WorkspaceContexts.jsx b/client/src/pages/WorkspaceContexts.jsx
index 86cc859fc7..e3cf47d871 100644
--- a/client/src/pages/WorkspaceContexts.jsx
+++ b/client/src/pages/WorkspaceContexts.jsx
@@ -4,10 +4,10 @@ import {
Layers, GitBranch, SquareTerminal, ListChecks, Save, RotateCcw,
Trash2, FolderGit2, AlertCircle, CheckCircle2, ArrowRight
} from 'lucide-react';
-import BrailleSpinner from '../components/BrailleSpinner';
import toast from '../components/ui/Toast';
import { useAsyncAction } from '../hooks/useAsyncAction';
import { timeAgo } from '../utils/formatters';
+import PageSkeleton from '../components/ui/PageSkeleton';
import {
listWorkspaceContexts, getWorkspaceContext, saveWorkspaceContext,
restoreWorkspaceContext, deleteWorkspaceContext
@@ -68,7 +68,7 @@ function ContextDetail({ appId }) {
}, { errorMessage: 'Failed to clear context' });
if (loading) {
- return
;
+ return
;
}
if (!ctx) return null;
@@ -209,7 +209,15 @@ function ContextList() {
useEffect(() => { load(); }, [load]);
if (loading) {
- return
;
+ return (
+
+ );
}
if (rows.length === 0) {
diff --git a/client/src/pages/loadingSkeletons.test.jsx b/client/src/pages/loadingSkeletons.test.jsx
new file mode 100644
index 0000000000..ee8c76bdf9
--- /dev/null
+++ b/client/src/pages/loadingSkeletons.test.jsx
@@ -0,0 +1,174 @@
+import { readFileSync, readdirSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { act, render, screen } from '@testing-library/react';
+import { createMemoryRouter, RouterProvider } from 'react-router';
+
+// A hoisted declaration, so the hoisted `vi.mock` factories below can reach it.
+// Every exported FUNCTION resolves to a call that never settles, so the page
+// stays in its first-paint state for the whole assertion; exported constants
+// pass through untouched (a page that reads `PIPELINE_STAGES.includes(...)`
+// during render would otherwise crash before it could paint anything).
+function pendingModule(path) {
+ return async () => {
+ const actual = await vi.importActual(path);
+ return Object.fromEntries(Object.entries(actual).map(([name, value]) => [
+ name,
+ typeof value === 'function' ? vi.fn(() => new Promise(() => {})) : value,
+ ]));
+ };
+}
+
+vi.mock('../services/api', pendingModule('../services/api'));
+vi.mock('../services/apiCatalog.js', pendingModule('../services/apiCatalog.js'));
+vi.mock('../services/apiCreativeDirector.js', pendingModule('../services/apiCreativeDirector.js'));
+vi.mock('../services/apiImageVideo.js', pendingModule('../services/apiImageVideo.js'));
+vi.mock('../services/apiPipeline.js', pendingModule('../services/apiPipeline.js'));
+vi.mock('../services/apiPrompts', pendingModule('../services/apiPrompts'));
+vi.mock('../services/apiProviders', pendingModule('../services/apiProviders'));
+vi.mock('../services/apiUniverseBuilder.js', pendingModule('../services/apiUniverseBuilder.js'));
+vi.mock('../services/socket', () => ({
+ default: { on: vi.fn(), off: vi.fn(), emit: vi.fn(), connected: false },
+}));
+
+const PAGES_DIR = dirname(fileURLToPath(import.meta.url));
+
+// ---------------------------------------------------------------------------
+// Structural guard — tree-wide, and the one that catches the actual regression
+// this file exists for: a page's top-level `if (loading)` reverting to a bare
+// `
Loading…
`, which exposes no `status` role and reserves no layout,
+// so the header/tabs pop in and shove the viewport down (#2843, #5659).
+// ---------------------------------------------------------------------------
+const LOADING_GUARD = /^ {2}if \((?:loading|isLoading)\b[^)]*\)\s*(\{)?/;
+
+// Reading the source (rather than rendering all ~50 pages) is deliberate: it
+// covers every page including ones whose render harness would need a bespoke
+// mock, and it keeps working as pages are added.
+function loadingGuardBodies(source) {
+ const lines = source.split('\n');
+ const bodies = [];
+ lines.forEach((line, i) => {
+ const match = LOADING_GUARD.exec(line);
+ if (!match) return;
+ if (match[1]) {
+ // Braced block: consume until the brace depth returns to zero.
+ let depth = 0;
+ const block = [];
+ for (let j = i; j < lines.length; j += 1) {
+ block.push(lines[j]);
+ depth += (lines[j].match(/\{/g) || []).length - (lines[j].match(/\}/g) || []).length;
+ if (j > i && depth <= 0) break;
+ }
+ bodies.push({ line: i + 1, body: block.join('\n') });
+ return;
+ }
+ // Single-expression guard: consume until the statement terminates.
+ const block = [lines[i]];
+ for (let j = i + 1; !block[block.length - 1].trimEnd().endsWith(';') && j < lines.length; j += 1) {
+ block.push(lines[j]);
+ }
+ bodies.push({ line: i + 1, body: block.join('\n') });
+ });
+ return bodies;
+}
+
+const pageFiles = readdirSync(PAGES_DIR)
+ .filter((f) => f.endsWith('.jsx') && !f.includes('.test.'))
+ .sort();
+
+describe('page loading states reserve their layout', () => {
+ it('renders PageSkeleton from every top-level loading guard', () => {
+ const offenders = [];
+ pageFiles.forEach((file) => {
+ const source = readFileSync(join(PAGES_DIR, file), 'utf8');
+ loadingGuardBodies(source).forEach(({ line, body }) => {
+ // `return null` / bare `return` render nothing at all, so there is no
+ // chrome to reflow — they are not skeleton candidates.
+ if (/return\s*(null)?\s*;/.test(body) && !body.includes('<')) return;
+ if (!body.includes('PageSkeleton')) offenders.push(`${file}:${line}`);
+ });
+ });
+ // A bare `
Loading…
` exposes no `status` role and reserves no
+ // layout, so the header/tabs pop in and shove the viewport down.
+ expect(offenders).toEqual([]);
+ });
+
+ it('gives every PageSkeleton a label naming what is loading, never the bare default', () => {
+ const offenders = [];
+ pageFiles.forEach((file) => {
+ const source = readFileSync(join(PAGES_DIR, file), 'utf8');
+ // One entry per `
` element, self-closing or not.
+ const calls = source.match(//g) || [];
+ calls.forEach((call) => {
+ const label = /label="([^"]*)"/.exec(call);
+ if (!label || label[1].trim() === '' || label[1] === 'Loading') offenders.push(`${file}: ${call.split('\n')[0]}`);
+ });
+ });
+ expect(offenders).toEqual([]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Render guard — the pages converted in #5659, each mounted with its fetch
+// still in flight. A bare text loader exposes no `status` role, so these fail
+// loudly if one comes back.
+// ---------------------------------------------------------------------------
+const RENDERED = [
+ ['AgentsPage', () => import('./AgentsPage').then((m) => m.AgentsPage), '/devtools/agents', '/devtools/agents', 'Scanning for AI agents'],
+ ['AIProviders', () => import('./AIProviders'), '/ai', '/ai', 'Loading providers'],
+ ['BrainScanReport', () => import('./BrainScanReport'), '/brain/links/:id/scan-report', '/brain/links/l1/scan-report', 'Loading scan report'],
+ ['CatalogIngredient', () => import('./CatalogIngredient'), '/catalog/:type/:id', '/catalog/idea/i1', 'Loading ingredient'],
+ ['CreativeDirector', () => import('./CreativeDirector'), '/creative-director', '/creative-director', 'Loading Creative Director projects'],
+ ['Game', () => import('./Game'), '/game', '/game', 'Loading Game studio'],
+ ['Insights (OverviewTab)', () => import('./Insights').then((m) => m.OverviewTab), '/insights/overview', '/insights/overview', 'Loading insights overview'],
+ ['Instances', () => import('./Instances'), '/instances', '/instances', 'Loading instances'],
+ ['Media3DDetail', () => import('./Media3DDetail'), '/3d/:id', '/3d/abc', 'Loading 3D model'],
+ ['PipelineContinuityBible', () => import('./PipelineContinuityBible'), '/pipeline/series/:seriesId/continuity', '/pipeline/series/s1/continuity', 'Loading series continuity'],
+ ['PipelineExport', () => import('./PipelineExport'), '/pipeline/series/:seriesId/export', '/pipeline/series/s1/export', 'Loading export options'],
+ ['PipelineIssue', () => import('./PipelineIssue'), '/pipeline/issues/:issueId/:stageId', '/pipeline/issues/i1/outline', 'Loading pipeline issue'],
+ ['PipelineManuscriptEditor', () => import('./PipelineManuscriptEditor'), '/pipeline/series/:seriesId/manuscript', '/pipeline/series/s1/manuscript', 'Loading manuscript'],
+ ['PipelineReverseOutline', () => import('./PipelineReverseOutline'), '/pipeline/series/:seriesId/outline', '/pipeline/series/s1/outline', 'Loading reverse outline'],
+ ['PipelineSeries', () => import('./PipelineSeries'), '/pipeline/series/:seriesId', '/pipeline/series/s1', 'Loading series'],
+ ['PipelineSeriesRoadmap', () => import('./PipelineSeriesRoadmap'), '/pipeline/series/:seriesId/roadmap', '/pipeline/series/s1/roadmap', 'Loading reader map'],
+ ['PipelineVoiceFingerprint', () => import('./PipelineVoiceFingerprint'), '/pipeline/series/:seriesId/voice', '/pipeline/series/s1/voice', 'Loading voice fingerprint'],
+ ['PromptManager', () => import('./PromptManager'), '/prompts', '/prompts', 'Loading prompts'],
+ ['QuotaBurn', () => import('./QuotaBurn'), '/devtools/quota-burn', '/devtools/quota-burn', 'Loading burn plan'],
+ ['RoundEditor', () => import('./RoundEditor'), '/rounds/:id', '/rounds/r1', 'Loading round'],
+ ['SongBookViewer', () => import('./SongBookViewer'), '/songbook/:id', '/songbook/s1', 'Loading song'],
+ ['Templates', () => import('./Templates'), '/templates', '/templates', 'Loading app templates'],
+ ['VideoTimelineEditor', () => import('./VideoTimelineEditor'), '/media/timeline/:projectId', '/media/timeline/p1', 'Loading timeline project'],
+ ['WorkspaceContexts', () => import('./WorkspaceContexts'), '/workspace-contexts', '/workspace-contexts', 'Loading workspace projects'],
+];
+
+describe('converted pages announce a labelled busy region on first paint', () => {
+ beforeEach(() => {
+ // jsdom ships no `matchMedia`, and a page that reads one on mount throws
+ // before it can paint its skeleton.
+ window.matchMedia = vi.fn((query) => ({
+ matches: false,
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }));
+ });
+
+ // A DATA router, not ``: pages that guard unsaved edits call
+ // `useBlocker`, which throws outside one.
+ it.each(RENDERED)('%s', async (_name, load, routePath, entry, label) => {
+ const mod = await load();
+ const Page = mod.default || mod;
+ const router = createMemoryRouter(
+ [{ path: routePath, element: }],
+ { initialEntries: [entry] },
+ );
+ render();
+ // Settle the mount effects that fire before the (never-settling) fetch, so
+ // the assertion isn't racing an act() warning.
+ await act(async () => {});
+
+ const status = screen.getAllByRole('status')[0];
+ expect(status).toHaveAttribute('aria-busy', 'true');
+ expect(status).toHaveAttribute('aria-label', label);
+ });
+});