From d35066d485fc24e72864c84c6f4faebbbf8ebce8 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Wed, 22 Jul 2026 02:34:18 +0200 Subject: [PATCH] Add vote-tally capture and "How a decision gets made" section - Introduced `capture-vote.mjs` script to capture real voting proposal cards as visual evidence. - Added `VoteEvidence` component to the about page to support the "Democratic" governance claim with verifiable proof from the `/vote` page. - Integrated desktop and mobile screenshot handling, switching via `` media queries for optimal display. - Updated about page layout to embed the proof next to the assertion, removing redundancy from the feature cards. - Documented implementation details and selection rationale in `marketing-visuals.md`. - Enhanced build scripts and updated image handling to support vote screenshots (WebP format, lazy-loaded). --- common/messages/de.json | 4 +- common/messages/fr.json | 4 +- docs/marketing-visuals.md | 79 +++++++++++++-- media-creator/scripts/capture-vote.mjs | 127 +++++++++++++++++++++++++ media-creator/scripts/upload-media.sh | 10 +- web/components/about/vote-evidence.tsx | 127 +++++++++++++++++++++++++ web/components/widgets/charts.tsx | 98 +++++++++++++++++++ web/pages/about.tsx | 43 ++++----- web/public/md/financials.md | 16 ++-- web/scripts/fetch-media.mjs | 6 +- 10 files changed, 466 insertions(+), 48 deletions(-) create mode 100644 media-creator/scripts/capture-vote.mjs create mode 100644 web/components/about/vote-evidence.tsx diff --git a/common/messages/de.json b/common/messages/de.json index 53b8e3d3..72a1b55a 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -47,13 +47,15 @@ "about.suggestions.title": "Vorschläge machen oder beitragen", "about.title": "Warum Compass wählen?", "about.vote.label": "Wie eine Entscheidung zustande kommt", - "about.vote.intro": "Kein Slogan. Mitglieder schlagen Änderungen vor, alle stimmen ab, und das Ergebnis ist bindend — auch dann, wenn es das Produkt umständlicher macht. Diese Entscheidung hat der Anmeldung einen Schritt hinzugefügt:", + "about.vote.intro": "Mitglieder schlagen Änderungen vor, alle stimmen ab, und das Ergebnis ist bindend — auch dann, wenn es das Produkt umständlicher macht. Diese Entscheidung hat der Anmeldung einen Schritt hinzugefügt:", "about.vote.caption": "Jeder Vorschlag und jedes Ergebnis ist öffentlich — auch die abgelehnten. ", "about.vote.link": "Alle Vorschläge ansehen →", "about.vote.alt": "Ein Vorschlag auf der Abstimmungsseite von Compass: „E-Mail-Verifizierung erforderlich, bevor man mit anderen Personen interagiert“. 11 Stimmen dafür, 0 Enthaltungen, 1 dagegen. Status: umgesetzt.", "about.copy_link": " Link kopieren", "about.eyebrow": "Über Compass", "about.features.label": "Was uns unterscheidet", + "about.growth.label": "Mitglieder, Tendenz steigend", + "about.growth.today": "Heute", "about.help.label": "Helfen Sie Compass zu wachsen", "add_photos.add_description": "Beschreibung hinzufügen", "add_photos.profile_picture_hint": "Das hervorgehobene Bild ist Ihr Profilbild", diff --git a/common/messages/fr.json b/common/messages/fr.json index 7af69f9b..f4feba5a 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -47,13 +47,15 @@ "about.suggestions.title": "Faire des suggestions ou contribuer", "about.title": "Pourquoi choisir Compass ?", "about.vote.label": "Comment une décision est prise", - "about.vote.intro": "Ce n'est pas un slogan. Les membres proposent des changements, tout le monde vote, et le résultat s'impose — même quand il rend le produit plus contraignant. Celui-ci a ajouté une étape à l'inscription :", + "about.vote.intro": "Les membres proposent des changements, tout le monde vote, et le résultat s'impose — même quand il rend le produit plus contraignant. Celui-ci a ajouté une étape à l'inscription :", "about.vote.caption": "Chaque proposition et chaque décompte sont publics — y compris ceux qui ont été rejetés. ", "about.vote.link": "Voir toutes les propositions →", "about.vote.alt": "Une proposition sur la page de vote de Compass : « Exiger la vérification de l'e-mail avant d'interagir avec d'autres personnes ». 11 voix pour, 0 abstention, 1 contre. Statut : implémentée.", "about.copy_link": " Copier le lien", "about.eyebrow": "À propos de Compass", "about.features.label": "Ce qui nous différencie", + "about.growth.label": "membres, et ça continue", + "about.growth.today": "Aujourd'hui", "about.help.label": "Aide Compass à grandir", "add_photos.add_description": "Ajouter une description", "add_photos.profile_picture_hint": "L'image mise en surbrillance est votre photo de profil", diff --git a/docs/marketing-visuals.md b/docs/marketing-visuals.md index eb4f0fbd..bf25e6ab 100644 --- a/docs/marketing-visuals.md +++ b/docs/marketing-visuals.md @@ -329,20 +329,81 @@ blocking. Do **not** substitute showcase-seed photos: the copy claims these are This page is a reference doc, not a pitch; it needs evidence, not a hero. -### A1 — Proof for "Democratic" +### A1 — Proof for "Democratic" — **done** -`about.tsx:235-252` links to `/vote` and `/constitution` in text. A small real screenshot of an actual vote -tally turns the assertion into evidence. +The Democratic feature card links to `/vote` and `/constitution` in text. A real vote card turns the +assertion into evidence, so there is now a "How a decision gets made" section between the feature grid and +the help cards (`web/components/about/vote-evidence.tsx`). + +**Which vote, and why it matters.** The first candidate was a UX proposal reading 7 For / 2 Abstain / +0 Against, `Voting Open`. Rejected on three counts, all of which are the selection criteria for a +replacement: + +1. **`Implemented ✔️`, not `Voting Open`.** An open ballot proves the mechanism exists. A shipped one + proves it decides. The claim is that members govern, not that they can express opinions. +2. **Contested.** A tally with nothing against reads as ceremonial and invites the reading that the vote + was a formality. Some opposition is what makes it evidence. +3. **Governance, not UX.** The claim being evidenced is about how the project is run. + +The one used — "Require email verification before interacting with other people", 11 For / 0 Abstain / +1 Against, `Implemented ✔️` — satisfies all three, and is verifiable by a reader: the shipped behaviour is +enforced in `backend/api/src/create-private-user-message-channel.ts`. + +**Turnout is the honest tension.** Twelve voters is a small number to place near "700+ members". It is not +a reason to fake anything; it is a reason to keep the tally off any screen that also shows the member +count, which is why this lives on the about page and not the home page. + +**Privacy.** `/vote` renders for logged-out visitors (`web/pages/vote.tsx` gates only on +`user === undefined`), so tallies and proposer names are already public — screenshotting one is not an +escalation. The capture shoots the single card element rather than the viewport, so no neighbouring +proposal or member name can wander into frame. + +Captured from **production** by `media-creator/scripts/capture-vote.mjs` (`npm run capture:vote`), because +the whole point is that the decision is real — a local seed would be a mock-up. Re-run it after any restyle +of `web/components/votes/vote-item.tsx`; the tally will have moved on by then, which is correct. + +**Two widths, four files.** The desktop capture is 868 CSS px; scaled into a phone column it renders the +card's 14px description at about 6px — the same failure that forced H1 to mobile capture. So the card is +also shot at 390px, where it reflows into its own mobile layout and is displayed at roughly 1:1. The page +chooses with a `` media query, so exactly one file is downloaded. That rules out `next/image`, +which cannot emit a ``; the files are instead authored at the sizes actually used and encoded as +WebP, which for flat fill and text is about 45% smaller than the JPEG (80 KB vs 145 KB). `loading="lazy"` +means the hidden theme's copy is never fetched at all, so a visit costs one 80 KB image. + +**The Democratic feature card no longer links to `/vote`.** This section is its proof and owns that link; +two links to the same page within one screen sent readers away before they reached the evidence. The +wording is unchanged, so the existing `fr`/`de` translations still apply. + +The card's own text is English only. Translating a screenshot of a real proposal would mean fabricating it, +so the surrounding caption carries the meaning instead and is translated normally. ### A2 — Proof for "Community Owned / no VC" The one place a genuine human photo belongs, because it evidences the governance claim rather than selling a lifestyle: contributor photos, a Discord screenshot, or the GitHub contributor wall. -### A3 — Growth chart (optional) +### A3 — Growth chart — **done** -A simple line of the member count over time near the `ShareStrip`. `web/public/md/financials.md` already -establishes the transparency framing. +A single cumulative line of the member count, sitting under the "Help Compass grow" label as the setup for +the help cards — this is what you would be helping grow. `MemberGrowth` in +`web/components/widgets/charts.tsx`. + +**It lives in `charts.tsx`, not `components/about/`,** because it shares that file's date helpers with the +big `/stats` chart. Only the presentation differs, and it differs deliberately: `/stats` is an instrument — +two series, axes, grid, tooltip — for someone who came to read numbers. This is one claim on a page someone +is skimming, so it is a single line with no axes and no tooltip, and the endpoints are labelled in real +HTML rather than recharts ticks. That last part is not only taste: the tick and tooltip colours in the +`/stats` chart are hardcoded light-theme values, which HTML labels sidestep entirely. + +**Queried live, never hardcoded.** A member count that quietly goes stale on a page arguing for +transparency is the failure mode worth engineering against. For the same reason it renders _nothing_ when +the query comes back empty rather than a zero, a spinner, or an empty chart frame — an empty frame claims +more than it shows. Rendering nothing is also why it has no section heading of its own: a heading with +nothing beneath it is worse than no section. + +**Scaling caveat**, inherited from `getProfilesCreations`: one row per profile, cumulative curve rolled up +in the browser. Fine at ~700 and identical to what `/stats` already does, but linear — past a few thousand +members this wants an aggregate endpoint returning daily totals. ## Cross-cutting constraints @@ -366,11 +427,11 @@ establishes the transparency framing. | H1 | Hero search demo | W0c | Claude ✅ | | H2 | Feature-card micro-screenshots | — | — dropped | | H3 | Fabricated avatars removed | — | Claude ✅ | -| A1 | Vote-tally screenshot | — | Claude | +| A1 | Vote-tally screenshot | — | Claude ✅ | | A2 | Community photo | — | **Martin** | -| A3 | Growth chart | — | Claude | +| A3 | Growth chart | — | Claude ✅ | -The home page is finished. What is left is the about page, which currently carries no visuals at all. +The home page is finished. On the about page, A2 needs a photo only Martin can supply and A3 is optional. H3 option 1 (real member photos, opt-in) stays available but is not blocking anything. --- diff --git a/media-creator/scripts/capture-vote.mjs b/media-creator/scripts/capture-vote.mjs new file mode 100644 index 00000000..d6949f37 --- /dev/null +++ b/media-creator/scripts/capture-vote.mjs @@ -0,0 +1,127 @@ +/** + * Captures a real proposal card from /vote for the about page (A1 in docs/marketing-visuals.md). + * + * The about page asserts that members govern the project and links to /vote in plain text. One card + * showing a contested ballot that ended in `Implemented ✔️` converts that assertion into evidence: + * proposal → vote → shipped, verifiable by the reader. + * + * Captured against **production**, not a local seed, because the entire point is that this is a real + * decision. /vote renders for logged-out visitors (`web/pages/vote.tsx` only gates on + * `user === undefined`), so no auth is involved and nothing private is on screen — the tally is + * aggregate counts and the proposer's name, both already public on that page. + * + * Only the single card element is shot, never the viewport, so no neighbouring proposal or member + * name can wander into frame. + * + * Usage: + * npm run capture:vote # both themes + * npm run capture:vote -- --title "Require email verification" --base http://localhost:3000 + * + * Re-run after any restyle of `web/components/votes/vote-item.tsx`. The tally will have moved on by + * then, which is fine and in fact the point — it is a screenshot, not a claim frozen in a PNG. + * + * Borrows Playwright from the monorepo root by absolute path, deliberately — this package stays + * Remotion-only. Same trick as capture-search.mjs. + */ + +import {mkdirSync} from 'node:fs' +import {dirname, join} from 'node:path' +import {fileURLToPath} from 'node:url' + +import pw from '../../node_modules/@playwright/test/index.js' +import sharp from '../../node_modules/sharp/lib/index.js' + +const HERE = dirname(fileURLToPath(import.meta.url)) +// Straight into web/public: these are small stills, not Remotion inputs, so they need no render step. +const OUT_DIR = join(HERE, '../../web/public/images') + +const args = process.argv.slice(2) +const argOf = (name, fallback) => { + const i = args.indexOf(`--${name}`) + return i === -1 ? fallback : args[i + 1] +} + +const BASE = argOf('base', 'https://compassmeet.com') + +/** + * Which proposal to shoot, matched on a prefix of its title. + * + * Chosen over the alternatives on the page because it is the one that best evidences the claim: + * contested (11 For / 1 Against — a unanimous tally reads as ceremonial), a governance and safety + * decision rather than a UX tweak, and `Implemented ✔️` rather than `Voting Open`, so it shows the + * vote actually decided something. A reader can verify the outcome by trying to message someone from + * an unverified account. + */ +const TITLE = argOf('title', 'Require email verification') + +/** + * Two widths, not one, for the same reason H1 is captured on mobile: a 900px-wide card shown in a + * ~350px phone column is scaled to about 44%, which puts the card's 14px description at ~6px. The + * narrow capture lets the card reflow into its own mobile layout and is then displayed at 1:1. + * + * The page picks between them with a `` media query, so only one is ever downloaded. + */ +const WIDTHS = [ + // Wide enough that the description stays on few lines and the tally sits on one row, without the + // card reflowing into its narrow layout. + {suffix: '', viewport: {width: 900, height: 1000}}, + // iPhone-13 logical width, matching capture-search.mjs. + {suffix: '-narrow', viewport: {width: 390, height: 844}}, +] +const SCALE = 2 + +async function capture(browser, theme, {suffix, viewport}) { + const ctx = await browser.newContext({ + viewport, + deviceScaleFactor: SCALE, + // The app resolves the manual toggle and the system preference into the `dark` class on + // (see web/public/init-theme.js). With no stored preference the default is 'auto', so emulating + // the media query is enough — no localStorage seeding needed. + colorScheme: theme, + }) + const page = await ctx.newPage() + + await page.goto(`${BASE}/vote`, {waitUntil: 'networkidle', timeout: 60000}) + + // The card is `Col.mb-4.rounded-lg.border` wrapping a `p.text-2xl` title + // (web/components/votes/vote-item.tsx). Match on the title, then climb to the card. + const heading = page.locator('p.text-2xl', {hasText: TITLE}).first() + await heading.waitFor({state: 'visible', timeout: 30000}) + const card = heading.locator('xpath=ancestor::div[contains(@class,"rounded-lg")][1]') + + // Let webfonts settle before shooting, or the text reflows a fraction after the screenshot. + await page.evaluate(() => document.fonts.ready) + await page.waitForTimeout(500) + + // WebP, not JPEG. The card is flat fill and text, which is what JPEG handles worst — the same shot + // is about 45% smaller here (80 KB vs 145 KB). That matters because the page needs a to + // choose a width, and next/image cannot emit one, so these are served unoptimised as authored. + const out = join(OUT_DIR, `vote-tally-${theme}${suffix}.webp`) + const png = await card.screenshot({type: 'png', scale: 'device'}) + const {size} = await sharp(png).webp({quality: 82}).toFile(out) + + const {width, height} = await card.boundingBox() + const status = await card.innerText() + console.log( + `[vote] ${theme}${suffix}: ${out} (${width}x${height} css, ${(size / 1024).toFixed(0)} KB)`, + ) + if (!/Implemented/i.test(status)) { + console.warn( + `[vote] WARNING: the captured card no longer reads "Implemented". The about page copy ` + + `describes it as a shipped decision — check web/pages/about.tsx before publishing.`, + ) + } + + await ctx.close() +} + +const browser = await pw.chromium.launch() +try { + mkdirSync(OUT_DIR, {recursive: true}) + console.log(`[vote] capturing "${TITLE}" from ${BASE}/vote`) + for (const theme of ['light', 'dark']) { + for (const width of WIDTHS) await capture(browser, theme, width) + } +} finally { + await browser.close() +} diff --git a/media-creator/scripts/upload-media.sh b/media-creator/scripts/upload-media.sh index ec8b3b94..254ba76a 100755 --- a/media-creator/scripts/upload-media.sh +++ b/media-creator/scripts/upload-media.sh @@ -92,8 +92,14 @@ upload "$OUT_DIR/compass-search-demo-dark.mp4" "videos/search-demo-dark.mp4" "vi upload "$WEB_IMAGES/search-demo-poster-light.jpg" "images/search-demo-poster-light.jpg" "image/jpeg" upload "$WEB_IMAGES/search-demo-poster-dark.jpg" "images/search-demo-poster-dark.jpg" "image/jpeg" # About-page vote card (A1). Written straight to web/public by capture-vote.mjs — no render step. -upload "$WEB_IMAGES/vote-tally-light.jpg" "images/vote-tally-light.jpg" "image/jpeg" -upload "$WEB_IMAGES/vote-tally-dark.jpg" "images/vote-tally-dark.jpg" "image/jpeg" +# Two widths per theme: the desktop shot is illegible scaled into a phone column, so the page picks +# with a media query. +for theme in light dark; do + for suffix in "" "-narrow"; do + upload "$WEB_IMAGES/vote-tally-${theme}${suffix}.webp" \ + "images/vote-tally-${theme}${suffix}.webp" "image/webp" + done +done echo echo "Done. The next Vercel build pulls these into web/public/videos via MEDIA_SOURCE_BASE_URL." diff --git a/web/components/about/vote-evidence.tsx b/web/components/about/vote-evidence.tsx new file mode 100644 index 00000000..e5d4b219 --- /dev/null +++ b/web/components/about/vote-evidence.tsx @@ -0,0 +1,127 @@ +import clsx from 'clsx' +import Link from 'next/link' +import {useT} from 'web/lib/locale' + +/** + * A real proposal card from /vote, on the about page (A1 in docs/marketing-visuals.md). + * + * The page asserts that the community governs the project and links to /vote in plain text. This is + * the evidence for that assertion: a contested ballot — 11 for, 1 against, so it was a real vote and + * not a formality — that ended in `Implemented ✔️`. Proposal → vote → shipped, and this particular + * decision is one a reader can verify by trying to message someone from an unverified account. + * + * It is a screenshot of the live page rather than a re-implementation of the card, so it cannot drift + * into claiming something /vote does not actually show. Regenerate with + * `npm run capture:vote` from media-creator/ — including after any restyle of + * `web/components/votes/vote-item.tsx`. + * + * Both themes are captured and CSS picks between them: `init-theme.js` sets the `dark` class on + * in a render-blocking script, so the choice is made before first paint. Resolving it in an + * effect instead would paint the light image first and visibly swap. Same approach as + * `components/home/search-demo.tsx`, which has the longer note. + * + * The card's own text is English only — it is a screenshot of a real proposal, and translating it + * would mean fabricating it. The caption around it is translated, so the point still lands in every + * locale. + */ + +/** + * Both captures from capture-vote.mjs, at DPR 2, cropped to the card element. + * + * Two widths because one does not work: the desktop shot is 868 CSS px, and scaling that into a phone + * column renders the card's 14px description at about 6px. The narrow shot lets the card reflow into + * its own mobile layout, and is then shown at 1:1. Same reasoning as the mobile capture for H1. + */ +const SHOTS = { + wide: {width: 1736, height: 560}, + narrow: {width: 716, height: 1168}, +} + +// Matches Tailwind's `sm` (640px): below it, the card's own layout has already gone narrow. +const NARROW_MEDIA = '(max-width: 420px)' + +/** + * A rather than next/image, so the browser picks a width itself and downloads only that + * one. next/image cannot emit a , and its optimisation is not missed here: the files are + * authored at exactly the sizes used and already WebP. + * + * The theme still needs two of these — CSS picks between them on the `dark` class, which + * `init-theme.js` sets before first paint (see components/home/search-demo.tsx for the long note). + */ +function Shot({theme, alt, className}: {theme: 'light' | 'dark'; alt: string; className: string}) { + return ( + + {/* width/height on the too, not just the : the two shots have different aspect + ratios, and without them the box is reserved at the wide ratio and jumps on load. */} + + {alt} + + ) +} + +export function VoteEvidence() { + const t = useT() + + const alt = t( + 'about.vote.alt', + 'A proposal on the Compass vote page: "Require email verification before interacting with other people". 11 votes for, 0 abstain, 1 against. Status: Implemented.', + ) + + return ( +
+ {/* This was the "Democratic" feature card, moved down here verbatim. Asserting it one screen + above its own evidence read as a duplicate; stated immediately before the vote that proves + it, the claim and the proof are one thought. The translation keys are unchanged from the + card, so the existing fr/de strings apply as they are. */} +

+ {t('about.block.democratic.prefix', 'Governed and ')} + {t('about.block.democratic.link_voted', 'voted')} + {t( + 'about.block.democratic.middle', + ' by the community, while ensuring no drift through our ', + )} + + {t('about.block.democratic.link_constitution', 'constitution')} + + {t('about.block.democratic.suffix', '.')} +

+ +

+ {t( + 'about.vote.intro', + 'Members propose changes, everyone votes, and the result is binding. This one added a step to signing up:', + )} +

+ + {/* No border here: the captured card brings its own, and adding a second draws a double rule + along the same edge. */} +
+ + +
+ +
+ {t( + 'about.vote.caption', + 'Every proposal and tally is public — including the ones that lost. ', + )} + + {t('about.vote.link', 'See all proposals →')} + +
+
+ ) +} diff --git a/web/components/widgets/charts.tsx b/web/components/widgets/charts.tsx index e8bba0db..d3a2cdcc 100644 --- a/web/components/widgets/charts.tsx +++ b/web/components/widgets/charts.tsx @@ -162,6 +162,104 @@ function CustomLegend({payload}: any) { // ─── Chart ──────────────────────────────────────────────────────────────────── +/** + * A stripped-down version of the growth curve for the about page (A3 in docs/marketing-visuals.md). + * + * Lives here rather than under components/about/ so it shares this file's date helpers — the shape of + * the data is the same, only the presentation differs. + * + * Deliberately not the chart above. That one is an instrument: two series, axes, grid, tooltip, for + * someone who came to /stats to read numbers. This one is a single claim on a page someone is + * skimming, so it is one line, no axes, no tooltip, with the endpoints labelled in real HTML instead + * of recharts ticks — which also sidesteps the hardcoded light-theme tick colours above. + * + * The number is queried live, never hardcoded. A count that quietly goes stale on a page arguing for + * transparency is the one failure mode worth engineering against, and it is also why this renders + * nothing at all rather than a placeholder when the query comes back empty. + * + * Scaling caveat, inherited from `getProfilesCreations`: this pulls one row per profile and rolls the + * cumulative curve up in the browser. Fine at the current size, and identical to what /stats already + * does, but it grows linearly — past a few thousand members this wants an aggregate endpoint + * returning daily totals instead. + */ +export function MemberGrowth() { + const t = useT() + const [data, setData] = useState<{dateTs: number; members: number}[]>([]) + const [failed, setFailed] = useState(false) + + useEffect(() => { + async function load() { + try { + const profiles = await getProfilesCreations() + if (!profiles?.length) return setFailed(true) + + const counts = buildCounts(profiles) + const dates = Object.keys(counts).sort((a, b) => a.localeCompare(b)) + const range = buildDailyRange(dates[0], dates[dates.length - 1]) + const cumulative = cumulativeFromCounts(counts, range) + + setData( + range.map((date) => ({ + dateTs: new Date(date + 'T00:00:00.000Z').getTime(), + members: cumulative[date] || 0, + })), + ) + } catch { + setFailed(true) + } + } + void load() + }, []) + + // Render nothing rather than a zero or a spinner: this is supporting evidence on a page that reads + // fine without it, and an empty chart frame claims more than it shows. + if (failed || !data.length) return null + + const total = data[data.length - 1].members + const monthYear = (ts: number) => + new Date(ts).toLocaleDateString('en-US', {month: 'short', year: 'numeric'}) + + return ( +
+
+ {total.toLocaleString()} + + {t('about.growth.label', 'members and counting')} + +
+ +
+ + + + + + + + + + + +
+ + {/* Endpoints as text rather than an axis: two labels are all this needs, and they inherit the + theme tokens instead of recharts' hardcoded tick colours. */} +
+ {monthYear(data[0].dateTs)} + {t('about.growth.today', 'Today')} +
+
+ ) +} + export default function ChartMembers() { const [data, setData] = useState([]) const [chartHeight, setChartHeight] = useState(380) diff --git a/web/pages/about.tsx b/web/pages/about.tsx index 123f8e37..1990f03c 100644 --- a/web/pages/about.tsx +++ b/web/pages/about.tsx @@ -9,13 +9,11 @@ import { LightBulbIcon, MagnifyingGlassIcon, MegaphoneIcon, - ScaleIcon, SparklesIcon, } from '@heroicons/react/24/outline' import clsx from 'clsx' import {discordLink, formLink, githubRepo} from 'common/constants' import {DEPLOYED_WEB_URL} from 'common/envs/constants' -import Link from 'next/link' import {ComponentType, ReactNode, SVGProps} from 'react' import {VoteEvidence} from 'web/components/about/vote-evidence' import {CopyLinkOrShareButton, ShareProfileOnXButton} from 'web/components/buttons/copy-link-button' @@ -23,6 +21,7 @@ import {GeneralButton} from 'web/components/buttons/general-button' import {Row} from 'web/components/layout/row' import {PageBase} from 'web/components/page-base' import {SEO} from 'web/components/SEO' +import {MemberGrowth} from 'web/components/widgets/charts' import {useT} from 'web/lib/locale' // ─── Types ──────────────────────────────────────────────────────────────────── @@ -231,26 +230,10 @@ export default function About() { title: t('about.block.free.title', 'Completely Free'), text: t('about.block.free.text', 'Subscription-free. Paywall-free. Ad-free.'), }, - { - icon: ScaleIcon, - title: t('about.block.democratic.title', 'Democratic'), - text: ( - - {t('about.block.democratic.prefix', 'Governed and ')} - - {t('about.block.democratic.link_voted', 'voted')} - - {t( - 'about.block.democratic.middle', - ' by the community, while ensuring no drift through our ', - )} - - {t('about.block.democratic.link_constitution', 'constitution')} - - {t('about.block.democratic.suffix', '.')} - - ), - }, + // The "Democratic" card used to sit here. Its claim now opens the "How a decision gets made" + // section below, next to the vote that proves it — a card asserting the same thing one screen + // above its own evidence was reading as a duplicate. Same translation keys, moved verbatim, so + // the fr/de strings carry over. See docs/marketing-visuals.md (A1). { icon: FlagIcon, title: t('about.block.mission.title', 'One Mission'), @@ -369,10 +352,12 @@ export default function About() { {/* ── Help ── */} {t('about.help.label', 'Help Compass grow')} -
- {helpCards.map((card) => ( - - ))} + {/* Sits inside the Help section rather than getting a heading of its own: it renders nothing + when the query comes back empty, and a section label with nothing under it is worse than + no section. It also reads as the setup for the cards below — this is what you would be + helping grow. */} +
+
{/* ── Share strip ── */} @@ -383,6 +368,12 @@ export default function About() { 'The best way to grow Compass is word of mouth. Thank you for supporting our mission.', )} /> + +
+ {helpCards.map((card) => ( + + ))} +
) diff --git a/web/public/md/financials.md b/web/public/md/financials.md index 8498991d..db2ac5a6 100644 --- a/web/public/md/financials.md +++ b/web/public/md/financials.md @@ -6,28 +6,30 @@ donating [here](/support). ### Expenses -- **Hosting & Infrastructure**: $300.83 +- **Hosting & Infrastructure**: $456.65 - **Development**: $0 (all volunteer-driven) - **Marketing**: $0 (growth is organic and community-led) - **Miscellaneous / Private Costs**: $0 ### Funding Sources -- **Donations**: $29.78 +- **Donations**: $144.70 - **Grants**: $0 ### Financial Summary -- **Total Income**: $29.78 -- **Total Expenses**: $300.83 -- **Net Surplus**: -$271.05 +- **Total Income**: $144.70 +- **Total Expenses**: $456.65 +- **Net Surplus**: -$311.95 ### Why Compass is Money-Efficient -Compass is built entirely by volunteers and contributors, meaning every dollar donated goes **directly into infrastructure** (servers, hosting, maintenance). +Compass is built entirely by volunteers and contributors, meaning every dollar donated goes **directly into +infrastructure** (servers, hosting, maintenance). - No salaries. - No marketing budget. - No hidden private spending. -This ensures that donations are never diluted — 100% of funds serve the mission of keeping Compass running and accessible. +This ensures that donations are never diluted — 100% of funds serve the mission of keeping Compass running and +accessible. diff --git a/web/scripts/fetch-media.mjs b/web/scripts/fetch-media.mjs index ce9057e7..16d1b489 100644 --- a/web/scripts/fetch-media.mjs +++ b/web/scripts/fetch-media.mjs @@ -41,8 +41,10 @@ const ASSETS = [ {key: 'videos/search-demo-dark.mp4', dest: 'videos/search-demo-dark.mp4'}, {key: 'images/search-demo-poster-light.jpg', dest: 'images/search-demo-poster-light.jpg'}, {key: 'images/search-demo-poster-dark.jpg', dest: 'images/search-demo-poster-dark.jpg'}, - {key: 'images/vote-tally-light.jpg', dest: 'images/vote-tally-light.jpg'}, - {key: 'images/vote-tally-dark.jpg', dest: 'images/vote-tally-dark.jpg'}, + {key: 'images/vote-tally-light.webp', dest: 'images/vote-tally-light.webp'}, + {key: 'images/vote-tally-dark.webp', dest: 'images/vote-tally-dark.webp'}, + {key: 'images/vote-tally-light-narrow.webp', dest: 'images/vote-tally-light-narrow.webp'}, + {key: 'images/vote-tally-dark-narrow.webp', dest: 'images/vote-tally-dark-narrow.webp'}, ] /**