Redesign and enhance About page layout and content

- Updated `VoteEvidence` and `NotifySpotlight` components with improved layouts, scaling, and responsiveness.
- Added live mobile sharing button leveraging the Web Share API on supported devices.
- Simplified and modernized repo activity metrics, introducing display floors and "last updated" live ping messaging.
- Improved German and French translations for new and existing components.
- Removed outdated or redundant components and feature cards for cleaner structure and focus.
- Refined media handling, accessibility, and content ordering for better usability and readability.
This commit is contained in:
MartinBraquet
2026-07-23 16:18:31 +02:00
parent 799cca3c03
commit 8d66e324e8
6 changed files with 376 additions and 189 deletions

2
.gitignore vendored
View File

@@ -107,3 +107,5 @@ test-results
# The home-page hero clips and their posters are NOT committed. They live in Cloudflare R2 and are
# downloaded into web/public/ by web/scripts/fetch-media.mjs during the build, so Vercel still serves
# them same-origin. Push new renders with media-creator's `npm run upload:media`.
*.back

View File

@@ -14,6 +14,12 @@
"about.block.keyword.title": "Suche nach Schlüsselwörtern",
"about.block.mission.text": "Unsere einzige Mission ist es, mehr authentische menschliche Beziehungen zu schaffen, und jede Entscheidung muss diesem Ziel dienen.",
"about.block.mission.title": "Eine Mission",
"about.block.notify.step1.text": "Filtern Sie die gesamte Community bis zur genau passenden Person — Werte, Interessen, Ort.",
"about.block.notify.step1.title": "Nach Stichwort suchen",
"about.block.notify.step2.text": "Noch niemand dabei? Speichern Sie sie mit einem Tippen, statt erneut nachzusehen.",
"about.block.notify.step2.title": "Suche speichern",
"about.block.notify.step3.text": "Wir schreiben Ihnen an dem Tag, an dem jemand Passendes beitritt.",
"about.block.notify.step3.title": "E-Mail erhalten",
"about.block.notify.text": "Sie müssen die App nicht ständig überprüfen! Wir kontaktieren Sie, wenn neue Benutzer Ihren Suchkriterien entsprechen.",
"about.block.notify.title": "Suchbenachrichtigungen erhalten",
"about.block.personality.text": "Werte und Interessen zuerst, Fotos sind zweitrangig.",
@@ -41,6 +47,9 @@
"about.seo.title": "Über uns",
"about.settings.copied": "Kopiert!",
"about.settings.copy_info": "Infos kopieren",
"about.share.button": "Teilen",
"about.share.text": "Durchdachte Eins-zu-eins-Verbindungen, offen entwickelt.",
"about.share.title": "Compass",
"about.subtitle": "Finden Sie Ihre Leute ganz einfach — basierend auf wer sie sind, nicht wie sie aussehen.",
"about.suggestions.button": "Hier vorschlagen →",
"about.suggestions.text": "Machen Sie Vorschläge oder lassen Sie uns wissen, dass Sie durch dieses Formular helfen möchten. Jede Idee zählt.",

View File

@@ -14,6 +14,12 @@
"about.block.keyword.title": "Recherche par mots-clés",
"about.block.mission.text": "Notre unique mission est de créer plus de relations humaines authentiques, et chaque décision doit servir cet objectif.",
"about.block.mission.title": "Une mission",
"about.block.notify.step1.text": "Filtre toute la communauté jusqu'à la personne exacte — valeurs, intérêts, localisation.",
"about.block.notify.step1.title": "Recherche par mot-clé",
"about.block.notify.step2.text": "Personne ne correspond encore ? Enregistre-la en un geste au lieu de revenir vérifier.",
"about.block.notify.step2.title": "Enregistre la recherche",
"about.block.notify.step3.text": "Nous t'écrivons le jour où quelqu'un qui correspond nous rejoint.",
"about.block.notify.step3.title": "Reçois l'e-mail",
"about.block.notify.text": "Plus besoin de vérifier constamment l'application ! Nous to contacterons lorsque de nouveaux utilisateurs correspondent à tes recherches.",
"about.block.notify.title": "Reçois des notifications de recherches",
"about.block.personality.text": "Valeurs et centres d'intérêt d'abord, les photos sont secondaires.",
@@ -41,6 +47,9 @@
"about.seo.title": "À propos",
"about.settings.copied": "Copié !",
"about.settings.copy_info": "Copier les infos",
"about.share.button": "Partager",
"about.share.text": "Des connexions authentiques en tête-à-tête, construites en toute transparence.",
"about.share.title": "Compass",
"about.subtitle": "Trouve tes personnes en toute simplicité — selon qui ils sont, pas selon leur apparence.",
"about.suggestions.button": "Suggérer ici →",
"about.suggestions.text": "Donne des suggestions ou fais-nous savoir que tu souhaites aider via ce formulaire. Chaque idée compte.",

View File

@@ -2,9 +2,8 @@ import {CodeBracketIcon, StarIcon, UserGroupIcon} from '@heroicons/react/24/outl
import clsx from 'clsx'
import {githubRepo} from 'common/constants'
import {ComponentType, ReactNode, SVGProps} from 'react'
import {SectionLabel} from 'web/components/about/section'
import {Reveal} from 'web/components/widgets/reveal'
import {Section, surface} from 'web/components/widgets/surface'
import {surface} from 'web/components/widgets/surface'
import {useAPIGetter} from 'web/hooks/use-api-getter'
import {useT} from 'web/lib/locale'
@@ -31,14 +30,34 @@ import {useT} from 'web/lib/locale'
type IconType = ComponentType<SVGProps<SVGSVGElement>>
function Metric({icon: Icon, value, label}: {icon: IconType; value: ReactNode; label: string}) {
function Metric({
icon: Icon,
value,
label,
live,
}: {
icon: IconType
value: ReactNode
label: string
live?: boolean
}) {
return (
<div className="flex items-center gap-3.5">
<div className="w-10 h-10 rounded-xl bg-primary-100 ring-1 ring-primary-200 flex items-center justify-center shrink-0">
<Icon className="w-[18px] h-[18px] text-primary-600" strokeWidth={1.8} />
</div>
<div className="min-w-0">
<div className="font-heading text-xl font-bold text-ink-900 leading-tight tabular-nums">
<div className="flex items-center gap-2 font-heading text-xl font-bold text-ink-900 leading-tight tabular-nums">
{/* The recency stat is the one signal that survives when the counts are still under their
display floors, so it carries the "the project is alive" claim on its own. The pulse makes
that read at a glance instead of asking the reader to parse a date. motion-safe so
reduced-motion visitors just get the static dot. */}
{live && (
<span className="relative flex h-2 w-2 shrink-0" aria-hidden>
<span className="absolute inline-flex h-full w-full rounded-full bg-primary-500/70 motion-safe:animate-ping" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary-500" />
</span>
)}
{value}
</div>
<div className="text-xs text-ink-600 truncate">{label}</div>
@@ -55,7 +74,7 @@ function daysAgoLabel(date: Date, t: ReturnType<typeof useT>) {
return t('about.repo.days_ago', '{count} days ago', {count: days})
}
export function RepoActivity() {
export function RepoActivity({className}: {className?: string}) {
const t = useT()
const {data} = useAPIGetter('repo-stats', {})
@@ -64,66 +83,80 @@ export function RepoActivity() {
// The Date survives the wire as a Date via the schema's Zod serialization, but a cached response
// rehydrated from a string would not — normalise rather than trust it.
const lastCommit = data.lastCommitTime ? new Date(data.lastCommitTime) : null
const hasAny =
data.contributors !== null ||
data.stars !== null ||
(lastCommit && !isNaN(lastCommit.getTime()))
if (!hasAny) return null
const hasValidCommit = !!lastCommit && !isNaN(lastCommit.getTime())
// A contributor or star count only supports the "community owned" claim once it is large enough to
// read as momentum; below these floors the honest move is to omit the number rather than sit a small
// one next to a sentence about being community-owned. Recency has no floor — "changed this week" is
// proof of life whatever the other two say — so in practice this block is usually just that one stat,
// and the layout below treats one metric as its normal case, not a degenerate one.
const CONTRIBUTORS_FLOOR = 10
const STARS_FLOOR = 100
const metrics: {icon: IconType; value: ReactNode; label: string; live?: boolean}[] = []
if (data.contributors !== null && data.contributors >= CONTRIBUTORS_FLOOR)
metrics.push({
icon: UserGroupIcon,
value: data.contributors,
label: t('about.repo.contributors', 'Contributors'),
})
if (data.stars !== null && data.stars >= STARS_FLOOR)
metrics.push({
icon: StarIcon,
value: data.stars,
label: t('about.repo.stars', 'GitHub stars'),
})
if (hasValidCommit)
metrics.push({
icon: CodeBracketIcon,
value: daysAgoLabel(lastCommit as Date, t),
label: t('about.repo.last_commit', 'Last change'),
live: true,
})
// Every count fell below its display floor and there is no commit date — nothing here earns a heading.
if (metrics.length === 0) return null
// {
// /* Section and label live in here rather than in the page, so that a failed GitHub call takes
// the whole section with it instead of leaving a heading over nothing. */
// }
return (
<Section>
{/* Section and label live in here rather than in the page, so that a failed GitHub call takes
the whole section with it instead of leaving a heading over nothing. */}
<SectionLabel>{t('about.repo.label', 'Built in the open')}</SectionLabel>
<Reveal className={clsx(surface, 'p-6 sm:p-8')}>
<p className="font-heading text-ink-900 text-xl sm:text-2xl leading-snug tracking-tight mb-3 max-w-2xl text-balance">
{t(
'about.repo.claim',
'Community owned. No investors, no acquisition, nothing to sell you.',
)}
</p>
<p className="text-sm text-ink-600 leading-relaxed mb-7 max-w-2xl">
{t(
'about.repo.intro',
'The whole thing is built in public — every line of code, every change, open to read and to challenge:',
)}
</p>
// <Section>
// <SectionLabel>{t('about.repo.label', 'Built in the open')}</SectionLabel>
<Reveal className={clsx(surface, 'p-6 sm:p-8', className)}>
<p className="font-heading text-ink-900 text-xl sm:text-2xl leading-snug tracking-tight mb-3 max-w-2xl text-balance">
{t(
'about.repo.claim',
'Community owned. No investors, no acquisition, nothing to sell you.',
)}
</p>
<p className="text-sm text-ink-600 leading-relaxed max-w-2xl">
{t(
'about.repo.intro',
'The whole thing is built in public — every line of code, every change, open to read and to challenge:',
)}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
{data.contributors !== null && (
<Metric
icon={UserGroupIcon}
value={data.contributors}
label={t('about.repo.contributors', 'Contributors')}
/>
)}
{data.stars !== null && (
<Metric
icon={StarIcon}
value={data.stars}
label={t('about.repo.stars', 'GitHub stars')}
/>
)}
{lastCommit && !isNaN(lastCommit.getTime()) && (
<Metric
icon={CodeBracketIcon}
value={daysAgoLabel(lastCommit, t)}
label={t('about.repo.last_commit', 'Last change')}
/>
)}
{/* A footer bar, not a grid: the metric count is usually one (the counts sit below their display
floors), and one cell in a three-column grid reads as broken. Here the stats and the source
link share a rule-topped row that stays balanced whether one metric shows or three — the stats
settle to the left, the link to the right, and both wrap cleanly on a phone. */}
<div className="mt-7 border-t border-canvas-200 pt-7 flex flex-wrap items-center justify-between gap-x-8 gap-y-5">
<div className="flex flex-wrap items-center gap-x-8 gap-y-5">
{metrics.map((m) => (
<Metric key={m.label} icon={m.icon} value={m.value} label={m.label} live={m.live} />
))}
</div>
<p className="text-sm leading-relaxed mt-7">
<a
href={githubRepo}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary-700 hover:underline"
>
{t('about.repo.link', 'Read the source →')}
</a>
</p>
</Reveal>
</Section>
<a
href={githubRepo}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-xl border border-canvas-200 bg-canvas-100 px-4 py-2 text-sm font-medium text-primary-700 transition-colors hover:bg-canvas-200/60"
>
{t('about.repo.link', 'Read the source →')}
</a>
</div>
</Reveal>
// </Section>
)
}

View File

@@ -39,7 +39,13 @@ const SHOTS = {
}
// Matches Tailwind's `sm` (640px): below it, the card's own layout has already gone narrow.
const NARROW_MEDIA = '(max-width: 420px)'
// const NARROW_MEDIA = '(max-width: 420px)'
// From lg up (see the grid below), the block is two columns and the about main column is only ~760px
// wide, so a landscape shot would be squeezed into ~300px and rendered near-illegible. There the
// reflowed *portrait* capture is the right one: it fills the column's height beside the copy and stays
// at a legible scale. Keep this breakpoint in sync with the grid's `lg:`.
// const WIDE_COLUMN_MEDIA = '(min-width: 1024px)'
/**
* A <picture> rather than next/image, so the browser picks a width itself and downloads only that
@@ -52,16 +58,23 @@ const NARROW_MEDIA = '(max-width: 420px)'
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. */}
{/* width/height on every <source> too, not just the <img>: the shots have different aspect
ratios, and without them the box is reserved at the wide ratio and jumps on load. The first
matching source wins, so the two portrait cases are listed before the wide fallback. */}
<source
media={NARROW_MEDIA}
media={'(min-width: 0px)'}
srcSet={`/images/vote-tally-${theme}-narrow.webp`}
width={SHOTS.narrow.width}
height={SHOTS.narrow.height}
/>
{/*<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`}
src={`/images/vote-tally-${theme}-narrow.webp`}
alt={alt}
width={SHOTS.wide.width}
height={SHOTS.wide.height}
@@ -82,47 +95,56 @@ export function VoteEvidence() {
)
return (
<figure className={clsx(surface, 'p-6 sm:p-8')}>
{/* 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
<figure className={clsx(surface, 'p-6 sm:p-8 lg:p-10')}>
{/* Two columns from lg up: the claim and its link sit left, the ballot that proves them fills the
right — the width the capped screenshot used to leave empty. This was the "Democratic" feature
card, moved down here verbatim; asserting it one screen above its own evidence read as a
duplicate, so claim and proof are now one thought. The translation keys are unchanged from the
card, so the existing fr/de strings apply as they are. */}
<p className="font-heading text-ink-900 text-xl sm:text-2xl leading-snug tracking-tight mb-3 max-w-2xl text-balance">
{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>
<div className="grid grid-cols-1 items-center gap-8 sm:grid-cols-[1fr_auto] lg:gap-12">
<div className="min-w-0">
<p className="font-heading text-ink-900 text-xl sm:text-2xl leading-snug tracking-tight mb-3 max-w-2xl text-balance">
{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-7 max-w-2xl">
{t(
'about.vote.intro',
'Members propose changes, everyone votes, and the result is binding. This one added a step to signing up:',
)}
</p>
<p className="text-sm text-ink-600 leading-relaxed max-w-md">
{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" />
<figcaption className="text-sm text-ink-600 leading-relaxed mt-5">
{t(
'about.vote.caption',
'Every proposal and tally is public — including the ones that lost. ',
)}
<Link href="/vote" className="font-medium text-primary-700 hover:underline">
{t('about.vote.link', 'See all proposals →')}
</Link>
</figcaption>
</div>
{/* No border here: the captured card brings its own, and adding a second draws a double rule
along the same edge. Below lg it is the landscape shot, capped and left-aligned so its
baked-in title stays under this block's heading (the shot is 868 CSS px, and filling the
card scaled it past 1:1, blowing that title larger than the heading). From lg up the column
is only ~300px, so <picture> serves the portrait capture instead; a fixed width holds it at
a legible scale and a soft shadow lifts it off the surface. */}
<div className="max-w-xl mx-auto overflow-hidden rounded-xl w-[300px] lg:w-[264px] lg:max-w-none lg:shadow-[0_24px_60px_-28px_rgb(44_36_22/0.5)]">
<Shot theme="light" alt={alt} className="dark:hidden" />
<Shot theme="dark" alt={alt} className="hidden dark:block" />
</div>
</div>
<figcaption className="text-sm text-ink-600 leading-relaxed mt-5">
{t(
'about.vote.caption',
'Every proposal and tally is public — including the ones that lost. ',
)}
<Link href="/vote" className="font-medium text-primary-700 hover:underline">
{t('about.vote.link', 'See all proposals →')}
</Link>
</figcaption>
</figure>
)
}

View File

@@ -1,33 +1,35 @@
import {
BellIcon,
BookmarkIcon,
ChatBubbleLeftRightIcon,
CodeBracketIcon,
EnvelopeIcon,
FlagIcon,
GiftIcon,
GlobeAltIcon,
HeartIcon,
LightBulbIcon,
MagnifyingGlassIcon,
MegaphoneIcon,
ShareIcon,
SparklesIcon,
} from '@heroicons/react/24/outline'
import {GlobeAltIcon} from '@heroicons/react/24/solid'
import clsx from 'clsx'
import {discordLink, formLink, githubRepo} from 'common/constants'
import {DEPLOYED_WEB_URL} from 'common/envs/constants'
import {ComponentType, ReactNode, SVGProps} from 'react'
import {ComponentType, ReactNode, SVGProps, useEffect, useState} from 'react'
import {StatBand} from 'web/components/about/platform-stats'
import {RepoActivity} from 'web/components/about/repo-activity'
import {AlertDemo} from 'web/components/about/search-alert-demo'
import {SectionLabel} from 'web/components/about/section'
import {VoteEvidence} from 'web/components/about/vote-evidence'
import {CopyLinkOrShareButton, ShareProfileOnXButton} from 'web/components/buttons/copy-link-button'
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 {Reveal} from 'web/components/widgets/reveal'
import {eyebrow, Section, surface, surfaceHover} from 'web/components/widgets/surface'
import {useIsMobile} from 'web/hooks/use-is-mobile'
import {useT} from 'web/lib/locale'
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -86,26 +88,26 @@ function FeatureCard({icon, title, text}: FeatureCardProps) {
// ─── Full-width Feature Card ──────────────────────────────────────────────────
function FeatureCardWide({icon, title, text}: FeatureCardProps) {
return (
<div
className={clsx(
surface,
surfaceHover,
'col-span-1 md:col-span-2 p-6 sm:p-7',
'flex flex-col sm:flex-row items-start sm:items-center gap-5 sm:gap-6',
)}
>
<IconChip icon={icon} />
<div className="min-w-0">
<h3 className="font-bold text-ink-900 mb-2">{title}</h3>
{/* Capped: the card is full-width, so without this the line runs to ~800px at desktop, well
past a readable measure. The wider page container buys layout room, not longer lines. */}
<p className="text-sm text-ink-600 leading-relaxed max-w-3xl">{text}</p>
</div>
</div>
)
}
// function FeatureCardWide({icon, title, text}: FeatureCardProps) {
// return (
// <div
// className={clsx(
// surface,
// surfaceHover,
// 'col-span-1 md:col-span-2 p-6 sm:p-7',
// 'flex flex-col sm:flex-row items-start sm:items-center gap-5 sm:gap-6',
// )}
// >
// <IconChip icon={icon} />
// <div className="min-w-0">
// <h3 className="font-bold text-ink-900 mb-2">{title}</h3>
// {/* Capped: the card is full-width, so without this the line runs to ~800px at desktop, well
// past a readable measure. The wider page container buys layout room, not longer lines. */}
// <p className="text-sm text-ink-600 leading-relaxed max-w-3xl">{text}</p>
// </div>
// </div>
// )
// }
// ─── Spotlight: "Get Notified About Searches" ─────────────────────────────────
@@ -121,7 +123,72 @@ function FeatureCardWide({icon, title, text}: FeatureCardProps) {
* from floating in dead space at wide viewports — at 1900px the old fixed-width column left roughly half
* the screen empty beside it.
*/
function NotifySpotlight({title, text, note}: {title: string; text: string; note: string}) {
/**
* One rung of the three-step flow in `NotifySpotlight`. The steps are the fix for the block's dead
* space *and* the honest caption for the clip: the video loops through search → save → email, but a
* glancing reader only ever catches one frame of it, so on its own the phone under-sells the "keyword
* search" half of the promise. Spelling the arc out beside the device means the two capabilities
* (find the exact person; be told when they arrive) both land whether or not the loop is watched.
*/
function FlowStep({
icon: Icon,
title,
text,
last,
}: {
icon: IconType
title: string
text: string
last?: boolean
}) {
return (
<li className="relative flex gap-4 pb-6 last:pb-0">
{/* The connector runs from just under this rung's marker to the next one, so it stops at the
last step rather than trailing into empty space. */}
{!last && (
<span aria-hidden className="absolute left-5 top-11 -bottom-0 w-px bg-canvas-200" />
)}
<div className="relative z-10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-primary-100 ring-1 ring-primary-200">
<Icon className="h-5 w-5 text-primary-600" strokeWidth={1.8} />
</div>
<div className="min-w-0 pt-1">
<div className="font-semibold text-ink-900 leading-snug">{title}</div>
<div className="text-sm text-ink-600 leading-relaxed mt-1">{text}</div>
</div>
</li>
)
}
function NotifySpotlight({title, text}: {title: string; text: string}) {
const t = useT()
const steps = [
{
icon: MagnifyingGlassIcon,
title: t('about.block.notify.step1.title', 'Search by keyword'),
text: t(
'about.block.notify.step1.text',
'Filter the whole community down to the exact person — values, interests, location.',
),
},
{
icon: BookmarkIcon,
title: t('about.block.notify.step2.title', 'Save the search'),
text: t(
'about.block.notify.step2.text',
'Nobody matches yet? Save it in one tap instead of checking back.',
),
},
{
icon: EnvelopeIcon,
title: t('about.block.notify.step3.title', 'Get the email'),
text: t(
'about.block.notify.step3.text',
'We email you the day someone who fits actually joins.',
),
},
]
return (
<div className={clsx(surface, 'relative overflow-hidden p-6 sm:p-10')}>
<div
@@ -129,13 +196,26 @@ function NotifySpotlight({title, text, note}: {title: string; text: string; note
className="pointer-events-none absolute -right-24 top-1/2 -translate-y-1/2 w-[520px] h-[520px] rounded-full bg-primary-500/[0.07] blur-3xl"
/>
<div className="relative grid grid-cols-1 md:grid-cols-[1fr_auto] gap-10 md:gap-14 items-center">
{/* The three steps are what close the gap: short copy centred against a ~580px device is what
left the panel two-thirds empty. They give the column real height, so the row is balanced
by content rather than padded by air. */}
<div className="min-w-0">
<IconChip icon={BellIcon} large />
<h3 className="font-heading font-bold text-ink-900 text-[clamp(26px,3vw,38px)] leading-[1.15] tracking-tight mt-6 mb-4 text-balance">
{title}
</h3>
<p className="text-base sm:text-lg text-ink-600 leading-relaxed max-w-lg">{text}</p>
<p className="text-sm text-ink-600 leading-relaxed max-w-lg mt-4">{note}</p>
<ol className="mt-8 max-w-md">
{steps.map((s, i) => (
<FlowStep
key={s.title}
icon={s.icon}
title={s.title}
text={s.text}
last={i === steps.length - 1}
/>
))}
</ol>
</div>
{/* The device is 2.16x as tall as it is wide, so at any width that keeps its UI legible it is
far taller than the text beside it — centring it just produced ~200px of dead panel above
@@ -225,6 +305,60 @@ function HelpCard({icon, title, text, buttonLabel, buttonUrl, buttonPrimary, id}
* statement above is tinted-light rather than dark. Two dark full-width panels on one page would read as
* a repeating band and neither would be the ending.
*/
/**
* The share control on the closing strip.
*
* Deliberately mobile-only. On a phone the platform's own share sheet is the right surface — it lets the
* reader pick WhatsApp, Messages, whatever they actually talk to friends and family in, which is exactly
* who this block asks them to tell. On desktop there is no good equivalent (a "copy link" pill was the
* old stand-in and it was doing very little), so we show nothing rather than a weaker button.
*
* Two gates, both required: `useIsMobile()` (viewport under the sm breakpoint) AND a live check that the
* Web Share API exists. Width alone would render a dead button on the odd narrow desktop window; the API
* check alone would surface it on desktop Safari/Edge, which do implement `navigator.share`. `canShare`
* starts false and is only set in an effect, so SSR and the first client paint render nothing — the safe
* desktop state — and the button appears on phones once mounted.
*/
function MobileShareButton() {
const t = useT()
const isMobile = useIsMobile()
const [canShare, setCanShare] = useState(false)
useEffect(() => {
setCanShare(typeof navigator !== 'undefined' && typeof navigator.share === 'function')
}, [])
if (!isMobile || !canShare) return null
const onClick = async () => {
try {
await navigator.share({
title: t('about.share.title', 'Compass'),
text: t('about.share.text', 'Thoughtful 1-on-1 connections, built in the open.'),
url: DEPLOYED_WEB_URL,
})
} catch {
// The user dismissing the share sheet rejects the promise; that is not an error worth surfacing.
}
}
return (
<button
type="button"
onClick={onClick}
className={clsx(
'inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold border',
'transition-all duration-200 ease-out',
'bg-cta text-white border-cta hover:bg-cta-hover',
'shadow-[0_6px_20px_-6px_rgba(193,127,62,0.6)]',
)}
>
<ShareIcon className="w-[1.05rem] h-[1.05rem]" strokeWidth={2} aria-hidden="true" />
{t('about.share.button', 'Share')}
</button>
)
}
function ShareStrip({title, text}: {title: string; text: string}) {
const t = useT()
return (
@@ -248,29 +382,9 @@ function ShareStrip({title, text}: {title: string; text: string}) {
and the icon; the sentence itself reads better in plain warm white. */}
<p className="text-white/70 text-base leading-relaxed">{text}</p>
</div>
<Row className="flex gap-2 flex-wrap">
{/*// */}
{/*// ${*/}
{/*// primary*/}
{/*// ? 'bg-primary-500 text-white border-primary-500 hover:bg-primary-600'*/}
{/*// : 'bg-white/[0.06] text-canvas-200 border-white/10 hover:bg-white/[0.12] hover:text-canvas-50'*/}
{/*// }*/}
<ShareProfileOnXButton
className={clsx(
'px-5 py-2.5 rounded-xl text-sm font-semibold border transition-all duration-200 ease-out',
'bg-white/[0.06] !text-white/70 border-white/10 hover:bg-white/[0.12] hover:text-white',
)}
/>
<CopyLinkOrShareButton
url={DEPLOYED_WEB_URL}
children={t('about.copy_link', ' Copy Link')}
className={clsx(
'px-5 py-2.5 rounded-xl text-sm font-semibold border transition-all duration-200 ease-out',
'bg-cta text-white hover:text-white border-cta hover:bg-cta-hover',
'shadow-[0_6px_20px_-6px_rgba(193,127,62,0.6)]',
)}
/>
</Row>
{/* Renders only on mobile; on desktop MobileShareButton returns null and the strip is copy-only
by design. */}
<MobileShareButton />
</div>
</div>
)
@@ -285,14 +399,14 @@ export default function About() {
// the middle of these three; it is now the spotlight block, so what is left is the pair that sets it
// up (you can search for anything) and the one that follows from it (what we match on).
const searchFeatures: FeatureCardProps[] = [
{
icon: MagnifyingGlassIcon,
title: t('about.block.keyword.title', 'Keyword Search the Database'),
text: t(
'about.block.keyword.text',
'"Meditation", "Hiking", "Neuroscience", "Nietzsche". Access any profile and get niche.',
),
},
// {
// icon: MagnifyingGlassIcon,
// title: t('about.block.keyword.title', 'Keyword Search the Database'),
// text: t(
// 'about.block.keyword.text',
// '"Meditation", "Hiking", "Neuroscience", "Nietzsche". Access any profile and get niche.',
// ),
// },
{
icon: SparklesIcon,
title: t('about.block.personality.title', 'Personality-Centered'),
@@ -303,6 +417,14 @@ 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: GlobeAltIcon,
title: t('about.block.vision.title', 'Digital Public Good'),
text: t(
'about.block.vision.text',
'Built by the people who use it, for the benefit of everyone.',
),
},
]
// The "Democratic" card used to sit here. Its claim now opens the "How a decision gets made"
@@ -411,10 +533,6 @@ export default function About() {
'about.block.notify.text',
"No need to constantly check the app! We'll contact you when new users fit your searches.",
)}
note={t(
'about.alert.caption',
'The email is the real one, sent to people with saved searches. Daily at most, and only when somebody new actually matches.',
)}
/>
</Reveal>
@@ -441,44 +559,34 @@ export default function About() {
</Reveal>
{/* Standalone rather than a grid cell: it is the only card in this section now that "One
Mission" has been promoted, and a one-item grid is just an indirection. */}
<Reveal className="mt-4 sm:mt-5">
<FeatureCardWide
icon={GlobeAltIcon}
title={t('about.block.vision.title', 'Vision')}
text={t(
'about.block.vision.text',
'Compass is to human connection what Linux, Wikipedia, and Firefox are to software and knowledge: a public good built by the people who use it, for the benefit of everyone.',
)}
/>
</Reveal>
{/*<Reveal className="mt-4 sm:mt-5">*/}
{/* <FeatureCardWide*/}
{/* icon={GlobeAltIcon}*/}
{/* title={t('about.block.vision.title', 'Vision')}*/}
{/* text={t(*/}
{/* 'about.block.vision.text',*/}
{/* 'Compass is to human connection what Linux, Wikipedia, and Firefox are to software and knowledge: a public good built by the people who use it, for the benefit of everyone.',*/}
{/* )}*/}
{/* />*/}
{/*</Reveal>*/}
</Section>
{/* ── How a decision gets made ── */}
<Section>
<SectionLabel>{t('about.vote.label', 'How a decision gets made')}</SectionLabel>
<Reveal>
<VoteEvidence />
</Reveal>
<Reveal>
<RepoActivity className="mt-4 sm:mt-5" />
</Reveal>
</Section>
{/* Owns its own heading, because it renders nothing when GitHub is unreachable and a label with
nothing under it is worse than no section. */}
<RepoActivity />
{/* ── Help ── */}
<Section>
<SectionLabel>{t('about.help.label', 'Help Compass grow')}</SectionLabel>
{/* 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. The country spread that used to sit beside it now lives on /stats: it is a
distribution readout for someone who came to read numbers, and this page already makes its
one claim about reach in the stat band up top. */}
<div className="mb-5">
<MemberGrowth />
</div>
{/* ── Share strip ── */}
<Reveal>
<ShareStrip
@@ -490,6 +598,10 @@ export default function About() {
/>
</Reveal>
<div className="mt-5">
<MemberGrowth />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-5 mt-5">
{helpCards.map((card, i) => (
<Reveal key={card.id} delay={(i % 2) * 70}>