diff --git a/src/hooks/__tests__/use-surface-visible.test.tsx b/src/hooks/__tests__/use-surface-visible.test.tsx
new file mode 100644
index 000000000..7004b2bb3
--- /dev/null
+++ b/src/hooks/__tests__/use-surface-visible.test.tsx
@@ -0,0 +1,45 @@
+import React from "react";
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+const useAuthMock = vi.fn();
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => useAuthMock(),
+}));
+const hasClientMock = vi.fn(() => true);
+vi.mock("@/hooks/_internal/use-query-client-safe", () => ({
+ useQueryClientMounted: () => hasClientMock(),
+}));
+
+import { useSurfaceVisible } from "../use-surface-visible";
+
+function Probe({ id }: { id: string }) {
+ return {String(useSurfaceVisible(id))};
+}
+
+const visible = (id: string) =>
+ renderToStaticMarkup().includes(">true<");
+
+describe("useSurfaceVisible", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ hasClientMock.mockReturnValue(true);
+ });
+
+ it("hides a surface whose module the record switched off", () => {
+ useAuthMock.mockReturnValue({ user: { modules: { mood: false } } });
+ expect(visible("capture:mood")).toBe(false);
+ expect(visible("capture:medication")).toBe(true);
+ });
+
+ it("is on before the account resolves", () => {
+ useAuthMock.mockReturnValue({ user: null });
+ expect(visible("capture:mood")).toBe(true);
+ });
+
+ it("is on without a query client, and never reads the account then", () => {
+ hasClientMock.mockReturnValue(false);
+ expect(visible("capture:mood")).toBe(true);
+ expect(useAuthMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/hooks/use-surface-visible.ts b/src/hooks/use-surface-visible.ts
new file mode 100644
index 000000000..e05168db1
--- /dev/null
+++ b/src/hooks/use-surface-visible.ts
@@ -0,0 +1,34 @@
+"use client";
+
+import { useAuth } from "@/hooks/use-auth";
+import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
+import { isSurfaceVisible, type StaticSurfaceId } from "@/lib/modules/surface";
+
+/**
+ * Whether a surface shows for the record this session is on, read through the
+ * one surface map (`src/lib/modules/surface.ts`) against the resolved
+ * `modules` of `GET /api/auth/me`.
+ *
+ * SSR-safe the way `useModuleEnabled` is: a presentational component rendered
+ * without a `QueryClientProvider` gets the default-on answer instead of a
+ * crash. The branch is stable for a component's lifetime, so the conditional
+ * hook call is safe.
+ *
+ * Default-on: before the account resolves, and for every surface no module
+ * owns, the answer is `true`. A caller whose first paint must match the server
+ * render (nav lists, anything behind a hydration gate) keeps its own mounted
+ * check on top.
+ */
+export function useSurfaceVisible(
+ surfaceId: StaticSurfaceId | (string & {}),
+): boolean {
+ const hasClient = useQueryClientMounted();
+ if (!hasClient) return true;
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ return useSurfaceVisibleInner(surfaceId);
+}
+
+function useSurfaceVisibleInner(surfaceId: string): boolean {
+ const { user } = useAuth();
+ return isSurfaceVisible(surfaceId, user?.modules);
+}
diff --git a/src/lib/modules/__tests__/surface.test.ts b/src/lib/modules/__tests__/surface.test.ts
new file mode 100644
index 000000000..7680ed184
--- /dev/null
+++ b/src/lib/modules/__tests__/surface.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+
+import { MODULE_KEYS } from "@/lib/modules/registry";
+import {
+ SURFACE_KINDS,
+ SURFACE_MODULE,
+ isSurfaceVisible,
+ surfaceModule,
+ surfaceModulesOfKind,
+} from "@/lib/modules/surface";
+
+describe("SURFACE_MODULE", () => {
+ it("owns every surface by a real module key", () => {
+ const keys = new Set(MODULE_KEYS);
+ for (const [id, owner] of Object.entries(SURFACE_MODULE)) {
+ expect(keys.has(owner), `${id} → ${owner}`).toBe(true);
+ }
+ });
+
+ it("names every surface as : with a known kind", () => {
+ const kinds = new Set(SURFACE_KINDS);
+ for (const id of Object.keys(SURFACE_MODULE)) {
+ const kind = id.slice(0, id.indexOf(":"));
+ expect(kinds.has(kind), id).toBe(true);
+ expect(id.length).toBeGreaterThan(kind.length + 1);
+ }
+ });
+
+ it("leaves the Insights area to no module (the insights key is AI analysis only)", () => {
+ expect(surfaceModule("nav:/insights")).toBeUndefined();
+ expect(Object.values(SURFACE_MODULE)).not.toContain("insights");
+ });
+});
+
+describe("isSurfaceVisible", () => {
+ it("hides an owned surface only on an explicit false", () => {
+ expect(isSurfaceVisible("trend:mood", { mood: false })).toBe(false);
+ expect(isSurfaceVisible("trend:mood", { mood: true })).toBe(true);
+ expect(isSurfaceVisible("trend:mood", {})).toBe(true);
+ expect(isSurfaceVisible("trend:mood", undefined)).toBe(true);
+ });
+
+ it("never hides a surface no module owns", () => {
+ expect(isSurfaceVisible("trend:bp", { mood: false, sleep: false })).toBe(
+ true,
+ );
+ expect(isSurfaceVisible("nav:/measurements", { mood: false })).toBe(true);
+ });
+
+ it("reads the owner's key, not the surface's name", () => {
+ expect(isSurfaceVisible("capture:medication", { medications: false })).toBe(
+ false,
+ );
+ expect(
+ isSurfaceVisible("insights-page:blood-glucose", { glucose: false }),
+ ).toBe(false);
+ });
+});
+
+describe("surfaceModulesOfKind", () => {
+ it("strips the kind prefix and keeps only that kind", () => {
+ const widgets = surfaceModulesOfKind("widget");
+ expect(widgets.mood).toBe("mood");
+ expect(widgets.recentWorkouts).toBe("workouts");
+ expect(Object.keys(widgets).some((k) => k.includes(":"))).toBe(false);
+ expect(widgets["/mood"]).toBeUndefined();
+ });
+
+ it("returns an empty view for a kind with no entries rather than throwing", () => {
+ for (const kind of SURFACE_KINDS) {
+ expect(typeof surfaceModulesOfKind(kind)).toBe("object");
+ }
+ });
+});
diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts
new file mode 100644
index 000000000..e1c0c7602
--- /dev/null
+++ b/src/lib/modules/surface.ts
@@ -0,0 +1,264 @@
+/**
+ * Which module owns which surface, in one declarative map.
+ *
+ * A module a person switches off has to disappear everywhere at once: the nav
+ * entry, the Insights pill, the overview card, the trends slot, the add menu,
+ * the dashboard tile, the Settings row, the statistics. Each of those used to
+ * keep its own little map (or none), and the ones with none were exactly where
+ * a switched-off module kept showing up. This file is the one answer. Every
+ * surface a toggleable module owns is listed here as `:`, and a
+ * surface that is absent belongs to no module and is never hidden by one.
+ *
+ * The older per-surface maps are views of this one (`surfaceModulesOfKind`),
+ * so they cannot drift from it. `src/lib/modules/__tests__/
+ * surface-module-registry.test.ts` holds every list of surfaces the app renders
+ * against this map.
+ *
+ * The `insights` key is deliberately absent. It means "AI analysis" (model
+ * written text) and owns no surface: the Insights area, its charts and its
+ * statistics are data and stay whatever their own module says. The AI
+ * capability resolver reads that key; surfaces never do.
+ *
+ * Pure data plus two pure functions, no server or React dependency, so the
+ * server builders and the browser read the same file. The React accessor is
+ * `useSurfaceVisible` in `src/hooks/use-surface-visible.ts`.
+ *
+ * Paint, not enforcement: the routes behind a module refuse on their own
+ * through `requireModuleEnabled`. Hiding a surface spares a click that would
+ * end in a refusal or an empty chart.
+ */
+import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields";
+import type { ModuleKey } from "@/lib/modules/registry";
+
+/**
+ * The surface families. The part of an id before the first `:`.
+ *
+ * - `nav`: a navigation destination, by its href (sidebar, bottom bar, the
+ * More hub, the tour stop on that page, and the direct-URL notice).
+ * - `insights-page`: an Insights sub-page, by its slug (the tab-strip pill
+ * and the direct-URL notice under `/insights/`).
+ * - `overview`: a block on the Insights overview, by its section id.
+ * - `trend`: a trends-row chart slot, by its briefing `sourceMetric`.
+ * - `capture`: an entry in the add menu, by its capture kind.
+ * - `widget`: a dashboard widget, by its layout id.
+ * - `summary`: a dashboard summary key (a `MeasurementType` or a synthetic
+ * key such as `MOOD_ENTRY`), stripped from every summaries feed.
+ * - `derived`: a derived score, by its `DerivedMetricId`.
+ * - `settings`: a Settings section, by its slug.
+ * - `settings-layout`: a Settings, Layout group, by its id.
+ * - `correlation`: a correlation-discovery channel, by its channel key.
+ */
+export const SURFACE_KINDS = [
+ "nav",
+ "insights-page",
+ "overview",
+ "trend",
+ "capture",
+ "widget",
+ "summary",
+ "derived",
+ "settings",
+ "settings-layout",
+ "correlation",
+] as const;
+
+export type SurfaceKind = (typeof SURFACE_KINDS)[number];
+
+const STATIC_SURFACE_MODULE = {
+ // ── Navigation destinations ──
+ // `/insights` carries no owner: the area is data, not AI analysis.
+ "nav:/mood": "mood",
+ "nav:/mental-wellbeing": "mentalHealth",
+ "nav:/cycle": "cycle",
+ "nav:/medications": "medications",
+ "nav:/labs": "labs",
+ "nav:/illness": "illness",
+ "nav:/vaccinations": "vaccinations",
+ "nav:/documents": "inboundDocuments",
+ "nav:/coach": "coach",
+ "nav:/achievements": "achievements",
+
+ // ── Insights sub-pages ──
+ "insights-page:mood": "mood",
+ "insights-page:sleep": "sleep",
+ // The per-night breathing-disturbance index is a sleep-page signal.
+ "insights-page:breathing-disturbances": "sleep",
+ "insights-page:blood-glucose": "glucose",
+ "insights-page:workouts": "workouts",
+ "insights-page:nutrients": "nutrients",
+ // Medications is surface-gated by design: its data routes stay open so an
+ // import or a sync keeps working, and these surfaces are what the switch
+ // hides.
+ "insights-page:medications": "medications",
+ // Recovery is a composite page, not a slug with a series; the four
+ // HealthKit recovery metrics below are the same module's, as on the
+ // dashboard (`widget:cardioRecovery` …).
+ "insights-page:recovery": "recovery",
+ "insights-page:cardio-recovery": "recovery",
+ "insights-page:six-minute-walk": "recovery",
+ "insights-page:stair-ascent-speed": "recovery",
+ "insights-page:stair-descent-speed": "recovery",
+
+ // ── Insights overview blocks ──
+ "overview:cycle-summary": "cycle",
+ // The cycle ring rides the wellness-scores strip rather than being a
+ // section of its own; it follows the same key as the cycle summary, so the
+ // two cannot disagree.
+ "overview:cycle-ring": "cycle",
+ // Breathing disturbances are measured overnight; the screening card is the
+ // sleep module's.
+ "overview:breathing": "sleep",
+ "overview:labs-changes": "labs",
+
+ // ── Trends row ──
+ "trend:mood": "mood",
+ "trend:sleep": "sleep",
+
+ // ── Add menu ──
+ "capture:mood": "mood",
+ "capture:medication": "medications",
+
+ // ── Dashboard widgets ──
+ // CORE widgets (weight, blood pressure, pulse, body fat, the vital-derived
+ // HealthKit metrics) carry no entry and are never hidden.
+ "widget:mood": "mood",
+ "widget:sleep": "sleep",
+ "widget:glucose": "glucose",
+ "widget:achievements": "achievements",
+ "widget:recentWorkouts": "workouts",
+ "widget:medications": "medications",
+ "widget:cardioRecovery": "recovery",
+ "widget:sixMinuteWalk": "recovery",
+ "widget:stairAscentSpeed": "recovery",
+ "widget:stairDescentSpeed": "recovery",
+ "widget:breathingDisturbances": "sleep",
+ // The fluid-intake tile reads the nutrient store.
+ "widget:waterIntake": "nutrients",
+
+ // ── Dashboard summary keys ──
+ "summary:SLEEP_DURATION": "sleep",
+ "summary:BLOOD_GLUCOSE": "glucose",
+ "summary:CARDIO_RECOVERY": "recovery",
+ "summary:SIX_MINUTE_WALK_DISTANCE": "recovery",
+ "summary:STAIR_ASCENT_SPEED": "recovery",
+ "summary:STAIR_DESCENT_SPEED": "recovery",
+ // The nightly RMSSD series is the recovery module's. The dashboard HRV tile
+ // falls back to it when an account has no SDNN, and a fallback must not
+ // serve recovery data the primary type refuses. `HEART_RATE_VARIABILITY`
+ // (SDNN) is a plain vital and stays unowned.
+ "summary:HRV_RMSSD": "recovery",
+ "summary:BREATHING_DISTURBANCES": "sleep",
+ // Synthetic keys: derived from `NutrientIntakeDay` and `MoodEntry` rather
+ // than a `MeasurementType`, riding the same gate.
+ "summary:NUTRIENT_WATER": "nutrients",
+ "summary:MOOD_ENTRY": "mood",
+
+ // ── Derived scores ──
+ // READINESS equals the computed recovery score, so it follows recovery.
+ // Composite baselines built on core vitals (VITALS_BASELINE, HRV_BALANCE …)
+ // carry no entry.
+ "derived:SLEEP_SCORE": "sleep",
+ "derived:RECOVERY_SCORE": "recovery",
+ "derived:STRAIN_SCORE": "recovery",
+ "derived:STRESS_SCORE": "recovery",
+ "derived:READINESS": "recovery",
+
+ // ── Settings sections ──
+ "settings:environment": "environment",
+ "settings:coach": "coach",
+
+ // ── Settings, Layout groups ──
+ "settings-layout:medications": "medications",
+ "settings-layout:mood": "mood",
+ "settings-layout:labs": "labs",
+ "settings-layout:illness": "illness",
+
+ // ── Correlation channels ──
+ // Rated mood factors (`FACTOR:`) are mood's too; see
+ // `correlationChannelSurfaceId`.
+ "correlation:MOOD": "mood",
+ "correlation:SLEEP_DURATION": "sleep",
+ "correlation:BLOOD_GLUCOSE": "glucose",
+ "correlation:MEDICATION_COMPLIANCE": "medications",
+ // Symptom burden is read from the illness journal.
+ "correlation:SYMPTOM_SEVERITY": "illness",
+ // The labs ↔ outcome pass over lab draws.
+ "correlation:LAB_DRAWS": "labs",
+} as const satisfies Record;
+
+/** A surface id the map declares literally. */
+export type StaticSurfaceId = keyof typeof STATIC_SURFACE_MODULE;
+
+/**
+ * The map. The weather channels are spread in from the environment field
+ * table so a field added there is owned the moment it exists.
+ */
+export const SURFACE_MODULE: Readonly> =
+ Object.freeze({
+ ...STATIC_SURFACE_MODULE,
+ ...Object.fromEntries(
+ ENVIRONMENT_FIELDS.map((field) => [
+ `correlation:${field.key}`,
+ "environment" as const,
+ ]),
+ ),
+ });
+
+/**
+ * A partial `ModuleKey → enabled` map: the `modules` field of
+ * `GET /api/auth/me`, or `resolveModuleMap()` on the server. Only an explicit
+ * `false` hides; a missing key or a missing map reads as on, matching the
+ * gate's default-on contract.
+ */
+export type SurfaceModuleMap = Partial>;
+
+/** The module that owns a surface, or `undefined` for an unowned one. */
+export function surfaceModule(surfaceId: string): ModuleKey | undefined {
+ return Object.prototype.hasOwnProperty.call(SURFACE_MODULE, surfaceId)
+ ? SURFACE_MODULE[surfaceId]
+ : undefined;
+}
+
+/**
+ * Whether a surface shows under a module map. An unowned surface always
+ * shows; an owned one hides only when its module is explicitly `false`.
+ *
+ * Takes any string so callers can build ids from runtime values
+ * (`widget:${id}`); the guard test is what keeps those ids honest.
+ */
+export function isSurfaceVisible(
+ surfaceId: StaticSurfaceId | (string & {}),
+ modules: SurfaceModuleMap | null | undefined,
+): boolean {
+ const owner = surfaceModule(surfaceId);
+ if (owner === undefined) return true;
+ return modules?.[owner] !== false;
+}
+
+/**
+ * One family of the map with the `:` prefix stripped: `{ mood: "mood",
+ * recentWorkouts: "workouts", … }` for `"widget"`. This is how the older
+ * per-surface maps (`WIDGET_MODULE_BY_ID`, `SUMMARY_TYPE_MODULE`, …) are now
+ * built, so they are views of this file rather than copies of it.
+ */
+export function surfaceModulesOfKind(
+ kind: SurfaceKind,
+): Partial> {
+ const prefix = `${kind}:`;
+ const out: Partial> = {};
+ for (const [id, owner] of Object.entries(SURFACE_MODULE)) {
+ if (id.startsWith(prefix)) out[id.slice(prefix.length)] = owner;
+ }
+ return out;
+}
+
+/**
+ * The surface id of a correlation channel. Rated mood factors carry their own
+ * `FACTOR:` channel names but are mood entries all the same, so they
+ * resolve to the mood channel.
+ */
+export function correlationChannelSurfaceId(channelKey: string): string {
+ return channelKey.startsWith("FACTOR:")
+ ? "correlation:MOOD"
+ : `correlation:${channelKey}`;
+}