diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index fe80ce8..845d7f7 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -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 diff --git a/docs/architecture/09-visual-design.md b/docs/architecture/09-visual-design.md index 48faa0a..78f20bf 100644 --- a/docs/architecture/09-visual-design.md +++ b/docs/architecture/09-visual-design.md @@ -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))`, 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 diff --git a/docs/architecture/visual-specimen.html b/docs/architecture/visual-specimen.html index 20e0a50..47df8b4 100644 --- a/docs/architecture/visual-specimen.html +++ b/docs/architecture/visual-specimen.html @@ -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 @@

Emphasis hierarchy — a header cluster

Utilities go ghost (icon + text, borderless) so they recede; a divider - sets off Donate on a soft-accent wash — one low-key + sets off Support on a soft-accent wash — one low-key solicitation, no border. (Carbon: one high-emphasis action per region; utilities at low emphasis.)

@@ -1024,7 +1024,7 @@ >About - + diff --git a/docs/spec/01-application-shell.md b/docs/spec/01-application-shell.md index bad067f..c30f7a8 100644 --- a/docs/spec/01-application-shell.md +++ b/docs/spec/01-application-shell.md @@ -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. | +| 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. | +| 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. diff --git a/src/app/App.module.css b/src/app/App.module.css index ba26c0e..c645e7d 100644 --- a/src/app/App.module.css +++ b/src/app/App.module.css @@ -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 diff --git a/src/app/App.tsx b/src/app/App.tsx index 3fd3365..7ef4195 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -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. */} openModal('datasets')} @@ -117,9 +118,9 @@ export function App() { {/* Hidden picker for Import; restricted to JSON (spec §08). */} diff --git a/src/app/components/AboutModal.module.css b/src/app/components/AboutModal.module.css index d5c2185..7000e7b 100644 --- a/src/app/components/AboutModal.module.css +++ b/src/app/components/AboutModal.module.css @@ -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; diff --git a/src/app/components/AboutModal.test.tsx b/src/app/components/AboutModal.test.tsx index 34eaef4..1c68f8f 100644 --- a/src/app/components/AboutModal.test.tsx +++ b/src/app/components/AboutModal.test.tsx @@ -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'); }); }); diff --git a/src/app/components/AboutModal.tsx b/src/app/components/AboutModal.tsx index 792e565..d169784 100644 --- a/src/app/components/AboutModal.tsx +++ b/src/app/components/AboutModal.tsx @@ -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() {

+ + {/* Feedback */} +
+

Feedback

+

+ Found a bug or have an idea? Email{' '} + + {FEEDBACK_EMAIL} + + . +

+
); } diff --git a/src/app/components/DonateModal.module.css b/src/app/components/DonateModal.module.css index d92cb78..32be6cc 100644 --- a/src/app/components/DonateModal.module.css +++ b/src/app/components/DonateModal.module.css @@ -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 so it matches diff --git a/src/app/components/DonateModal.test.tsx b/src/app/components/DonateModal.test.tsx index db1280d..07145ab 100644 --- a/src/app/components/DonateModal.test.tsx +++ b/src/app/components/DonateModal.test.tsx @@ -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); }); }); diff --git a/src/app/components/DonateModal.tsx b/src/app/components/DonateModal.tsx index 11ff18f..c30693d 100644 --- a/src/app/components/DonateModal.tsx +++ b/src/app/components/DonateModal.tsx @@ -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 (

- Astrolabe is free and open-source, built in spare time — there's nothing to buy and no one - to pay. -

-

- 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.

-
- - Donate to Ukraine's defense - -
+
+

+ The most useful thing you can send back is feedback — what's broken, what's missing, what + you'd want next. +

+
+ + {FEEDBACK_EMAIL} + + + {/* A polite live region announces the copy to assistive tech, which the + button's label swap alone would not reliably do. */} + + {copied ? 'Email address copied to clipboard' : ''} + +
+
+ +
+

+ 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. +

+
+ + Donate to Ukraine's defense + +
+
); } diff --git a/src/app/feedback.ts b/src/app/feedback.ts new file mode 100644 index 0000000..6502469 --- /dev/null +++ b/src/app/feedback.ts @@ -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}`; +} diff --git a/src/app/modals/modal-registry.ts b/src/app/modals/modal-registry.ts index 98f4f59..66232d8 100644 --- a/src/app/modals/modal-registry.ts +++ b/src/app/modals/modal-registry.ts @@ -118,9 +118,11 @@ const MODAL_REGISTRY: Partial> = { 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', }, }; diff --git a/src/app/modals/types.ts b/src/app/modals/types.ts index b735c69..e5329af 100644 --- a/src/app/modals/types.ts +++ b/src/app/modals/types.ts @@ -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)