mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Support: add a feedback email, rebrand from Donate, tighten privacy copy
This commit is contained in:
@@ -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
|
IDB-transaction atomicity would require exposing a raw transaction from `db.ts` — see
|
||||||
[arch 02](architecture/02-persistence.md)).
|
[arch 02](architecture/02-persistence.md)).
|
||||||
- **A11y:** modal focus trap + return, labelled icon buttons, contrast in both themes (§10) — ✅ in place.
|
- **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
|
- **About** and **Support** modals ~~(§01)~~ ✅. The project solicits nothing for
|
||||||
itself — **Donate** redirects to Ukraine's defense (`savelife.in.ua`, the Come Back
|
itself — **Support** offers feedback to the author and redirects donations to Ukraine's
|
||||||
Alive foundation), where the author forwards any project donations anyway.
|
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 /
|
- **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`,
|
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
|
and the SW precaches the shell — the app is **installable**. ✅ Manual verification in a
|
||||||
|
|||||||
@@ -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) 12–20%, var(--bg))`,
|
`--accent-soft-hover` are `color-mix(in srgb, var(--accent) 12–20%, var(--bg))`,
|
||||||
so the wash follows whatever accent + theme is active rather than carrying a
|
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
|
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
|
- **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
|
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`
|
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 —
|
`--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
|
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 —
|
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,
|
`--support-error` — the confirm step), **danger-outline** (secondary geometry,
|
||||||
red label, filling solid red on hover/focus — a destructive action sitting
|
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
|
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
|
Import / Export / About are **icon-only** IconButtons per Carbon's UI-shell
|
||||||
header — global actions as a right-aligned icon row — with accessible names
|
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";
|
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).
|
a solicitation needs the word — and the ghost theme toggle).
|
||||||
- **Hover is variant-specific:** filled buttons (primary/danger) **darken**
|
- **Hover is variant-specific:** filled buttons (primary/danger) **darken**
|
||||||
(`--accent-hover` / a slight brightness drop); outlined/ghost buttons **gain a
|
(`--accent-hover` / a slight brightness drop); outlined/ghost buttons **gain a
|
||||||
|
|||||||
@@ -466,7 +466,7 @@
|
|||||||
.btn-ghost-util:hover {
|
.btn-ghost-util:hover {
|
||||||
background: var(--layer-02);
|
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 {
|
.btn-soft {
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
color: var(--accent-hover);
|
color: var(--accent-hover);
|
||||||
@@ -984,7 +984,7 @@
|
|||||||
<h3 style="margin-top: var(--space-6)">Emphasis hierarchy — a header cluster</h3>
|
<h3 style="margin-top: var(--space-6)">Emphasis hierarchy — a header cluster</h3>
|
||||||
<p class="cap" style="margin-bottom: var(--space-4)">
|
<p class="cap" style="margin-bottom: var(--space-4)">
|
||||||
Utilities go <strong>ghost</strong> (icon + text, borderless) so they recede; a divider
|
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
|
solicitation, no border. (Carbon: one high-emphasis action per region; utilities at low
|
||||||
emphasis.)
|
emphasis.)
|
||||||
</p>
|
</p>
|
||||||
@@ -1024,7 +1024,7 @@
|
|||||||
>About
|
>About
|
||||||
</button>
|
</button>
|
||||||
<span class="hdr-divider"></span>
|
<span class="hdr-divider"></span>
|
||||||
<button class="btn btn-soft">Donate</button>
|
<button class="btn btn-soft">Support</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -25,21 +25,21 @@ Behavior:
|
|||||||
A fixed header spans the top of the app.
|
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.
|
- **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 |
|
| Entry point | Opens |
|
||||||
| ---------------- | --------------------------------------------------------------------------------------------------------------- |
|
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Datasets | The Datasets manager modal (see _Datasets_). |
|
| 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_). |
|
| 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_). |
|
| 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. |
|
| 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:
|
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.
|
- 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.
|
- 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
|
- **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
|
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
|
Editor / Performance / Formatting clusters open from a gear control in their own pane. There
|
||||||
@@ -47,7 +47,7 @@ Notes:
|
|||||||
|
|
||||||
## C. Modal System
|
## 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -65,9 +65,9 @@
|
|||||||
flex: 1;
|
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
|
(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
|
/* 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
|
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
|
is near-invisible on the --layer-01 header in light theme. A separator may want
|
||||||
|
|||||||
+5
-4
@@ -82,8 +82,9 @@ export function App() {
|
|||||||
actions are a right-aligned row of icon-only buttons). The utilities
|
actions are a right-aligned row of icon-only buttons). The utilities
|
||||||
are IconButtons — tooltips carry the full action, accessible names
|
are IconButtons — tooltips carry the full action, accessible names
|
||||||
scope it ("Export workspace" vs the preview pane's per-chart
|
scope it ("Export workspace" vs the preview pane's per-chart
|
||||||
"Export") — then a divider sets off Donate — the one solicitation,
|
"Export") — then a divider sets off Support (feedback + the one
|
||||||
given a soft-accent wash — and the theme toggle. */}
|
donation solicitation), given a soft-accent wash — and the theme
|
||||||
|
toggle. */}
|
||||||
<IconButton
|
<IconButton
|
||||||
label="Datasets"
|
label="Datasets"
|
||||||
onClick={() => openModal('datasets')}
|
onClick={() => openModal('datasets')}
|
||||||
@@ -117,9 +118,9 @@ export function App() {
|
|||||||
<Button
|
<Button
|
||||||
variant="soft-accent"
|
variant="soft-accent"
|
||||||
onClick={() => openModal('donate')}
|
onClick={() => openModal('donate')}
|
||||||
title="Support Ukraine's defense"
|
title="Send feedback or support Ukraine's defense"
|
||||||
>
|
>
|
||||||
Donate
|
Support
|
||||||
</Button>
|
</Button>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{/* Hidden picker for Import; restricted to JSON (spec §08). */}
|
{/* Hidden picker for Import; restricted to JSON (spec §08). */}
|
||||||
|
|||||||
@@ -36,6 +36,26 @@
|
|||||||
color: var(--text-secondary);
|
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 table */
|
||||||
.shortcuts {
|
.shortcuts {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|||||||
@@ -55,12 +55,21 @@ describe('AboutModal', () => {
|
|||||||
expect(text).toMatch(/offline/i);
|
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(
|
const headings = Array.from(container.querySelectorAll('h3')).map(
|
||||||
(h) => h.textContent?.toLowerCase() ?? '',
|
(h) => h.textContent?.toLowerCase() ?? '',
|
||||||
);
|
);
|
||||||
expect(headings.some((h) => h.includes('astrolabe'))).toBe(true);
|
expect(headings.some((h) => h.includes('astrolabe'))).toBe(true);
|
||||||
expect(headings.some((h) => h.includes('keyboard'))).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('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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* (SOUL.md — local-only, no accounts, no telemetry).
|
* (SOUL.md — local-only, no accounts, no telemetry).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { FEEDBACK_EMAIL, feedbackMailtoHref } from '../feedback';
|
||||||
import styles from './AboutModal.module.css';
|
import styles from './AboutModal.module.css';
|
||||||
|
|
||||||
/** True when the user agent is macOS / iOS — drives the Cmd vs. Ctrl label. */
|
/** True when the user agent is macOS / iOS — drives the Cmd vs. Ctrl label. */
|
||||||
@@ -64,7 +65,7 @@ export function AboutModal() {
|
|||||||
</p>
|
</p>
|
||||||
<ul className={styles.list}>
|
<ul className={styles.list}>
|
||||||
<li>No account, no sign-in, no server-side storage.</li>
|
<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>
|
<li>
|
||||||
The only outbound network requests are ones you create: URL-sourced datasets you add
|
The only outbound network requests are ones you create: URL-sourced datasets you add
|
||||||
yourself.
|
yourself.
|
||||||
@@ -75,6 +76,18 @@ export function AboutModal() {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
.donate {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -8,6 +8,12 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -15,9 +21,33 @@
|
|||||||
color: var(--text);
|
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 {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding-top: var(--space-3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Primary CTA — styled as a button even though it's an <a> so it matches
|
/* Primary CTA — styled as a button even though it's an <a> so it matches
|
||||||
|
|||||||
@@ -20,32 +20,36 @@ afterEach(() => {
|
|||||||
container.remove();
|
container.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DonateModal', () => {
|
const links = () => Array.from(container.querySelectorAll('a'));
|
||||||
test('renders a sincere message referencing the project', () => {
|
|
||||||
|
describe('DonateModal (Support)', () => {
|
||||||
|
test('renders a sincere message referencing the project and feedback', () => {
|
||||||
const text = container.textContent ?? '';
|
const text = container.textContent ?? '';
|
||||||
expect(text).toMatch(/astrolabe/i);
|
expect(text).toMatch(/astrolabe/i);
|
||||||
// Some mention of contribution / support
|
expect(text).toMatch(/feedback/i);
|
||||||
expect(text).toMatch(/contribut|support/i);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders exactly one primary CTA link', () => {
|
test('offers the feedback address as a mailto link', () => {
|
||||||
const links = container.querySelectorAll('a');
|
const mailto = links().find((a) => a.getAttribute('href')?.startsWith('mailto:'));
|
||||||
expect(links).toHaveLength(1);
|
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', () => {
|
test('provides a copy-address button', () => {
|
||||||
const link = container.querySelector('a')!;
|
const copy = Array.from(container.querySelectorAll('button')).find((b) =>
|
||||||
expect(link.getAttribute('target')).toBe('_blank');
|
/copy/i.test(b.textContent ?? ''),
|
||||||
expect(link.getAttribute('rel')).toContain('noopener');
|
);
|
||||||
|
expect(copy).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the CTA link has visible text (not icon-only)', () => {
|
test('the donation CTA points to Ukraine-defense and opens safely in a new tab', () => {
|
||||||
const link = container.querySelector('a')!;
|
const donate = links().find(
|
||||||
expect((link.textContent ?? '').trim().length).toBeGreaterThan(0);
|
(a) => a.getAttribute('href') === 'https://savelife.in.ua/en/donate-en/',
|
||||||
});
|
);
|
||||||
|
expect(donate).toBeTruthy();
|
||||||
test('the CTA points to the Ukraine-defense donation, not a placeholder', () => {
|
expect(donate!.getAttribute('target')).toBe('_blank');
|
||||||
const link = container.querySelector('a')!;
|
expect(donate!.getAttribute('rel')).toContain('noopener');
|
||||||
expect(link.getAttribute('href')).toBe('https://savelife.in.ua/en/donate-en/');
|
expect((donate!.textContent ?? '').trim().length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
* 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
|
* the shell owns all of that (docs/architecture/03 → Layer 3).
|
||||||
* for nothing for itself: the one solicitation redirects to Ukraine's defense,
|
|
||||||
* where the author would forward any project donations anyway.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
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';
|
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/';
|
const DONATE_URL = 'https://savelife.in.ua/en/donate-en/';
|
||||||
|
|
||||||
export function DonateModal() {
|
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 (
|
return (
|
||||||
<div className={styles.donate}>
|
<div className={styles.donate}>
|
||||||
<p className={styles.body}>
|
<p className={styles.body}>
|
||||||
Astrolabe is free and open-source, built in spare time — there's nothing to buy and no one
|
Astrolabe is free, built in spare time — there's nothing to buy and no one to pay.
|
||||||
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.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={styles.actions}>
|
<section className={styles.section}>
|
||||||
<a href={DONATE_URL} className={styles.primary} target="_blank" rel="noopener noreferrer">
|
<p className={styles.body}>
|
||||||
Donate to Ukraine's defense
|
The most useful thing you can send back is feedback — what's broken, what's missing, what
|
||||||
</a>
|
you'd want next.
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -118,9 +118,11 @@ const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
|
|||||||
name: 'about',
|
name: 'about',
|
||||||
title: 'About & Help',
|
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: {
|
donate: {
|
||||||
name: 'donate',
|
name: 'donate',
|
||||||
title: 'Donate',
|
title: 'Support',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
export type ModalName =
|
export type ModalName =
|
||||||
| 'datasets' // Datasets manager (list / detail / new-dataset form)
|
| 'datasets' // Datasets manager (list / detail / new-dataset form)
|
||||||
| 'about' // About & Help (M6)
|
| 'about' // About & Help (M6)
|
||||||
| 'donate' // Donate (M6)
|
| 'donate' // Support — feedback + Ukraine-defense donation (M6)
|
||||||
| 'chartBuilder' // Visual no-JSON chart composition for a dataset (M4)
|
| 'chartBuilder' // Visual no-JSON chart composition for a dataset (M4)
|
||||||
| 'extract' // Extract inline spec data into a new dataset (M3)
|
| 'extract' // Extract inline spec data into a new dataset (M3)
|
||||||
| 'themeBuilder'; // Custom chart theme editor with a live preview gallery (spec §04)
|
| 'themeBuilder'; // Custom chart theme editor with a live preview gallery (spec §04)
|
||||||
|
|||||||
Reference in New Issue
Block a user