diff --git a/src/app/App.tsx b/src/app/App.tsx
index 3ff645a..d15d741 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -89,6 +89,22 @@ export function App() {
>
Export
+
+
{/* Hidden picker for Import; restricted to JSON (spec §08). */}
styling — reset it */
+ font-style: normal;
+}
+
+.action {
+ font-size: 13px;
+ color: var(--text);
+ vertical-align: top;
+}
+
+/* Privacy list */
+.list {
+ margin: 0;
+ padding-left: var(--space-5);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.list li {
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--text);
+}
diff --git a/src/app/components/AboutModal.test.tsx b/src/app/components/AboutModal.test.tsx
new file mode 100644
index 0000000..34eaef4
--- /dev/null
+++ b/src/app/components/AboutModal.test.tsx
@@ -0,0 +1,66 @@
+import { afterEach, beforeEach, describe, expect, test } from 'vitest';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { AboutModal } from './AboutModal';
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ act(() => root.render());
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+});
+
+describe('AboutModal', () => {
+ test('renders the app name', () => {
+ expect(container.textContent).toContain('Astrolabe');
+ });
+
+ test('renders the app version from __APP_VERSION__', () => {
+ // __APP_VERSION__ is injected by vite.config.ts `define` (shared with Vitest).
+ expect(container.textContent).toContain(__APP_VERSION__);
+ });
+
+ test('renders all five keyboard shortcuts from spec §01D', () => {
+ // Platform-specific modifier (Cmd on Mac, Ctrl elsewhere) + the five actions.
+ const text = container.textContent ?? '';
+ // Key combinations — platform-aware modifier is either ⌘ or Ctrl
+ expect(text).toMatch(/(?:⌘|Ctrl)\+Shift\+N/);
+ expect(text).toMatch(/(?:⌘|Ctrl)\+K/);
+ expect(text).toMatch(/(?:⌘|Ctrl)\+S/);
+ expect(text).toMatch(/(?:⌘|Ctrl)\+,/);
+ expect(text).toContain('Esc');
+ });
+
+ test('shortcuts are in an accessible table with an aria-label', () => {
+ const table = container.querySelector('table[aria-label]');
+ expect(table).not.toBeNull();
+ expect(table!.getAttribute('aria-label')).toBe('Keyboard shortcuts');
+ });
+
+ test('renders a Privacy section that mentions local-first posture', () => {
+ const text = container.textContent ?? '';
+ // Core privacy claims from SOUL.md / spec §10
+ expect(text).toMatch(/no account/i);
+ expect(text).toMatch(/no telemetry|no analytics|no tracking/i);
+ expect(text).toMatch(/offline/i);
+ });
+
+ test('has three labelled sections: identity, shortcuts, privacy', () => {
+ const headings = Array.from(container.querySelectorAll('h3')).map(
+ (h) => h.textContent?.toLowerCase() ?? '',
+ );
+ expect(headings.some((h) => h.includes('astrolabe'))).toBe(true);
+ expect(headings.some((h) => h.includes('keyboard'))).toBe(true);
+ expect(headings.some((h) => h.includes('privacy'))).toBe(true);
+ });
+});
diff --git a/src/app/components/AboutModal.tsx b/src/app/components/AboutModal.tsx
new file mode 100644
index 0000000..9962577
--- /dev/null
+++ b/src/app/components/AboutModal.tsx
@@ -0,0 +1,78 @@
+/**
+ * About & Help modal body (spec §01B/§01C).
+ *
+ * Rendered inside ModalShell — no backdrop, close button, or focus trap here;
+ * the shell owns all of that (docs/architecture/03 → Layer 3). This component
+ * is pure content: app identity, keyboard shortcuts (§01D), and privacy posture
+ * (SOUL.md — local-only, no accounts, no telemetry).
+ */
+
+import styles from './AboutModal.module.css';
+
+/** True when the user agent is macOS / iOS — drives the Cmd vs. Ctrl label. */
+const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform);
+const mod = isMac ? '⌘' : 'Ctrl';
+
+/** Keyboard shortcut rows sourced from spec §01D. */
+const SHORTCUTS: readonly { keys: string; action: string }[] = [
+ { keys: `${mod}+Shift+N`, action: 'Create a new snippet' },
+ { keys: `${mod}+K`, action: 'Toggle the Datasets manager' },
+ { keys: `${mod}+S`, action: 'Publish the current snippet draft' },
+ { keys: `${mod}+,`, action: 'Open editor settings' },
+ { keys: 'Esc', action: 'Close the active modal' },
+];
+
+export function AboutModal() {
+ return (
+
+ {/* Identity */}
+
+
Astrolabe
+
+ v{__APP_VERSION__}
+
+
+ A local-first workspace for authoring, organizing, and previewing Vega-Lite charts. Edit
+ JSON, see the chart update live, and keep a personal library of snippets — with no
+ account, no server, and full offline support.
+
+
+
+ {/* Keyboard shortcuts */}
+
+
Keyboard shortcuts
+
+
+ {SHORTCUTS.map(({ keys, action }) => (
+
+
+ {keys}
+
+
{action}
+
+ ))}
+
+
+
+
+ {/* Privacy */}
+
+
Privacy
+
+ Astrolabe runs entirely in your browser. Your snippets, datasets, and settings are stored
+ locally and never leave your machine.
+
+
+
No account, no sign-in, no server-side storage.
+
No telemetry, no analytics, no tracking of any kind.
+
+ The only outbound network requests are ones you create: URL-sourced datasets you add
+ yourself.
+
+
After the first load, the app works fully offline.
+
Use Import / Export to move your library between devices.