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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user