12 KiB
Base Table Skeleton Loading State 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: Replace the centered Mantine <Loader> that currently renders while a base is loading with a layout-matching skeleton of the toolbar + grid built from Mantine <Skeleton> shimmers, so there is no layout shift when real data lands.
Architecture: A new self-contained component that renders the same DOM skeleton as the real table (toolbar row + header row + N body rows) using Mantine's Skeleton primitive, styled with the existing grid CSS module so tracks and heights match 1:1. BaseTable swaps its loading branch from <Loader> to <BaseTableSkeleton />.
Tech Stack: React 18, Mantine v8 Skeleton, existing CSS module (grid.module.css).
Background
Current loading branch in base-table.tsx:157-163:
if (baseLoading || rowsLoading) {
return (
<div className={classes.loadingOverlay}>
<Loader size="md" />
</div>
);
}
.loadingOverlay (grid.module.css:290-295) is a centered flex container. Only used here.
Real table structure (for reference so the skeleton matches):
- Toolbar row — view tabs on the left (each is a ~32px-wide pill), four
ActionIcons on the right (16px icons). - Header row — subgrid. Pinned row-number column (64px). Primary column pinned. Each header cell is 34px tall, has a 14px type icon, and a short property-name label.
- Body rows — subgrid, 36px min-height, cells separated by 1px borders.
Matching the real layout 1:1 means:
- Same
display: grid+grid-template-columnson the outer container. - Same
.headerRow/.row/.cellclasses fromgrid.module.cssso padding, borders, and heights line up. - When the real data lands, the only visual change is
<Skeleton>→ real content — no reflow, no column-width jump.
Skeleton dimensions (tuned for a neutral default, since we don't yet know the view's column widths):
- 6 columns, 180px each (matches
DEFAULT_COLUMN_WIDTHinuse-base-table.ts:25). - Row-number column: 64px (matches
ROW_NUMBER_COLUMN_WIDTH). - 10 body rows.
- Toolbar: 3 view tab pills (44px each), 4 action icons (22px each).
Varying the per-cell skeleton width within each column (between ~50% and ~85% of the cell width) adds realism — otherwise every cell skeleton is identical and screams "fake".
File Structure
New files:
apps/client/src/features/base/components/base-table-skeleton.tsx— the skeleton component.apps/client/src/features/base/styles/base-table-skeleton.module.css— minimal additional styles (the skeleton cell wrapper needs width-constrained<Skeleton>children that center vertically in the 36px cell).
Modified files:
apps/client/src/features/base/components/base-table.tsx— swap the loading branch to render<BaseTableSkeleton />; drop the now-unusedLoaderimport.apps/client/src/features/base/styles/grid.module.css— remove.loadingOverlay(dead).
No new deps — Skeleton is already exported from @mantine/core.
Task 1: Build the skeleton component
Files:
-
Create:
apps/client/src/features/base/components/base-table-skeleton.tsx -
Create:
apps/client/src/features/base/styles/base-table-skeleton.module.css -
Step 1: Create the CSS module
apps/client/src/features/base/styles/base-table-skeleton.module.css:
.toolbar {
display: flex;
align-items: center;
gap: var(--mantine-spacing-xs);
padding: var(--mantine-spacing-xs) 0;
margin-bottom: var(--mantine-spacing-xs);
}
.toolbarTabs {
display: flex;
gap: 6px;
flex: 1;
}
.toolbarActions {
display: flex;
gap: var(--mantine-spacing-xs);
margin-left: auto;
}
.gridWrapper {
overflow: hidden;
flex: 1;
border-top: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.grid {
display: grid;
}
.cellInner {
display: flex;
align-items: center;
height: 100%;
width: 100%;
padding: 0 8px;
}
.headerCellInner {
display: flex;
align-items: center;
gap: 6px;
height: 100%;
width: 100%;
padding: 0 8px;
}
- Step 2: Create the skeleton component
apps/client/src/features/base/components/base-table-skeleton.tsx:
import { Skeleton } from "@mantine/core";
import gridClasses from "@/features/base/styles/grid.module.css";
import classes from "@/features/base/styles/base-table-skeleton.module.css";
const ROW_NUMBER_WIDTH = 64;
const COLUMN_WIDTH = 180;
const COLUMN_COUNT = 6;
const ROW_COUNT = 10;
// Pseudo-random but deterministic widths so the skeleton doesn't flicker
// between renders. Values are a rough normal distribution around
// 60-85 % of the cell width.
const CELL_WIDTH_RATIOS = [0.78, 0.62, 0.84, 0.55, 0.71, 0.66];
const HEADER_WIDTH_RATIOS = [0.42, 0.58, 0.5, 0.64, 0.46, 0.54];
export function BaseTableSkeleton() {
const gridTemplateColumns = [
`${ROW_NUMBER_WIDTH}px`,
...Array.from({ length: COLUMN_COUNT }, () => `${COLUMN_WIDTH}px`),
].join(" ");
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className={classes.toolbar}>
<div className={classes.toolbarTabs}>
<Skeleton height={22} width={44} radius="sm" />
<Skeleton height={22} width={64} radius="sm" />
<Skeleton height={22} width={48} radius="sm" />
</div>
<div className={classes.toolbarActions}>
<Skeleton height={22} width={22} circle />
<Skeleton height={22} width={22} circle />
<Skeleton height={22} width={22} circle />
<Skeleton height={22} width={22} circle />
</div>
</div>
<div className={classes.gridWrapper}>
<div className={classes.grid} style={{ gridTemplateColumns }}>
{/* Header row */}
<div className={gridClasses.headerCell}>
<div className={classes.headerCellInner}>
<Skeleton height={14} width={14} circle />
</div>
</div>
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
<div key={`h-${colIndex}`} className={gridClasses.headerCell}>
<div className={classes.headerCellInner}>
<Skeleton height={14} width={14} circle />
<Skeleton
height={10}
width={`${HEADER_WIDTH_RATIOS[colIndex] * 100}%`}
radius="sm"
/>
</div>
</div>
))}
{/* Body rows */}
{Array.from({ length: ROW_COUNT }).map((_, rowIndex) => (
<div
key={`row-${rowIndex}`}
style={{ display: "contents" }}
>
<div className={gridClasses.cell}>
<div className={classes.cellInner}>
<Skeleton height={10} width={18} radius="sm" />
</div>
</div>
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
<div
key={`cell-${rowIndex}-${colIndex}`}
className={gridClasses.cell}
>
<div className={classes.cellInner}>
<Skeleton
height={10}
width={`${CELL_WIDTH_RATIOS[(rowIndex + colIndex) % CELL_WIDTH_RATIOS.length] * 100}%`}
radius="sm"
/>
</div>
</div>
))}
</div>
))}
</div>
</div>
</div>
);
}
Key points the implementer must not change:
-
gridClasses.headerCellandgridClasses.cellcome from the REAL table's CSS module so borders, heights, and hover semantics match exactly. Don't reinvent them. -
The
style={{ display: "contents" }}row wrapper is intentional — the outer.gridis a single CSS grid, and each "row" is just a flattened sequence of cells that span the grid columns viadisplay: contents. This mirrors how the real table flattens rows (see.rowwithgrid-column: 1 / -1; grid-template-columns: subgrid;ingrid.module.css:119-123). We usecontentsinstead of subgrid because the skeleton's outer grid is not a subgrid. -
Using
Skeletonwithcircleprop for the row-number placeholder and type-icon placeholders — these match the real UI's round/small icon presence. -
The
CELL_WIDTH_RATIOS[(rowIndex + colIndex) % ...]gives each cell a deterministic-but-varied skeleton width so it doesn't look like a stamped pattern. -
Step 3: Build to verify TypeScript compiles
pnpm nx run client:build
Expected: success.
- Step 4: Commit
git add \
apps/client/src/features/base/components/base-table-skeleton.tsx \
apps/client/src/features/base/styles/base-table-skeleton.module.css
git commit -m "feat(base): add layout-matching skeleton loading component"
Task 2: Swap the loader for the skeleton in BaseTable
Files:
-
Modify:
apps/client/src/features/base/components/base-table.tsx -
Modify:
apps/client/src/features/base/styles/grid.module.css -
Step 1: Replace the loading branch
In base-table.tsx:
Drop Loader from the @mantine/core import (line 2). Leave Text and Stack — they're still used by the error branch.
Add near the other imports:
import { BaseTableSkeleton } from "@/features/base/components/base-table-skeleton";
Change lines 157-163:
Before:
if (baseLoading || rowsLoading) {
return (
<div className={classes.loadingOverlay}>
<Loader size="md" />
</div>
);
}
After:
if (baseLoading || rowsLoading) {
return <BaseTableSkeleton />;
}
- Step 2: Remove the dead
.loadingOverlayclass
In apps/client/src/features/base/styles/grid.module.css, delete lines 290-295 (the .loadingOverlay { ... } block — exact content):
.loadingOverlay {
display: flex;
align-items: center;
justify-content: center;
padding: var(--mantine-spacing-xl);
}
- Step 3: Build
pnpm nx run client:build
Expected: success with no "unused" warnings from the removed class.
- Step 4: Commit
git add \
apps/client/src/features/base/components/base-table.tsx \
apps/client/src/features/base/styles/grid.module.css
git commit -m "feat(base): show table skeleton instead of centered loader on load"
Task 3: USER smoke test
⚠️ Do not run
pnpm devas an agent. Hand off.
Ask the user to:
-
Fresh load. Open a base fresh (full tab reload). The skeleton should render immediately, then transition cleanly to the real table. No jarring jump, no centered spinner.
-
Throttled load. DevTools → Network tab → throttle to "Slow 3G" → reload. The skeleton should stay visible for the duration of the slow request, shimmer visible the whole time.
-
Dark mode. Toggle to dark mode. Skeleton colors should render appropriately (Mantine's
Skeletonhandles this automatically via light-dark tokens). -
Window resize during load. Resize the browser window while the skeleton is showing. Skeleton's CSS grid should stretch the columns proportionally — no layout break.
-
Error state still works. Hard to trigger; if you can, disable network entirely and reload. You should see the existing "Failed to load base" message, NOT the skeleton stuck forever.
-
No console errors / CSS warnings during transition from skeleton → real table.
If all pass, the swap is done.
Out of scope
- Matching the exact column count and widths the view ends up rendering. The skeleton is a neutral placeholder; a perfect match would require knowing the view config, which we don't have before the base query resolves. A 6-column, 180px default is "close enough to not flash".
- Skeleton for
GridContainerinside an already-loaded base (e.g., when switching views of the same base, where we already have properties). Out of scope — this plan only addresses the initial load path. - Progressive hydration (render the toolbar first, then skeleton rows as they stream in). Overkill for a small base query.