From 123f1498b46bf65dee15da4220bdba7819bd2552 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Sun, 7 Jun 2026 20:04:08 +0300 Subject: [PATCH] =?UTF-8?q?Add=20About=20&=20Privacy=20and=20Donate=20moda?= =?UTF-8?q?ls=20(M6,=20=C2=A701)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/App.tsx | 16 ++++ src/app/components/AboutModal.module.css | 89 +++++++++++++++++++++++ src/app/components/AboutModal.test.tsx | 66 +++++++++++++++++ src/app/components/AboutModal.tsx | 78 ++++++++++++++++++++ src/app/components/DonateModal.module.css | 50 +++++++++++++ src/app/components/DonateModal.test.tsx | 46 ++++++++++++ src/app/components/DonateModal.tsx | 38 ++++++++++ src/app/modals/modal-registry.ts | 19 ++++- 8 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 src/app/components/AboutModal.module.css create mode 100644 src/app/components/AboutModal.test.tsx create mode 100644 src/app/components/AboutModal.tsx create mode 100644 src/app/components/DonateModal.module.css create mode 100644 src/app/components/DonateModal.test.tsx create mode 100644 src/app/components/DonateModal.tsx 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.
  • +
+
+
+ ); +} diff --git a/src/app/components/DonateModal.module.css b/src/app/components/DonateModal.module.css new file mode 100644 index 0000000..cfb5975 --- /dev/null +++ b/src/app/components/DonateModal.module.css @@ -0,0 +1,50 @@ +/* Donate modal body — minimal, sincere, one action. */ + +.donate { + display: flex; + flex-direction: column; + gap: var(--space-5); + padding: var(--space-6); + min-width: 0; +} + +.body { + margin: 0; + font-size: 14px; + line-height: 1.5; + color: var(--text); +} + +.actions { + display: flex; + padding-top: var(--space-3); +} + +/* Primary CTA — styled as a button even though it's an so it matches + the app's design language. Uses accent color as in ExtractModal .primary. */ +.primary { + display: inline-flex; + align-items: center; + justify-content: center; + height: 36px; + padding: 0 var(--space-6); + background: var(--accent); + border: var(--border-width) solid transparent; + border-radius: var(--radius); + color: var(--accent-contrast); + font: inherit; + font-size: 13px; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: background var(--dur-fast) var(--ease); +} + +.primary:hover { + background: var(--accent-hover); +} + +.primary:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} diff --git a/src/app/components/DonateModal.test.tsx b/src/app/components/DonateModal.test.tsx new file mode 100644 index 0000000..8cf8f3e --- /dev/null +++ b/src/app/components/DonateModal.test.tsx @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { DonateModal } from './DonateModal'; + +(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('DonateModal', () => { + test('renders a sincere message referencing the project', () => { + const text = container.textContent ?? ''; + expect(text).toMatch(/astrolabe/i); + // Some mention of contribution / support + expect(text).toMatch(/contribut|support/i); + }); + + test('renders exactly one primary CTA link', () => { + const links = container.querySelectorAll('a'); + expect(links).toHaveLength(1); + }); + + test('the CTA link opens in a new tab with rel noopener', () => { + const link = container.querySelector('a')!; + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')).toContain('noopener'); + }); + + test('the CTA link has visible text (not icon-only)', () => { + const link = container.querySelector('a')!; + expect((link.textContent ?? '').trim().length).toBeGreaterThan(0); + }); +}); diff --git a/src/app/components/DonateModal.tsx b/src/app/components/DonateModal.tsx new file mode 100644 index 0000000..60c0dd7 --- /dev/null +++ b/src/app/components/DonateModal.tsx @@ -0,0 +1,38 @@ +/** + * Donate 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). Minimal by design: + * a sincere message and a single clear primary action. + * + * TODO: Replace DONATE_URL placeholder with the real donation URL before launch. + */ + +import styles from './DonateModal.module.css'; + +/** + * Placeholder donation URL — replace before launch. + * + * TODO: Set the real donation URL (e.g. a Ko-fi / Open Collective / GitHub + * Sponsors link) here. The href is intentionally "#" until that is decided so + * the modal renders correctly without causing any outbound navigation. + */ +const DONATE_URL = '#'; // TODO: replace with real donation URL + +export function DonateModal() { + return ( +
+

+ Astrolabe is free, open-source, and built in spare time. If it has saved you hours of + context-switching or helped you ship a chart faster, consider supporting its development. +

+

Every contribution, however small, keeps the project alive.

+ +
+ + Support Astrolabe + +
+
+ ); +} diff --git a/src/app/modals/modal-registry.ts b/src/app/modals/modal-registry.ts index 52571c4..2646170 100644 --- a/src/app/modals/modal-registry.ts +++ b/src/app/modals/modal-registry.ts @@ -10,16 +10,17 @@ * single shell-level Save/Cancel doesn't fit). The registry therefore omits * `hasError`/`getError` (validity is the modal's own concern) and keeps only * `getState` for unsaved-change detection on close. The registry is a partial - * map: modals land milestone by milestone (about/donate → M6, chartBuilder → M4), - * so only the implemented ones are registered here. Settings is deliberately NOT + * map — only the implemented ones are registered here. Settings is deliberately NOT * a modal — preferences are distributed to per-pane disclosure popovers (spec §07; * see components/SettingsPopover). */ import type { ComponentType } from 'react'; import type { ActiveModal, ModalName } from './types'; +import { AboutModal } from '../components/AboutModal'; import { ChartBuilderModal } from '../components/ChartBuilderModal'; import { DatasetsModal } from '../components/DatasetsModal'; +import { DonateModal } from '../components/DonateModal'; import { ExtractModal } from '../components/ExtractModal'; import { useChartBuilderStore } from '../stores/ChartBuilderStore'; import { useDatasetStore } from '../stores/DatasetStore'; @@ -77,6 +78,20 @@ export const MODAL_REGISTRY: Partial> = { isUrlNavigable: true, init: (datasetId) => useChartBuilderStore.getState().init(datasetId ? Number(datasetId) : null), }, + + // Pure info modals — no state, no validity check, no discard prompt on close. + // `about` is URL-navigable (reload-restore); `donate` is not (spec §01E hash + // table omits both, but `about` is still a permanent, bookmark-worthy surface). + about: { + name: 'about', + title: 'About & Help', + component: AboutModal, + }, + donate: { + name: 'donate', + title: 'Donate', + component: DonateModal, + }, }; export const getModalConfig = (name: ActiveModal): ModalConfig | undefined =>