diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts
new file mode 100644
index 000000000..6947dbd08
--- /dev/null
+++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts
@@ -0,0 +1,72 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+/**
+ * v1.4.43 W11-M5 — dashboard tile-strip skeleton during slow slim
+ * fetches.
+ *
+ * Pre-fix the strip only painted once `trendCards.length > 0`. When
+ * both slim + thick `/api/analytics` slices lagged (cache eviction,
+ * cold start) the user saw the page header + zero tiles + then a
+ * burst of tiles 9 s later. The fix paints a layout-stable silhouette
+ * keyed off the user's configured tile count
+ * (`layout.widgets.filter(w => w.tileVisible ?? w.visible).length`)
+ * while `analyticsSlimQuery.isLoading` is still in flight, so the
+ * page reserves the strip's footprint immediately and swaps to the
+ * real tiles the moment slim resolves.
+ *
+ * Textual pins keep the contract honest — render-mounting the
+ * dashboard page would haul in TanStack-Query, auth, i18n, layout
+ * and analytics scaffolding for a guard a substring scan already
+ * proves.
+ */
+const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
+
+describe("v1.4.43 W11 — dashboard tile-strip skeleton", () => {
+ it("derives `configuredTileCount` from the resolved layout", () => {
+ const src = readFileSync(PAGE_PATH, "utf8");
+ expect(src).toMatch(
+ /const\s+configuredTileCount\s*=\s*layout\.widgets\.filter\([\s\S]*?w\.tileVisible\s*\?\?\s*w\.visible[\s\S]*?\)\.length;/,
+ );
+ });
+
+ it("gates the skeleton on slim-analytics loading + empty trend cards + configured tiles", () => {
+ const src = readFileSync(PAGE_PATH, "utf8");
+ expect(src).toMatch(
+ /const\s+showTileStripSkeleton\s*=\s*[\s\S]*?trendCards\.length\s*===\s*0[\s\S]*?analyticsSlimQuery\.isLoading[\s\S]*?configuredTileCount\s*>\s*0/,
+ );
+ });
+
+ it("renders the skeleton with the same auto-fit grid track the real strip uses", () => {
+ const src = readFileSync(PAGE_PATH, "utf8");
+ expect(src).toMatch(
+ /showTileStripSkeleton\s*&&\s*\(\s*
{
+ const src = readFileSync(PAGE_PATH, "utf8");
+ expect(src).toMatch(
+ /Array\.from\(\{\s*length:\s*configuredTileCount\s*\}\)/,
+ );
+ });
+
+ it("marks the skeleton aria-hidden + carries motion-reduce", () => {
+ const src = readFileSync(PAGE_PATH, "utf8");
+ const skeletonBlock = src.match(
+ /showTileStripSkeleton\s*&&[\s\S]*?<\/div>\s*\)\}/,
+ );
+ expect(skeletonBlock).not.toBeNull();
+ const block = skeletonBlock?.[0] ?? "";
+ expect(block).toContain('aria-hidden="true"');
+ expect(block).toContain("motion-reduce:animate-none");
+ });
+
+ it("suppresses the EmptyState while the skeleton is showing", () => {
+ const src = readFileSync(PAGE_PATH, "utf8");
+ expect(src).toMatch(
+ /trendCards\.length\s*===\s*0\s*&&\s*charts\.length\s*===\s*0\s*&&\s*!showTileStripSkeleton/,
+ );
+ });
+});
diff --git a/src/app/page.tsx b/src/app/page.tsx
index f457f6459..9c469aa12 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1303,6 +1303,27 @@ export default function DashboardPage() {
charts.sort((a, b) => a.order - b.order);
+ // v1.4.43 W11-M5 — tile-strip skeleton during slow slim-analytics
+ // fetches.
+ //
+ // `trendCards` only fills once the slim `/api/analytics` slice
+ // resolves and reports per-type `count > 0` flags. The v1.4.39.2
+ // slim/thick split keeps the strip painting fast when slim wins,
+ // but when *both* slices lag (cache eviction, cold start) the
+ // user used to see the page header + 0 tiles + then 7 tiles
+ // appear at once 9 s later. The audit recommendation is to
+ // render a layout-stable tile silhouette keyed off the user's
+ // configured tile count so the strip's footprint is reserved
+ // during the slow window. The skeleton swaps in for the real
+ // strip the moment `analyticsSlimQuery.isLoading` flips false.
+ const configuredTileCount = layout.widgets.filter(
+ (w) => w.tileVisible ?? w.visible,
+ ).length;
+ const showTileStripSkeleton =
+ trendCards.length === 0 &&
+ analyticsSlimQuery.isLoading &&
+ configuredTileCount > 0;
+
// v1.4.15 phase-C5: dashboard fully-empty state. When no tile
// and no chart has data the dashboard would otherwise paint a
// 0-px tile strip with the welcome banner above it — visually
@@ -1313,7 +1334,15 @@ export default function DashboardPage() {
// surface for very-new accounts; this empty state covers the
// case where the checklist has been dismissed but no data was
// logged afterwards.
- if (trendCards.length === 0 && charts.length === 0) {
+ //
+ // v1.4.43 W11 — the empty-state only fires once the slim slice
+ // resolves with no tiles to show. While slim is in flight the
+ // skeleton strip below carries the layout footprint.
+ if (
+ trendCards.length === 0 &&
+ charts.length === 0 &&
+ !showTileStripSkeleton
+ ) {
return (
}
@@ -1357,6 +1386,30 @@ export default function DashboardPage() {
CSS-grid `auto-fit + minmax + auto-rows-fr` track that
continues to give every visible tile equal width / equal
height for any non-zero count. */}
+ {showTileStripSkeleton && (
+
+ {Array.from({ length: configuredTileCount }).map((_, idx) => (
+
+ ))}
+
+ )}
{trendCards.length > 0 && (