diff --git a/client/src/components/brain/constants.js b/client/src/components/brain/constants.js index bfb6ccfdaa..d47904a466 100644 --- a/client/src/components/brain/constants.js +++ b/client/src/components/brain/constants.js @@ -1,25 +1,31 @@ import { MessageSquare, Database, Calendar, Rss, Shield, Users, FolderKanban, Lightbulb, ClipboardList, Settings, Link2, BookOpen, Network, FileText, NotebookPen, Upload, Target, BookText, Music, Video } from 'lucide-react'; +import { getPageNavTabs } from '../../../../server/lib/navManifest.js'; +import { buildPageNavTabs } from '../../lib/pageNavTabs.js'; -// Main navigation tabs. -// `fullBleed: true` marks a tab that fills the available height and owns its -// own internal scroll — Brain renders these inside an overflow-hidden wrapper -// with no padding (the rest scroll inside a padded wrapper). See issue #1177. -export const TABS = [ - { id: 'inbox', label: 'Inbox', icon: MessageSquare }, - { id: 'ideas', label: 'Ideas', icon: Lightbulb }, - { id: 'daily-log', label: 'Daily Log', icon: NotebookPen, fullBleed: true }, - { id: 'links', label: 'Links', icon: Link2 }, - { id: 'memory', label: 'Memory', icon: Database }, - { id: 'notes', label: 'Notes', icon: FileText, fullBleed: true }, - { id: 'graph', label: 'Graph', icon: Network, fullBleed: true }, - { id: 'digest', label: 'Digest', icon: Calendar }, - { id: 'feeds', label: 'Feeds', icon: Rss }, - { id: 'trust', label: 'Trust', icon: Shield }, - { id: 'import', label: 'Import', icon: Upload }, - { id: 'spotify', label: 'Spotify', icon: Music }, - { id: 'youtube', label: 'YouTube', icon: Video }, - { id: 'config', label: 'Config', icon: Settings } -]; +// Icon + layout per tab id. The manifest (`tabGroup: 'brain'`) owns id/label/ +// order — this file owns only how each tab looks. `fullBleed: true` marks a tab +// that fills the available height and owns its own internal scroll: Brain +// renders those inside an overflow-hidden wrapper with no padding (the rest +// scroll inside a padded wrapper). See issue #1177. Throws at import time on +// drift between the manifest and this map. +const TAB_PRESENTATION = { + inbox: { icon: MessageSquare }, + ideas: { icon: Lightbulb }, + 'daily-log': { icon: NotebookPen, fullBleed: true }, + links: { icon: Link2 }, + memory: { icon: Database }, + notes: { icon: FileText, fullBleed: true }, + graph: { icon: Network, fullBleed: true }, + digest: { icon: Calendar }, + feeds: { icon: Rss }, + trust: { icon: Shield }, + import: { icon: Upload }, + spotify: { icon: Music }, + youtube: { icon: Video }, + config: { icon: Settings }, +}; + +export const TABS = buildPageNavTabs(getPageNavTabs('brain'), TAB_PRESENTATION, 'Brain'); // Tab ids that render full-bleed (derived from TABS so the list can't drift). export const FULL_BLEED_TAB_IDS = new Set(TABS.filter((t) => t.fullBleed).map((t) => t.id)); diff --git a/client/src/components/brain/constants.test.js b/client/src/components/brain/constants.test.js index 773f642ec4..0860ab9adf 100644 --- a/client/src/components/brain/constants.test.js +++ b/client/src/components/brain/constants.test.js @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { MEMORY_TABS, TABS } from './constants'; +import { FULL_BLEED_TAB_IDS, MEMORY_TABS, TABS } from './constants'; +import { expectPageNavTabs } from '../../test/pageNavTabAssertions.js'; describe('Brain navigation', () => { it('keeps native Ideas on its dedicated URL-backed Brain tab', () => { @@ -7,3 +8,22 @@ describe('Brain navigation', () => { expect(MEMORY_TABS.map(({ id }) => id)).not.toContain('ideas'); }); }); + +// Brain derives its tab bar from the nav manifest's `tabGroup: 'brain'` (#6383) +// — this pins the id/label/order the page means to render, and that every +// manifest tab has a presentation entry (icon, `fullBleed`) in constants.js, +// which would otherwise only surface as a thrown import-time error. +describe('Brain TABS ↔ nav manifest', () => { + it('renders the brain tabGroup in page order with a presentation entry each', () => { + expectPageNavTabs(TABS, [ + 'inbox:Inbox', 'ideas:Ideas', 'daily-log:Daily Log', 'links:Links', + 'memory:Memory', 'notes:Notes', 'graph:Graph', 'digest:Digest', + 'feeds:Feeds', 'trust:Trust', 'import:Import', 'spotify:Spotify', + 'youtube:YouTube', 'config:Config', + ]); + }); + + it('keeps the full-bleed set derived from the presentation map', () => { + expect([...FULL_BLEED_TAB_IDS].sort()).toEqual(['daily-log', 'graph', 'notes']); + }); +}); diff --git a/client/src/components/cos/constants.js b/client/src/components/cos/constants.js index 58c0e55c9c..1daba96312 100644 --- a/client/src/components/cos/constants.js +++ b/client/src/components/cos/constants.js @@ -19,25 +19,31 @@ import { import { normalizeReviewerSlug, REVIEWER_VALUES } from '../../lib/reviewerPins'; import { AVATAR_STYLE_LABELS } from '../../lib/avatarStyles'; import { inPlaceClipName } from '../../utils/animationClips'; +import { getPageNavTabs } from '../../../../server/lib/navManifest.js'; +import { buildPageNavTabs } from '../../lib/pageNavTabs.js'; + +// Icon per tab id. The manifest (`tabGroup: 'cos'`) owns id/label/order — this +// file owns only how each tab looks. Throws at import time on drift. +const TAB_PRESENTATION = { + briefing: { icon: Newspaper }, + tasks: { icon: FileText }, + agents: { icon: Cpu }, + jobs: { icon: Bot }, + runs: { icon: Play }, + 'run-events': { icon: ScrollText }, + schedule: { icon: Clock }, + workflow: { icon: ChartGantt }, + digest: { icon: Calendar }, + gsd: { icon: Compass }, + productivity: { icon: BarChart2 }, + learning: { icon: GraduationCap }, + memory: { icon: Brain }, + mind: { icon: MessageCircle }, + health: { icon: Activity }, + config: { icon: Settings }, +}; -export const TABS = [ - { id: 'briefing', label: 'Briefing', icon: Newspaper }, - { id: 'tasks', label: 'Tasks', icon: FileText }, - { id: 'agents', label: 'Agents', icon: Cpu }, - { id: 'jobs', label: 'System Tasks', icon: Bot }, - { id: 'runs', label: 'Runs', icon: Play }, - { id: 'run-events', label: 'Run Events', icon: ScrollText }, - { id: 'schedule', label: 'Schedule', icon: Clock }, - { id: 'workflow', label: 'Timeline', icon: ChartGantt }, - { id: 'digest', label: 'Digest', icon: Calendar }, - { id: 'gsd', label: 'GSD', icon: Compass }, - { id: 'productivity', label: 'Productivity', icon: BarChart2 }, - { id: 'learning', label: 'Learning', icon: GraduationCap }, - { id: 'memory', label: 'Memory', icon: Brain }, - { id: 'mind', label: 'Mind', icon: MessageCircle }, - { id: 'health', label: 'Health', icon: Activity }, - { id: 'config', label: 'Config', icon: Settings } -]; +export const TABS = buildPageNavTabs(getPageNavTabs('cos'), TAB_PRESENTATION, 'CoS'); // Intentional category-color enum (#1909/#1924 caution), NOT off-token theme // inconsistency: 9 files (CoSCharacter, CyberCoSAvatar, EsotericCoSAvatar, diff --git a/client/src/components/cos/constants.test.js b/client/src/components/cos/constants.test.js index aa0cc4bc97..1ea3418f64 100644 --- a/client/src/components/cos/constants.test.js +++ b/client/src/components/cos/constants.test.js @@ -21,8 +21,10 @@ import { fresherHealth, providerPinPatch, hasProviderPin, - providerPinDivergesFromSchedule + providerPinDivergesFromSchedule, + TABS } from './constants'; +import { expectPageNavTabs } from '../../test/pageNavTabAssertions.js'; // These mirror the server's domainBudgets/domainAutonomy helpers so the UI's // "is a cap set?" / "what mode?" view never disagrees with enforcement. @@ -375,3 +377,18 @@ describe('REVIEWER_OPTIONS derivation', () => { } }); }); + +// CoS derives its tab bar from the nav manifest's `tabGroup: 'cos'` (#6383) — +// this pins the id/label/order the page means to render, and that every manifest +// tab has a presentation entry (icon) in constants.js, which would otherwise +// only surface as a thrown import-time error. +describe('CoS TABS ↔ nav manifest', () => { + it('renders the cos tabGroup in page order with a presentation entry each', () => { + expectPageNavTabs(TABS, [ + 'briefing:Briefing', 'tasks:Tasks', 'agents:Agents', 'jobs:System Tasks', + 'runs:Runs', 'run-events:Run Events', 'schedule:Schedule', 'workflow:Timeline', + 'digest:Digest', 'gsd:GSD', 'productivity:Productivity', 'learning:Learning', + 'memory:Memory', 'mind:Mind', 'health:Health', 'config:Config', + ]); + }); +}); diff --git a/client/src/components/digital-twin/constants.js b/client/src/components/digital-twin/constants.js index 22d9c24be2..4fa42c01ba 100644 --- a/client/src/components/digital-twin/constants.js +++ b/client/src/components/digital-twin/constants.js @@ -32,39 +32,43 @@ import { Package, UserRound } from 'lucide-react'; +import { getPageNavTabs } from '../../../../server/lib/navManifest.js'; +import { buildPageNavTabs } from '../../lib/pageNavTabs.js'; -// Main navigation sections, ordered by the group they belong to (see -// SECTION_GROUPS below). This array stays FLAT and stays the single registry of -// section ids: `server/lib/navManifest.test.js` scrapes `id:` out of it to prove -// every section is addressable as `/digital-twin/` from ⌘K and voice, and -// its extractor stops at the first `];` — so a nested array here would silently -// truncate the guard. Grouping therefore lives in a separate constant. -export const TABS = [ +// Icon per section id. The manifest (`tabGroup: 'digital-twin'`) owns +// id/label/order — this file owns only how each section looks, plus the +// SECTION_GROUPS slicing below. The page-local "Goals"/"Legacy" labels (vs the +// manifest's "Twin Goals"/"Legacy Bundle", which need the qualifier to be +// unambiguous in ⌘K) come from the manifest's `tabLabel`. Throws at import time +// on drift. +const TAB_PRESENTATION = { // Profile - { id: 'overview', label: 'Overview', icon: Heart }, - { id: 'identity', label: 'Identity', icon: Fingerprint }, - { id: 'personas', label: 'Personas', icon: Drama }, - { id: 'goals', label: 'Goals', icon: Target }, - { id: 'taste', label: 'Taste', icon: Palette }, + overview: { icon: Heart }, + identity: { icon: Fingerprint }, + personas: { icon: Drama }, + goals: { icon: Target }, + taste: { icon: Palette }, // Sources - { id: 'documents', label: 'Documents', icon: FileText }, - { id: 'import', label: 'Import', icon: Upload }, - { id: 'accounts', label: 'Accounts', icon: Globe }, - { id: 'interview', label: 'Interview', icon: MessageSquare }, - { id: 'autobiography', label: 'Autobiography', icon: PenLine }, - { id: 'enrich', label: 'Enrich', icon: Sparkles }, + documents: { icon: FileText }, + import: { icon: Upload }, + accounts: { icon: Globe }, + interview: { icon: MessageSquare }, + autobiography: { icon: PenLine }, + enrich: { icon: Sparkles }, // Assessment - { id: 'test', label: 'Test', icon: CheckCircle }, - { id: 'personality', label: 'Personality', icon: Brain }, + test: { icon: CheckCircle }, + personality: { icon: Brain }, // Presence - { id: 'voice', label: 'Voice', icon: Mic }, - { id: 'appearance', label: 'Appearance', icon: Camera }, - { id: 'avatar-bio', label: 'Avatar Bio', icon: UserRound }, + voice: { icon: Mic }, + appearance: { icon: Camera }, + 'avatar-bio': { icon: UserRound }, // Legacy - { id: 'export', label: 'Export', icon: Download }, - { id: 'legacy', label: 'Legacy', icon: Package }, - { id: 'time-capsule', label: 'Time Capsule', icon: Archive } -]; + export: { icon: Download }, + legacy: { icon: Package }, + 'time-capsule': { icon: Archive }, +}; + +export const TABS = buildPageNavTabs(getPageNavTabs('digital-twin'), TAB_PRESENTATION, 'Digital Twin'); // Two-level nav taxonomy (#3795). 19 sections in one flat strip stopped working // as navigation, so they collapse into five groups keyed on what the user is diff --git a/client/src/components/digital-twin/constants.test.js b/client/src/components/digital-twin/constants.test.js new file mode 100644 index 0000000000..504e20219d --- /dev/null +++ b/client/src/components/digital-twin/constants.test.js @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { SECTION_GROUPS, TABS } from './constants'; +import { expectPageNavTabs } from '../../test/pageNavTabAssertions.js'; + +// Digital Twin derives its section strip from the nav manifest's +// `tabGroup: 'digital-twin'` (#6383) — this pins the id/label/order the page +// means to render, and that every manifest section has a presentation entry +// (icon) in constants.js, which would otherwise only surface as a thrown +// import-time error. The short "Goals"/"Legacy" labels come from the manifest's +// `tabLabel`; ⌘K and voice still show the qualified "Twin Goals"/"Legacy Bundle". +describe('Digital Twin TABS ↔ nav manifest', () => { + it('renders the digital-twin tabGroup in page order with a presentation entry each', () => { + expectPageNavTabs(TABS, [ + 'overview:Overview', 'identity:Identity', 'personas:Personas', 'goals:Goals', + 'taste:Taste', 'documents:Documents', 'import:Import', 'accounts:Accounts', + 'interview:Interview', 'autobiography:Autobiography', 'enrich:Enrich', + 'test:Test', 'personality:Personality', 'voice:Voice', 'appearance:Appearance', + 'avatar-bio:Avatar Bio', 'export:Export', 'legacy:Legacy', 'time-capsule:Time Capsule', + ]); + }); + + // The two-level nav (#3795) slices the SAME ids back out of the manifest + // order, so a section added to the tabGroup without a group lands nowhere. + it('assigns every section to exactly one SECTION_GROUPS group', () => { + const grouped = SECTION_GROUPS.flatMap((group) => group.sectionIds); + expect([...grouped].sort()).toEqual(TABS.map((tab) => tab.id).sort()); + expect(new Set(grouped).size).toBe(grouped.length); + }); +}); diff --git a/client/src/components/meatspace/constants.js b/client/src/components/meatspace/constants.js index 7652e0378b..62bd8058eb 100644 --- a/client/src/components/meatspace/constants.js +++ b/client/src/components/meatspace/constants.js @@ -11,20 +11,28 @@ import { Stethoscope, FileText, } from 'lucide-react'; +import { getPageNavTabs } from '../../../../server/lib/navManifest.js'; +import { buildPageNavTabs } from '../../lib/pageNavTabs.js'; -export const TABS = [ - { id: 'overview', label: 'Overview', icon: Activity }, - { id: 'age', label: 'Age', icon: Clock }, - { id: 'alcohol', label: 'Alcohol', icon: Beer }, - { id: 'blood', label: 'Blood', icon: HeartPulse }, - { id: 'body', label: 'Body', icon: Scale }, - { id: 'export', label: 'Export', icon: FileText }, - { id: 'genome', label: 'Genome', icon: Dna }, - { id: 'health', label: 'Health', icon: Stethoscope }, - { id: 'settings', label: 'Settings', icon: Settings }, - { id: 'lifestyle', label: 'Lifestyle', icon: ClipboardList }, - { id: 'nicotine', label: 'Nicotine', icon: Cigarette }, -]; +// Icon per tab id. The manifest (`tabGroup: 'meatspace'`) owns id/label/order — +// this file owns only how each tab looks; the page-local "Health" label (vs the +// manifest's "Body Health", which disambiguates it from CoS Health in ⌘K) comes +// from the manifest's `tabLabel`. Throws at import time on drift. +const TAB_PRESENTATION = { + overview: { icon: Activity }, + age: { icon: Clock }, + alcohol: { icon: Beer }, + blood: { icon: HeartPulse }, + body: { icon: Scale }, + export: { icon: FileText }, + genome: { icon: Dna }, + health: { icon: Stethoscope }, + settings: { icon: Settings }, + lifestyle: { icon: ClipboardList }, + nicotine: { icon: Cigarette }, +}; + +export const TABS = buildPageNavTabs(getPageNavTabs('meatspace'), TAB_PRESENTATION, 'MeatSpace'); // Lifestyle adjustment table for death clock export const LIFESTYLE_ADJUSTMENTS = { diff --git a/client/src/components/meatspace/constants.test.js b/client/src/components/meatspace/constants.test.js new file mode 100644 index 0000000000..00ed2f8abc --- /dev/null +++ b/client/src/components/meatspace/constants.test.js @@ -0,0 +1,19 @@ +import { describe, it } from 'vitest'; +import { TABS } from './constants'; +import { expectPageNavTabs } from '../../test/pageNavTabAssertions.js'; + +// MeatSpace derives its tab bar from the nav manifest's `tabGroup: 'meatspace'` +// (#6383) — this pins the id/label/order the page means to render, and that +// every manifest tab has a presentation entry (icon) in constants.js, which +// would otherwise only surface as a thrown import-time error. The short "Health" +// label comes from the manifest's `tabLabel`; ⌘K and voice still show +// "Body Health" so it doesn't collide with CoS Health. +describe('MeatSpace TABS ↔ nav manifest', () => { + it('renders the meatspace tabGroup in page order with a presentation entry each', () => { + expectPageNavTabs(TABS, [ + 'overview:Overview', 'age:Age', 'alcohol:Alcohol', 'blood:Blood', 'body:Body', + 'export:Export', 'genome:Genome', 'health:Health', 'settings:Settings', + 'lifestyle:Lifestyle', 'nicotine:Nicotine', + ]); + }); +}); diff --git a/client/src/lib/README.md b/client/src/lib/README.md index 147b1d5697..28f9e534dd 100644 --- a/client/src/lib/README.md +++ b/client/src/lib/README.md @@ -24,6 +24,7 @@ grep -i "what you want to do" client/src/lib/README.md ``` | `navFeatures.js` | `filterNavByFeatures(navEntries, isFeatureEnabled)` — drops nav-manifest entries whose optional instance feature (`post`, `datadog`, `jira`, `gsd`) is off. The single gate for BROWSE surfaces (sidebar, ⌘K); routes stay reachable by URL, bookmark, and voice. Pair with `useInstanceFeatures`. | +| `pageNavTabs.js` | `buildPageNavTabs(manifestTabs, presentation, pageName)` — merges `getPageNavTabs(group)` (nav-manifest, owns id/label/order) with a page-owned presentation map (icon, layout flags), throwing at import time when a manifest tab has no entry. The one way a tabbed page builds its tab bar. | | `eidoverseFrame.js` | Versioned hosted Eidoverse message guards, exact section navigation allowlist, and browser label preferences. | | `eidoverseWorldReset.js` | Client reset-reconciliation maps for Eidoverse source kinds and district asset slots; parity-tested against the authoritative server world-design contracts. | | `postQuickSession.js` | Pure Quick POST duration presets, local-observation estimator, deterministic budget composer, and preview metadata. | diff --git a/client/src/lib/index.js b/client/src/lib/index.js index 9ae14659c8..2e3cf69dce 100644 --- a/client/src/lib/index.js +++ b/client/src/lib/index.js @@ -150,6 +150,7 @@ export * from './managedAppSources.js'; export * from './metronome.js'; export * from './mindThinkingPresets.js'; export * from './navFeatures.js'; +export * from './pageNavTabs.js'; export * from './midiChords.js'; export * from './midiNotes.js'; export * from './midiPlayback.js'; diff --git a/client/src/lib/pageNavTabs.js b/client/src/lib/pageNavTabs.js new file mode 100644 index 0000000000..34bcb5a1f4 --- /dev/null +++ b/client/src/lib/pageNavTabs.js @@ -0,0 +1,28 @@ +// The one way a page builds its own tab bar from the nav manifest. +// +// `server/lib/navManifest.js` is the single registry of navigable destinations, +// so a tabbed page must not restate its tab ids, labels or order in a local +// array — that second list drifts, and a tab that exists only there is +// unreachable from ⌘K and voice `ui_navigate`. Instead the page declares +// `tabGroup: ''` on each manifest entry and pairs `getPageNavTabs(group)` +// with a PRESENTATION map holding only what the manifest has no business +// knowing: the icon, and any page-only layout flag (`fullBleed`, …). +// +// A manifest tab with no presentation entry throws HERE, at module load, rather +// than rendering an iconless tab or silently dropping it — a page that can't +// render its own nav is a build error, not a runtime degradation. + +/** + * @param {Array<{id: string}>} manifestTabs from `getPageNavTabs(group)` + * @param {Record} presentation per-tab-id icon/layout, page-owned + * @param {string} pageName used in the drift error, e.g. "Wiki" + */ +export const buildPageNavTabs = (manifestTabs, presentation, pageName) => ( + manifestTabs.map((tab) => { + const tabPresentation = presentation[tab.id]; + if (!tabPresentation) throw new Error(`${pageName}: no tab presentation for manifest tab "${tab.id}"`); + return { ...tab, ...tabPresentation }; + }) +); + +export default buildPageNavTabs; diff --git a/client/src/pages/Calendar.jsx b/client/src/pages/Calendar.jsx index 73275d5c5b..fe5e87f4f7 100644 --- a/client/src/pages/Calendar.jsx +++ b/client/src/pages/Calendar.jsx @@ -8,6 +8,7 @@ import TabPills from '../components/ui/TabPills'; import { useValidTab } from '../hooks/useValidTab'; import useUrlParams from '../hooks/useUrlParams'; import { getPageNavTabs } from '../../../server/lib/navManifest.js'; +import { buildPageNavTabs } from '../lib/pageNavTabs.js'; import AgendaTab from '../components/calendar/AgendaTab'; import DayView from '../components/calendar/DayView'; @@ -33,11 +34,7 @@ const TAB_PRESENTATION = { 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 const TABS = buildPageNavTabs(getPageNavTabs('calendar'), TAB_PRESENTATION, 'Calendar'); export default function Calendar() { const navigate = useNavigate(); diff --git a/client/src/pages/Calendar.test.jsx b/client/src/pages/Calendar.test.jsx index b0ee3f146c..5edefc2bae 100644 --- a/client/src/pages/Calendar.test.jsx +++ b/client/src/pages/Calendar.test.jsx @@ -1,16 +1,15 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it } from 'vitest'; import { TABS } from './Calendar'; -import { getPageNavTabs } from '../../../server/lib/navManifest.js'; +import { expectPageNavTabs } from '../test/pageNavTabAssertions.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); + it('renders the calendar tabGroup in page order with a presentation entry each', () => { + expectPageNavTabs(TABS, [ + 'agenda:Agenda', 'day:Day', 'week:Week', 'month:Month', 'lifetime:Lifetime', 'review:Review', 'sync:Sync', 'config:Config', + ]); }); }); diff --git a/client/src/pages/Goals.jsx b/client/src/pages/Goals.jsx index f8ba661a5f..0c2ab5d6fe 100644 --- a/client/src/pages/Goals.jsx +++ b/client/src/pages/Goals.jsx @@ -9,6 +9,7 @@ import TabPills from '../components/ui/TabPills'; import PageSkeleton from '../components/ui/PageSkeleton'; import { useValidTab } from '../hooks/useValidTab'; import { getPageNavTabs } from '../../../server/lib/navManifest.js'; +import { buildPageNavTabs } from '../lib/pageNavTabs.js'; const GoalsTreeView = lazy(() => import('../components/goals/GoalsTreeView')); @@ -21,11 +22,7 @@ const TAB_PRESENTATION = { 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 const TABS = buildPageNavTabs(getPageNavTabs('goals'), TAB_PRESENTATION, 'Goals'); 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 index eccea24f5a..ee04f04054 100644 --- a/client/src/pages/Goals.test.jsx +++ b/client/src/pages/Goals.test.jsx @@ -1,6 +1,6 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it } from 'vitest'; import { TABS } from './Goals'; -import { getPageNavTabs } from '../../../server/lib/navManifest.js'; +import { expectPageNavTabs } from '../test/pageNavTabAssertions.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 @@ -9,10 +9,9 @@ import { getPageNavTabs } from '../../../server/lib/navManifest.js'; // "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); + it('renders the goals tabGroup in page order with a presentation entry each', () => { + expectPageNavTabs(TABS, [ + 'list:List', 'tree:Tree', + ]); }); }); diff --git a/client/src/pages/Insights.jsx b/client/src/pages/Insights.jsx index 4fae348ae3..6a6cec8f48 100644 --- a/client/src/pages/Insights.jsx +++ b/client/src/pages/Insights.jsx @@ -20,6 +20,7 @@ import TabPills from '../components/ui/TabPills'; import PageSkeleton from '../components/ui/PageSkeleton'; import { timeAgo } from '../utils/formatters'; import { getPageNavTabs } from '../../../server/lib/navManifest.js'; +import { buildPageNavTabs } from '../lib/pageNavTabs.js'; // 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. @@ -31,11 +32,7 @@ const TAB_PRESENTATION = { '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 const TABS = buildPageNavTabs(getPageNavTabs('insights'), TAB_PRESENTATION, 'Insights'); export function OverviewTab() { const navigate = useNavigate(); diff --git a/client/src/pages/Insights.test.jsx b/client/src/pages/Insights.test.jsx index 8b489f62c0..6a168253fd 100644 --- a/client/src/pages/Insights.test.jsx +++ b/client/src/pages/Insights.test.jsx @@ -28,14 +28,13 @@ import { refreshInsightNarrative, } from '../services/api'; import { OverviewTab, TABS } from './Insights'; -import { getPageNavTabs } from '../../../server/lib/navManifest.js'; +import { expectPageNavTabs } from '../test/pageNavTabAssertions.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); + it('renders the insights tabGroup in page order with a presentation entry each', () => { + expectPageNavTabs(TABS, [ + 'overview:Overview', 'genome-health:Genome-Health', 'taste-identity:Taste & Identity', 'cross-domain:Cross-Domain Patterns', 'goal-scorecard:Goal Scorecard', + ]); }); }); diff --git a/client/src/pages/MediaGen.jsx b/client/src/pages/MediaGen.jsx index 91949727bb..ad4eeff1e8 100644 --- a/client/src/pages/MediaGen.jsx +++ b/client/src/pages/MediaGen.jsx @@ -1,18 +1,27 @@ import { useNavigate, useLocation, Outlet } from 'react-router'; import { Layers, Image as ImageIcon, Film, History, Scissors, FolderOpen, Box, Pencil } from 'lucide-react'; import TabPills from '../components/ui/TabPills'; +import { getPageNavTabs } from '../../../server/lib/navManifest.js'; +import { buildPageNavTabs } from '../lib/pageNavTabs.js'; +// Icon per tab id. The manifest (`tabGroup: 'media'`) owns id/label/order — +// this page owns only how each tab looks; the page-local "History"/"Three.js" +// labels (vs the manifest's "Media History"/"Three.js Models", which need the +// qualifier to be unambiguous in ⌘K) come from the manifest's `tabLabel`. // LoRAs, Training and Models moved to the Models section (#4728) — they manage // installed weights, while everything left here generates or browses output. -export const TABS = [ - { id: 'image', label: 'Image', icon: ImageIcon }, - { id: 'video', label: 'Video', icon: Film }, - { id: 'threejs', label: 'Three.js', icon: Box }, - { id: 'annotate', label: 'Annotate', icon: Pencil }, - { id: 'timeline', label: 'Timeline', icon: Scissors }, - { id: 'history', label: 'History', icon: History }, - { id: 'collections', label: 'Collections', icon: FolderOpen } -]; +// Throws at import time on drift. +const TAB_PRESENTATION = { + image: { icon: ImageIcon }, + video: { icon: Film }, + threejs: { icon: Box }, + annotate: { icon: Pencil }, + timeline: { icon: Scissors }, + history: { icon: History }, + collections: { icon: FolderOpen }, +}; + +export const TABS = buildPageNavTabs(getPageNavTabs('media'), TAB_PRESENTATION, 'Media Gen'); export default function MediaGen() { const navigate = useNavigate(); diff --git a/client/src/pages/MediaGen.test.jsx b/client/src/pages/MediaGen.test.jsx index 2dc58f00c5..6c6e0b6566 100644 --- a/client/src/pages/MediaGen.test.jsx +++ b/client/src/pages/MediaGen.test.jsx @@ -3,6 +3,7 @@ import { render, screen, within } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import MediaGen, { TABS } from './MediaGen.jsx'; +import { expectPageNavTabs } from '../test/pageNavTabAssertions.js'; describe('', () => { it('provides a labeled mobile section selector for the full tab set', () => { @@ -18,3 +19,17 @@ describe('', () => { expect(select).toHaveValue('image'); }); }); + +// Media Gen derives its tab bar from the nav manifest's `tabGroup: 'media'` +// (#6383) — this pins the id/label/order the page means to render, and that +// every manifest tab has a presentation entry (icon) in MediaGen.jsx, which +// would otherwise only surface as a thrown import-time error. The short +// "History"/"Three.js" labels come from the manifest's `tabLabel`. +describe('MediaGen TABS ↔ nav manifest', () => { + it('renders the media tabGroup in page order with a presentation entry each', () => { + expectPageNavTabs(TABS, [ + 'image:Image', 'video:Video', 'threejs:Three.js', 'annotate:Annotate', + 'timeline:Timeline', 'history:History', 'collections:Collections', + ]); + }); +}); diff --git a/client/src/pages/Messages.jsx b/client/src/pages/Messages.jsx index 6e8f6b6ee7..ebfcdc1967 100644 --- a/client/src/pages/Messages.jsx +++ b/client/src/pages/Messages.jsx @@ -15,6 +15,7 @@ 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'; +import { buildPageNavTabs } from '../lib/pageNavTabs.js'; // Presentation per tab id. The manifest (`tabGroup: 'messages'`) owns // id/label/order — this page owns how each tab looks and behaves. @@ -31,11 +32,7 @@ const TAB_PRESENTATION = { 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 }; -}); +export const TABS = buildPageNavTabs(getPageNavTabs('messages'), TAB_PRESENTATION, 'Messages'); 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 index ff7a2b2393..c5052e1c3c 100644 --- a/client/src/pages/Messages.test.jsx +++ b/client/src/pages/Messages.test.jsx @@ -1,6 +1,6 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it } from 'vitest'; import { TABS } from './Messages'; -import { getPageNavTabs } from '../../../server/lib/navManifest.js'; +import { expectPageNavTabs } from '../test/pageNavTabAssertions.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) @@ -8,10 +8,9 @@ import { getPageNavTabs } from '../../../server/lib/navManifest.js'; // `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); + it('renders the messages tabGroup in page order with a presentation entry each', () => { + expectPageNavTabs(TABS, [ + 'inbox:Inbox', 'drafts:Drafts', 'imessage:iMessage', 'signal:Signal', 'contacts:Contacts', 'sync:Sync', 'config:Config', + ]); }); }); diff --git a/client/src/pages/Music.jsx b/client/src/pages/Music.jsx index 54e4a600eb..03a32df909 100644 --- a/client/src/pages/Music.jsx +++ b/client/src/pages/Music.jsx @@ -17,13 +17,22 @@ import AlbumsManager from '../components/music/AlbumsManager'; import TracksManager from '../components/music/TracksManager'; import MusicDesigner from '../components/music/MusicDesigner'; import TabPills from '../components/ui/TabPills'; +import { getPageNavTabs } from '../../../server/lib/navManifest.js'; +import { buildPageNavTabs } from '../lib/pageNavTabs.js'; -export const TABS = [ - { id: 'generate', label: 'Generate', icon: Wand2 }, - { id: 'artists', label: 'Artists', icon: Mic }, - { id: 'albums', label: 'Albums', icon: Disc3 }, - { id: 'tracks', label: 'Tracks', icon: AudioLines }, -]; +// Icon per tab id. The manifest (`tabGroup: 'music'`) owns id/label/order — +// this page owns only how each tab looks; the short page-local labels (vs the +// manifest's "Music Designer"/"Music Artists"/… , which need the "Music" +// qualifier to be unambiguous in ⌘K) come from the manifest's `tabLabel`. +// Throws at import time on drift. +const TAB_PRESENTATION = { + generate: { icon: Wand2 }, + artists: { icon: Mic }, + albums: { icon: Disc3 }, + tracks: { icon: AudioLines }, +}; + +export const TABS = buildPageNavTabs(getPageNavTabs('music'), TAB_PRESENTATION, 'Music'); const VALID = new Set(TABS.map((t) => t.id)); diff --git a/client/src/pages/Music.test.jsx b/client/src/pages/Music.test.jsx index 6d1f3e4e57..4308431b63 100644 --- a/client/src/pages/Music.test.jsx +++ b/client/src/pages/Music.test.jsx @@ -11,7 +11,8 @@ vi.mock('../components/music/AlbumsManager', () => ({ default: () =>
({ default: () =>
})); vi.mock('../components/music/MusicDesigner', () => ({ default: () =>
})); -import Music from './Music.jsx'; +import Music, { TABS } from './Music.jsx'; +import { expectPageNavTabs } from '../test/pageNavTabAssertions.js'; // Sibling readout of the current route, rendered alongside the page so a // redirect's resulting pathname is directly observable (mirrors the @@ -66,3 +67,17 @@ describe('', () => { expect(screen.queryByTestId('artists-manager')).toBeNull(); }); }); + +// Music derives its tab bar from the nav manifest's `tabGroup: 'music'` (#6383) +// — this pins the id/label/order the page means to render, and that every +// manifest tab has a presentation entry (icon) in Music.jsx, which would +// otherwise only surface as a thrown import-time error. The short labels come +// from the manifest's `tabLabel`; ⌘K and voice still show "Music Designer", +// "Music Artists", … so they don't collide with the Create-section pages. +describe('Music TABS ↔ nav manifest', () => { + it('renders the music tabGroup in page order with a presentation entry each', () => { + expectPageNavTabs(TABS, [ + 'generate:Generate', 'artists:Artists', 'albums:Albums', 'tracks:Tracks', + ]); + }); +}); diff --git a/client/src/pages/Privacy.jsx b/client/src/pages/Privacy.jsx index abf31fbe0a..bf0c3718df 100644 --- a/client/src/pages/Privacy.jsx +++ b/client/src/pages/Privacy.jsx @@ -15,6 +15,7 @@ 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'; +import { buildPageNavTabs } from '../lib/pageNavTabs.js'; // 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 @@ -28,11 +29,7 @@ const TAB_PRESENTATION = { 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 const TABS = buildPageNavTabs(getPageNavTabs('privacy'), TAB_PRESENTATION, 'Privacy'); export default function Privacy() { const navigate = useNavigate(); diff --git a/client/src/pages/Privacy.test.jsx b/client/src/pages/Privacy.test.jsx index 0a0a460b45..9f19551b93 100644 --- a/client/src/pages/Privacy.test.jsx +++ b/client/src/pages/Privacy.test.jsx @@ -73,14 +73,13 @@ vi.mock('../services/api', () => ({ })); import Privacy, { TABS } from './Privacy'; -import { getPageNavTabs } from '../../../server/lib/navManifest.js'; +import { expectPageNavTabs } from '../test/pageNavTabAssertions.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); + it('renders the privacy tabGroup in page order with a presentation entry each', () => { + expectPageNavTabs(TABS, [ + 'overview:Overview', 'vault:Vault', 'organizations:Organizations', 'changes:Changes', 'brokers:Brokers', + ]); }); }); import { diff --git a/client/src/pages/Sharing.jsx b/client/src/pages/Sharing.jsx index 17f93e65ac..cf296017b1 100644 --- a/client/src/pages/Sharing.jsx +++ b/client/src/pages/Sharing.jsx @@ -28,6 +28,8 @@ import { listShareInbox, promoteShareInboxItem, dismissShareInboxItem, listShareActivity, getSettings, updateSettings, } from '../services/api'; +import { getPageNavTabs } from '../../../server/lib/navManifest.js'; +import { buildPageNavTabs } from '../lib/pageNavTabs.js'; const emptyForm = () => ({ name: '', path: '', mode: 'inbox', displayNameOverride: '', bioOverride: '' }); @@ -44,14 +46,19 @@ export function isLiveSubscription(item, now = Date.now()) { return (now - receivedMs) < SUBSCRIPTION_LIVE_WINDOW_MS; } -// Top-level deep-linkable sections. Buckets is the bucket-management view; -// Duplicates + Conflicts are the sync-hygiene review surfaces. -// Exported for the nav-manifest tab-coverage guard (server/lib/navManifest.test.js). -export const SECTIONS = [ - { id: 'buckets', label: 'Buckets', icon: Folder, path: '/sharing' }, - { id: 'duplicates', label: 'Duplicates', icon: Copy, path: '/sharing/duplicates' }, - { id: 'conflicts', label: 'Conflicts', icon: GitMerge, path: '/sharing/conflicts' }, -]; +// Icon per top-level section id. The manifest (`tabGroup: 'sharing'`) owns +// id/label/order and the absolute path (`to`) — this page owns only how each +// section looks; the page-local "Buckets" label (vs the manifest's page-level +// "Sharing") comes from the manifest's `tabLabel`. Buckets is the +// bucket-management view; Duplicates + Conflicts are the sync-hygiene review +// surfaces. Throws at import time on drift. +const SECTION_PRESENTATION = { + buckets: { icon: Folder }, + duplicates: { icon: Copy }, + conflicts: { icon: GitMerge }, +}; + +export const SECTIONS = buildPageNavTabs(getPageNavTabs('sharing'), SECTION_PRESENTATION, 'Sharing'); // Per-bucket detail sub-tabs. Held in the `?tab=` URL search param (not local // state) so the open view is deep-linkable, reload-safe, and stays in sync with @@ -66,10 +73,10 @@ function SharingHeader({ active }) {

Sharing