Support: add a feedback email, rebrand from Donate, tighten privacy copy

This commit is contained in:
2026-06-21 01:46:19 +03:00
parent ae9d897e50
commit 318a0919c6
15 changed files with 209 additions and 66 deletions
+20
View File
@@ -36,6 +36,26 @@
color: var(--text-secondary);
}
/* Inline accent link. Restated as DonateModal's `.email` — the two are the same
recipe; a shared link primitive would be the home if a third site appears. */
/* TODO: if inline accent links recur, lift this recipe to a base.css element
baseline or a small primitive rather than copying it a third time. */
.link {
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
.link:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
border-radius: var(--radius);
}
/* Shortcuts table */
.shortcuts {
border-collapse: collapse;
+10 -1
View File
@@ -55,12 +55,21 @@ describe('AboutModal', () => {
expect(text).toMatch(/offline/i);
});
test('has three labelled sections: identity, shortcuts, privacy', () => {
test('has labelled sections: identity, shortcuts, privacy, feedback', () => {
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);
expect(headings.some((h) => h.includes('feedback'))).toBe(true);
});
test('offers the feedback address as a mailto link', () => {
const mailto = Array.from(container.querySelectorAll('a')).find((a) =>
a.getAttribute('href')?.startsWith('mailto:'),
);
expect(mailto).toBeTruthy();
expect(mailto!.getAttribute('href')).toContain('feedback@astrolabe-viz.com');
});
});
+14 -1
View File
@@ -7,6 +7,7 @@
* (SOUL.md — local-only, no accounts, no telemetry).
*/
import { FEEDBACK_EMAIL, feedbackMailtoHref } from '../feedback';
import styles from './AboutModal.module.css';
/** True when the user agent is macOS / iOS — drives the Cmd vs. Ctrl label. */
@@ -64,7 +65,7 @@ export function AboutModal() {
</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 app runs no analytics or tracking no cookies, no telemetry, no profiling.</li>
<li>
The only outbound network requests are ones you create: URL-sourced datasets you add
yourself.
@@ -75,6 +76,18 @@ export function AboutModal() {
</li>
</ul>
</section>
{/* Feedback */}
<section className={styles.section}>
<h3 className={styles.heading}>Feedback</h3>
<p className={styles.body}>
Found a bug or have an idea? Email{' '}
<a className={styles.link} href={feedbackMailtoHref()}>
{FEEDBACK_EMAIL}
</a>
.
</p>
</section>
</div>
);
}
+32 -2
View File
@@ -1,4 +1,4 @@
/* Donate modal body — minimal, sincere, one action. */
/* Support modal body — two ways to give back: feedback, or a donation to Ukraine. */
.donate {
display: flex;
@@ -8,6 +8,12 @@
min-width: 0;
}
.section {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.body {
margin: 0;
font-size: 14px;
@@ -15,9 +21,33 @@
color: var(--text);
}
/* Feedback row: the address (as a mailto link) beside a copy button. */
.contact {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.email {
font-size: 14px;
font-weight: 600;
color: var(--accent);
text-decoration: none;
}
.email:hover {
text-decoration: underline;
}
.email:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
border-radius: var(--radius);
}
.actions {
display: flex;
padding-top: var(--space-3);
}
/* Primary CTA — styled as a button even though it's an <a> so it matches
+23 -19
View File
@@ -20,32 +20,36 @@ afterEach(() => {
container.remove();
});
describe('DonateModal', () => {
test('renders a sincere message referencing the project', () => {
const links = () => Array.from(container.querySelectorAll('a'));
describe('DonateModal (Support)', () => {
test('renders a sincere message referencing the project and feedback', () => {
const text = container.textContent ?? '';
expect(text).toMatch(/astrolabe/i);
// Some mention of contribution / support
expect(text).toMatch(/contribut|support/i);
expect(text).toMatch(/feedback/i);
});
test('renders exactly one primary CTA link', () => {
const links = container.querySelectorAll('a');
expect(links).toHaveLength(1);
test('offers the feedback address as a mailto link', () => {
const mailto = links().find((a) => a.getAttribute('href')?.startsWith('mailto:'));
expect(mailto).toBeTruthy();
expect(mailto!.getAttribute('href')).toContain('feedback@astrolabe-viz.com');
expect(mailto!.textContent).toContain('feedback@astrolabe-viz.com');
});
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('provides a copy-address button', () => {
const copy = Array.from(container.querySelectorAll('button')).find((b) =>
/copy/i.test(b.textContent ?? ''),
);
expect(copy).toBeTruthy();
});
test('the CTA link has visible text (not icon-only)', () => {
const link = container.querySelector('a')!;
expect((link.textContent ?? '').trim().length).toBeGreaterThan(0);
});
test('the CTA points to the Ukraine-defense donation, not a placeholder', () => {
const link = container.querySelector('a')!;
expect(link.getAttribute('href')).toBe('https://savelife.in.ua/en/donate-en/');
test('the donation CTA points to Ukraine-defense and opens safely in a new tab', () => {
const donate = links().find(
(a) => a.getAttribute('href') === 'https://savelife.in.ua/en/donate-en/',
);
expect(donate).toBeTruthy();
expect(donate!.getAttribute('target')).toBe('_blank');
expect(donate!.getAttribute('rel')).toContain('noopener');
expect((donate!.textContent ?? '').trim().length).toBeGreaterThan(0);
});
});
+61 -16
View File
@@ -1,12 +1,20 @@
/**
* Donate modal body (spec §01B/§01C).
* Support modal body (spec §01B/§01C).
*
* User-facing name is **Support**; the internal modal id stays `donate` (its
* origin, and still its primary accent CTA). The project asks nothing for
* itself, so this surface offers two ways to give back: feedback to the author,
* and a donation to Ukraine's defense — where any project donations would go
* anyway.
*
* Rendered inside ModalShell — no backdrop, close button, or focus trap here;
* the shell owns all of that (docs/architecture/03 → Layer 3). The project asks
* for nothing for itself: the one solicitation redirects to Ukraine's defense,
* where the author would forward any project donations anyway.
* the shell owns all of that (docs/architecture/03 → Layer 3).
*/
import { useState } from 'react';
import { Button } from './Button';
import { notify } from '../stores/NotificationStore';
import { FEEDBACK_EMAIL, feedbackMailtoHref } from '../feedback';
import styles from './DonateModal.module.css';
/**
@@ -17,23 +25,60 @@ import styles from './DonateModal.module.css';
const DONATE_URL = 'https://savelife.in.ua/en/donate-en/';
export function DonateModal() {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(FEEDBACK_EMAIL);
// The clipboard write is invisible, so the success is confirmed inline
// ("Copied") rather than by a toast (docs/architecture/10 → Toast copy).
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
notify({
kind: 'error',
title: "Couldn't copy",
message: 'Your browser blocked clipboard access. Select and copy the address manually.',
});
}
};
return (
<div className={styles.donate}>
<p className={styles.body}>
Astrolabe is free and open-source, built in spare time there's nothing to buy and no one
to pay.
</p>
<p className={styles.body}>
If it has saved you time and you'd like to give something back, please send it where it
matters far more than it would to me: Ukraine's defense against Russia's invasion. The Come
Back Alive foundation supports the people defending the country.
Astrolabe is free, built in spare time there's nothing to buy and no one to pay.
</p>
<div className={styles.actions}>
<a href={DONATE_URL} className={styles.primary} target="_blank" rel="noopener noreferrer">
Donate to Ukraine's defense
</a>
</div>
<section className={styles.section}>
<p className={styles.body}>
The most useful thing you can send back is feedback — what's broken, what's missing, what
you'd want next.
</p>
<div className={styles.contact}>
<a className={styles.email} href={feedbackMailtoHref()}>
{FEEDBACK_EMAIL}
</a>
<Button onClick={() => void handleCopy()}>{copied ? 'Copied' : 'Copy'}</Button>
{/* A polite live region announces the copy to assistive tech, which the
button's label swap alone would not reliably do. */}
<span role="status" className="visually-hidden">
{copied ? 'Email address copied to clipboard' : ''}
</span>
</div>
</section>
<section className={styles.section}>
<p className={styles.body}>
And if you'd like to give something beyond words, please send it where it matters far more
than it would to me: Ukraine's defense against Russia's invasion. The Come Back Alive
foundation supports the people defending the country.
</p>
<div className={styles.actions}>
<a href={DONATE_URL} className={styles.primary} target="_blank" rel="noopener noreferrer">
Donate to Ukraine's defense
</a>
</div>
</section>
</div>
);
}