From 52b1c6214da087463d2145ca092def4ec45ebb17 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 6 Sep 2026 07:50:05 +0000 Subject: [PATCH] derive Calendar/Goals/Insights/Privacy/Messages/Wiki tabs from the nav manifest (#6365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These six pages each hand-maintained a local TABS array that duplicated the same destinations already declared in server/lib/navManifest.js, kept in sync only by a source-scraping drift test. Extend the tabGroup/getPageNavTabs mechanism Settings and Models already use (3c8799a52) so the manifest is the one registry: each manifest entry now carries `tabGroup` (+ an optional `tabLabel` for the four short page-local labels that differ from the sidebar/⌘K label — Goals' List/Tree, Insights' and Privacy's Overview, and Wiki's Overview), and each page derives its TABS from `getPageNavTabs(group)` merged with a small local presentation map (icon, fullBleed, needsAccounts) that throws at import time if a manifest tab has no matching presentation entry. `tabId` uniqueness is now scoped to `tabGroup` when present (falling back to the existing per-section scope for Settings/Models), since Insights and Privacy both use the id "overview" while sharing the "Identity" section. `getSectionNavTabs`/`getNavSectionForPath` explicitly exclude tabGroup entries so the existing Settings/Models section child-nav is unaffected. navManifest.test.js drops these six pages' TABBED_PAGES rows (and their source-scraping); each page's own test file now asserts its TABS match `getPageNavTabs(group)` in id, label and declaration order instead. Brain, CoS, Digital Twin, MeatSpace, Media Gen, Music, Sharing and System Resources still use the old scraper — converting them needs either new manifest entries for tabs the two registries had already lost sync on (Brain's Spotify/YouTube tabs have no manifest entry at all) or handling a different source shape (Sharing's link list, POST's switch dispatch). Follow-up: #6383. --- client/src/pages/Calendar.jsx | 33 +++++--- client/src/pages/Calendar.test.jsx | 16 ++++ client/src/pages/Goals.jsx | 20 +++-- client/src/pages/Goals.test.jsx | 18 +++++ client/src/pages/Insights.jsx | 24 ++++-- client/src/pages/Insights.test.jsx | 12 ++- client/src/pages/Messages.jsx | 29 ++++--- client/src/pages/Messages.test.jsx | 17 +++++ client/src/pages/Privacy.jsx | 27 ++++--- client/src/pages/Privacy.test.jsx | 12 ++- client/src/pages/Wiki.jsx | 28 ++++--- client/src/pages/Wiki.test.jsx | 12 ++- server/lib/navManifest.js | 117 +++++++++++++++++++---------- server/lib/navManifest.test.js | 11 ++- 14 files changed, 273 insertions(+), 103 deletions(-) create mode 100644 client/src/pages/Calendar.test.jsx create mode 100644 client/src/pages/Goals.test.jsx create mode 100644 client/src/pages/Messages.test.jsx diff --git a/client/src/pages/Calendar.jsx b/client/src/pages/Calendar.jsx index 8165916dd2..73275d5c5b 100644 --- a/client/src/pages/Calendar.jsx +++ b/client/src/pages/Calendar.jsx @@ -7,6 +7,7 @@ import PageHeader from '../components/PageHeader'; import TabPills from '../components/ui/TabPills'; import { useValidTab } from '../hooks/useValidTab'; import useUrlParams from '../hooks/useUrlParams'; +import { getPageNavTabs } from '../../../server/lib/navManifest.js'; import AgendaTab from '../components/calendar/AgendaTab'; import DayView from '../components/calendar/DayView'; @@ -17,18 +18,26 @@ import ReviewTab from '../components/calendar/ReviewTab'; import CalendarLifetimeTab from '../components/meatspace/tabs/CalendarTab'; import SyncTab from '../components/calendar/SyncTab'; -// Exported so the nav-manifest tab-coverage guard (server/lib/navManifest.test.js) -// can assert each tab round-trips to a NAV_COMMANDS path. -export const TABS = [ - { id: 'agenda', label: 'Agenda', icon: CalendarDays }, - { id: 'day', label: 'Day', icon: CalendarIcon }, - { id: 'week', label: 'Week', icon: Columns }, - { id: 'month', label: 'Month', icon: LayoutGrid }, - { id: 'lifetime', label: 'Lifetime', icon: Clock }, - { id: 'review', label: 'Review', icon: ClipboardList }, - { id: 'sync', label: 'Sync', icon: RefreshCw }, - { id: 'config', label: 'Config', icon: Settings } -]; +// Icon (and any other presentation-only detail) per tab id. The manifest +// (`tabGroup: 'calendar'`) owns id/label/order — this page owns only how each +// tab looks. Throws at import time if the manifest and this map drift, so a +// new manifest tab can't ship silently unreachable from this page's tab bar. +const TAB_PRESENTATION = { + agenda: { icon: CalendarDays }, + day: { icon: CalendarIcon }, + week: { icon: Columns }, + month: { icon: LayoutGrid }, + lifetime: { icon: Clock }, + review: { icon: ClipboardList }, + sync: { icon: RefreshCw }, + config: { icon: Settings }, +}; + +export const TABS = getPageNavTabs('calendar').map((tab) => { + const presentation = TAB_PRESENTATION[tab.id]; + if (!presentation) throw new Error(`Calendar: no tab presentation for manifest tab "${tab.id}"`); + return { ...tab, ...presentation }; +}); export default function Calendar() { const navigate = useNavigate(); diff --git a/client/src/pages/Calendar.test.jsx b/client/src/pages/Calendar.test.jsx new file mode 100644 index 0000000000..b0ee3f146c --- /dev/null +++ b/client/src/pages/Calendar.test.jsx @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { TABS } from './Calendar'; +import { getPageNavTabs } from '../../../server/lib/navManifest.js'; + +// Calendar derives its tab bar from the nav manifest's `tabGroup: 'calendar'` +// (#6365) — this pins that TABS stays in sync (id, label, declaration order) +// and that every manifest tab has a presentation entry (icon) in Calendar.jsx, +// which would otherwise only surface as a thrown import-time error. +describe('Calendar TABS ↔ nav manifest', () => { + it('derives every tab, in order, from the "calendar" tabGroup with a presentation entry', () => { + const manifestTabs = getPageNavTabs('calendar'); + expect(TABS.map((t) => t.id)).toEqual(manifestTabs.map((t) => t.id)); + expect(TABS.map((t) => t.label)).toEqual(manifestTabs.map((t) => t.label)); + expect(TABS.every((t) => typeof t.icon === 'function' || typeof t.icon === 'object')).toBe(true); + }); +}); diff --git a/client/src/pages/Goals.jsx b/client/src/pages/Goals.jsx index b634f0478b..f8ba661a5f 100644 --- a/client/src/pages/Goals.jsx +++ b/client/src/pages/Goals.jsx @@ -8,14 +8,24 @@ import PageHeader from '../components/PageHeader'; import TabPills from '../components/ui/TabPills'; import PageSkeleton from '../components/ui/PageSkeleton'; import { useValidTab } from '../hooks/useValidTab'; +import { getPageNavTabs } from '../../../server/lib/navManifest.js'; const GoalsTreeView = lazy(() => import('../components/goals/GoalsTreeView')); -// Exported for the nav-manifest tab-coverage guard (server/lib/navManifest.test.js). -export const TABS = [ - { id: 'list', label: 'List', icon: List }, - { id: 'tree', label: 'Tree', icon: TreePine } -]; +// Icon per tab id. The manifest (`tabGroup: 'goals'`) owns id/label/order — +// this page owns only how each tab looks; the short page-local labels +// ("List"/"Tree" vs. the manifest's "Goals"/"Goals Tree") come from the +// manifest's `tabLabel`. Throws at import time on drift. +const TAB_PRESENTATION = { + list: { icon: List }, + tree: { icon: TreePine }, +}; + +export const TABS = getPageNavTabs('goals').map((tab) => { + const presentation = TAB_PRESENTATION[tab.id]; + if (!presentation) throw new Error(`Goals: no tab presentation for manifest tab "${tab.id}"`); + return { ...tab, ...presentation }; +}); export default function Goals() { // `/goals/list/:goalId` carries no `:tab` segment, so `useValidTab` falls back to diff --git a/client/src/pages/Goals.test.jsx b/client/src/pages/Goals.test.jsx new file mode 100644 index 0000000000..eccea24f5a --- /dev/null +++ b/client/src/pages/Goals.test.jsx @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { TABS } from './Goals'; +import { getPageNavTabs } from '../../../server/lib/navManifest.js'; + +// Goals derives its tab bar from the nav manifest's `tabGroup: 'goals'` (#6365) +// — this pins that TABS stays in sync (id, label, declaration order) and that +// every manifest tab has a presentation entry (icon) in Goals.jsx, which would +// otherwise only surface as a thrown import-time error. The page-local +// "List"/"Tree" labels differ from the manifest's "Goals"/"Goals Tree" via +// the manifest's `tabLabel`. +describe('Goals TABS ↔ nav manifest', () => { + it('derives every tab, in order, from the "goals" tabGroup with a presentation entry', () => { + const manifestTabs = getPageNavTabs('goals'); + expect(TABS.map((t) => t.id)).toEqual(manifestTabs.map((t) => t.id)); + expect(TABS.map((t) => t.label)).toEqual(['List', 'Tree']); + expect(TABS.every((t) => typeof t.icon === 'function' || typeof t.icon === 'object')).toBe(true); + }); +}); diff --git a/client/src/pages/Insights.jsx b/client/src/pages/Insights.jsx index bedb7fbd32..4fae348ae3 100644 --- a/client/src/pages/Insights.jsx +++ b/client/src/pages/Insights.jsx @@ -19,15 +19,23 @@ import PageHeader from '../components/PageHeader'; import TabPills from '../components/ui/TabPills'; import PageSkeleton from '../components/ui/PageSkeleton'; import { timeAgo } from '../utils/formatters'; +import { getPageNavTabs } from '../../../server/lib/navManifest.js'; -// Exported for the nav-manifest tab-coverage guard (server/lib/navManifest.test.js). -export const TABS = [ - { id: 'overview', label: 'Overview', icon: Lightbulb }, - { id: 'genome-health', label: 'Genome-Health', icon: Dna }, - { id: 'taste-identity', label: 'Taste & Identity', icon: Palette }, - { id: 'cross-domain', label: 'Cross-Domain Patterns', icon: Link2 }, - { id: 'goal-scorecard', label: 'Goal Scorecard', icon: Target } -]; +// Icon per tab id. The manifest (`tabGroup: 'insights'`) owns id/label/order — +// this page owns only how each tab looks. Throws at import time on drift. +const TAB_PRESENTATION = { + overview: { icon: Lightbulb }, + 'genome-health': { icon: Dna }, + 'taste-identity': { icon: Palette }, + 'cross-domain': { icon: Link2 }, + 'goal-scorecard': { icon: Target }, +}; + +export const TABS = getPageNavTabs('insights').map((tab) => { + const presentation = TAB_PRESENTATION[tab.id]; + if (!presentation) throw new Error(`Insights: no tab presentation for manifest tab "${tab.id}"`); + return { ...tab, ...presentation }; +}); export function OverviewTab() { const navigate = useNavigate(); diff --git a/client/src/pages/Insights.test.jsx b/client/src/pages/Insights.test.jsx index 364b37ae9d..8b489f62c0 100644 --- a/client/src/pages/Insights.test.jsx +++ b/client/src/pages/Insights.test.jsx @@ -27,7 +27,17 @@ import { refreshInsightThemes, refreshInsightNarrative, } from '../services/api'; -import { OverviewTab } from './Insights'; +import { OverviewTab, TABS } from './Insights'; +import { getPageNavTabs } from '../../../server/lib/navManifest.js'; + +describe('Insights TABS ↔ nav manifest', () => { + it('derives every tab, in order, from the "insights" tabGroup with a presentation entry', () => { + const manifestTabs = getPageNavTabs('insights'); + expect(TABS.map((t) => t.id)).toEqual(manifestTabs.map((t) => t.id)); + expect(TABS.map((t) => t.label)).toEqual(manifestTabs.map((t) => t.label)); + expect(TABS.every((t) => typeof t.icon === 'function' || typeof t.icon === 'object')).toBe(true); + }); +}); const renderOverview = () => render( diff --git a/client/src/pages/Messages.jsx b/client/src/pages/Messages.jsx index 3bc05fea9f..6e8f6b6ee7 100644 --- a/client/src/pages/Messages.jsx +++ b/client/src/pages/Messages.jsx @@ -14,19 +14,28 @@ import SyncTab from '../components/messages/SyncTab'; import IMessageTab from '../components/messages/IMessageTab'; import SignalTab from '../components/messages/SignalTab'; import ContactsTab from '../components/messages/ContactsTab'; +import { getPageNavTabs } from '../../../server/lib/navManifest.js'; -// Exported for the nav-manifest tab-coverage guard (server/lib/navManifest.test.js). +// Presentation per tab id. The manifest (`tabGroup: 'messages'`) owns +// id/label/order — this page owns how each tab looks and behaves. // `fullBleed: true` — tab owns internal scroll/height; Messages skips padded overflow wrapper. // `needsAccounts: true` — tab renders the account list, so it waits for that fetch. -export const TABS = [ - { id: 'inbox', label: 'Inbox', icon: Mail, needsAccounts: true }, - { id: 'drafts', label: 'Drafts', icon: Mail, needsAccounts: true }, - { id: 'imessage', label: 'iMessage', icon: MessageSquare, fullBleed: true }, - { id: 'signal', label: 'Signal', icon: MessageSquare }, - { id: 'contacts', label: 'Contacts', icon: Users }, - { id: 'sync', label: 'Sync', icon: RefreshCw, needsAccounts: true }, - { id: 'config', label: 'Config', icon: Settings, needsAccounts: true }, -]; +// Throws at import time if the manifest and this map drift. +const TAB_PRESENTATION = { + inbox: { icon: Mail, needsAccounts: true }, + drafts: { icon: Mail, needsAccounts: true }, + imessage: { icon: MessageSquare, fullBleed: true }, + signal: { icon: MessageSquare }, + contacts: { icon: Users }, + sync: { icon: RefreshCw, needsAccounts: true }, + config: { icon: Settings, needsAccounts: true }, +}; + +export const TABS = getPageNavTabs('messages').map((tab) => { + const presentation = TAB_PRESENTATION[tab.id]; + if (!presentation) throw new Error(`Messages: no tab presentation for manifest tab "${tab.id}"`); + return { ...tab, ...presentation }; +}); const FULL_BLEED_TAB_IDS = new Set(TABS.filter((t) => t.fullBleed).map((t) => t.id)); diff --git a/client/src/pages/Messages.test.jsx b/client/src/pages/Messages.test.jsx new file mode 100644 index 0000000000..ff7a2b2393 --- /dev/null +++ b/client/src/pages/Messages.test.jsx @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { TABS } from './Messages'; +import { getPageNavTabs } from '../../../server/lib/navManifest.js'; + +// Messages derives its tab bar from the nav manifest's `tabGroup: 'messages'` +// (#6365) — this pins that TABS stays in sync (id, label, declaration order) +// and that every manifest tab has a presentation entry (icon, plus the +// `fullBleed`/`needsAccounts` flags) in Messages.jsx, which would otherwise +// only surface as a thrown import-time error. +describe('Messages TABS ↔ nav manifest', () => { + it('derives every tab, in order, from the "messages" tabGroup with a presentation entry', () => { + const manifestTabs = getPageNavTabs('messages'); + expect(TABS.map((t) => t.id)).toEqual(manifestTabs.map((t) => t.id)); + expect(TABS.map((t) => t.label)).toEqual(manifestTabs.map((t) => t.label)); + expect(TABS.every((t) => typeof t.icon === 'function' || typeof t.icon === 'object')).toBe(true); + }); +}); diff --git a/client/src/pages/Privacy.jsx b/client/src/pages/Privacy.jsx index 7a55f79af9..abf31fbe0a 100644 --- a/client/src/pages/Privacy.jsx +++ b/client/src/pages/Privacy.jsx @@ -14,16 +14,25 @@ import PrivacyBrokersTab from '../components/privacy/PrivacyBrokersTab'; import SubjectSwitcher from '../components/privacy/SubjectSwitcher'; import SubjectsDrawer from '../components/privacy/SubjectsDrawer'; import { SELF_SUBJECT_ID, privacyTabPath } from '../components/privacy/constants'; +import { getPageNavTabs } from '../../../server/lib/navManifest.js'; -// Exported for the nav-manifest tab-coverage guard (server/lib/navManifest.test.js). -// Each id maps to `/privacy/` and needs a NAV_COMMANDS entry. -export const TABS = [ - { id: 'overview', label: 'Overview', icon: LayoutDashboard }, - { id: 'vault', label: 'Vault', icon: KeyRound }, - { id: 'organizations', label: 'Organizations', icon: Building2 }, - { id: 'changes', label: 'Changes', icon: Repeat }, - { id: 'brokers', label: 'Brokers', icon: ShieldOff }, -]; +// Icon per tab id. The manifest (`tabGroup: 'privacy'`) owns id/label/order — +// this page owns only how each tab looks; the page-local "Overview" label +// (vs. the manifest's "Privacy") comes from the manifest's `tabLabel`. Throws +// at import time on drift. +const TAB_PRESENTATION = { + overview: { icon: LayoutDashboard }, + vault: { icon: KeyRound }, + organizations: { icon: Building2 }, + changes: { icon: Repeat }, + brokers: { icon: ShieldOff }, +}; + +export const TABS = getPageNavTabs('privacy').map((tab) => { + const presentation = TAB_PRESENTATION[tab.id]; + if (!presentation) throw new Error(`Privacy: no tab presentation for manifest tab "${tab.id}"`); + return { ...tab, ...presentation }; +}); export default function Privacy() { const navigate = useNavigate(); diff --git a/client/src/pages/Privacy.test.jsx b/client/src/pages/Privacy.test.jsx index 5c762d8318..0a0a460b45 100644 --- a/client/src/pages/Privacy.test.jsx +++ b/client/src/pages/Privacy.test.jsx @@ -72,7 +72,17 @@ vi.mock('../services/api', () => ({ draftChangeUpdateEmail: vi.fn(), })); -import Privacy from './Privacy'; +import Privacy, { TABS } from './Privacy'; +import { getPageNavTabs } from '../../../server/lib/navManifest.js'; + +describe('Privacy TABS ↔ nav manifest', () => { + it('derives every tab, in order, from the "privacy" tabGroup with a presentation entry', () => { + const manifestTabs = getPageNavTabs('privacy'); + expect(TABS.map((t) => t.id)).toEqual(manifestTabs.map((t) => t.id)); + expect(TABS.map((t) => t.label)).toEqual(manifestTabs.map((t) => t.label)); + expect(TABS.every((t) => typeof t.icon === 'function' || typeof t.icon === 'object')).toBe(true); + }); +}); import { revealVaultRecord, getVaultRecords, getPrivacyStatus, getPrivacySubjects, } from '../services/api'; diff --git a/client/src/pages/Wiki.jsx b/client/src/pages/Wiki.jsx index 6710fc4268..6758c7e818 100644 --- a/client/src/pages/Wiki.jsx +++ b/client/src/pages/Wiki.jsx @@ -11,15 +11,25 @@ import WikiBrowseTab from '../components/wiki/tabs/BrowseTab'; import WikiSearchTab from '../components/wiki/tabs/SearchTab'; import WikiGraphTab from '../components/wiki/tabs/GraphTab'; import WikiLogTab from '../components/wiki/tabs/LogTab'; - -// Exported for the nav-manifest tab-coverage guard (server/lib/navManifest.test.js). -export const TABS = [ - { id: 'overview', label: 'Overview', icon: BarChart3 }, - { id: 'browse', label: 'Browse', icon: FileText }, - { id: 'search', label: 'Search', icon: Search }, - { id: 'graph', label: 'Graph', icon: Network }, - { id: 'log', label: 'Log', icon: Activity } -]; +import { getPageNavTabs } from '../../../server/lib/navManifest.js'; + +// Icon per tab id. The manifest (`tabGroup: 'wiki'`) owns id/label/order — +// this page owns only how each tab looks; the page-local "Overview" label +// (vs. the manifest's "Wiki") comes from the manifest's `tabLabel`. Throws at +// import time on drift. +const TAB_PRESENTATION = { + overview: { icon: BarChart3 }, + browse: { icon: FileText }, + search: { icon: Search }, + graph: { icon: Network }, + log: { icon: Activity }, +}; + +export const TABS = getPageNavTabs('wiki').map((tab) => { + const presentation = TAB_PRESENTATION[tab.id]; + if (!presentation) throw new Error(`Wiki: no tab presentation for manifest tab "${tab.id}"`); + return { ...tab, ...presentation }; +}); export default function Wiki() { const { tab } = useParams(); diff --git a/client/src/pages/Wiki.test.jsx b/client/src/pages/Wiki.test.jsx index a0691a7860..e718defe1b 100644 --- a/client/src/pages/Wiki.test.jsx +++ b/client/src/pages/Wiki.test.jsx @@ -16,8 +16,18 @@ vi.mock('../components/wiki/tabs/SearchTab', () => ({ default: () =>
search vi.mock('../components/wiki/tabs/GraphTab', () => ({ default: () =>
graph
})); vi.mock('../components/wiki/tabs/LogTab', () => ({ default: () =>
log
})); -import Wiki from './Wiki'; +import Wiki, { TABS } from './Wiki'; import { getNotesVaults, scanNotesVault } from '../services/api'; +import { getPageNavTabs } from '../../../server/lib/navManifest.js'; + +describe('Wiki TABS ↔ nav manifest', () => { + it('derives every tab, in order, from the "wiki" tabGroup with a presentation entry', () => { + const manifestTabs = getPageNavTabs('wiki'); + expect(TABS.map((t) => t.id)).toEqual(manifestTabs.map((t) => t.id)); + expect(TABS.map((t) => t.label)).toEqual(manifestTabs.map((t) => t.label)); + expect(TABS.every((t) => typeof t.icon === 'function' || typeof t.icon === 'object')).toBe(true); + }); +}); function LocationProbe() { const loc = useLocation(); diff --git a/server/lib/navManifest.js b/server/lib/navManifest.js index d52f332394..8b42c71516 100644 --- a/server/lib/navManifest.js +++ b/server/lib/navManifest.js @@ -1,6 +1,6 @@ // Single source of truth for PortOS navigation. Consumed by the sidebar, // server/services/voice/tools.js#ui_navigate, and the Cmd+K palette. -// Entry: { id, path, label, section, tabId?, aliases?, keywords?, previousPaths?, preservePreviousPathSuffix? }. +// Entry: { id, path, label, section, tabId?, tabGroup?, tabLabel?, aliases?, keywords?, previousPaths?, preservePreviousPathSuffix? }. // See AGENTS.md "Command Palette & Voice Nav" for the contract. // // `previousPaths` lists every path this page has ANSWERED TO BEFORE — including @@ -106,14 +106,14 @@ const RAW_NAV_COMMANDS = [ { id: 'nav.brain.songbook', path: '/songbook', label: 'SongBook', section: 'Brain', aliases: ['songbook', 'song-book', 'tabs', 'chords', 'guitar-tabs'], keywords: ['guitar', 'tab', 'tablature', 'chord chart', 'lyrics', 'sheet music', 'repertoire', 'autoscroll'] }, { id: 'nav.brain.trust', path: '/brain/trust', label: 'Trust', section: 'Brain', aliases: ['brain-trust'] }, - { id: 'nav.calendar.agenda', path: '/calendar/agenda', label: 'Agenda', section: 'Calendar', aliases: ['calendar', 'agenda'] }, - { id: 'nav.calendar.config', path: '/calendar/config', label: 'Config', section: 'Calendar', aliases: ['calendar-config'] }, - { id: 'nav.calendar.day', path: '/calendar/day', label: 'Day', section: 'Calendar', aliases: ['calendar-day'] }, - { id: 'nav.calendar.week', path: '/calendar/week', label: 'Week', section: 'Calendar', aliases: ['calendar-week'] }, - { id: 'nav.calendar.month', path: '/calendar/month', label: 'Month', section: 'Calendar', aliases: ['calendar-month'] }, - { id: 'nav.calendar.lifetime', path: '/calendar/lifetime', label: 'Lifetime', section: 'Calendar', aliases: ['calendar-lifetime'] }, - { id: 'nav.calendar.review', path: '/calendar/review', label: 'Review', section: 'Calendar', aliases: ['calendar-review'] }, - { id: 'nav.calendar.sync', path: '/calendar/sync', label: 'Sync', section: 'Calendar', aliases: ['calendar-sync'] }, + { id: 'nav.calendar.agenda', path: '/calendar/agenda', label: 'Agenda', section: 'Calendar', tabGroup: 'calendar', tabId: 'agenda', aliases: ['calendar', 'agenda'] }, + { id: 'nav.calendar.day', path: '/calendar/day', label: 'Day', section: 'Calendar', tabGroup: 'calendar', tabId: 'day', aliases: ['calendar-day'] }, + { id: 'nav.calendar.week', path: '/calendar/week', label: 'Week', section: 'Calendar', tabGroup: 'calendar', tabId: 'week', aliases: ['calendar-week'] }, + { id: 'nav.calendar.month', path: '/calendar/month', label: 'Month', section: 'Calendar', tabGroup: 'calendar', tabId: 'month', aliases: ['calendar-month'] }, + { id: 'nav.calendar.lifetime', path: '/calendar/lifetime', label: 'Lifetime', section: 'Calendar', tabGroup: 'calendar', tabId: 'lifetime', aliases: ['calendar-lifetime'] }, + { id: 'nav.calendar.review', path: '/calendar/review', label: 'Review', section: 'Calendar', tabGroup: 'calendar', tabId: 'review', aliases: ['calendar-review'] }, + { id: 'nav.calendar.sync', path: '/calendar/sync', label: 'Sync', section: 'Calendar', tabGroup: 'calendar', tabId: 'sync', aliases: ['calendar-sync'] }, + { id: 'nav.calendar.config', path: '/calendar/config', label: 'Config', section: 'Calendar', tabGroup: 'calendar', tabId: 'config', aliases: ['calendar-config'] }, { id: 'nav.cos.tasks', path: '/cos/tasks', label: 'Tasks', section: 'Chief of Staff', aliases: ['tasks', 'cos', 'cos-tasks', 'chief-of-staff'] }, { id: 'nav.cos.agents', path: '/cos/agents', label: 'Agents', section: 'Chief of Staff', aliases: ['agents', 'cos-agents'] }, @@ -137,16 +137,16 @@ const RAW_NAV_COMMANDS = [ { id: 'nav.cos.workflow', path: '/cos/workflow', label: 'Timeline', section: 'Chief of Staff', aliases: ['workflow', 'cos-workflow', 'cos-timeline', 'schedule-timeline'], keywords: ['timeline', 'schedule', 'launch order', 'run order', 'gantt', 'upcoming runs', 'overlap', 'dependencies'] }, { id: 'nav.cos.productivity', path: '/cos/productivity', label: 'Productivity', section: 'Chief of Staff', aliases: ['cos-productivity', 'work-patterns', 'streaks'] }, - { id: 'nav.messages.inbox', path: '/messages/inbox', label: 'Inbox', section: 'Comms', aliases: ['messages', 'comms', 'comms-inbox'], keywords: ['comms', 'email', 'inbox'] }, - { id: 'nav.messages.drafts', path: '/messages/drafts', label: 'Drafts', section: 'Comms', aliases: ['drafts', 'comms-drafts'], keywords: ['comms'] }, - { id: 'nav.messages.imessage', path: '/messages/imessage', label: 'iMessage', section: 'Comms', previousPaths: ['/imessage'], aliases: ['imessage', 'i-message', 'apple-messages', 'comms-imessage'], keywords: ['comms', 'imessage', 'sms', 'text messages', 'chat.db', 'blocklist', 'spam'] }, - { id: 'nav.messages.signal', path: '/messages/signal', label: 'Signal', section: 'Comms', previousPaths: ['/settings/signal'], aliases: ['signal', 'signal-desktop', 'comms-signal', 'signal-settings'], keywords: ['comms', 'signal', 'signal desktop', 'messages', 'sqlcipher', 'chat', 'tribe', 'timeline', 'encrypted', 'keychain'] }, - { id: 'nav.messages.contacts', path: '/messages/contacts', label: 'Contacts', section: 'Comms', previousPaths: ['/settings/contacts'], aliases: ['contacts', 'address-book', 'comms-contacts', 'settings-contacts'], keywords: ['comms', 'contacts', 'address book', 'phone', 'email', 'tribe', 'imessage', 'names', 'resolve'] }, + { id: 'nav.messages.inbox', path: '/messages/inbox', label: 'Inbox', section: 'Comms', tabGroup: 'messages', tabId: 'inbox', aliases: ['messages', 'comms', 'comms-inbox'], keywords: ['comms', 'email', 'inbox'] }, + { id: 'nav.messages.drafts', path: '/messages/drafts', label: 'Drafts', section: 'Comms', tabGroup: 'messages', tabId: 'drafts', aliases: ['drafts', 'comms-drafts'], keywords: ['comms'] }, + { id: 'nav.messages.imessage', path: '/messages/imessage', label: 'iMessage', section: 'Comms', tabGroup: 'messages', tabId: 'imessage', previousPaths: ['/imessage'], aliases: ['imessage', 'i-message', 'apple-messages', 'comms-imessage'], keywords: ['comms', 'imessage', 'sms', 'text messages', 'chat.db', 'blocklist', 'spam'] }, + { id: 'nav.messages.signal', path: '/messages/signal', label: 'Signal', section: 'Comms', tabGroup: 'messages', tabId: 'signal', previousPaths: ['/settings/signal'], aliases: ['signal', 'signal-desktop', 'comms-signal', 'signal-settings'], keywords: ['comms', 'signal', 'signal desktop', 'messages', 'sqlcipher', 'chat', 'tribe', 'timeline', 'encrypted', 'keychain'] }, + { id: 'nav.messages.contacts', path: '/messages/contacts', label: 'Contacts', section: 'Comms', tabGroup: 'messages', tabId: 'contacts', previousPaths: ['/settings/contacts'], aliases: ['contacts', 'address-book', 'comms-contacts', 'settings-contacts'], keywords: ['comms', 'contacts', 'address book', 'phone', 'email', 'tribe', 'imessage', 'names', 'resolve'] }, // Ingestion config is a drawer over the iMessage manager (?settings=1), not a // Settings page — the settings-* aliases stay so "open iMessage settings" still lands. { id: 'nav.messages.imessage-settings', path: '/messages/imessage?settings=1', label: 'iMessage Settings', section: 'Comms', aliases: ['settings-imessage', 'imessage-settings', 'imessage-sync'], keywords: ['imessage', 'sync', 'chat.db', 'sms', 'texts', 'tribe', 'timeline', 'full disk access'] }, - { id: 'nav.messages.config', path: '/messages/config', label: 'Config', section: 'Comms', aliases: ['messages-config', 'comms-config'], keywords: ['comms'] }, - { id: 'nav.messages.sync', path: '/messages/sync', label: 'Sync', section: 'Comms', aliases: ['messages-sync', 'comms-sync'], keywords: ['comms'] }, + { id: 'nav.messages.sync', path: '/messages/sync', label: 'Sync', section: 'Comms', tabGroup: 'messages', tabId: 'sync', aliases: ['messages-sync', 'comms-sync'], keywords: ['comms'] }, + { id: 'nav.messages.config', path: '/messages/config', label: 'Config', section: 'Comms', tabGroup: 'messages', tabId: 'config', aliases: ['messages-config', 'comms-config'], keywords: ['comms'] }, { id: 'nav.stacker-news', path: '/stacker-news', label: 'Stacker News', section: 'Comms', aliases: ['stacker-news', 'stacker', 'sn'], keywords: ['comms', 'community', 'territory', 'moderation', 'stewardship'] }, { id: 'nav.x', path: '/x', label: 'X', section: 'Comms', aliases: ['x', 'x-com', 'twitter', 'comms-x'], keywords: ['comms', 'social', 'reach', 'engagement', 'shadowban', 'diagnostics'] }, { id: 'nav.timeline', path: '/timeline', label: 'Timeline', section: 'Brain', aliases: ['activity-timeline', 'activity', 'my-day', 'life-log', 'life-timeline'], keywords: ['human activity', 'life log', 'timeline', 'messages', 'calendar', 'history', 'what did i do', 'daily', 'import', 'backfill', 'whatsapp', 'spotify', 'discord', 'youtube'] }, @@ -191,16 +191,16 @@ const RAW_NAV_COMMANDS = [ { id: 'nav.twin.enrich', path: '/digital-twin/enrich', label: 'Enrich', section: 'Identity', aliases: ['twin-enrich'], keywords: ['sources'] }, { id: 'nav.twin.export', path: '/digital-twin/export', label: 'Export', section: 'Identity', aliases: ['twin-export'], keywords: ['legacy'] }, { id: 'nav.twin.legacy', path: '/digital-twin/legacy', label: 'Legacy Bundle', section: 'Identity', aliases: ['twin-legacy', 'legacy-export', 'legacy-bundle', 'legacy'], keywords: ['legacy', 'bundle', 'backup', 'portable', 'pdf', 'archive', 'time capsule', 'export'] }, - { id: 'nav.goals', path: '/goals/list', label: 'Goals', section: 'Goals', aliases: ['goals'] }, - { id: 'nav.goals.tree', path: '/goals/tree', label: 'Goals Tree', section: 'Goals', aliases: ['goals-tree', 'goal-tree'], keywords: ['hierarchy', 'decomposition', 'subgoals', 'breakdown'] }, + { id: 'nav.goals', path: '/goals/list', label: 'Goals', section: 'Goals', tabGroup: 'goals', tabId: 'list', tabLabel: 'List', aliases: ['goals'] }, + { id: 'nav.goals.tree', path: '/goals/tree', label: 'Goals Tree', section: 'Goals', tabGroup: 'goals', tabId: 'tree', tabLabel: 'Tree', aliases: ['goals-tree', 'goal-tree'], keywords: ['hierarchy', 'decomposition', 'subgoals', 'breakdown'] }, { id: 'nav.twin.goals', path: '/digital-twin/goals', label: 'Twin Goals', section: 'Identity', aliases: ['twin-goals'], keywords: ['profile'] }, { id: 'nav.twin.identity', path: '/digital-twin/identity', label: 'Identity', section: 'Identity', aliases: ['twin-identity', 'identity'], keywords: ['profile'] }, { id: 'nav.twin.import', path: '/digital-twin/import', label: 'Import', section: 'Identity', aliases: ['twin-import'], keywords: ['sources'] }, - { id: 'nav.insights', path: '/insights/overview', label: 'Insights', section: 'Identity', aliases: ['insights'] }, - { id: 'nav.insights.genome-health', path: '/insights/genome-health', label: 'Genome-Health', section: 'Identity', aliases: ['genome-health', 'insights-genome-health'], keywords: ['genome', 'dna', 'health', 'longevity', 'genetic'] }, - { id: 'nav.insights.taste-identity', path: '/insights/taste-identity', label: 'Taste & Identity', section: 'Identity', aliases: ['taste-identity', 'insights-taste-identity'], keywords: ['taste', 'identity', 'preferences', 'aesthetic'] }, - { id: 'nav.insights.cross-domain', path: '/insights/cross-domain', label: 'Cross-Domain Patterns', section: 'Identity', aliases: ['cross-domain', 'insights-cross-domain', 'cross-domain-patterns'], keywords: ['cross domain', 'patterns', 'correlations', 'connections'] }, - { id: 'nav.insights.goal-scorecard', path: '/insights/goal-scorecard', label: 'Goal Scorecard', section: 'Identity', aliases: ['goal-scorecard', 'insights-goal-scorecard', 'scorecard'], keywords: ['goal', 'scorecard', 'time allocation', 'effectiveness', 'goal alignment', 'time vs goals'] }, + { id: 'nav.insights', path: '/insights/overview', label: 'Insights', section: 'Identity', tabGroup: 'insights', tabId: 'overview', tabLabel: 'Overview', aliases: ['insights'] }, + { id: 'nav.insights.genome-health', path: '/insights/genome-health', label: 'Genome-Health', section: 'Identity', tabGroup: 'insights', tabId: 'genome-health', aliases: ['genome-health', 'insights-genome-health'], keywords: ['genome', 'dna', 'health', 'longevity', 'genetic'] }, + { id: 'nav.insights.taste-identity', path: '/insights/taste-identity', label: 'Taste & Identity', section: 'Identity', tabGroup: 'insights', tabId: 'taste-identity', aliases: ['taste-identity', 'insights-taste-identity'], keywords: ['taste', 'identity', 'preferences', 'aesthetic'] }, + { id: 'nav.insights.cross-domain', path: '/insights/cross-domain', label: 'Cross-Domain Patterns', section: 'Identity', tabGroup: 'insights', tabId: 'cross-domain', aliases: ['cross-domain', 'insights-cross-domain', 'cross-domain-patterns'], keywords: ['cross domain', 'patterns', 'correlations', 'connections'] }, + { id: 'nav.insights.goal-scorecard', path: '/insights/goal-scorecard', label: 'Goal Scorecard', section: 'Identity', tabGroup: 'insights', tabId: 'goal-scorecard', aliases: ['goal-scorecard', 'insights-goal-scorecard', 'scorecard'], keywords: ['goal', 'scorecard', 'time allocation', 'effectiveness', 'goal alignment', 'time vs goals'] }, { id: 'nav.twin.interview', path: '/digital-twin/interview', label: 'Interview', section: 'Identity', aliases: ['twin-interview'], keywords: ['sources'] }, { id: 'nav.twin.personality', path: '/digital-twin/personality', label: 'Personality', section: 'Identity', aliases: ['twin-personality', 'personality', 'model-personality'], keywords: ['assessment', 'llm', 'model', 'traits', 'radar', 'alignment', 'self-profile', 'compare', 'sycophancy'] }, { id: 'nav.twin.personas', path: '/digital-twin/personas', label: 'Personas', section: 'Identity', aliases: ['twin-personas', 'personas', 'persona'], keywords: ['profile', 'context', 'professional', 'casual', 'voice', 'mode'] }, @@ -208,11 +208,11 @@ const RAW_NAV_COMMANDS = [ { id: 'nav.twin.test', path: '/digital-twin/test', label: 'Test', section: 'Identity', aliases: ['twin-assessment', 'twin-test'], keywords: ['assessment'] }, { id: 'nav.twin.time-capsule', path: '/digital-twin/time-capsule', label: 'Time Capsule', section: 'Identity', aliases: ['time-capsule', 'twin-time-capsule', 'capsule'], keywords: ['legacy', 'archive', 'snapshot'] }, { id: 'nav.twin.voice', path: '/digital-twin/voice', label: 'Voice', section: 'Identity', aliases: ['twin-presence', 'twin-voice', 'voice-style', 'spoken-written'], keywords: ['presence', 'speech', 'spoken', 'written', 'transcript', 'style', 'comparison', 'communication'] }, - { id: 'nav.identity.privacy-overview', path: '/privacy/overview', label: 'Privacy', section: 'Identity', aliases: ['privacy', 'privacy-center', 'my-data'], keywords: ['pii', 'privacy', 'personal data', 'identity facts', 'who has my data'] }, - { id: 'nav.identity.privacy-vault', path: '/privacy/vault', label: 'Vault', section: 'Identity', aliases: ['vault', 'pii-vault', 'privacy-vault'], keywords: ['pii', 'vault', 'encrypted', 'ssn', 'address', 'passport', 'identity'] }, - { id: 'nav.identity.privacy-organizations', path: '/privacy/organizations', label: 'Organizations', section: 'Identity', aliases: ['organizations', 'orgs', 'trusted-orgs'], keywords: ['organizations', 'banks', 'utilities', 'who holds my data', 'registry', 'holdings'] }, - { id: 'nav.identity.privacy-changes', path: '/privacy/changes', label: 'Changes', section: 'Identity', aliases: ['changes', 'change-of-address', 'address-change', 'privacy-changes'], keywords: ['change of address', 'moved', 'update address', 'inventory', 'who needs updating', 'new phone', 'new email'] }, - { id: 'nav.identity.privacy-brokers', path: '/privacy/brokers', label: 'Brokers', section: 'Identity', aliases: ['brokers', 'data brokers', 'opt out', 'remove my data', 'privacy-brokers'], keywords: ['data brokers', 'opt out', 'remove my data', 'people search', 'exposure', 'spokeo', 'whitepages', 'ccpa', 'delete my data'] }, + { id: 'nav.identity.privacy-overview', path: '/privacy/overview', label: 'Privacy', section: 'Identity', tabGroup: 'privacy', tabId: 'overview', tabLabel: 'Overview', aliases: ['privacy', 'privacy-center', 'my-data'], keywords: ['pii', 'privacy', 'personal data', 'identity facts', 'who has my data'] }, + { id: 'nav.identity.privacy-vault', path: '/privacy/vault', label: 'Vault', section: 'Identity', tabGroup: 'privacy', tabId: 'vault', aliases: ['vault', 'pii-vault', 'privacy-vault'], keywords: ['pii', 'vault', 'encrypted', 'ssn', 'address', 'passport', 'identity'] }, + { id: 'nav.identity.privacy-organizations', path: '/privacy/organizations', label: 'Organizations', section: 'Identity', tabGroup: 'privacy', tabId: 'organizations', aliases: ['organizations', 'orgs', 'trusted-orgs'], keywords: ['organizations', 'banks', 'utilities', 'who holds my data', 'registry', 'holdings'] }, + { id: 'nav.identity.privacy-changes', path: '/privacy/changes', label: 'Changes', section: 'Identity', tabGroup: 'privacy', tabId: 'changes', aliases: ['changes', 'change-of-address', 'address-change', 'privacy-changes'], keywords: ['change of address', 'moved', 'update address', 'inventory', 'who needs updating', 'new phone', 'new email'] }, + { id: 'nav.identity.privacy-brokers', path: '/privacy/brokers', label: 'Brokers', section: 'Identity', tabGroup: 'privacy', tabId: 'brokers', aliases: ['brokers', 'data brokers', 'opt out', 'remove my data', 'privacy-brokers'], keywords: ['data brokers', 'opt out', 'remove my data', 'people search', 'exposure', 'spokeo', 'whitepages', 'ccpa', 'delete my data'] }, { id: 'nav.meatspace.overview', path: '/meatspace/overview', label: 'Overview', section: 'Health', aliases: ['meatspace'] }, { id: 'nav.meatspace.health', path: '/meatspace/health', label: 'Body Health', section: 'Health', aliases: ['meatspace-health', 'body-health'], keywords: ['health', 'vitals', 'wellbeing', 'biometrics'] }, @@ -317,11 +317,11 @@ const RAW_NAV_COMMANDS = [ { id: 'nav.cos.jobs', path: '/cos/jobs', label: 'System Tasks', section: 'Chief of Staff', aliases: ['cos-jobs', 'system-tasks'] }, { id: 'nav.uploads', path: '/uploads', label: 'Uploads', section: 'Dev Tools', aliases: ['uploads'] }, - { id: 'nav.wiki.overview', path: '/wiki/overview', label: 'Wiki', section: 'Brain', aliases: ['wiki'] }, - { id: 'nav.wiki.browse', path: '/wiki/browse', label: 'Browse', section: 'Brain', aliases: ['wiki-browse'] }, - { id: 'nav.wiki.graph', path: '/wiki/graph', label: 'Graph', section: 'Brain', aliases: ['wiki-graph'] }, - { id: 'nav.wiki.log', path: '/wiki/log', label: 'Log', section: 'Brain', aliases: ['wiki-log'] }, - { id: 'nav.wiki.search', path: '/wiki/search', label: 'Search', section: 'Brain', aliases: ['wiki-search'] }, + { id: 'nav.wiki.overview', path: '/wiki/overview', label: 'Wiki', section: 'Brain', tabGroup: 'wiki', tabId: 'overview', tabLabel: 'Overview', aliases: ['wiki'] }, + { id: 'nav.wiki.browse', path: '/wiki/browse', label: 'Browse', section: 'Brain', tabGroup: 'wiki', tabId: 'browse', aliases: ['wiki-browse'] }, + { id: 'nav.wiki.search', path: '/wiki/search', label: 'Search', section: 'Brain', tabGroup: 'wiki', tabId: 'search', aliases: ['wiki-search'] }, + { id: 'nav.wiki.graph', path: '/wiki/graph', label: 'Graph', section: 'Brain', tabGroup: 'wiki', tabId: 'graph', aliases: ['wiki-graph'] }, + { id: 'nav.wiki.log', path: '/wiki/log', label: 'Log', section: 'Brain', tabGroup: 'wiki', tabId: 'log', aliases: ['wiki-log'] }, ]; // A gated entry carries `feature`: the id of the optional instance feature it @@ -348,7 +348,7 @@ export const NAV_COMMANDS = RAW_NAV_COMMANDS.map((cmd) => { // `tabId` are still valid navigation destinations, but are nested drill-downs // or workflow overlays rather than section-level tabs. export const getSectionNavTabs = (section) => NAV_COMMANDS - .filter((command) => command.section === section && command.tabId) + .filter((command) => command.section === section && command.tabId && !command.tabGroup) .sort((a, b) => a.label.localeCompare(b.label) || a.path.localeCompare(b.path)) .map(({ tabId, label, path, feature }) => ({ id: tabId, @@ -357,6 +357,26 @@ export const getSectionNavTabs = (section) => NAV_COMMANDS ...(feature ? { feature } : {}), })); +// `tabGroup` marks the destinations that make up ONE page's own local tab bar +// (Calendar, Goals, Insights, Privacy, Messages, Wiki, …) — a different axis +// from `tabId`'s section-level child nav above. A page can sit in any sidebar +// section (Insights and Privacy both live under "Identity") without its tab +// ids colliding with a sibling page's, because uniqueness below is scoped to +// the group, not the section. Order is DECLARATION order, not alphabetical — +// pages order their own tabs deliberately (e.g. Overview first, Settings +// last), unlike the alphabetical section headers `getSectionNavTabs` builds. +// The page pairs this with a local presentation map (icon, component, …) +// keyed by `id`, and should throw at module load if a returned tab has no +// matching presentation entry — see client/src/pages/Wiki.jsx for the pattern. +export const getPageNavTabs = (group) => NAV_COMMANDS + .filter((command) => command.tabGroup === group) + .map(({ tabId, label, tabLabel, path, feature }) => ({ + id: tabId, + label: tabLabel || label, + to: path, + ...(feature ? { feature } : {}), + })); + const normalizedNavPath = (pathname) => { const barePath = String(pathname || '').split(/[?#]/)[0]; if (!barePath || barePath === '/') return '/'; @@ -373,7 +393,7 @@ const pathContainsNavRoute = (pathname, routePath) => ( export const getNavSectionForPath = (pathname) => { const normalizedPath = normalizedNavPath(pathname); return NAV_COMMANDS - .filter((command) => command.tabId && pathContainsNavRoute( + .filter((command) => command.tabId && !command.tabGroup && pathContainsNavRoute( normalizedPath, normalizedNavPath(command.path), )) @@ -394,19 +414,34 @@ for (const cmd of NAV_COMMANDS) { if (cmd.tabId !== undefined && (typeof cmd.tabId !== 'string' || !cmd.tabId.trim())) { throw new Error(`navManifest: tabId must be a non-empty string — got "${cmd.tabId}" for ${cmd.id}`); } + if (cmd.tabGroup !== undefined && (typeof cmd.tabGroup !== 'string' || !cmd.tabGroup.trim())) { + throw new Error(`navManifest: tabGroup must be a non-empty string — got "${cmd.tabGroup}" for ${cmd.id}`); + } + if (cmd.tabGroup !== undefined && !cmd.tabId) { + throw new Error(`navManifest: tabGroup requires tabId — ${cmd.id} declares tabGroup "${cmd.tabGroup}" with no tabId`); + } + if (cmd.tabLabel !== undefined && (typeof cmd.tabLabel !== 'string' || !cmd.tabLabel.trim())) { + throw new Error(`navManifest: tabLabel must be a non-empty string — got "${cmd.tabLabel}" for ${cmd.id}`); + } if (seenIds.has(cmd.id)) throw new Error(`navManifest: duplicate id ${cmd.id}`); seenIds.add(cmd.id); } -const tabIdsBySection = new Map(); +// `tabId` uniqueness is scoped to whichever axis owns it: a page's `tabGroup` +// when it has one (Insights and Privacy can both use id "overview" — they're +// different groups sharing the "Identity" section), otherwise the legacy +// per-section scope `getSectionNavTabs` relies on (Settings/Models). +const tabIdScopes = new Map(); for (const command of NAV_COMMANDS) { if (!command.tabId) continue; - const sectionIds = tabIdsBySection.get(command.section) || new Set(); - if (sectionIds.has(command.tabId)) { - throw new Error(`navManifest: duplicate tabId "${command.tabId}" in section ${command.section}`); + const scopeKey = command.tabGroup ? `group:${command.tabGroup}` : `section:${command.section}`; + const scopedIds = tabIdScopes.get(scopeKey) || new Set(); + if (scopedIds.has(command.tabId)) { + const scopeDesc = command.tabGroup ? `tabGroup "${command.tabGroup}"` : `section ${command.section}`; + throw new Error(`navManifest: duplicate tabId "${command.tabId}" in ${scopeDesc}`); } - sectionIds.add(command.tabId); - tabIdsBySection.set(command.section, sectionIds); + scopedIds.add(command.tabId); + tabIdScopes.set(scopeKey, scopedIds); } // Alias collisions resolve to the first-declared entry; ordering is load-bearing. diff --git a/server/lib/navManifest.test.js b/server/lib/navManifest.test.js index 40aa773416..47cf645f4f 100644 --- a/server/lib/navManifest.test.js +++ b/server/lib/navManifest.test.js @@ -39,12 +39,11 @@ const TABBED_PAGES = [ { prefix: '/cos', file: 'client/src/components/cos/constants.js', kind: 'ids', constName: 'TABS' }, { prefix: '/digital-twin', file: 'client/src/components/digital-twin/constants.js', kind: 'ids', constName: 'TABS' }, { prefix: '/meatspace', file: 'client/src/components/meatspace/constants.js', kind: 'ids', constName: 'TABS' }, - { prefix: '/calendar', file: 'client/src/pages/Calendar.jsx', kind: 'ids', constName: 'TABS' }, - { prefix: '/goals', file: 'client/src/pages/Goals.jsx', kind: 'ids', constName: 'TABS' }, - { prefix: '/insights', file: 'client/src/pages/Insights.jsx', kind: 'ids', constName: 'TABS' }, - { prefix: '/privacy', file: 'client/src/pages/Privacy.jsx', kind: 'ids', constName: 'TABS' }, - { prefix: '/messages', file: 'client/src/pages/Messages.jsx', kind: 'ids', constName: 'TABS' }, - { prefix: '/wiki', file: 'client/src/pages/Wiki.jsx', kind: 'ids', constName: 'TABS' }, + // Calendar, Goals, Insights, Privacy, Messages and Wiki derive their TABS from + // `getPageNavTabs(group)` (#6365) — the manifest is the one registry for + // those six now, so they no longer need a source-shape scraper here. Each + // page's own test file asserts its presentation map covers every manifest + // tab in its `tabGroup` instead. { prefix: '/settings', section: 'Settings', file: 'client/src/components/settings/SettingsTabsHeader.jsx', kind: 'section', constName: 'TABS' }, { prefix: '/models', section: 'Models', file: 'client/src/components/models/ModelsTabsHeader.jsx', kind: 'section', constName: 'TABS', nestedIdSources: [