mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add About & Privacy and Donate modals (M6, §01)
This commit is contained in:
@@ -89,6 +89,22 @@ export function App() {
|
|||||||
>
|
>
|
||||||
Export
|
Export
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.headerButton}
|
||||||
|
onClick={() => openModal('about')}
|
||||||
|
title="About, keyboard shortcuts, and privacy information"
|
||||||
|
>
|
||||||
|
About & Privacy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.headerButton}
|
||||||
|
onClick={() => openModal('donate')}
|
||||||
|
title="Support Astrolabe"
|
||||||
|
>
|
||||||
|
Donate
|
||||||
|
</button>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{/* Hidden picker for Import; restricted to JSON (spec §08). */}
|
{/* Hidden picker for Import; restricted to JSON (spec §08). */}
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,16 +10,17 @@
|
|||||||
* single shell-level Save/Cancel doesn't fit). The registry therefore omits
|
* single shell-level Save/Cancel doesn't fit). The registry therefore omits
|
||||||
* `hasError`/`getError` (validity is the modal's own concern) and keeps only
|
* `hasError`/`getError` (validity is the modal's own concern) and keeps only
|
||||||
* `getState` for unsaved-change detection on close. The registry is a partial
|
* `getState` for unsaved-change detection on close. The registry is a partial
|
||||||
* map: modals land milestone by milestone (about/donate → M6, chartBuilder → M4),
|
* map — only the implemented ones are registered here. Settings is deliberately NOT
|
||||||
* so only the implemented ones are registered here. Settings is deliberately NOT
|
|
||||||
* a modal — preferences are distributed to per-pane disclosure popovers (spec §07;
|
* a modal — preferences are distributed to per-pane disclosure popovers (spec §07;
|
||||||
* see components/SettingsPopover).
|
* see components/SettingsPopover).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
import type { ActiveModal, ModalName } from './types';
|
import type { ActiveModal, ModalName } from './types';
|
||||||
|
import { AboutModal } from '../components/AboutModal';
|
||||||
import { ChartBuilderModal } from '../components/ChartBuilderModal';
|
import { ChartBuilderModal } from '../components/ChartBuilderModal';
|
||||||
import { DatasetsModal } from '../components/DatasetsModal';
|
import { DatasetsModal } from '../components/DatasetsModal';
|
||||||
|
import { DonateModal } from '../components/DonateModal';
|
||||||
import { ExtractModal } from '../components/ExtractModal';
|
import { ExtractModal } from '../components/ExtractModal';
|
||||||
import { useChartBuilderStore } from '../stores/ChartBuilderStore';
|
import { useChartBuilderStore } from '../stores/ChartBuilderStore';
|
||||||
import { useDatasetStore } from '../stores/DatasetStore';
|
import { useDatasetStore } from '../stores/DatasetStore';
|
||||||
@@ -77,6 +78,20 @@ export const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
|
|||||||
isUrlNavigable: true,
|
isUrlNavigable: true,
|
||||||
init: (datasetId) => useChartBuilderStore.getState().init(datasetId ? Number(datasetId) : null),
|
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 =>
|
export const getModalConfig = (name: ActiveModal): ModalConfig | undefined =>
|
||||||
|
|||||||
Reference in New Issue
Block a user