diff --git a/src/app/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts index 9e26a1939..898830436 100644 --- a/src/app/__tests__/dashboard-hero-wiring.test.ts +++ b/src/app/__tests__/dashboard-hero-wiring.test.ts @@ -7,9 +7,9 @@ import { join } from "node:path"; * * The hero band mounts between the page header and the tile strip: * - * - it gates on the layout's `heroVisible` flag (anything but the - * literal `false` renders — the resolver's clamp) AND on snapshot - * mode (the verdict needs the snapshot payload); + * - it gates on the layout's `heroVisible` flag (only the literal + * `true` renders — the hero is opt-in) AND on snapshot mode (the + * verdict needs the snapshot payload); * - while `primaryLoading` is true (same `!mounted ||` pin the tile * silhouettes use) a footprint-identical skeleton holds the band, * so the hero and the tiles swap to content in the SAME render @@ -30,7 +30,7 @@ describe("dashboard hero — page wiring", () => { it("gates the hero on snapshot mode + the layout's heroVisible flag", () => { expect(src).toMatch( - /const\s+heroVisible\s*=\s*snapshotEnabled\s*&&\s*layout\.heroVisible\s*!==\s*false;/, + /const\s+heroVisible\s*=\s*snapshotEnabled\s*&&\s*layout\.heroVisible\s*===\s*true;/, ); }); diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index 66458f54e..775c2d510 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -477,7 +477,7 @@ describe("PUT /api/dashboard/widgets — heroVisible thread-through", () => { expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false); }); - it("defaults heroVisible to true for a first save with no stored layout", async () => { + it("defaults heroVisible to false for a first save with no stored layout", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ dashboardWidgetsJson: null, } as never); @@ -495,7 +495,7 @@ describe("PUT /api/dashboard/widgets — heroVisible thread-through", () => { .calls[0]?.[0] as unknown as { data: { dashboardWidgetsJson: { heroVisible: boolean } }; }; - expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(true); + expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false); }); it("422s on a non-boolean heroVisible", async () => { diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 5896c6322..7795a07b2 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -283,7 +283,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {}; } if (mergedHeroVisible === undefined) { - mergedHeroVisible = existingLayout.heroVisible !== false; + mergedHeroVisible = existingLayout.heroVisible === true; } } const normalized = serializeDashboardLayout({ diff --git a/src/app/page.tsx b/src/app/page.tsx index 48e4dce3d..56d967b76 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -709,13 +709,13 @@ export default function DashboardPage() { // hero band (it derives from the snapshot's server-computed // `greetingHour`, so the old client-side Intl hour walk retired // with it). The hero gates on the layout's `heroVisible` flag - // (default on; anything but the literal `false` renders) and holds + // (opt-in; only the literal `true` renders) and holds // a footprint-identical skeleton while the snapshot is in flight, // so the band swaps in place — no growth, no greeting flash. When // the hero does NOT render (toggle off, or snapshot flag off) the // header takes the greeting back — it must never disappear with the // band. - const heroVisible = snapshotEnabled && layout.heroVisible !== false; + const heroVisible = snapshotEnabled && layout.heroVisible === true; const heroSnapshot = snapshotQuery.data; return ( diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx index 73ab9f58f..386955c60 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -299,17 +299,7 @@ describe(" — hero visibility switch", () => { expect(en).toContain("Daily overview"); }); - it("reflects heroVisible: default layout → checked, false → unchecked", () => { - const checkedHtml = render( - , - ); - const checked = checkedHtml.match( - /]*data-slot="hero-visible-switch"[^>]*>/, - ); - expect(checked).not.toBeNull(); - expect(checked![0]).toContain('data-state="checked"'); - - queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: false }; + it("reflects heroVisible: default layout → unchecked, true → checked", () => { const uncheckedHtml = render( , ); @@ -318,6 +308,16 @@ describe(" — hero visibility switch", () => { ); expect(unchecked).not.toBeNull(); expect(unchecked![0]).toContain('data-state="unchecked"'); + + queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }; + const checkedHtml = render( + , + ); + const checked = checkedHtml.match( + /]*data-slot="hero-visible-switch"[^>]*>/, + ); + expect(checked).not.toBeNull(); + expect(checked![0]).toContain('data-state="checked"'); }); it("persists through the existing PUT draft flow (source pins)", () => { diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 0699eb892..d93498f9d 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -408,7 +408,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { setHeroVisible(v)} disabled={saveMutation.isPending} data-slot="hero-visible-switch" diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 964ede10a..827b8498a 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -380,59 +380,60 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => { /** * Dashboard hero (daily verdict) visibility — `heroVisible` piggy-backs * on the layout blob like the B8 comparison baseline. Resolver contract: - * anything that is not the literal `false` clamps back to `true` - * (default-on for legacy blobs; a stale client cannot poison the field - * with a non-boolean). Serializer persists the resolved boolean - * explicitly so a re-read never has to guess the default. + * anything that is not the literal `true` clamps back to `false` + * (the hero is opt-in; legacy blobs keep it off and a stale client + * cannot poison the field with a non-boolean). Serializer persists the + * resolved boolean explicitly so a re-read never has to guess the + * default. */ describe("resolveDashboardLayout() — heroVisible", () => { - it("defaults to true for legacy layouts (no field saved)", () => { + it("defaults to false for legacy layouts (no field saved)", () => { const legacy = { version: 1, widgets: [{ id: "weight", visible: true, order: 0 }], }; - expect(resolveDashboardLayout(legacy).heroVisible).toBe(true); + expect(resolveDashboardLayout(legacy).heroVisible).toBe(false); }); - it("respects an explicit heroVisible: false", () => { + it("respects an explicit heroVisible: true", () => { const saved = { version: 1, widgets: [{ id: "weight", visible: true, order: 0 }], - heroVisible: false, + heroVisible: true, }; - expect(resolveDashboardLayout(saved).heroVisible).toBe(false); + expect(resolveDashboardLayout(saved).heroVisible).toBe(true); }); - it("clamps non-boolean values back to true", () => { - for (const poisoned of ["false", 0, null, 1, {}]) { + it("clamps non-boolean values back to false", () => { + for (const poisoned of ["true", 0, null, 1, {}]) { const saved = { version: 1, widgets: [{ id: "weight", visible: true, order: 0 }], heroVisible: poisoned, }; - expect(resolveDashboardLayout(saved).heroVisible).toBe(true); + expect(resolveDashboardLayout(saved).heroVisible).toBe(false); } }); - it("defaults heroVisible to true in DEFAULT_DASHBOARD_LAYOUT", () => { - expect(DEFAULT_DASHBOARD_LAYOUT.heroVisible).toBe(true); + it("defaults heroVisible to false in DEFAULT_DASHBOARD_LAYOUT", () => { + expect(DEFAULT_DASHBOARD_LAYOUT.heroVisible).toBe(false); }); - it("preserves heroVisible: false through serialize → resolve round-trip", () => { + it("preserves heroVisible: true through serialize → resolve round-trip", () => { const layout: DashboardLayout = { ...DEFAULT_DASHBOARD_LAYOUT, - heroVisible: false, + heroVisible: true, }; const resolved = resolveDashboardLayout(serializeDashboardLayout(layout)); - expect(resolved.heroVisible).toBe(false); + expect(resolved.heroVisible).toBe(true); }); - it("serializer derives true when the field is missing on input", () => { + it("serializer derives false when the field is missing on input", () => { const layout: DashboardLayout = { version: 1, widgets: [{ id: "weight", visible: true, order: 0 }], }; - expect(serializeDashboardLayout(layout).heroVisible).toBe(true); + expect(serializeDashboardLayout(layout).heroVisible).toBe(false); }); }); diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 06c7f3317..3c58e3cf7 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -324,9 +324,9 @@ export interface DashboardLayout { /** * Dashboard hero (daily verdict) visibility. Piggy-backs on the * layout blob like the B8 comparison baseline — a UI affordance, not - * an analytical attribute, so no Prisma migration. The resolver - * clamps anything that is not the literal `false` back to `true` - * (legacy blobs without the field render the hero by default; a + * an analytical attribute, so no Prisma migration. Opt-in: the + * resolver clamps anything that is not the literal `true` back to + * `false` (legacy blobs without the field keep the hero off; a * stale client cannot poison it with a non-boolean), and the * serializer persists the resolved boolean explicitly. */ @@ -342,9 +342,9 @@ const DASHBOARD_LAYOUT_VERSION = 1; */ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { version: DASHBOARD_LAYOUT_VERSION, - // Hero (daily verdict) is on by default; users opt out via + // Hero (daily verdict) is off by default; users opt in via // Settings → Dashboard. - heroVisible: true, + heroVisible: false, widgets: [ { id: "weight", visible: true, tileVisible: true, order: 0 }, { id: "bp", visible: true, tileVisible: true, order: 1 }, @@ -462,10 +462,10 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout { // client cannot poison the dashboard with values the renderer // doesn't know how to draw. chartOverlayPrefs: coerceChartOverlayPrefsMap(candidate.chartOverlayPrefs), - // Hero visibility — anything that is not the literal `false` - // clamps to `true` (default-on for legacy blobs and malformed - // values alike). - heroVisible: candidate.heroVisible !== false, + // Hero visibility — anything that is not the literal `true` + // clamps to `false` (default-off for legacy blobs and malformed + // values alike; the hero is opt-in). + heroVisible: candidate.heroVisible === true, }; } @@ -495,6 +495,6 @@ export function serializeDashboardLayout( chartOverlayPrefs: coerceChartOverlayPrefsMap(layout.chartOverlayPrefs), // Persist hero visibility explicitly (same clamp as the resolver) // so a re-read never has to guess the default. - heroVisible: layout.heroVisible !== false, + heroVisible: layout.heroVisible === true, }; }