From 65ed58d7a61cfd77725084b1cb4c7743b90815aa Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 6 Sep 2026 14:41:57 +0000 Subject: [PATCH] derive the last eight hand-maintained page tab lists from the nav manifest (#6383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brain, CoS, Digital Twin, MeatSpace, Media Gen, Music, Sharing and System Resources each still hand-maintained a local tab array that duplicated destinations already declared in server/lib/navManifest.js, kept in sync only by a source-scraping drift test. They now carry `tabGroup` + `tabId` on their manifest entries and build their tab bar from `getPageNavTabs(group)`, finishing the conversion #6365 started for the other six. Manifest entries in each new group were reordered so file order is the page's deliberate tab order (getPageNavTabs returns declaration order), and eleven tabs gained a `tabLabel` where the page's short label differs from the label ⌘K and voice need to be unambiguous — "Health" vs "Body Health", "Goals" vs "Twin Goals", "Legacy" vs "Legacy Bundle", "Generate"/"Artists"/"Albums"/"Tracks" vs the "Music …" pages, "History"/"Three.js" vs "Media History"/"Three.js Models", and "Overview"/"Storage"/"Queues" vs the "System Resources …" rows. Brain's Spotify and YouTube tabs turned out to already have manifest entries (under the ids nav.settings.spotify/youtube, kept stable because they're persisted palette-history values), so both were already reachable from ⌘K and voice and only needed the tabGroup tag. Sharing's SECTIONS list fits getPageNavTabs unchanged once `path` is read as `to`, so all eight ship together rather than carving Sharing into a follow-up. The 14 converted pages were each repeating the same five-line merge-and-throw, so that moves to `buildPageNavTabs` (client/src/lib/pageNavTabs.js), and the per-page parity test moves to a shared `expectPageNavTabs` assertion. The old per-page test compared the derived array back against its own source and so could not fail; each page now pins the literal id/label/order it means to render, which is what catches a manifest reorder or relabel. navManifest.test.js's TABBED_PAGES drops all eight rows along with the now-dead `kind: 'ids'`/`kind: 'links'` parsers and `allowBasePrefix`. It keeps extractConstArrayBlock/extractConstIds/extractSwitchCases and nestedIdSources, which are still load-bearing for Models' LLM_NAV_SUBROUTES and the Settings header ↔ `switch (activeTab)` parity test, so three rows remain: Settings, Models and POST. --- client/src/components/brain/constants.js | 46 ++--- client/src/components/brain/constants.test.js | 22 ++- client/src/components/cos/constants.js | 42 +++-- client/src/components/cos/constants.test.js | 19 +- .../src/components/digital-twin/constants.js | 58 +++--- .../components/digital-twin/constants.test.js | 29 +++ client/src/components/meatspace/constants.js | 34 ++-- .../components/meatspace/constants.test.js | 19 ++ client/src/lib/README.md | 1 + client/src/lib/index.js | 1 + client/src/lib/pageNavTabs.js | 28 +++ client/src/pages/Calendar.jsx | 7 +- client/src/pages/Calendar.test.jsx | 13 +- client/src/pages/Goals.jsx | 7 +- client/src/pages/Goals.test.jsx | 13 +- client/src/pages/Insights.jsx | 7 +- client/src/pages/Insights.test.jsx | 11 +- client/src/pages/MediaGen.jsx | 27 ++- client/src/pages/MediaGen.test.jsx | 15 ++ client/src/pages/Messages.jsx | 7 +- client/src/pages/Messages.test.jsx | 13 +- client/src/pages/Music.jsx | 21 ++- client/src/pages/Music.test.jsx | 17 +- client/src/pages/Privacy.jsx | 7 +- client/src/pages/Privacy.test.jsx | 11 +- client/src/pages/Sharing.jsx | 27 +-- client/src/pages/Sharing.test.jsx | 17 +- client/src/pages/SystemHealthPage.jsx | 25 ++- client/src/pages/SystemHealthPage.test.jsx | 16 +- client/src/pages/Wiki.jsx | 7 +- client/src/pages/Wiki.test.jsx | 11 +- client/src/test/pageNavTabAssertions.js | 28 +++ server/lib/navManifest.js | 170 +++++++++--------- server/lib/navManifest.test.js | 55 ++---- 34 files changed, 520 insertions(+), 311 deletions(-) create mode 100644 client/src/components/digital-twin/constants.test.js create mode 100644 client/src/components/meatspace/constants.test.js create mode 100644 client/src/lib/pageNavTabs.js create mode 100644 client/src/test/pageNavTabAssertions.js 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