diff --git a/.changeset/brand-menu-nvdocument.md b/.changeset/brand-menu-nvdocument.md
new file mode 100644
index 00000000..59eac58d
--- /dev/null
+++ b/.changeset/brand-menu-nvdocument.md
@@ -0,0 +1,28 @@
+---
+'@niivue/react': minor
+'@niivue/pwa': minor
+'@niivue/tauri': patch
+'niivue': patch
+'@niivue/streamlit': patch
+'@niivue/jupyter': patch
+---
+
+Fold the Home button into the brand, and turn "Save Scene" into an NVDocument Save/Load split button.
+
+- The standalone "Home" menu-bar button is gone. On standalone hosts (web, desktop)
+ the niivue logo + wordmark is now a dropdown: **Reset Viewer** (the old Home
+ action) and **About** (a dialog with the app's purpose, NiiVue + NeuroDesk
+ credits, the data-privacy note, and the build version linking to its commit).
+ The brand stays static on embedded hosts (VS Code; Streamlit, which sets
+ `menuItems.home: false`), so their behavior is unchanged.
+- "Save Scene" is renamed **NVDocument** and gains a chevron: clicking the label
+ still saves the active scene as a `.nvd` by default, while the dropdown offers
+ **Save** and a new **Load** (a `.nvd` file picker, complementing drag-and-drop).
+- Hosts can pass an optional `appInfo` ({ version, buildDate, repoUrl }) to
+ `Menu`; the PWA wires in its build-time git metadata. The About dialog degrades
+ gracefully (omits the version line) when a host supplies none.
+- The standalone home screens (PWA and desktop) now share a `HomeSection`
+ primitive for consistent styling while keeping their host-specific copy (PWA:
+ install / bookmarklet / update; desktop: native Open File). Both drop their
+ "Data Privacy" section, and the PWA also drops its version footer, since the
+ brand menu's About dialog now carries that information.
diff --git a/apps/desktop-tauri/src/components/DesktopHomeScreen.tsx b/apps/desktop-tauri/src/components/DesktopHomeScreen.tsx
index e88d9fe4..ddad8b1c 100644
--- a/apps/desktop-tauri/src/components/DesktopHomeScreen.tsx
+++ b/apps/desktop-tauri/src/components/DesktopHomeScreen.tsx
@@ -1,3 +1,4 @@
+import { HomeSection } from '@niivue/react'
import { open } from '@tauri-apps/plugin-dialog'
import {
MEDICAL_IMAGE_EXTENSIONS,
@@ -59,13 +60,10 @@ export const DesktopHomeScreen = () => {
return (
<>
-
- NiiVue Tauri
-
-
- Open medical images from your local filesystem. Drag and drop files
- onto this window, or use the button below.
-
+
+ Open medical images from your local filesystem. Drag and drop files onto this window, or use
+ the button below.
+
{isTauri() && (
)}
-
- Supported Formats
-
-
- NIfTI (.nii, .nii.gz), DICOM (.dcm), MHA/MHD, NRRD, FreeSurfer (.mgh,
- .mgz), GIfTI (.gii), and many more.
-
-
-
- Data Privacy
-
-
- All processing happens locally on your machine. No data is sent to any
- remote server. Your medical images never leave your computer.
-
+
+ NIfTI (.nii, .nii.gz), DICOM (.dcm), MHA/MHD, NRRD, FreeSurfer (.mgh, .mgz), GIfTI (.gii),
+ and many more.
+
>
)
}
diff --git a/apps/desktop-tauri/test/DesktopHomeScreen.test.tsx b/apps/desktop-tauri/test/DesktopHomeScreen.test.tsx
index f0c04fde..b0d7017c 100644
--- a/apps/desktop-tauri/test/DesktopHomeScreen.test.tsx
+++ b/apps/desktop-tauri/test/DesktopHomeScreen.test.tsx
@@ -3,6 +3,7 @@
* open flow that orchestrates registerOpenedPath + initCanvas + per-file
* loads.
*/
+import type { ComponentChildren } from 'preact'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { fireEvent, render } from '@testing-library/preact'
@@ -34,6 +35,13 @@ vi.mock('@niivue/react', () => ({
ImageDrop: ({ children }: { children: unknown }) => children,
Menu: () => null,
listenToMessages: vi.fn(),
+ // Mirror the real HomeSection's title + body so getByText assertions work.
+ HomeSection: ({ title, children }: { title: string; children: ComponentChildren }) => (
+
+
+
Drag and drop files to an empty space on this window. Many medical image and mesh formats are
supported.
-
+
-
Install as local App
-
+ (
/>
To install niivue-vscode as a local app, click the install button in the address bar.
This is currently only supported in chromium-based browsers.
-
+
-
Update App
-
+
The app will automatically check for updates and show a notification when a new version is
available. You can also manually force an update by pressing{' '}
Ctrl+Shift+R to force refresh and clear the
cache.
-
- The vscode extension (or static webpage) runs locally and only accesses the images from your
- machine for displaying. No data is sent or stored remotely. The extension is a complete
- offline solution and does not use any online cache, storage, or network connectivity. The
- extension does not track or log any user data. Local logging is minimal and only related to
- hardware events.
-
-
- The extension is an open source project depending on
-
- NiiVue
- {' '}
- and was initially developed at The University of Queensland by the Computational Imaging and
-
- NeuroDesk
- {' '}
- group.
-
-
+
>
)
diff --git a/apps/pwa/test/HomeScreen.test.tsx b/apps/pwa/test/HomeScreen.test.tsx
index e857019a..e422a98b 100644
--- a/apps/pwa/test/HomeScreen.test.tsx
+++ b/apps/pwa/test/HomeScreen.test.tsx
@@ -1,5 +1,20 @@
+import type { ComponentChildren } from 'preact'
import { render } from '@testing-library/preact'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
+
+// HomeScreen composes the shared HomeSection from @niivue/react. The PWA's
+// vitest config resolves that package to its built dist, so stub it here to keep
+// this unit test self-contained and independent of build order (mirrors the
+// desktop home-screen test).
+vi.mock('@niivue/react', () => ({
+ HomeSection: ({ title, children }: { title: string; children: ComponentChildren }) => (
+
+
{title}
+
{children}
+
+ ),
+}))
+
import { HomeScreen } from '../src/components/HomeScreen'
describe('HomeScreen', () => {
diff --git a/apps/pwa/tests/Document.spec.ts b/apps/pwa/tests/Document.spec.ts
index 29ba1989..0e449a6b 100644
--- a/apps/pwa/tests/Document.spec.ts
+++ b/apps/pwa/tests/Document.spec.ts
@@ -10,13 +10,14 @@ import { BASE_URL, loadTestImage, waitForImageLoad } from './utils'
* re-import it by dropping the file.
*/
-// Load one image, export the active canvas via the Save Scene button, and
-// return the downloaded `.nvd` file's text (downloadNvd writes uncompressed JSON).
+// Load one image, export the active canvas via the NVDocument button (its label
+// click saves by default), and return the downloaded `.nvd` file's text
+// (downloadNvd writes uncompressed JSON).
async function exportScene(page: Page): Promise {
await page.goto(BASE_URL)
await loadTestImage(page)
const downloadPromise = page.waitForEvent('download')
- await page.getByRole('button', { name: 'Save Scene' }).click()
+ await page.getByRole('button', { name: 'NVDocument' }).click()
const download = await downloadPromise
expect(download.suggestedFilename()).toMatch(/\.nvd$/)
const path = await download.path()
@@ -42,9 +43,9 @@ test.describe('NVDocument (.nvd) import/export', () => {
expect(viaFacade.blobs).toBeGreaterThan(0)
expect(viaFacade.hasOpts).toBeTruthy()
- // Export via the UI (Save Scene) downloads the same embedded shape.
+ // Export via the UI (NVDocument label click = Save) downloads the same shape.
const downloadPromise = page.waitForEvent('download')
- await page.getByRole('button', { name: 'Save Scene' }).click()
+ await page.getByRole('button', { name: 'NVDocument' }).click()
const download = await downloadPromise
expect(download.suggestedFilename()).toMatch(/\.nvd$/)
const doc = JSON.parse(await readFile((await download.path()) as string, 'utf-8'))
diff --git a/apps/pwa/tests/Menu.spec.ts b/apps/pwa/tests/Menu.spec.ts
index 6a1f8eac..05690163 100644
--- a/apps/pwa/tests/Menu.spec.ts
+++ b/apps/pwa/tests/Menu.spec.ts
@@ -5,18 +5,29 @@ test.describe('Menu', () => {
test('displays home screen', { tag: '@dom' }, async ({ page }) => {
await page.goto(BASE_URL)
- expect(await page.textContent('text=/Home/i')).toBeTruthy()
+ // The brand doubles as the viewer menu (Reset Viewer / About).
+ expect(await page.$('[data-testid="menu-brand"]')).toBeTruthy()
expect(await page.textContent('text=/Add Image/i')).toBeTruthy()
expect(await page.textContent('text=/View/i')).toBeTruthy()
expect(await page.textContent('text=/Bookmarklet/i')).toBeTruthy()
expect(await page.textContent('text=/Drop Files to load images/i')).toBeTruthy()
})
+ test('brand menu opens Reset Viewer and About', { tag: '@dom' }, async ({ page }) => {
+ await page.goto(BASE_URL)
+
+ await page.click('data-testid=menu-brand')
+ expect(await page.textContent('text=/Reset Viewer/i')).toBeTruthy()
+
+ await page.click('text=/About/i')
+ await expect(page.getByTestId('about-dialog')).toBeVisible()
+ expect(await page.textContent('text=/NeuroDesk/i')).toBeTruthy()
+ })
+
test('menubar updates with loading images', async ({ page }) => {
await page.goto(BASE_URL)
// initially only these menu items are visible
- expect(await page.textContent('text=/Home/i')).toBeTruthy()
expect(await page.textContent('text=/Add Image/i')).toBeTruthy()
expect(await page.textContent('text=/View/i')).toBeTruthy()
@@ -33,7 +44,6 @@ test.describe('Menu', () => {
).toBeTruthy()
// after loading an image these are visible
- expect(await page.textContent('text=/Home/i')).toBeTruthy()
expect(await page.textContent('text=/Add Image/i')).toBeTruthy()
expect(await page.textContent('text=/View/i')).toBeTruthy()
expect(await page.textContent('text=/ColorScale/i')).toBeTruthy()
@@ -56,7 +66,7 @@ test.describe('Menu', () => {
await page.textContent('text=/matrix size:.*voxelsize:/i'),
).toBeTruthy()
- const menuBar = ['Home', 'Add Image', 'View', 'ColorScale', 'Overlay', 'Header']
+ const menuBar = ['Add Image', 'View', 'ColorScale', 'Overlay', 'Header']
for (const item of menuBar) {
expect(await page.textContent(`text=/${item}/i`)).toBeTruthy()
}
diff --git a/apps/pwa/tests/MenuOverflow.spec.ts b/apps/pwa/tests/MenuOverflow.spec.ts
index ac5729b9..f2fe6850 100644
--- a/apps/pwa/tests/MenuOverflow.spec.ts
+++ b/apps/pwa/tests/MenuOverflow.spec.ts
@@ -6,7 +6,8 @@ test.describe('Menu overflow', () => {
await page.setViewportSize({ width: 1280, height: 800 })
await page.goto(BASE_URL)
- await expect(page.getByRole('button', { name: 'View' })).toBeVisible()
+ // `exact` so this doesn't also match the brand button ("niivue Viewer").
+ await expect(page.getByRole('button', { name: 'View', exact: true })).toBeVisible()
await expect(page.getByTestId('menu-overflow')).toHaveCount(0)
})
@@ -39,7 +40,7 @@ test.describe('Menu overflow', () => {
await page.setViewportSize({ width: 760, height: 800 })
await page.goto(BASE_URL)
- // With no image only Home/Add Image/View/Zoom show and all fit at 760px.
+ // With no image only Add Image/View/Zoom show and all fit at 760px.
await expect(page.getByTestId('menu-overflow')).toHaveCount(0)
await page.click('data-testid=menu-item-dropdown-Add Image')
diff --git a/packages/niivue-react/src/components/AboutDialog.css b/packages/niivue-react/src/components/AboutDialog.css
new file mode 100644
index 00000000..03569f10
--- /dev/null
+++ b/packages/niivue-react/src/components/AboutDialog.css
@@ -0,0 +1,86 @@
+/* About dialog (brand menu). Dark-themed to match the top bar; the `nv-`
+ prefix scopes these globally. */
+
+.nv-about {
+ width: min(440px, 92vw);
+ padding: 0;
+ border: 1px solid var(--line-2);
+ border-radius: var(--radius);
+ background: var(--bg-4);
+ color: var(--fg-1);
+ box-shadow: 0 24px 70px rgba(0, 0, 0, 0.6);
+}
+.nv-about::backdrop {
+ background: rgba(0, 0, 0, 0.55);
+}
+
+.nv-about-body {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 20px;
+}
+
+.nv-about-head {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.nv-about-title {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--fg-0);
+ letter-spacing: -0.01em;
+}
+.nv-about-tagline {
+ margin: 2px 0 0;
+ font-size: 12px;
+ color: var(--fg-2);
+}
+
+.nv-about-text {
+ margin: 0;
+ font-size: 12.5px;
+ line-height: 1.5;
+ color: var(--fg-1);
+}
+
+.nv-about-link {
+ color: var(--accent);
+ text-decoration: none;
+}
+.nv-about-link:hover {
+ text-decoration: underline;
+}
+
+.nv-about-links {
+ display: flex;
+ gap: 16px;
+ font-size: 12.5px;
+}
+
+.nv-about-version {
+ margin: 0;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--fg-3);
+}
+
+.nv-about-close {
+ align-self: flex-end;
+ margin-top: 4px;
+ padding: 7px 16px;
+ border: 1px solid var(--line-2);
+ border-radius: 7px;
+ background: var(--bg-3);
+ color: var(--fg-1);
+ font-size: 12.5px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.12s, color 0.12s;
+}
+.nv-about-close:hover {
+ background: var(--bg-5);
+ color: var(--fg-0);
+}
diff --git a/packages/niivue-react/src/components/AboutDialog.tsx b/packages/niivue-react/src/components/AboutDialog.tsx
new file mode 100644
index 00000000..e53eac9c
--- /dev/null
+++ b/packages/niivue-react/src/components/AboutDialog.tsx
@@ -0,0 +1,104 @@
+import './AboutDialog.css'
+import { type Signal, effect } from '@preact/signals'
+import { useRef } from 'preact/hooks'
+import { niivueLogo } from '../assets/niivue-logo'
+import type { AppInfo } from './AppProps'
+
+// Canonical source repo, used when a host doesn't supply its own `repoUrl`.
+const DEFAULT_REPO_URL = 'https://github.com/niivue/niivue-vscode'
+
+/**
+ * The brand menu's "About" dialog: what niivue Viewer is, its credits, the data
+ * privacy guarantee, and (when the host provides `appInfo`) the build version.
+ *
+ * `isOpen` is a trigger signal in the HeaderDialog mold: set it true to open the
+ * native modal; the effect opens the dialog and immediately flips the signal
+ * back to false so a later set reopens it. The dialog itself closes via its own
+ * form button.
+ */
+export const AboutDialog = ({
+ isOpen,
+ appInfo,
+}: {
+ isOpen: Signal
+ appInfo?: AppInfo
+}) => {
+ const dialogRef = useRef(null)
+
+ effect(() => {
+ if (isOpen.value) {
+ dialogRef.current?.showModal()
+ isOpen.value = false
+ }
+ })
+
+ const repoUrl = appInfo?.repoUrl || DEFAULT_REPO_URL
+ const version = appInfo?.version
+ const built = appInfo?.buildDate ? new Date(appInfo.buildDate).toLocaleDateString() : ''
+ // Link the version to its commit when we know the repo it was built from.
+ const versionHref = version && appInfo?.repoUrl ? `${appInfo.repoUrl}/commit/${version}` : repoUrl
+
+ return (
+
+ )
+}
diff --git a/packages/niivue-react/src/components/AppProps.ts b/packages/niivue-react/src/components/AppProps.ts
index e2b4c04b..7aa658fb 100644
--- a/packages/niivue-react/src/components/AppProps.ts
+++ b/packages/niivue-react/src/components/AppProps.ts
@@ -20,6 +20,21 @@ export interface AppProps {
syncedIndices: Signal>
}
+/**
+ * Host-supplied build metadata for the brand menu's About dialog. Optional and
+ * host-specific (e.g. the PWA injects its git hash at build time), so it is
+ * passed as a prop rather than threaded through the persisted settings - a
+ * version frozen into `localStorage` would go stale on the next deploy.
+ */
+export interface AppInfo {
+ /** Short build/version identifier, e.g. a git short hash. */
+ version?: string
+ /** ISO build timestamp; rendered as a localized date. */
+ buildDate?: string
+ /** Source repository URL; used for the version's commit link. */
+ repoUrl?: string
+}
+
export interface ScalingOpts {
isManual: boolean
min: number
diff --git a/packages/niivue-react/src/components/HomeSection.tsx b/packages/niivue-react/src/components/HomeSection.tsx
new file mode 100644
index 00000000..6ef093b5
--- /dev/null
+++ b/packages/niivue-react/src/components/HomeSection.tsx
@@ -0,0 +1,24 @@
+import { ComponentChildren } from 'preact'
+
+/**
+ * A titled block for the standalone hosts' empty-state home screens (PWA,
+ * desktop). The two home screens carry host-specific copy but should look
+ * identical, so the shared heading/body styling lives here instead of being
+ * copy-pasted into each app.
+ *
+ * `children` render inside a paragraph; inline content (links, , ,
+ * /) is fine. Standalone controls (e.g. a button) belong between
+ * sections, not inside one.
+ */
+export const HomeSection = ({
+ title,
+ children,
+}: {
+ title: string
+ children: ComponentChildren
+}) => (
+ <>
+
{title}
+
{children}
+ >
+)
diff --git a/packages/niivue-react/src/components/Menu.css b/packages/niivue-react/src/components/Menu.css
index e3bfffdf..cb672772 100644
--- a/packages/niivue-react/src/components/Menu.css
+++ b/packages/niivue-react/src/components/Menu.css
@@ -56,6 +56,25 @@
margin-top: 2px;
}
+/* When the brand doubles as a dropdown trigger (standalone hosts): reset the
+ button chrome, give it a hit area and a hover/open affordance, and nudge it
+ left so the logo keeps its original alignment despite the new padding. */
+.nv-brand-trigger {
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ padding: 4px 8px;
+ margin-left: -8px;
+ border-radius: 8px;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ transition: background 0.12s;
+}
+.nv-brand-trigger:hover,
+.nv-brand-trigger.is-active { background: var(--bg-3); }
+.nv-brand-caret { color: var(--fg-3); margin-left: 2px; flex-shrink: 0; }
+
.nv-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 12px;
diff --git a/packages/niivue-react/src/components/Menu.tsx b/packages/niivue-react/src/components/Menu.tsx
index 5d6a0bc3..5fc39663 100644
--- a/packages/niivue-react/src/components/Menu.tsx
+++ b/packages/niivue-react/src/components/Menu.tsx
@@ -12,11 +12,13 @@ import {
addDcmFolderEvent,
addImagesEvent,
addOverlayEvent,
+ loadDocumentEvent,
openImageFromURL,
} from '../events'
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'
import { getMetadataString, getNumberOfPoints } from '../utility'
-import { AppProps, SelectionMode } from './AppProps'
+import { AboutDialog } from './AboutDialog'
+import { AppInfo, AppProps, SelectionMode } from './AppProps'
import { HeaderBox } from './HeaderBox'
import {
HeaderDialog,
@@ -24,18 +26,20 @@ import {
MenuEntry,
StepperEntry,
ToggleEntry,
+ activeMenu,
toggle,
} from './MenuElements'
import { BarItem, MenuBar } from './MenuBar'
import { DEFAULT_TILE_SPACING } from '../settings'
import { ScalingBox } from './ScalingBox'
-export const Menu = (props: AppProps) => {
- const { selection, selectionMode, nvArray, sliceType, hideUI, settings } = props
+export const Menu = (props: AppProps & { appInfo?: AppInfo }) => {
+ const { selection, selectionMode, nvArray, sliceType, hideUI, settings, appInfo } = props
const isVscode = typeof (globalThis as any).vscode === 'object'
// State
const headerDialog = useSignal(false)
+ const aboutDialog = useSignal(false)
const selectedOverlayNumber = useSignal(0)
const overlayMenu = useSignal(false)
const setHeaderMenu = useSignal(false)
@@ -129,6 +133,8 @@ export const Menu = (props: AppProps) => {
})
// Menu Click events
+ // "Reset Viewer" (brand menu): drop any loaded scene and query params by
+ // navigating back to the bare origin+path, then reload to a clean state.
const homeEvent = () => {
const url = new URL(location.href)
location.href = url.origin + url.pathname
@@ -487,13 +493,6 @@ export const Menu = (props: AppProps) => {
// both the per-item settings flag and the data-dependent conditions that used
// to live on the JSX (e.g. isVolume / isVolumeOrMesh).
const barItems: BarItem[] = [
- {
- key: 'home',
- type: 'button',
- label: 'Home',
- visible: !isVscode && !!settings.value.menuItems?.home,
- onClick: homeEvent,
- },
{
key: 'addImage',
type: 'menu',
@@ -515,11 +514,20 @@ export const Menu = (props: AppProps) => {
),
},
{
+ // NVDocument (.nvd) scene file. The label is the default action (Save);
+ // the chevron opens Save / Load. Load is also reachable by dropping a
+ // `.nvd`, but the explicit entry pairs naturally with Save here.
key: 'saveScene',
- type: 'button',
- label: 'Save Scene',
+ type: 'menu',
+ label: 'NVDocument',
visible: !!settings.value.menuItems?.saveScene && !isVscode && isVolumeOrMesh.value,
onClick: saveScene,
+ children: (
+ <>
+
+
+ >
+ ),
},
{
key: 'view',
@@ -748,13 +756,12 @@ export const Menu = (props: AppProps) => {
<>
-
-
-
- niivue
- {!isVscode && Viewer}
-
-
+ (aboutDialog.value = true)}
+ />
@@ -773,10 +780,88 @@ export const Menu = (props: AppProps) => {
/>
+
>
)
}
+// activeMenu key reserved for the brand dropdown.
+const BRAND_KEY = '__brand__'
+
+// The niivue logo + wordmark. On standalone hosts (`interactive`) it doubles as
+// a dropdown trigger for viewer-level actions (Reset Viewer, About); elsewhere
+// (vscode, embedded Streamlit) it renders as a static brand, unchanged.
+const BrandMenu = ({
+ showSubtext,
+ interactive,
+ onReset,
+ onAbout,
+}: {
+ showSubtext: boolean
+ interactive: boolean
+ onReset: () => void
+ onAbout: () => void
+}) => {
+ const open = computed(() => activeMenu.value === BRAND_KEY)
+ const inner = (
+ <>
+
+
+ niivue
+ {showSubtext && Viewer}
+
+ >
+ )
+
+ if (!interactive) {
+ return
{inner}
+ }
+
+ return (
+
+
+ {open.value && (
+
+
+
+
+ )}
+
+ )
+}
+
+const BrandCaret = () => (
+
+)
+
function applySelectionModeChange(
selectionMode: Signal,
selectionActive: Signal,
diff --git a/packages/niivue-react/src/events.ts b/packages/niivue-react/src/events.ts
index 75933cec..c276d43d 100644
--- a/packages/niivue-react/src/events.ts
+++ b/packages/niivue-react/src/events.ts
@@ -361,6 +361,31 @@ export function addImagesEvent() {
}
}
+/**
+ * Open a file picker for a NiiVue scene document (.nvd) and import it into a
+ * fresh canvas via the `loadDocument` message. This is the browser-host
+ * counterpart to `saveScene`'s download; vscode hosts persist scenes through
+ * their own services, so the menu gates this to non-vscode.
+ */
+export function loadDocumentEvent() {
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.accept = '.nvd'
+
+ input.onchange = async (e) => {
+ const file = (e.target as HTMLInputElement)?.files?.[0]
+ if (!file) return
+ try {
+ const document = await readNvdFile(file)
+ window.postMessage({ type: 'loadDocument', body: { document, name: file.name } })
+ } catch (err) {
+ console.error(`Failed to read .nvd file ${file.name}:`, err)
+ }
+ }
+
+ input.click()
+}
+
export function addDcmFolderEvent() {
if (typeof vscode === 'object') {
vscode.postMessage({ type: 'addDcmFolder' })
diff --git a/packages/niivue-react/src/index.ts b/packages/niivue-react/src/index.ts
index 67cbcbc1..8d365e91 100644
--- a/packages/niivue-react/src/index.ts
+++ b/packages/niivue-react/src/index.ts
@@ -1,10 +1,12 @@
import './index.css'
+export { AboutDialog } from './components/AboutDialog'
export { App } from './components/App'
export { useAppState } from './components/AppProps'
-export type { AppProps, ScalingOpts, SelectionMode } from './components/AppProps'
+export type { AppInfo, AppProps, ScalingOpts, SelectionMode } from './components/AppProps'
export { Container } from './components/Container'
export { HeaderBox } from './components/HeaderBox'
+export { HomeSection } from './components/HomeSection'
export { ImageDrop } from './components/ImageDrop'
export { Menu } from './components/Menu'
export { NiiVueCanvas } from './components/NiiVueCanvas'
diff --git a/packages/niivue-react/src/settings.ts b/packages/niivue-react/src/settings.ts
index 79505f31..ef51f372 100644
--- a/packages/niivue-react/src/settings.ts
+++ b/packages/niivue-react/src/settings.ts
@@ -1,4 +1,7 @@
export interface MenuItems {
+ /** Gates the brand "viewer" dropdown (Reset Viewer / About). Named `home`
+ * for back-compat: it formerly toggled the standalone Home button, whose
+ * Reset action now lives in the brand menu. */
home: boolean
addImage: boolean
view: boolean
diff --git a/packages/niivue-react/src/test/AboutDialog.test.tsx b/packages/niivue-react/src/test/AboutDialog.test.tsx
new file mode 100644
index 00000000..e8e67e0e
--- /dev/null
+++ b/packages/niivue-react/src/test/AboutDialog.test.tsx
@@ -0,0 +1,55 @@
+import { signal } from '@preact/signals'
+import { cleanup, render, screen } from '@testing-library/preact'
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
+import { AboutDialog } from '../components/AboutDialog'
+
+// jsdom doesn't implement showModal; the open effect just needs it to not throw.
+beforeAll(() => {
+ HTMLDialogElement.prototype.showModal = vi.fn()
+ HTMLDialogElement.prototype.close = vi.fn()
+})
+
+afterEach(() => cleanup())
+
+const anchorIn = (testId: string) =>
+ screen.getByTestId(testId).querySelector('a') as HTMLAnchorElement
+
+describe('AboutDialog version line', () => {
+ it('links the version to its commit and shows the build date when repoUrl is known', () => {
+ render(
+ ,
+ )
+
+ const link = anchorIn('about-version')
+ expect(link.getAttribute('href')).toBe(
+ 'https://github.com/niivue/niivue-vscode/commit/abc1234',
+ )
+ expect(link.textContent).toContain('abc1234')
+ expect(link.textContent).toContain('built')
+ })
+
+ it('falls back to the repo link (no commit, no date) when only a version is given', () => {
+ render()
+
+ const link = anchorIn('about-version')
+ // No repoUrl -> can't build a commit URL, so it points at the default repo.
+ expect(link.getAttribute('href')).toBe('https://github.com/niivue/niivue-vscode')
+ expect(link.textContent).toContain('deadbee')
+ expect(link.textContent).not.toContain('built')
+ })
+
+ it('omits the version line and uses the default GitHub link when no appInfo is supplied', () => {
+ render()
+
+ expect(screen.queryByTestId('about-version')).toBeNull()
+ const source = screen.getByText('Source on GitHub') as HTMLAnchorElement
+ expect(source.getAttribute('href')).toBe('https://github.com/niivue/niivue-vscode')
+ })
+})
diff --git a/packages/niivue-react/src/test/MenuBrandDocument.test.tsx b/packages/niivue-react/src/test/MenuBrandDocument.test.tsx
new file mode 100644
index 00000000..d781d8f4
--- /dev/null
+++ b/packages/niivue-react/src/test/MenuBrandDocument.test.tsx
@@ -0,0 +1,141 @@
+import { SLICE_TYPE } from '@niivue/niivue'
+import { signal } from '@preact/signals'
+import { cleanup, fireEvent, render, screen } from '@testing-library/preact'
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
+import { AppProps, SelectionMode } from '../components/AppProps'
+import { Menu } from '../components/Menu'
+import { activeMenu } from '../components/MenuElements'
+
+// jsdom has no WebGL and no