mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 09:48:47 -04:00
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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
127
media-creator/scripts/capture-vote.mjs
Normal file
127
media-creator/scripts/capture-vote.mjs
Normal 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()
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
127
web/components/about/vote-evidence.tsx
Normal file
127
web/components/about/vote-evidence.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'},
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user