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 `<picture>` 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).
This commit is contained in:
MartinBraquet
2026-07-22 02:34:18 +02:00
parent 0136f1f386
commit d35066d485
10 changed files with 466 additions and 48 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 `<picture>` media query, so exactly one file is downloaded. That rules out `next/image`,
which cannot emit a `<picture>`; 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.
---

View File

@@ -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 `<picture>` 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 <html>
// (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 <picture> 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()
}

View File

@@ -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 <picture> 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."

View File

@@ -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
* <html> 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 <picture> rather than next/image, so the browser picks a width itself and downloads only that
* one. next/image cannot emit a <picture>, 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 (
<picture>
{/* width/height on the <source> too, not just the <img>: the two shots have different aspect
ratios, and without them the box is reserved at the wide ratio and jumps on load. */}
<source
media={NARROW_MEDIA}
srcSet={`/images/vote-tally-${theme}-narrow.webp`}
width={SHOTS.narrow.width}
height={SHOTS.narrow.height}
/>
<img
src={`/images/vote-tally-${theme}.webp`}
alt={alt}
width={SHOTS.wide.width}
height={SHOTS.wide.height}
loading="lazy"
decoding="async"
className={clsx('w-full h-auto rounded-xl', className)}
/>
</picture>
)
}
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 (
<figure className="bg-canvas-50 border-[1.5px] border-canvas-200 rounded-2xl p-5 sm:p-7">
{/* 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. */}
<p className="text-base text-ink-900 leading-relaxed mb-3">
{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 ',
)}
<Link href="/constitution" className="text-primary-500 hover:underline">
{t('about.block.democratic.link_constitution', 'constitution')}
</Link>
{t('about.block.democratic.suffix', '.')}
</p>
<p className="text-sm text-ink-600 leading-relaxed mb-5">
{t(
'about.vote.intro',
'Members propose changes, everyone votes, and the result is binding. This one added a step to signing up:',
)}
</p>
{/* No border here: the captured card brings its own, and adding a second draws a double rule
along the same edge. */}
<div className="overflow-hidden rounded-xl">
<Shot theme="light" alt={alt} className="dark:hidden" />
<Shot theme="dark" alt={alt} className="hidden dark:block" />
</div>
<figcaption className="text-sm text-ink-500 leading-relaxed mt-4">
{t(
'about.vote.caption',
'Every proposal and tally is public — including the ones that lost. ',
)}
<Link href="/vote" className="text-primary-500 hover:underline">
{t('about.vote.link', 'See all proposals →')}
</Link>
</figcaption>
</figure>
)
}

View File

@@ -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 (
<figure className="bg-canvas-50 border-[1.5px] border-canvas-200 rounded-2xl p-5 sm:p-7">
<div className="flex items-baseline gap-2 mb-[-20px] sm:mb-[-40px]">
<span className="text-3xl font-bold text-ink-1000">{total.toLocaleString()}</span>
<span className="text-sm text-ink-600">
{t('about.growth.label', 'members and counting')}
</span>
</div>
<div className="h-[120px] sm:h-[140px] -mx-1">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{top: 8, right: 4, bottom: 0, left: 4}}>
<defs>
<linearGradient id="gradGrowth" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="rgb(193 127 62)" stopOpacity={0.22} />
<stop offset="95%" stopColor="rgb(193 127 62)" stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="members"
stroke="rgb(193 127 62)"
strokeWidth={2.5}
fill="url(#gradGrowth)"
dot={false}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
{/* 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. */}
<figcaption className="flex justify-between text-xs text-ink-500 mt-1">
<span>{monthYear(data[0].dateTs)}</span>
<span>{t('about.growth.today', 'Today')}</span>
</figcaption>
</figure>
)
}
export default function ChartMembers() {
const [data, setData] = useState<any[]>([])
const [chartHeight, setChartHeight] = useState(380)

View File

@@ -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: (
<span>
{t('about.block.democratic.prefix', 'Governed and ')}
<Link href="/vote" className="text-primary-500 hover:underline">
{t('about.block.democratic.link_voted', 'voted')}
</Link>
{t(
'about.block.democratic.middle',
' by the community, while ensuring no drift through our ',
)}
<Link href="/constitution" className="text-primary-500 hover:underline">
{t('about.block.democratic.link_constitution', 'constitution')}
</Link>
{t('about.block.democratic.suffix', '.')}
</span>
),
},
// 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 ── */}
<SectionLabel>{t('about.help.label', 'Help Compass grow')}</SectionLabel>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{helpCards.map((card) => (
<HelpCard key={card.id} {...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. */}
<div className="mb-4">
<MemberGrowth />
</div>
{/* ── 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.',
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
{helpCards.map((card) => (
<HelpCard key={card.id} {...card} />
))}
</div>
</div>
</PageBase>
)

View File

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

View File

@@ -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'},
]
/**