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)