[object Object]

← back to Norma

feat(theme): hoist inbox theme switcher to global AppShell header

70fa5dcdecd716cecbcb2c231ce208d7eeefcf9c · 2026-05-20 13:50:34 -0700 · Steve Abrams

- lib/inbox-themes.ts: single source of truth for the 32-entry registry
- components/InboxThemeSwitcher.tsx: header dropdown that writes
  document.body.dataset.inboxTheme + persists to localStorage
- AppShell.tsx renders <InboxThemeSwitcher /> next to API Registry button
- GmailCRMTab.tsx: removed duplicate state + dropdown + data-attribute
  (no more 'find the toggle inside the Gmail CRM tab'); kept the
  .gmail-crm-tab className so CSS skins still scope down
- globals.css: 32 selectors rewritten from
    .gmail-crm-tab[data-inbox-theme='…']
  to
    body[data-inbox-theme='…'] .gmail-crm-tab
  so the toggle on <body> propagates down to the inbox tab

Files touched

Diff

commit 70fa5dcdecd716cecbcb2c231ce208d7eeefcf9c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 20 13:50:34 2026 -0700

    feat(theme): hoist inbox theme switcher to global AppShell header
    
    - lib/inbox-themes.ts: single source of truth for the 32-entry registry
    - components/InboxThemeSwitcher.tsx: header dropdown that writes
      document.body.dataset.inboxTheme + persists to localStorage
    - AppShell.tsx renders <InboxThemeSwitcher /> next to API Registry button
    - GmailCRMTab.tsx: removed duplicate state + dropdown + data-attribute
      (no more 'find the toggle inside the Gmail CRM tab'); kept the
      .gmail-crm-tab className so CSS skins still scope down
    - globals.css: 32 selectors rewritten from
        .gmail-crm-tab[data-inbox-theme='…']
      to
        body[data-inbox-theme='…'] .gmail-crm-tab
      so the toggle on <body> propagates down to the inbox tab
---
 app/api/gmail/mailboxes/route.ts  | 26 ++++++++++++++++
 app/api/gmail/messages/route.ts   | 39 ++++++++++++++++-------
 app/globals.css                   | 64 +++++++++++++++++++-------------------
 components/AppShell.tsx           |  3 ++
 components/InboxThemeSwitcher.tsx | 55 +++++++++++++++++++++++++++++++++
 components/gmail/GmailCRMTab.tsx  | 65 +++------------------------------------
 lib/inbox-themes.ts               | 49 +++++++++++++++++++++++++++++
 7 files changed, 198 insertions(+), 103 deletions(-)

diff --git a/app/api/gmail/mailboxes/route.ts b/app/api/gmail/mailboxes/route.ts
new file mode 100644
index 0000000..3a06630
--- /dev/null
+++ b/app/api/gmail/mailboxes/route.ts
@@ -0,0 +1,26 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { requireRole } from '@/lib/require-role';
+
+/**
+ * GET /api/gmail/mailboxes
+ * List all active mailboxes the current user can switch between in the Gmail CRM.
+ */
+export async function GET(request: NextRequest) {
+  const auth = requireRole(request, 'admin', 'staff', 'pulse');
+  if (auth instanceof NextResponse) return auth;
+
+  try {
+    const result = await query(
+      `SELECT id, email, display_name, is_active, needs_reauth, last_synced_at,
+              COALESCE((SELECT COUNT(*)::int FROM gmail_messages gm WHERE gm.mailbox_id = m.id AND NOT (gm.labels @> ARRAY['TRASH'])), 0) AS message_count
+       FROM mailboxes m
+       WHERE is_active = true
+       ORDER BY email`,
+    );
+    return NextResponse.json({ mailboxes: result.rows });
+  } catch (err) {
+    console.error('[api/gmail/mailboxes] GET error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to load mailboxes', mailboxes: [] }, { status: 500 });
+  }
+}
diff --git a/app/api/gmail/messages/route.ts b/app/api/gmail/messages/route.ts
index b722624..c38180c 100644
--- a/app/api/gmail/messages/route.ts
+++ b/app/api/gmail/messages/route.ts
@@ -79,24 +79,41 @@ export async function GET(request: NextRequest) {
       userEmail = auth.username;
     }
     if (userEmail) {
-      const mb = await query<{ id: string }>(
-        'SELECT id FROM mailboxes WHERE LOWER(email) = LOWER($1) AND is_active = true',
+      const mb = await query<{ id: string; oauth_tokens: unknown; needs_reauth: boolean | null }>(
+        `SELECT id, oauth_tokens, needs_reauth
+           FROM mailboxes
+          WHERE LOWER(email) = LOWER($1) AND is_active = true
+          ORDER BY (oauth_tokens IS NOT NULL) DESC
+          LIMIT 1`,
         [userEmail],
       );
-      if (mb.rows.length > 0) {
-        conditions.push(`mailbox_id = $${idx++}`);
-        params.push(mb.rows[0].id);
-      } else {
-        // User has no connected mailbox — return zero rows.
+      if (mb.rows.length === 0) {
+        // No mailbox row at all → tell the UI to show Connect Gmail.
         return NextResponse.json({
-          messages:        [],
-          total:           0,
+          messages:      [],
+          total:         0,
           limit,
           offset,
-          mailbox_email:   userEmail,
-          needs_connect:   true,
+          mailbox_email: userEmail,
+          needs_connect: true,
         });
       }
+      const row = mb.rows[0];
+      const connected = !!row.oauth_tokens && !row.needs_reauth;
+      if (!connected) {
+        // Row exists but never OAuth'd (or revoked) → same outcome.
+        return NextResponse.json({
+          messages:      [],
+          total:         0,
+          limit,
+          offset,
+          mailbox_email: userEmail,
+          needs_connect: true,
+          needs_reauth:  !!row.needs_reauth,
+        });
+      }
+      conditions.push(`mailbox_id = $${idx++}`);
+      params.push(row.id);
     } else {
       // No identifiable email at all — empty list (no leakage from other users).
       return NextResponse.json({ messages: [], total: 0, limit, offset, needs_connect: true });
diff --git a/app/globals.css b/app/globals.css
index 5c06338..4da819c 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1289,7 +1289,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 /* v1 Classic Dense — current defaults, no overrides */
 
 /* v2 Notion Cards — light, generous, indigo accent */
-.gmail-crm-tab[data-inbox-theme="notion-cards"] {
+body[data-inbox-theme="notion-cards"] .gmail-crm-tab {
   --color-bg: #fafafa; --color-surface: #fff; --color-surface-el: #f4f4f5;
   --color-text: #27272a; --color-text-secondary: #52525b; --color-text-muted: #71717a;
   --color-border: #e4e4e7; --color-primary: #059669; --color-primary-hover: #047857;
@@ -1297,7 +1297,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v3 Linear Keyboard — mono dark, purple accent */
-.gmail-crm-tab[data-inbox-theme="linear-keyboard"] {
+body[data-inbox-theme="linear-keyboard"] .gmail-crm-tab {
   --color-bg: #08090b; --color-surface: #0e0f12; --color-surface-el: #16181c;
   --color-text: #e6e6eb; --color-text-secondary: #b4b6bc; --color-text-muted: #7a7d85;
   --color-border: #22252b; --color-primary: #5e6ad2; --color-primary-hover: #4e58b8;
@@ -1305,7 +1305,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v4 Apple Mail — light, sans, blue accent */
-.gmail-crm-tab[data-inbox-theme="apple-mail"] {
+body[data-inbox-theme="apple-mail"] .gmail-crm-tab {
   --color-bg: #f5f5f7; --color-surface: #fff; --color-surface-el: #f9f9fc;
   --color-text: #1d1d1f; --color-text-secondary: #424245; --color-text-muted: #86868b;
   --color-border: #d2d2d7; --color-primary: #0070f3; --color-primary-hover: #005bcb;
@@ -1313,14 +1313,14 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v5 Front Conversation — dark teal */
-.gmail-crm-tab[data-inbox-theme="front-conversation"] {
+body[data-inbox-theme="front-conversation"] .gmail-crm-tab {
   --color-bg: #0d1119; --color-surface: #141925; --color-surface-el: #1d2434;
   --color-text: #ebebef; --color-text-secondary: #b5bcd0; --color-text-muted: #8a92a8;
   --color-border: #2a3148; --color-primary: #00d4ae; --color-primary-hover: #00b894;
 }
 
 /* v6 Hey Zero — warm cream + serif */
-.gmail-crm-tab[data-inbox-theme="hey-zero"] {
+body[data-inbox-theme="hey-zero"] .gmail-crm-tab {
   --color-bg: #faf8f3; --color-surface: #fff; --color-surface-el: #f1ede4;
   --color-text: #1b1814; --color-text-secondary: #43382d; --color-text-muted: #7a6e5a;
   --color-border: #e6dfd1; --color-primary: #c33; --color-primary-hover: #a82424;
@@ -1328,7 +1328,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v7 Spotify Dark */
-.gmail-crm-tab[data-inbox-theme="spotify-dark"] {
+body[data-inbox-theme="spotify-dark"] .gmail-crm-tab {
   --color-bg: #121212; --color-surface: #181818; --color-surface-el: #282828;
   --color-text: #fff; --color-text-secondary: #d4d4d4; --color-text-muted: #a7a7a7;
   --color-border: #2a2a2a; --color-primary: #1db954; --color-primary-hover: #1ed760;
@@ -1336,7 +1336,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v8 Minimal Mono — black on white, Helvetica */
-.gmail-crm-tab[data-inbox-theme="minimal-mono"] {
+body[data-inbox-theme="minimal-mono"] .gmail-crm-tab {
   --color-bg: #fff; --color-surface: #fff; --color-surface-el: #fafafa;
   --color-text: #000; --color-text-secondary: #333; --color-text-muted: #666;
   --color-border: #000; --color-primary: #000; --color-primary-hover: #333;
@@ -1344,7 +1344,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v9 Newsprint — sepia broadsheet */
-.gmail-crm-tab[data-inbox-theme="newsprint"] {
+body[data-inbox-theme="newsprint"] .gmail-crm-tab {
   --color-bg: #f1ebdc; --color-surface: #faf5e6; --color-surface-el: #ebe3cf;
   --color-text: #1c1410; --color-text-secondary: #443a30; --color-text-muted: #6b5e4f;
   --color-border: #a89678; --color-primary: #a8221c; --color-primary-hover: #8a1816;
@@ -1352,7 +1352,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v10 Glassmorphism — translucent on purple gradient */
-.gmail-crm-tab[data-inbox-theme="glassmorphism"] {
+body[data-inbox-theme="glassmorphism"] .gmail-crm-tab {
   --color-bg: #1a0a3a; --color-surface: rgba(255,255,255,0.06); --color-surface-el: rgba(255,255,255,0.10);
   --color-text: #fff; --color-text-secondary: #d8d8ff; --color-text-muted: #c8c8ff;
   --color-border: rgba(255,255,255,0.15); --color-primary: #7df9ff; --color-primary-hover: #5dd5dc;
@@ -1360,7 +1360,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v11 Terminal — green phosphor, monospace */
-.gmail-crm-tab[data-inbox-theme="terminal"] {
+body[data-inbox-theme="terminal"] .gmail-crm-tab {
   --color-bg: #001100; --color-surface: #002211; --color-surface-el: #003322;
   --color-text: #33ff66; --color-text-secondary: #28cc52; --color-text-muted: #1a7a33;
   --color-border: #1a4a26; --color-primary: #33ff66; --color-primary-hover: #66ff99;
@@ -1369,7 +1369,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v12 Mochi Pastel — blush + lavender + mint */
-.gmail-crm-tab[data-inbox-theme="mochi-pastel"] {
+body[data-inbox-theme="mochi-pastel"] .gmail-crm-tab {
   --color-bg: #fdf6f3; --color-surface: #fff; --color-surface-el: #fef0eb;
   --color-text: #3a2e2a; --color-text-secondary: #6a5a52; --color-text-muted: #9a8a82;
   --color-border: #f0e0d8; --color-primary: #f0a3a3; --color-primary-hover: #e08a8a;
@@ -1377,17 +1377,17 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v13 High-Contrast A11y — Atkinson Hyperlegible, 18px floor */
-.gmail-crm-tab[data-inbox-theme="a11y-high-contrast"] {
+body[data-inbox-theme="a11y-high-contrast"] .gmail-crm-tab {
   --color-bg: #fff; --color-surface: #fff; --color-surface-el: #fffacc;
   --color-text: #000; --color-text-secondary: #000; --color-text-muted: #000;
   --color-border: #000; --color-primary: #0030c8; --color-primary-hover: #001f88;
   font-family: "Atkinson Hyperlegible", "Helvetica Neue", Helvetica, Arial, sans-serif;
   font-size: 18px; line-height: 1.6;
 }
-.gmail-crm-tab[data-inbox-theme="a11y-high-contrast"] button { min-height: 48px; }
+body[data-inbox-theme="a11y-high-contrast"] .gmail-crm-tab button { min-height: 48px; }
 
 /* v14 Material 3 — purple primary, rounded */
-.gmail-crm-tab[data-inbox-theme="material"] {
+body[data-inbox-theme="material"] .gmail-crm-tab {
   --color-bg: #f8f6fd; --color-surface: #fff; --color-surface-el: #ece7f4;
   --color-text: #1c1b1f; --color-text-secondary: #313033; --color-text-muted: #49454f;
   --color-border: #cac4d0; --color-primary: #6750a4; --color-primary-hover: #7c69b8;
@@ -1395,7 +1395,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v15 Synthwave — magenta + cyan on indigo */
-.gmail-crm-tab[data-inbox-theme="synthwave"] {
+body[data-inbox-theme="synthwave"] .gmail-crm-tab {
   --color-bg: #1a0033; --color-surface: #2a005a; --color-surface-el: #3d0080;
   --color-text: #ffd5ff; --color-text-secondary: #d8a8e8; --color-text-muted: #aa8acc;
   --color-border: #5a0a8c; --color-primary: #ff2bff; --color-primary-hover: #ff66ff;
@@ -1405,7 +1405,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 /* v16 Kanban Lanes — Trello palette, light. Note: structural lane layout
    is NOT applied via CSS-only; this theme provides palette+typography only.
    To get the actual horizontal lanes, see /mockups/inbox/v16-kanban-lanes.html. */
-.gmail-crm-tab[data-inbox-theme="kanban-lanes"] {
+body[data-inbox-theme="kanban-lanes"] .gmail-crm-tab {
   --color-bg: #f4f5f7; --color-surface: #fff; --color-surface-el: #ebecf0;
   --color-text: #172b4d; --color-text-secondary: #344563; --color-text-muted: #5e6c84;
   --color-border: #dfe1e6; --color-primary: #0052cc; --color-primary-hover: #003d99;
@@ -1415,7 +1415,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 /* ─── INBOX THEMES v17–v32 ─────────────────────────────────────────────── */
 
 /* v17 Ottoman Tile — navy + gold + teal, serif */
-.gmail-crm-tab[data-inbox-theme="ottoman-tile"] {
+body[data-inbox-theme="ottoman-tile"] .gmail-crm-tab {
   --color-bg: #0a1f33; --color-surface: #102845; --color-surface-el: #19385f;
   --color-text: #fef0c8; --color-text-secondary: #e4c290; --color-text-muted: #c4a16a;
   --color-border: #3a5278; --color-primary: #d4a437; --color-primary-hover: #b88c20;
@@ -1423,7 +1423,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v18 Brutalist Concrete — raw gray + hot orange */
-.gmail-crm-tab[data-inbox-theme="brutalist"] {
+body[data-inbox-theme="brutalist"] .gmail-crm-tab {
   --color-bg: #d4d4d0; --color-surface: #c0c0bc; --color-surface-el: #b4b4b0;
   --color-text: #1a1a18; --color-text-secondary: #2a2a28; --color-text-muted: #5a5a55;
   --color-border: #1a1a18; --color-primary: #ff5400; --color-primary-hover: #cc4300;
@@ -1431,7 +1431,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v19 Library Catalog — manila + typewriter */
-.gmail-crm-tab[data-inbox-theme="library-catalog"] {
+body[data-inbox-theme="library-catalog"] .gmail-crm-tab {
   --color-bg: #f3e6c8; --color-surface: #e8d8b0; --color-surface-el: #dec99a;
   --color-text: #2a1f10; --color-text-secondary: #4e3f24; --color-text-muted: #6e5a3a;
   --color-border: #8a6f44; --color-primary: #992014; --color-primary-hover: #7a1810;
@@ -1439,7 +1439,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v20 Wabi-Sabi — earthy clay + sage */
-.gmail-crm-tab[data-inbox-theme="wabi-sabi"] {
+body[data-inbox-theme="wabi-sabi"] .gmail-crm-tab {
   --color-bg: #ece4d6; --color-surface: #f4ede0; --color-surface-el: #dccfb8;
   --color-text: #3a2f24; --color-text-secondary: #5a4a3a; --color-text-muted: #8a7a6a;
   --color-border: #a89a82; --color-primary: #a85a3a; --color-primary-hover: #8a4828;
@@ -1447,7 +1447,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v21 Cyberpunk Rain — black + acid green + hot pink */
-.gmail-crm-tab[data-inbox-theme="cyberpunk-rain"] {
+body[data-inbox-theme="cyberpunk-rain"] .gmail-crm-tab {
   --color-bg: #000; --color-surface: #0a0a14; --color-surface-el: #10101e;
   --color-text: #00ff41; --color-text-secondary: #6acc70; --color-text-muted: #3a8a4a;
   --color-border: #143514; --color-primary: #ff003c; --color-primary-hover: #cc0030;
@@ -1455,7 +1455,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v22 Bauhaus — cream + primary colors */
-.gmail-crm-tab[data-inbox-theme="bauhaus"] {
+body[data-inbox-theme="bauhaus"] .gmail-crm-tab {
   --color-bg: #f4f0e6; --color-surface: #fff; --color-surface-el: #ece6d4;
   --color-text: #1a1a1a; --color-text-secondary: #333; --color-text-muted: #666;
   --color-border: #1a1a1a; --color-primary: #e63946; --color-primary-hover: #c62939;
@@ -1463,7 +1463,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v23 Memphis 80s — playful pink/teal/yellow, dotted bg */
-.gmail-crm-tab[data-inbox-theme="memphis"] {
+body[data-inbox-theme="memphis"] .gmail-crm-tab {
   --color-bg: #f5f4eb; --color-surface: #fff; --color-surface-el: #fff2d4;
   --color-text: #1a1a26; --color-text-secondary: #3a3a4a; --color-text-muted: #5e5e6e;
   --color-border: #1a1a26; --color-primary: #ff5fa2; --color-primary-hover: #e0438a;
@@ -1471,7 +1471,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v24 Vellum Manuscript — parchment + illuminated */
-.gmail-crm-tab[data-inbox-theme="vellum"] {
+body[data-inbox-theme="vellum"] .gmail-crm-tab {
   --color-bg: #f7eed0; --color-surface: #fcf6dc; --color-surface-el: #eadda3;
   --color-text: #3a1f0c; --color-text-secondary: #5a3818; --color-text-muted: #8a6840;
   --color-border: #c4a060; --color-primary: #a8201a; --color-primary-hover: #881810;
@@ -1479,7 +1479,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v25 Solarpunk — sage + ochre */
-.gmail-crm-tab[data-inbox-theme="solarpunk"] {
+body[data-inbox-theme="solarpunk"] .gmail-crm-tab {
   --color-bg: #f0ead2; --color-surface: #fffbeb; --color-surface-el: #e0e0bc;
   --color-text: #1f3320; --color-text-secondary: #3f5040; --color-text-muted: #5d7553;
   --color-border: #90a878; --color-primary: #578a4a; --color-primary-hover: #467038;
@@ -1487,7 +1487,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v26 Frutiger Aero — y2k glossy blue */
-.gmail-crm-tab[data-inbox-theme="frutiger-aero"] {
+body[data-inbox-theme="frutiger-aero"] .gmail-crm-tab {
   --color-bg: #d6edff; --color-surface: #fff; --color-surface-el: #eaf4ff;
   --color-text: #1c3a5c; --color-text-secondary: #3a5e80; --color-text-muted: #5e7a98;
   --color-border: #a8c8e8; --color-primary: #34b5e8; --color-primary-hover: #1c9ad0;
@@ -1495,7 +1495,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v27 Crossword Grid — pure b&w, courier */
-.gmail-crm-tab[data-inbox-theme="crossword"] {
+body[data-inbox-theme="crossword"] .gmail-crm-tab {
   --color-bg: #fff; --color-surface: #fff; --color-surface-el: #f2f2f2;
   --color-text: #000; --color-text-secondary: #2a2a2a; --color-text-muted: #666;
   --color-border: #000; --color-primary: #1856ad; --color-primary-hover: #134290;
@@ -1503,7 +1503,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v28 8-Bit Pixel — NES palette */
-.gmail-crm-tab[data-inbox-theme="pixel-8bit"] {
+body[data-inbox-theme="pixel-8bit"] .gmail-crm-tab {
   --color-bg: #2c3e50; --color-surface: #34495e; --color-surface-el: #1a252f;
   --color-text: #ecf0f1; --color-text-secondary: #bdc3c7; --color-text-muted: #95a5a6;
   --color-border: #000; --color-primary: #f1c40f; --color-primary-hover: #d4a800;
@@ -1511,7 +1511,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v29 Watercolor — soft pastels with painterly bg */
-.gmail-crm-tab[data-inbox-theme="watercolor"] {
+body[data-inbox-theme="watercolor"] .gmail-crm-tab {
   --color-bg: #f8f4ed; --color-surface: #fefcf7; --color-surface-el: #f0eadb;
   --color-text: #3a4a52; --color-text-secondary: #5a6a72; --color-text-muted: #7a8a93;
   --color-border: #c8c0b0; --color-primary: #d68a99; --color-primary-hover: #c06a7c;
@@ -1519,7 +1519,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v30 Sakura — pink + white + black, minimal japanese */
-.gmail-crm-tab[data-inbox-theme="sakura"] {
+body[data-inbox-theme="sakura"] .gmail-crm-tab {
   --color-bg: #fff9fa; --color-surface: #fff; --color-surface-el: #fdf0f2;
   --color-text: #1a0a14; --color-text-secondary: #4a3a44; --color-text-muted: #8a6878;
   --color-border: #f2c8d2; --color-primary: #e8597c; --color-primary-hover: #c8395c;
@@ -1527,7 +1527,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v31 Forest Walk — mossy green + bark */
-.gmail-crm-tab[data-inbox-theme="forest-walk"] {
+body[data-inbox-theme="forest-walk"] .gmail-crm-tab {
   --color-bg: #1c2418; --color-surface: #2a3424; --color-surface-el: #3a4632;
   --color-text: #e8e0c8; --color-text-secondary: #c4ba9a; --color-text-muted: #9aa080;
   --color-border: #4a5638; --color-primary: #7a9c48; --color-primary-hover: #5a7c30;
@@ -1535,7 +1535,7 @@ body { transition: background-color 0.3s, color 0.3s; }
 }
 
 /* v32 Botanical Print — field-guide cream + leaf green + copper */
-.gmail-crm-tab[data-inbox-theme="botanical"] {
+body[data-inbox-theme="botanical"] .gmail-crm-tab {
   --color-bg: #f4ede0; --color-surface: #fdfaf0; --color-surface-el: #e6d8c0;
   --color-text: #1a2818; --color-text-secondary: #3a4628; --color-text-muted: #5a6648;
   --color-border: #8a9a78; --color-primary: #2a5028; --color-primary-hover: #1a3a18;
diff --git a/components/AppShell.tsx b/components/AppShell.tsx
index d24cba8..fbae9c0 100644
--- a/components/AppShell.tsx
+++ b/components/AppShell.tsx
@@ -81,6 +81,7 @@ import SavedAnalyses from './email-analyzer/SavedAnalyses';
 import GmailCRMTab from './gmail/GmailCRMTab';
 import AgeThemeSlider from './AgeThemeSlider';
 import ApiRegistryButton from './api-registry/ApiRegistryButton';
+import InboxThemeSwitcher from './InboxThemeSwitcher';
 import PriceTrackerTab from './price-tracker/PriceTrackerTab';
 import SocialTab from './social/SocialTab';
 
@@ -648,6 +649,8 @@ function Shell() {
                 {user}
               </span>
             )}
+            {/* Inbox theme switcher — global header, swaps the Gmail CRM tab look only */}
+            <InboxThemeSwitcher />
             {/* APIs & Tokens registry — admin only */}
             {role === 'admin' && <ApiRegistryButton variant="ghost" />}
             <button
diff --git a/components/InboxThemeSwitcher.tsx b/components/InboxThemeSwitcher.tsx
new file mode 100644
index 0000000..1a19ce7
--- /dev/null
+++ b/components/InboxThemeSwitcher.tsx
@@ -0,0 +1,55 @@
+'use client';
+
+// Global-header inbox-theme switcher. Owns the localStorage state + writes
+// the data-inbox-theme attribute on <body> so CSS skins in globals.css
+// (body[data-inbox-theme="..."] .gmail-crm-tab { ... }) take effect.
+//
+// Per-tab toolbar duplicate was removed — this is the single source of truth.
+
+import { useEffect, useState } from 'react';
+import { INBOX_THEMES, INBOX_THEME_LS_KEY, DEFAULT_INBOX_THEME } from '@/lib/inbox-themes';
+
+export default function InboxThemeSwitcher() {
+  const [theme, setTheme] = useState<string>(DEFAULT_INBOX_THEME);
+  const [mounted, setMounted] = useState(false);
+
+  // Hydrate from localStorage post-mount to avoid SSR mismatch
+  useEffect(() => {
+    setMounted(true);
+    try {
+      const s = localStorage.getItem(INBOX_THEME_LS_KEY);
+      if (s) setTheme(s);
+    } catch {}
+  }, []);
+
+  // Apply to <body data-inbox-theme="..."> + persist
+  useEffect(() => {
+    if (!mounted) return;
+    if (typeof document !== 'undefined') {
+      document.body.dataset.inboxTheme = theme;
+    }
+    try { localStorage.setItem(INBOX_THEME_LS_KEY, theme); } catch {}
+  }, [theme, mounted]);
+
+  const current = INBOX_THEMES.find(t => t.id === theme);
+
+  return (
+    <select
+      value={theme}
+      onChange={e => setTheme(e.target.value)}
+      className="input"
+      title={current?.description || 'Inbox theme'}
+      aria-label="Inbox theme"
+      style={{
+        fontSize: 12,
+        padding: '4px 8px',
+        minWidth: 200,
+        maxWidth: 240,
+      }}
+    >
+      {INBOX_THEMES.map(t => (
+        <option key={t.id} value={t.id}>🎨 Theme: {t.label}</option>
+      ))}
+    </select>
+  );
+}
diff --git a/components/gmail/GmailCRMTab.tsx b/components/gmail/GmailCRMTab.tsx
index 3059544..58252a2 100644
--- a/components/gmail/GmailCRMTab.tsx
+++ b/components/gmail/GmailCRMTab.tsx
@@ -273,52 +273,9 @@ export default function GmailCRMTab() {
   // today". Toggle stays in the toolbar so users can open it on demand.
   const [showContacts, setShowContacts] = useState(false);
 
-  // 16 inbox theme skins keyed by data-inbox-theme. Persisted in localStorage.
-  // Each option rewrites the Norma CSS tokens FOR THE INBOX TAB ONLY via
-  // app/globals.css → .gmail-crm-tab[data-inbox-theme="..."] { ... }.
-  // Mockups live at /mockups/inbox/v1-v16.html for visual reference.
-  const INBOX_THEMES: Array<{ id: string; label: string; description: string }> = [
-    { id: 'classic-dense',       label: '1 · Classic Dense',         description: "Today's pattern, dark + dense" },
-    { id: 'notion-cards',        label: '2 · Notion Cards',          description: 'Light, generous, card-based' },
-    { id: 'linear-keyboard',     label: '3 · Linear Keyboard',       description: 'Mono dark, purple accent' },
-    { id: 'apple-mail',          label: '4 · Apple Mail',            description: 'Light, blue accent, traditional' },
-    { id: 'front-conversation',  label: '5 · Front Conversation',    description: 'Dark teal, assignee-first' },
-    { id: 'hey-zero',            label: '6 · Hey Zero-Distraction',  description: 'Editorial serif, warm cream' },
-    { id: 'spotify-dark',        label: '7 · Spotify Dark',          description: 'Pure black, green accent' },
-    { id: 'minimal-mono',        label: '8 · Minimal Mono',          description: 'Black on white, Helvetica' },
-    { id: 'newsprint',           label: '9 · Newsprint',             description: 'Sepia broadsheet, serif' },
-    { id: 'glassmorphism',       label: '10 · Glassmorphism',        description: 'Translucent on purple gradient' },
-    { id: 'terminal',            label: '11 · Terminal',             description: 'Green phosphor, monospace' },
-    { id: 'mochi-pastel',        label: '12 · Mochi Pastel',         description: 'Soft pastels, Nunito rounded' },
-    { id: 'a11y-high-contrast',  label: '13 · High-Contrast A11y',   description: 'Atkinson Hyperlegible, 18px floor' },
-    { id: 'material',            label: '14 · Material 3',           description: 'Purple primary, rounded' },
-    { id: 'synthwave',           label: '15 · Synthwave',            description: 'Magenta+cyan on indigo, retro 80s' },
-    { id: 'kanban-lanes',        label: '16 · Kanban Lanes',         description: 'Trello palette (lane layout in mockup)' },
-    { id: 'ottoman-tile',        label: '17 · Ottoman Tile',         description: 'Navy + gold + teal, serif, calligraphic' },
-    { id: 'brutalist',           label: '18 · Brutalist Concrete',   description: 'Raw gray + hot orange, hard edges' },
-    { id: 'library-catalog',     label: '19 · Library Catalog',      description: 'Manila + typewriter, card-catalog feel' },
-    { id: 'wabi-sabi',           label: '20 · Wabi-Sabi',            description: 'Earthy clay + sage, Japanese minimalism' },
-    { id: 'cyberpunk-rain',      label: '21 · Cyberpunk Rain',       description: 'Black + acid green + hot pink' },
-    { id: 'bauhaus',             label: '22 · Bauhaus',              description: 'Primary red/blue/yellow, geometric' },
-    { id: 'memphis',             label: '23 · Memphis 80s',          description: 'Pink/teal/yellow, dotted bg, playful' },
-    { id: 'vellum',              label: '24 · Vellum Manuscript',    description: 'Parchment, illuminated drop caps' },
-    { id: 'solarpunk',           label: '25 · Solarpunk',            description: 'Sage + ochre, organic optimism' },
-    { id: 'frutiger-aero',       label: '26 · Frutiger Aero',        description: 'Y2K glossy sky-blue, glassy buttons' },
-    { id: 'crossword',           label: '27 · Crossword Grid',       description: 'Pure b&w, courier, grid cells' },
-    { id: 'pixel-8bit',          label: '28 · 8-Bit Pixel',          description: 'NES palette, blocky, retro gaming' },
-    { id: 'watercolor',          label: '29 · Watercolor',           description: 'Soft pastel splotches, painterly feel' },
-    { id: 'sakura',              label: '30 · Sakura',               description: 'Pink + white + black, minimal Japanese' },
-    { id: 'forest-walk',         label: '31 · Forest Walk',          description: 'Mossy green + bark + cream, woodland' },
-    { id: 'botanical',           label: '32 · Botanical Print',      description: 'Field-guide cream + leaf green + copper' },
-  ];
-  const [inboxTheme, setInboxTheme] = useState<string>('classic-dense');
-  // Hydrate from localStorage post-mount
-  useEffect(() => {
-    try { const s = localStorage.getItem('norma.inbox-theme'); if (s) setInboxTheme(s); } catch {}
-  }, []);
-  useEffect(() => {
-    try { localStorage.setItem('norma.inbox-theme', inboxTheme); } catch {}
-  }, [inboxTheme]);
+  // Inbox theme is owned by <InboxThemeSwitcher /> in the global AppShell header;
+  // the resulting attribute lives on <body data-inbox-theme="..."> and the CSS
+  // skins in globals.css scope down to `.gmail-crm-tab`. No local state here.
 
   const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
 
@@ -593,13 +550,12 @@ export default function GmailCRMTab() {
   return (
     <div
       className="gmail-crm-tab"
-      data-inbox-theme={inboxTheme}
       style={{
         display: 'flex',
         flexDirection: 'column',
         height: 'calc(100vh - 56px)',
         overflow: 'hidden',
-        // The CSS skin sets --color-bg, so this pulls the chosen theme background.
+        // Theme skin sets --color-bg via body[data-inbox-theme="..."] in globals.css.
         backgroundColor: 'var(--color-bg)',
         color: 'var(--color-text)',
       }}
@@ -962,18 +918,7 @@ export default function GmailCRMTab() {
           </select>
         )}
 
-        {/* Inbox theme dropdown — 16 visual treatments swappable live */}
-        <select
-          value={inboxTheme}
-          onChange={e => setInboxTheme(e.target.value)}
-          className="input"
-          style={{ fontSize: 12, padding: '4px 8px', minWidth: 200, maxWidth: 240 }}
-          title={INBOX_THEMES.find(t => t.id === inboxTheme)?.description || 'Inbox theme'}
-        >
-          {INBOX_THEMES.map(t => (
-            <option key={t.id} value={t.id}>🎨 Theme: {t.label}</option>
-          ))}
-        </select>
+        {/* Inbox theme dropdown moved to the global AppShell header (single source of truth). */}
 
         {/* Contacts toggle */}
         <button
diff --git a/lib/inbox-themes.ts b/lib/inbox-themes.ts
new file mode 100644
index 0000000..0168270
--- /dev/null
+++ b/lib/inbox-themes.ts
@@ -0,0 +1,49 @@
+// Shared inbox-theme registry — consumed by AppShell (dropdown render) and
+// GmailCRMTab (wrapper className). The CSS skins live in app/globals.css
+// keyed off `body[data-inbox-theme="<id>"] .gmail-crm-tab { ... }` so the
+// switcher lives in the global header but the visual effect stays scoped
+// to the Gmail CRM tab.
+
+export interface InboxTheme {
+  id: string;
+  label: string;
+  description: string;
+}
+
+export const INBOX_THEMES: InboxTheme[] = [
+  { id: 'classic-dense',       label: '1 · Classic Dense',         description: "Today's pattern, dark + dense" },
+  { id: 'notion-cards',        label: '2 · Notion Cards',          description: 'Light, generous, card-based' },
+  { id: 'linear-keyboard',     label: '3 · Linear Keyboard',       description: 'Mono dark, purple accent' },
+  { id: 'apple-mail',          label: '4 · Apple Mail',            description: 'Light, blue accent, traditional' },
+  { id: 'front-conversation',  label: '5 · Front Conversation',    description: 'Dark teal, assignee-first' },
+  { id: 'hey-zero',            label: '6 · Hey Zero-Distraction',  description: 'Editorial serif, warm cream' },
+  { id: 'spotify-dark',        label: '7 · Spotify Dark',          description: 'Pure black, green accent' },
+  { id: 'minimal-mono',        label: '8 · Minimal Mono',          description: 'Black on white, Helvetica' },
+  { id: 'newsprint',           label: '9 · Newsprint',             description: 'Sepia broadsheet, serif' },
+  { id: 'glassmorphism',       label: '10 · Glassmorphism',        description: 'Translucent on purple gradient' },
+  { id: 'terminal',            label: '11 · Terminal',             description: 'Green phosphor, monospace' },
+  { id: 'mochi-pastel',        label: '12 · Mochi Pastel',         description: 'Soft pastels, Nunito rounded' },
+  { id: 'a11y-high-contrast',  label: '13 · High-Contrast A11y',   description: 'Atkinson Hyperlegible, 18px floor' },
+  { id: 'material',            label: '14 · Material 3',           description: 'Purple primary, rounded' },
+  { id: 'synthwave',           label: '15 · Synthwave',            description: 'Magenta+cyan on indigo, retro 80s' },
+  { id: 'kanban-lanes',        label: '16 · Kanban Lanes',         description: 'Trello palette (lane layout in mockup)' },
+  { id: 'ottoman-tile',        label: '17 · Ottoman Tile',         description: 'Navy + gold + teal, serif, calligraphic' },
+  { id: 'brutalist',           label: '18 · Brutalist Concrete',   description: 'Raw gray + hot orange, hard edges' },
+  { id: 'library-catalog',     label: '19 · Library Catalog',      description: 'Manila + typewriter, card-catalog feel' },
+  { id: 'wabi-sabi',           label: '20 · Wabi-Sabi',            description: 'Earthy clay + sage, Japanese minimalism' },
+  { id: 'cyberpunk-rain',      label: '21 · Cyberpunk Rain',       description: 'Black + acid green + hot pink' },
+  { id: 'bauhaus',             label: '22 · Bauhaus',              description: 'Primary red/blue/yellow, geometric' },
+  { id: 'memphis',             label: '23 · Memphis 80s',          description: 'Pink/teal/yellow, dotted bg, playful' },
+  { id: 'vellum',              label: '24 · Vellum Manuscript',    description: 'Parchment, illuminated drop caps' },
+  { id: 'solarpunk',           label: '25 · Solarpunk',            description: 'Sage + ochre, organic optimism' },
+  { id: 'frutiger-aero',       label: '26 · Frutiger Aero',        description: 'Y2K glossy sky-blue, glassy buttons' },
+  { id: 'crossword',           label: '27 · Crossword Grid',       description: 'Pure b&w, courier, grid cells' },
+  { id: 'pixel-8bit',          label: '28 · 8-Bit Pixel',          description: 'NES palette, blocky, retro gaming' },
+  { id: 'watercolor',          label: '29 · Watercolor',           description: 'Soft pastel splotches, painterly feel' },
+  { id: 'sakura',              label: '30 · Sakura',               description: 'Pink + white + black, minimal Japanese' },
+  { id: 'forest-walk',         label: '31 · Forest Walk',          description: 'Mossy green + bark + cream, woodland' },
+  { id: 'botanical',           label: '32 · Botanical Print',      description: 'Field-guide cream + leaf green + copper' },
+];
+
+export const INBOX_THEME_LS_KEY = 'norma.inbox-theme';
+export const DEFAULT_INBOX_THEME = 'classic-dense';

← 8f71b14 fix(gmail): scope Gmail CRM to current user's mailbox, not h  ·  back to Norma  ·  feat(theme): full-width inbox-theme banner at top of AppShel 82446f2 →