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
+4 -3
View File
@@ -459,9 +459,10 @@ tablet tool; phones are out of scope. So the touch/installable surface we target
IDB-transaction atomicity would require exposing a raw transaction from `db.ts` — see
[arch 02](architecture/02-persistence.md)).
- **A11y:** modal focus trap + return, labelled icon buttons, contrast in both themes (§10) — ✅ in place.
- **About** and **Donate** modals ~~(§01)~~ ✅. The project solicits nothing for
itself — **Donate** redirects to Ukraine's defense (`savelife.in.ua`, the Come Back
Alive foundation), where the author forwards any project donations anyway.
- **About** and **Support** modals ~~(§01)~~ ✅. The project solicits nothing for
itself — **Support** offers feedback to the author and redirects donations to Ukraine's
defense (`savelife.in.ua`, the Come Back Alive foundation), where the author forwards any
project donations anyway.
- **Offline/installable:** the manifest ships a full SVG icon set (favicon / maskable /
monochrome) + a 180×180 `apple-touch-icon.png` (iPad add-to-home-screen) + `theme_color`,
and the SW precaches the shell — the app is **installable**. ✅ Manual verification in a
+3 -3
View File
@@ -124,7 +124,7 @@ UI by swapping one set of values. Borrowed from Carbon's layering model:
`--accent-soft-hover` are `color-mix(in srgb, var(--accent) 1220%, var(--bg))`,
so the wash follows whatever accent + theme is active rather than carrying a
per-accent value. Use it for a surface that should be _noticed_ without competing
with a primary action (the header's **Donate** button).
with a primary action (the header's **Support** button).
- **Field-on-layer (Carbon layering).** A field's fill is **one step off the
surface it sits on**, alternating like Carbon's field set: `--field-01` on the
canvas (gray on white / near-black on black), `--field-02` on a `--layer-01`
@@ -188,7 +188,7 @@ UI by swapping one set of values. Borrowed from Carbon's layering model:
`--accent`), **secondary** (1px `--border-strong`, `--bg` fill —
so a bordered control on a gray panel goes white, never a darker gray), **ghost** (text-only, borderless; a transparent
border holds the box size), **soft-accent** (ghost on an `--accent-soft` wash —
a low-emphasis solicitation, e.g. Donate), **danger** (filled
a low-emphasis solicitation, e.g. Support), **danger** (filled
`--support-error` — the confirm step), **danger-outline** (secondary geometry,
red label, filling solid red on hover/focus — a destructive action sitting
among peers, e.g. a detail view's Delete). 13px 600-weight label. Clear hover/active and a visible
@@ -218,7 +218,7 @@ UI by swapping one set of values. Borrowed from Carbon's layering model:
Import / Export / About are **icon-only** IconButtons per Carbon's UI-shell
header — global actions as a right-aligned icon row — with accessible names
that carry scope, e.g. "Export workspace" vs the preview's per-chart "Export";
a divider then sets off the soft-accent Donate, which keeps its text label —
a divider then sets off the soft-accent Support, which keeps its text label —
a solicitation needs the word — and the ghost theme toggle).
- **Hover is variant-specific:** filled buttons (primary/danger) **darken**
(`--accent-hover` / a slight brightness drop); outlined/ghost buttons **gain a
+3 -3
View File
@@ -466,7 +466,7 @@
.btn-ghost-util:hover {
background: var(--layer-02);
}
/* Soft-accent: a low-emphasis accent wash (Donate) — noticed, not shouting. */
/* Soft-accent: a low-emphasis accent wash (Support) — noticed, not shouting. */
.btn-soft {
background: var(--accent-soft);
color: var(--accent-hover);
@@ -984,7 +984,7 @@
<h3 style="margin-top: var(--space-6)">Emphasis hierarchy — a header cluster</h3>
<p class="cap" style="margin-bottom: var(--space-4)">
Utilities go <strong>ghost</strong> (icon + text, borderless) so they recede; a divider
sets off <strong>Donate</strong> on a <strong>soft-accent</strong> wash — one low-key
sets off <strong>Support</strong> on a <strong>soft-accent</strong> wash — one low-key
solicitation, no border. (Carbon: one high-emphasis action per region; utilities at low
emphasis.)
</p>
@@ -1024,7 +1024,7 @@
>About
</button>
<span class="hdr-divider"></span>
<button class="btn btn-soft">Donate</button>
<button class="btn btn-soft">Support</button>
</div>
</section>
+5 -5
View File
@@ -25,21 +25,21 @@ Behavior:
A fixed header spans the top of the app.
- **Left side**: the app icon, the app title ("Astrolabe"), and a version badge showing the current app version.
- **Right side**: a row of **icon-only** utility entry points (each with a tooltip and an accessible name), then — set off by a divider — the text-labelled Donate button and the theme toggle. Each opens a destination:
- **Right side**: a row of **icon-only** utility entry points (each with a tooltip and an accessible name), then — set off by a divider — the text-labelled Support button and the theme toggle. Each opens a destination:
| Entry point | Opens |
| ---------------- | --------------------------------------------------------------------------------------------------------------- |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Datasets | The Datasets manager modal (see _Datasets_). |
| Import workspace | A file-picker dialog to choose a previously exported file; the chosen file is imported (see _Import & Export_). |
| Export workspace | Immediately produces a downloaded file containing all snippets and datasets (see _Import & Export_). |
| About | The About & Help modal (keyboard shortcuts, about, and privacy information); privacy lives inside. |
| Donate | The Donate modal. The one text-labelled, soft-accent button — the solicitation keeps its word. |
| Support | The Support modal — two ways to give back: feedback to the author, or a donation to Ukraine's defense. The one text-labelled, soft-accent button. |
Notes:
- The utilities are icon-only so the header reads as quiet chrome (Carbon UI-shell header: global actions are a right-aligned icon row). The accessible names scope the workspace-level Import/Export ("Export workspace") apart from the preview pane's per-chart "Export" — the two were previously both labelled "Export" at once.
- Import and Export act directly (file dialog / file download); they do not open in-app modals.
- The Datasets, About, and Donate entry points each open a modal (see _Modal System_).
- The Datasets, About, and Support entry points each open a modal (see _Modal System_).
- **Settings are not a header entry point.** A design review (see _Settings_) distributed
preferences to the panes they affect — the appearance theme is a header toggle, and the
Editor / Performance / Formatting clusters open from a gear control in their own pane. There
@@ -47,7 +47,7 @@ Notes:
## C. Modal System
The app shows at most one modal at a time. The modal set is: Datasets, About & Help, Donate, Chart Builder, Extract-to-Dataset, and Theme Builder. (Settings are deliberately _not_ a modal — they are distributed to per-pane controls; see _Settings_.)
The app shows at most one modal at a time. The modal set is: Datasets, About & Help, Support, Chart Builder, Extract-to-Dataset, and Theme Builder. (Settings are deliberately _not_ a modal — they are distributed to per-pane controls; see _Settings_.)
- Opening any modal closes whichever modal was previously open; the two never overlap.
- Every modal can be dismissed by: clicking its close button, pressing **Escape**, or clicking the backdrop outside the modal body. Exception: modals holding in-progress work (the Chart Builder and Theme Builder) ignore backdrop clicks; Escape and the close button still dismiss them.
+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);
});
});
+56 -11
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>
<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)