Add About & Privacy and Donate modals (M6, §01)

This commit is contained in:
2026-06-07 20:04:08 +03:00
parent 30ff7ae357
commit 123f1498b4
8 changed files with 400 additions and 2 deletions
+89
View File
@@ -0,0 +1,89 @@
/* About & Help modal body — informational content, no interactive controls. */
.about {
display: flex;
flex-direction: column;
gap: var(--space-7);
padding: var(--space-6);
min-width: 0;
}
.section {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.heading {
margin: 0;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
}
.body {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: var(--text);
}
.version {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-secondary);
}
/* Shortcuts table */
.shortcuts {
border-collapse: collapse;
width: 100%;
}
.row + .row .keys,
.row + .row .action {
padding-top: var(--space-3);
}
.keys {
width: 1%;
white-space: nowrap;
padding-right: var(--space-5);
vertical-align: top;
}
.kbd {
display: inline-block;
padding: var(--space-1) var(--space-3);
background: var(--layer-01);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
font-family: var(--font-mono);
font-size: 12px;
color: var(--text);
/* No user-agent <kbd> 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);
}
+66
View File
@@ -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(<AboutModal />));
});
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);
});
});
+78
View File
@@ -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 (
<div className={styles.about}>
{/* Identity */}
<section className={styles.section}>
<h3 className={styles.heading}>Astrolabe</h3>
<p className={styles.body}>
v<span className={styles.version}>{__APP_VERSION__}</span>
</p>
<p className={styles.body}>
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.
</p>
</section>
{/* Keyboard shortcuts */}
<section className={styles.section}>
<h3 className={styles.heading}>Keyboard shortcuts</h3>
<table className={styles.shortcuts} aria-label="Keyboard shortcuts">
<tbody>
{SHORTCUTS.map(({ keys, action }) => (
<tr key={keys} className={styles.row}>
<td className={styles.keys}>
<kbd className={styles.kbd}>{keys}</kbd>
</td>
<td className={styles.action}>{action}</td>
</tr>
))}
</tbody>
</table>
</section>
{/* Privacy */}
<section className={styles.section}>
<h3 className={styles.heading}>Privacy</h3>
<p className={styles.body}>
Astrolabe runs entirely in your browser. Your snippets, datasets, and settings are stored
locally and never leave your machine.
</p>
<ul className={styles.list}>
<li>No account, no sign-in, no server-side storage.</li>
<li>No telemetry, no analytics, no tracking of any kind.</li>
<li>
The only outbound network requests are ones you create: URL-sourced datasets you add
yourself.
</li>
<li>After the first load, the app works fully offline.</li>
<li>Use Import / Export to move your library between devices.</li>
</ul>
</section>
</div>
);
}
+50
View File
@@ -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 <a> 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;
}
+46
View File
@@ -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(<DonateModal />));
});
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);
});
});
+38
View File
@@ -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 (
<div className={styles.donate}>
<p className={styles.body}>
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.
</p>
<p className={styles.body}>Every contribution, however small, keeps the project alive.</p>
<div className={styles.actions}>
<a href={DONATE_URL} className={styles.primary} target="_blank" rel="noopener noreferrer">
Support Astrolabe
</a>
</div>
</div>
);
}