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
+2 -2
View File
@@ -65,9 +65,9 @@
flex: 1;
}
/* Vertical rule separating the utilities from Donate + the theme toggle.
/* Vertical rule separating the utilities from Support + the theme toggle.
(The header buttons themselves are the shared Button primitive — ghost
utilities, soft-accent Donate; arch 09 §4.) */
utilities, soft-accent Support; arch 09 §4.) */
/* TODO: --border-strong is the 3:1 component-boundary token (arch 09 §3.3), which
makes this passive divider fairly dark; --border would be the semantic choice but
is near-invisible on the --layer-01 header in light theme. A separator may want
+5 -4
View File
@@ -82,8 +82,9 @@ export function App() {
actions are a right-aligned row of icon-only buttons). The utilities
are IconButtons — tooltips carry the full action, accessible names
scope it ("Export workspace" vs the preview pane's per-chart
"Export") — then a divider sets off Donate — the one solicitation,
given a soft-accent wash — and the theme toggle. */}
"Export") — then a divider sets off Support (feedback + the one
donation solicitation), given a soft-accent wash — and the theme
toggle. */}
<IconButton
label="Datasets"
onClick={() => openModal('datasets')}
@@ -117,9 +118,9 @@ export function App() {
<Button
variant="soft-accent"
onClick={() => openModal('donate')}
title="Support Ukraine's defense"
title="Send feedback or support Ukraine's defense"
>
Donate
Support
</Button>
<ThemeToggle />
{/* Hidden picker for Import; restricted to JSON (spec §08). */}
+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>
);
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Project feedback channel.
*
* There is no server and no tracker (see the About modal — no telemetry of any
* kind); feedback is a plain email the user composes and sends from their own
* client. The address is a Cloudflare Email Routing alias that forwards to the
* author, so it can be retired without exposing or churning a personal inbox.
*
* Shared by the Support modal and the About modal — a contact address is worth
* a single source of truth so the two surfaces can't drift.
*/
export const FEEDBACK_EMAIL = 'feedback@astrolabe-viz.com';
/** A `mailto:` href prefilled with a version-stamped subject, to ease triage. */
export function feedbackMailtoHref(): string {
const subject = encodeURIComponent(`Astrolabe v${__APP_VERSION__} — feedback`);
return `mailto:${FEEDBACK_EMAIL}?subject=${subject}`;
}
+3 -1
View File
@@ -118,9 +118,11 @@ const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
name: 'about',
title: 'About & Help',
},
// User-facing name is "Support" (feedback + the Ukraine-defense donation); the
// id stays `donate` — its origin and still its primary accent CTA.
donate: {
name: 'donate',
title: 'Donate',
title: 'Support',
},
};
+1 -1
View File
@@ -10,7 +10,7 @@
export type ModalName =
| 'datasets' // Datasets manager (list / detail / new-dataset form)
| 'about' // About & Help (M6)
| 'donate' // Donate (M6)
| 'donate' // Support — feedback + Ukraine-defense donation (M6)
| 'chartBuilder' // Visual no-JSON chart composition for a dataset (M4)
| 'extract' // Extract inline spec data into a new dataset (M3)
| 'themeBuilder'; // Custom chart theme editor with a live preview gallery (spec §04)