Add alert loop video generation and platform stats enhancements

- Created `capture-alert.mjs` to generate alert loop video for the about page claim "Get Notified About Searches."
- Added `CountrySpread` widget for a ranked bar display of member distribution by country.
- Implemented `StatBand` component for live platform statistics on the about page.
- Introduced template rendering script (`render-search-alert.tsx`) for generating search alert email visuals.
- Updated documentation and improved workflows for media creation, video rendering, and data integration.
This commit is contained in:
MartinBraquet
2026-07-23 00:28:45 +02:00
parent accdf56a79
commit ba3af2e5d7
65 changed files with 3690 additions and 757 deletions

View File

@@ -80,6 +80,7 @@ import {likeProfile} from './like-profile'
import {llmExtractProfileEndpoint} from './llm-extract-profile'
import {markAllNotifsRead} from './mark-all-notifications-read'
import {removePinnedPhoto} from './remove-pinned-photo'
import {repoStats} from './repo-stats'
import {report} from './report'
import {rsvpEvent} from './rsvp-event'
import {searchLocationEndpoint} from './search-location'
@@ -658,6 +659,7 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
'update-event': updateEvent,
health: health,
stats: stats,
'repo-stats': repoStats,
'unsubscribe/:token': unsubscribe,
me: getMe,
'get-user-and-profile': getUserAndProfileHandler,

View File

@@ -0,0 +1,81 @@
import {githubRepoSlug} from 'common/constants'
import {RepoStats} from 'common/stats'
import {HOUR_MS} from 'common/util/time'
import {log} from 'shared/monitoring/log'
import {APIHandler} from './helpers/endpoint'
// Cached far longer than `stats`: this is an outbound call to a third party, the numbers move slowly,
// and unauthenticated GitHub allows only 60 requests/hour per IP. One call per 6h leaves that budget
// almost untouched even with several API instances running.
const CACHE_DURATION_MS = 6 * HOUR_MS
// Counting contributors by list length rather than paginating the whole set. The repo is well under
// this, and the alternative (parsing the Link header for the last page) buys accuracy we do not need.
const CONTRIBUTOR_PAGE_SIZE = 100
let cachedData: RepoStats | null = null
let cacheTimestamp = 0
const EMPTY: RepoStats = {
stars: null,
forks: null,
contributors: null,
openIssues: null,
lastCommitTime: null,
}
async function github(path: string) {
const res = await fetch(`https://api.github.com/repos/${githubRepoSlug}${path}`, {
headers: {
accept: 'application/vnd.github+json',
// GitHub rejects requests without one.
'user-agent': 'compass-api',
},
})
if (!res.ok) throw new Error(`GitHub ${res.status} ${res.statusText} for ${path}`)
return res.json()
}
export const repoStats: APIHandler<'repo-stats'> = async () => {
const now = Date.now()
if (cachedData && now - cacheTimestamp < CACHE_DURATION_MS) return cachedData
const [repo, contributors, commits] = await Promise.allSettled([
github(''),
github(`/contributors?per_page=${CONTRIBUTOR_PAGE_SIZE}&anon=1`),
github('/commits?per_page=1'),
])
// Settled individually so one failing call does not blank the other two — each field independently
// falls back to null, and the page just omits whatever is missing.
const result: RepoStats = {
stars: repo.status === 'fulfilled' ? (repo.value.stargazers_count ?? null) : null,
forks: repo.status === 'fulfilled' ? (repo.value.forks_count ?? null) : null,
openIssues: repo.status === 'fulfilled' ? (repo.value.open_issues_count ?? null) : null,
contributors:
contributors.status === 'fulfilled' && Array.isArray(contributors.value)
? contributors.value.length
: null,
lastCommitTime:
commits.status === 'fulfilled' && commits.value?.[0]?.commit?.committer?.date
? new Date(commits.value[0].commit.committer.date)
: null,
}
const failures = [repo, contributors, commits].filter((r) => r.status === 'rejected')
if (failures.length) {
log.error('repo-stats: some GitHub calls failed', {
reasons: failures.map((f) => String((f as PromiseRejectedResult).reason)),
})
}
// Never cache a wholly empty result — that would pin the page to "nothing" for six hours because
// GitHub happened to be down for one request.
const gotSomething = Object.values(result).some((v) => v !== null)
if (!gotSomething) return EMPTY
cachedData = result
cacheTimestamp = now
return result
}

View File

@@ -1,4 +1,5 @@
import {getMessagesCount} from 'api/get-messages-count'
import {CountryCount} from 'common/stats'
import {HOUR_MS} from 'common/util/time'
import {createSupabaseDirectClient} from 'shared/supabase/init'
@@ -9,6 +10,10 @@ let cachedData: any = null
let cacheTimestamp: number = 0
const CACHE_DURATION_MS = HOUR_MS
// How many countries the breakdown returns. The about page shows a short ranked list, so a long tail
// would be payload nobody renders; `countryCount` still reports the full spread.
const TOP_COUNTRIES = 8
export const stats: APIHandler<'stats'> = async (_, _auth) => {
const now = Date.now()
@@ -21,16 +26,36 @@ export const stats: APIHandler<'stats'> = async (_, _auth) => {
const pg = createSupabaseDirectClient()
const [userCount, profileCount, eventsCount, messagesCount, genderStats] = await Promise.all([
const [
userCount,
profileCount,
eventsCount,
messagesCount,
conversationCount,
genderStats,
countryStats,
] = await Promise.all([
pg.one(`SELECT COUNT(*)::int as count FROM users`),
pg.one(`SELECT COUNT(*)::int as count FROM profiles`),
pg.one(
`SELECT COUNT(*)::int as count FROM events WHERE event_start_time > now() and status = 'active'`,
),
getMessagesCount(),
// Counted off the messages table rather than the channels table: a channel that was created but
// never written in isn't a conversation anyone had.
pg.one(`select count(distinct channel_id)::int as count from private_user_messages`),
pg.manyOrNone(
`SELECT gender, COUNT(*)::int as count FROM profiles WHERE gender IS NOT NULL GROUP BY gender`,
),
// Grouped in full rather than limited in SQL: the about page shows a top-N bar list but also
// needs the total number of distinct countries, and both come off the same small result set.
pg.manyOrNone(
`select country, count(*)::int as count
from profiles
where country is not null and country <> ''
group by country
order by count desc, country asc`,
),
])
// Calculate gender ratios
@@ -49,13 +74,21 @@ export const stats: APIHandler<'stats'> = async (_, _auth) => {
genderPercentage[gender] = Math.round((count / totalWithGender) * 100)
})
const countries: CountryCount[] = (countryStats ?? []).map((row: any) => ({
country: row.country,
count: row.count,
}))
const result = {
users: userCount.count,
profiles: profileCount.count,
upcomingEvents: eventsCount.count,
messages: messagesCount.count,
conversations: conversationCount.count,
genderRatio: genderPercentage,
genderCounts: genderRatio,
countries: countries.slice(0, TOP_COUNTRIES),
countryCount: countries.length,
}
// Update cache

View File

@@ -0,0 +1,130 @@
/**
* Renders the search-alert email to a standalone HTML file, for the about-page clip (A4 in
* docs/marketing-visuals.md).
*
* The clip's middle beat is the email that a saved search produces. That email had to be the *real*
* template rather than a mock-up — the whole principle of these visuals is that anything which can be
* a capture of the real thing should be, so it goes stale visibly instead of silently.
*
* This lives in `backend/email` rather than in media-creator because this is where the `common/` and
* `shared/` path aliases already resolve. media-creator is a standalone npm project outside the yarn
* workspaces and cannot import the template at all.
*
* The props are the showcase personas — fictional by construction, same standing as everything else
* the capture scripts photograph, and never real member data. `NewSearchAlertsEmail.PreviewProps` is
* deliberately left alone: that serves developers opening the react-email preview, and it should keep
* showing the multi-search case rather than this one narrow scenario.
*
* Usage, from the repo root:
* npx tsx backend/email/emails/functions/render-search-alert.tsx /tmp/alert.html
*/
import {writeFileSync} from 'node:fs'
import {render} from '@react-email/components'
import {type User} from 'common/user'
import React from 'react'
import {NewSearchAlertsEmail} from '../new-search-alerts'
/**
* The showcase viewer, not `mockUser` from ./mock.
*
* `mockUser` is Martin's own name and a photo of him hosted on his personal site. That is fine for a
* developer eyeballing the preview, but this output is a marketing asset — it should carry the same
* fictional persona the rest of the clip is shot as (`SHOWCASE_VIEWER` in
* tests/e2e/utils/showcase-profiles.ts), so the email is addressed to the person whose session the
* surrounding frames were captured in.
*/
const RECIPIENT: User = {
createdTime: 0,
avatarUrl: '/images/showcase/alexmorel-1.jpg',
id: 'showcase-viewer',
username: 'alexmorel',
name: 'Alex Morel',
}
/**
* Mirrors the saved search the capture script builds in the UI: man, 2440, atheist, vegan, near
* Grenoble. If you change the filters in `capture-alert.mjs`, change them here too — the email is
* supposed to be describing the search the viewer just saved, and a mismatch would make the clip
* quietly incoherent.
*
* No wants-kids facet, deliberately: that filter only renders for viewers whose own profile seeks a
* relationship, and the showcase viewer does not. See the note in capture-alert.mjs.
*/
const SAVED_SEARCH = {
genders: ['male'],
religion: ['atheist'],
diet: ['vegan'],
// These are the real `FilterFields` keys, and they have to be exact. `formatFilters` special-cases
// them — it folds the two age bounds into one "Age: 24-40" entry. Anything it does not recognise
// falls through to a generic de-camel-casing path, which is how an earlier draft of this file
// rendered "Agemin: 24 • Agemax: 40" into the email.
pref_age_min: 24,
pref_age_max: 40,
orderBy: 'created_time',
}
/**
* One match, not several. The clip's premise is that the search had no results when it was saved, so
* the honest payoff is the single person who has since joined — a list of five would imply the search
* was already working and undercut the reason to save it.
*/
const MATCHES = [
{
name: 'Julien Sarr',
username: 'juliensarr',
avatarUrl: '/images/showcase/juliensarr-1.jpg',
},
]
async function main() {
const out = process.argv[2]
if (!out) throw new Error('usage: render-search-alert.tsx <output.html> [baseUrl]')
// The template's logo and the match avatar are root-relative paths, which resolve against the
// recipient's mail host in a real send. Opened as a local file they resolve against file:// and both
// render as broken-image icons. A <base> makes them resolve against the dev server instead, so the
// capture shows the email as it actually arrives.
const base = process.argv[3] ?? 'http://localhost:3000'
const html = await render(
<NewSearchAlertsEmail
toUser={RECIPIENT}
email="alex.morel@compass.showcase"
unsubscribeUrl="https://compassmeet.com/unsubscribe"
matches={[
{
id: 'showcase-saved-search',
description: {
filters: SAVED_SEARCH,
location: {location: {name: 'Grenoble'}, radius: 100},
},
matches: MATCHES,
},
]}
/>,
)
// A viewport meta as well as the base. Email HTML has none — mail clients supply their own chrome —
// so a mobile browser opening this file falls back to a ~980px layout viewport and scales the whole
// thing down, which renders the body text at about 5px. With this, the template's `maxWidth: 600px`
// container reflows to the actual 390px viewport and reads at roughly 1:1, which is the entire
// reason this clip is captured on a phone rather than a desktop.
// No theme handling here any more. The template now carries real `prefers-color-scheme` support
// (DARK_MODE_CSS in emails/utils.tsx), so a dark capture just needs Playwright's colorScheme and the
// email renders dark on its own — which is also what a dark-mode mail client shows a real recipient.
// The earlier version framed a light email in invented dark chrome; supporting dark mode properly
// made that unnecessary and is the better fix.
const theme = process.argv[4] ?? 'light'
const head = `<head><base href="${base}/"><meta name="viewport" content="width=device-width, initial-scale=1">`
writeFileSync(out, html.replace('<head>', head))
console.log(`wrote ${out} (${theme}, assets resolved against ${base})`)
}
main().catch((err) => {
console.error(err.message)
process.exit(1)
})

View File

@@ -14,7 +14,7 @@ import {FilterFields} from 'common/filters'
import {formatFilters, locationType} from 'common/filters-format'
import {MatchesType} from 'common/profiles/bookmarked_searches'
import {type User} from 'common/user'
import {container, content, Footer, main, paragraph} from 'email/utils'
import {container, content, DARK_MODE_CSS, Footer, main, paragraph} from 'email/utils'
import React from 'react'
import {createT} from 'shared/locale'
@@ -43,13 +43,15 @@ export const NewSearchAlertsEmail = ({
return (
<Html>
<Head />
<Head>
<style>{DARK_MODE_CSS}</style>
</Head>
<Preview>
{t('email.search_alerts.preview', 'New people share your values — reach out and connect')}
</Preview>
<Body style={main}>
<Body style={main} className="cm-body">
<Container style={container}>
<Section style={content}>
<Section style={content} className="cm-surface">
<Text style={paragraph}>{t('email.search_alerts.greeting', 'Hi {name},', {name})}</Text>
<Text style={paragraph}>
@@ -62,6 +64,7 @@ export const NewSearchAlertsEmail = ({
{(matches || []).map((match) => (
<Section key={match.id} style={{marginBottom: '20px'}}>
<div
className="cm-card"
style={{
backgroundColor: '#f7f4ef',
border: '1px solid #dee5b2',
@@ -94,6 +97,7 @@ export const NewSearchAlertsEmail = ({
{match.matches.map((p) => (
<Link
key={p.username}
className="cm-chip"
href={`https://${DOMAIN}/${p.username}`}
style={{
display: 'inline-block',
@@ -138,6 +142,7 @@ export const NewSearchAlertsEmail = ({
)}
<td style={{verticalAlign: 'middle'}}>
<span
className="cm-name"
style={{
display: 'block',
color: '#1e1a14',
@@ -149,6 +154,7 @@ export const NewSearchAlertsEmail = ({
{p.name}
</span>
<span
className="cm-accent"
style={{
display: 'block',
color: '#c17f3e',

View File

@@ -221,3 +221,30 @@ export const button = {
padding: '6px 10px',
margin: '10px 0',
}
/**
* Dark-mode overrides for email clients that honour `prefers-color-scheme` (Apple Mail, iOS Mail,
* and Outlook.com among others). Drop into a template's <Head/>.
*
* Emails are built from inline styles because that is what mail clients reliably support, and inline
* styles beat a stylesheet — hence `!important` throughout. The hooks are the `cm-*` class names on
* the handful of surfaces that actually carry colour; everything else inherits.
*
* Clients that ignore the media query keep the light email exactly as before, so this is additive.
*/
export const DARK_MODE_CSS = `
@media (prefers-color-scheme: dark) {
body, .cm-body { background-color: #16130F !important; }
.cm-surface { background-color: #16130F !important; }
.cm-surface p, .cm-surface div, .cm-surface td { color: #EDE6DA !important; }
.cm-card { background-color: #221D17 !important; border-color: #3A332B !important; }
.cm-card p { color: #F5EFE6 !important; }
.cm-chip { background-color: #2C251D !important; border-color: #5A4A33 !important; }
.cm-name { color: #F5EFE6 !important; }
.cm-muted { color: #A99C8C !important; }
/* The amber accent and the primary button already read correctly on a dark ground; leaving them
alone keeps the brand colour identical in both modes. */
.cm-accent { color: #D9975A !important; }
hr { border-color: #3A332B !important; }
}
`

View File

@@ -7,6 +7,7 @@
"dev": "email dev -p 3001",
"lint": "npx eslint . --max-warnings 0",
"lint-fix": "npx eslint . --fix",
"render:alert": "tsx emails/functions/render-search-alert.tsx",
"test": "jest --config jest.config.ts --passWithNoTests",
"typecheck": "npx tsc --noEmit"
},

View File

@@ -57,6 +57,24 @@
"about.growth.label": "Mitglieder, Tendenz steigend",
"about.growth.today": "Heute",
"about.help.label": "Helfen Sie Compass zu wachsen",
"about.mission.label": "Warum es uns gibt",
"about.final.label": "Sag es weiter",
"about.alert.caption": "Die E-Mail ist echt und geht an alle mit gespeicherten Suchen. Höchstens einmal täglich, und nur wenn wirklich jemand Neues passt.",
"about.stat.members": "Mitglieder",
"about.stat.countries": "Länder",
"about.stat.conversations": "Gespräche",
"about.stat.price": "Nutzungskosten",
"about.stat.price_value": "0 €",
"about.repo.label": "Offen entwickelt",
"about.repo.claim": "Der Community gehörend. Keine Investoren, kein Verkauf, nichts, was wir dir andrehen wollen.",
"about.repo.intro": "Alles entsteht öffentlich — jede Zeile Code, jede Änderung, offen zum Nachlesen und Hinterfragen:",
"about.repo.contributors": "Mitwirkende",
"about.repo.stars": "GitHub-Sterne",
"about.repo.last_commit": "Letzte Änderung",
"about.repo.today": "heute",
"about.repo.yesterday": "gestern",
"about.repo.days_ago": "vor {count} Tagen",
"about.repo.link": "Quellcode lesen →",
"add_photos.add_description": "Beschreibung hinzufügen",
"add_photos.profile_picture_hint": "Das hervorgehobene Bild ist Ihr Profilbild",
"answers.add.error_create": "Fehler beim Erstellen der Kompatibilitätsfrage. Erneut versuchen?",
@@ -142,6 +160,7 @@
"common.saved": "Gespeichert!",
"common.with": "mit",
"common.yes": "Ja",
"common.step_progress": "Schritt {current} von {total}",
"compatibility.empty.answered": "Sie haben noch keine Fragen beantwortet.",
"compatibility.empty.no_results": "Keine Ergebnisse für \"{keyword}\"",
"compatibility.empty.not_answered": "Alle Fragen wurden beantwortet!",
@@ -1193,6 +1212,7 @@
"profile.wants_kids_4": "Möchte Kinder",
"profile.work": "Arbeit",
"profile.wont_be_notified_unless_mutual": "Sie werden nicht benachrichtigt, es sei denn, das Interesse ist gegenseitig.",
"profile.big5_reset": "Zurücksetzen",
"profile_card.alt": "Profilkarte von {username}",
"profile_card.crafting": "Profilkarte wird erstellt...",
"profile_card.loading": "Ihre Karte wird geladen, es kann ein paar Sekunden dauern...",

View File

@@ -57,6 +57,24 @@
"about.growth.label": "membres, et ça continue",
"about.growth.today": "Aujourd'hui",
"about.help.label": "Aide Compass à grandir",
"about.mission.label": "Pourquoi nous existons",
"about.final.label": "Faites passer le mot",
"about.alert.caption": "L'e-mail est le vrai, celui envoyé aux personnes ayant des recherches enregistrées. Une fois par jour au maximum, et uniquement quand quelqu'un de nouveau correspond vraiment.",
"about.stat.members": "Membres",
"about.stat.countries": "Pays",
"about.stat.conversations": "Conversations",
"about.stat.price": "Coût d'utilisation",
"about.stat.price_value": "0 €",
"about.repo.label": "Développé au grand jour",
"about.repo.claim": "Propriété de la communauté. Aucun investisseur, aucun rachat, rien à vous vendre.",
"about.repo.intro": "Tout est construit en public — chaque ligne de code, chaque changement, ouvert à la lecture et à la critique :",
"about.repo.contributors": "Contributeurs",
"about.repo.stars": "Étoiles GitHub",
"about.repo.last_commit": "Dernière modification",
"about.repo.today": "aujourd'hui",
"about.repo.yesterday": "hier",
"about.repo.days_ago": "il y a {count} jours",
"about.repo.link": "Lire le code source →",
"add_photos.add_description": "Ajouter une description",
"add_photos.profile_picture_hint": "L'image mise en surbrillance est votre photo de profil",
"answers.add.error_create": "Erreur lors de la création de la question de compatibilité. Réessayez ?",
@@ -142,6 +160,7 @@
"common.saved": "Enregistré !",
"common.with": "avec",
"common.yes": "Oui",
"common.step_progress": "Étape {current} sur {total}",
"compatibility.empty.answered": "Vous n'avez répondu à aucune question pour le moment.",
"compatibility.empty.no_results": "Aucun résultat pour \"{keyword}\"",
"compatibility.empty.not_answered": "Toutes les questions ont été répondues !",
@@ -1192,6 +1211,7 @@
"profile.wants_kids_4": "Veut des enfants",
"profile.work": "Travail",
"profile.wont_be_notified_unless_mutual": "Il/elle ne sera pas notifié(e) à moins que l'intérêt soit mutuel.",
"profile.big5_reset": "Réinitialiser",
"profile_card.alt": "Carte de profil de {username}",
"profile_card.crafting": "Création de la carte de profil...",
"profile_card.loading": "Chargement de votre carte, cela peut prendre quelques secondes...",

View File

@@ -11,7 +11,7 @@ import {Notification} from 'common/notifications'
import {CompatibilityScore} from 'common/profiles/compatibility-score'
import {MAX_COMPATIBILITY_QUESTION_LENGTH, OPTION_TABLES} from 'common/profiles/constants'
import {Profile, ProfileRow, ProfileWithoutUser} from 'common/profiles/profile'
import {Stats} from 'common/stats' // mqp: very unscientific, just balancing our willingness to accept load
import {RepoStats, Stats} from 'common/stats' // mqp: very unscientific, just balancing our willingness to accept load
import {PrivateMessageChannel} from 'common/supabase/private-messages'
import {Row} from 'common/supabase/utils'
import {PrivateUser, User} from 'common/user'
@@ -156,6 +156,24 @@ export const API = (_apiTypeCheck = {
summary: 'Get platform statistics',
tag: 'General',
},
/**
* Get open-source repository activity
* Proxies a small slice of the GitHub API so the about page can evidence the "community owned"
* claim. Proxied rather than called from the browser because unauthenticated GitHub is limited to
* 60 requests/hour per IP, which a public page would exhaust.
*
* @returns Contributor / commit / star counts, or nulls when GitHub is unreachable
*/
'repo-stats': {
method: 'GET',
authed: false,
rateLimited: true,
props: z.object({}),
cache: 'public, max-age=3600',
returns: {} as RepoStats,
summary: 'Get open-source repository activity',
tag: 'General',
},
/**
* Get Supabase JWT token
* Returns a JWT token for authenticated clients to access Supabase directly

View File

@@ -1,8 +1,33 @@
/** One row of the country breakdown. `country` is the display name stored on `profiles.country`. */
export type CountryCount = {
country: string
count: number
}
export type Stats = {
users: number
profiles: number
upcomingEvents: number
messages: number
/** Message channels that carry at least one message — empty channels aren't conversations. */
conversations: number
genderRatio: Record<string, number>
genderCounts: Record<string, number>
/** Top countries by profile count, descending. Profiles with no country set are excluded. */
countries: CountryCount[]
/** Distinct countries represented, including any outside the `countries` top-N. */
countryCount: number
}
/**
* Open-source activity for the public repo. Every field is nullable: the about page evidences the
* "community owned" claim with these, and a number we could not actually fetch is worse than no
* number, so the UI renders nothing rather than a zero when GitHub is unreachable.
*/
export type RepoStats = {
stars: number | null
forks: number | null
contributors: number | null
openIssues: number | null
lastCommitTime: Date | null
}

View File

@@ -198,13 +198,11 @@ they are still same-origin off Vercel's CDN and another ~270 KB of binaries stay
Uploads use `Cache-Control: public, max-age=86400` rather than `immutable`, since filenames are
stable across re-renders and a long TTL would strand the build on an old clip.
Nothing about the hero is committed now, so **a deploy without `MEDIA_SOURCE_BASE_URL` set renders
neither the clip nor its poster.** The build warns rather than failing in that case, because a
contributor without R2 access should still be able to build.
> The root `.gitignore` blanket-ignores `*.jpg` / `*.mp4`. Those two paths are explicitly un-ignored,
> and only the two **posters** are un-ignored, because the app serves them as the LCP image. The
> videos are intentionally left ignored — R2 serves those. The showcase portraits are likewise
> The root `.gitignore` blanket-ignores `*.jpg` / `*.mp4` / `*.webp`, and **nothing here is un-ignored** —
> not the videos, not the posters, not the vote-tally stills. Every one of them reaches `public/` through
> `fetch-media.mjs` at build time, which is why that script failing has to fail the build. (An earlier
> version of this note claimed the two posters were committed; `git ls-files web/public/images` says
> otherwise.) The showcase portraits are likewise
> **not** un-ignored: they are dev-only seed data and `scripts/generate-showcase-portraits.ts`
> regenerates them, so they stay out of the repo in line with the existing no-binaries policy. A fresh
> clone seeds profiles with the default avatar until that script is run.
@@ -405,6 +403,201 @@ nothing beneath it is worse than no section.
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.
### A4 — Search-alert demo video — **done** (pending R2 upload)
"Get Notified About Searches" is the page's most distinctive claim and the only one with no proof at all.
It is also the hardest to evidence with a still, because the whole point of it happens _later_. The clip:
1. `/people`, filtered down — Man, 2440, atheist, vegan, near Grenoble.
2. **Nobody matches.** Not a compromise — this is the premise. The app's own empty state says it:
"No profiles found. Feel free to click on Get Notified…". The clip follows the product's instruction.
3. Press **Get notified for selected filters**; the saved-search modal confirms it.
4. **Three days pass** — the interstitial, the one beat the product does not render.
5. The alert email, describing that saved search and the one person who has since joined.
6. His profile, then the composer.
**Captured in two passes, and it has to be.** The saved search must _match_ Julien for the email to mean
anything and _not_ match him for the "nobody fits yet" beat. Both cannot be true of one database at one
moment. So the first half is shot while he does not exist and the second after he is seeded:
```bash
SHOWCASE_SKIP=juliensarr SHOWCASE=1 ./scripts/seed.sh
npm run capture:alert -- --phase before # and capture:alert:dark
SHOWCASE=1 ./scripts/seed.sh
npm run capture:alert -- --phase after # merges both halves into manifest.json
```
Staging the empty state instead — the result card has a hide control — would have been one pass and a
fiction, and an invisible one to anyone watching. This costs a seed run and stays true.
`SHOWCASE_SKIP` (in `seed-showcase.ts`) **deletes** the named slugs before skipping them, rather than
merely not inserting. It has to: seeding is otherwise a no-op for anyone who already exists, which also
means edits to a persona — or portraits generated _after_ the first seed — never take effect. Delete and
re-run is how you pick them up. That bit us: Julien's first seed predated his portraits, so his row
pointed at `default-avatar.png` and the whole clip had a placeholder where his face should be.
**Which database.** These run against the **local** stack (`yarn dev:isolated` + `./scripts/seed.sh`),
not the remote dev DB. Deleting a persona is destructive, and the remote dev DB is shared.
**The "wants kids" facet was dropped.** It was in the original brief, but the Relationship group renders
only when the _signed-in viewer's own_ `pref_relation_styles` includes `'relationship'`, and the showcase
viewer seeks friendship and collaboration (`filters.tsx`, `youSeekingRelationship`). Same class of
constraint as H1's gender-filter note. Including it would have meant changing the viewer persona, which
H1 is also captured as; the remaining four facets already narrow to exactly one person.
Four things that bite when scripting the filter rail, none of which apply to H1:
- **Filters are nested and collapsed groups are not in the DOM.** `FilterGroup` renders
`{isOpen && children}`, so looking for "Religion" before opening "Values & Beliefs" finds nothing at
all. Only gender and location are top level.
- **Scope every query to `#headlessui-portal-root`.** `firstVisible()` is not enough here: the desktop
rail is not reliably `display: none` at this viewport, so Playwright calls it visible, clicks it, and
times out with the sheet's own overlay "intercepting pointer events".
- **Section headers read as label-plus-selection** — "Living anywhere", "Any gender". So the location
section is "Living", never "Location", and anchoring a regex at the end matches nothing.
- Groups _and_ sections are accordions, so each selection closes the previous one. Harmless — the
selections persist, and the folding reads as progress through a form.
**The email needed a viewport meta.** Email HTML has none, because mail clients supply their own chrome —
so a mobile browser falls back to a ~980px layout viewport and scales the whole thing down, rendering the
body text at about 5px. With it, the template's `maxWidth: 600px` container reflows to the real 390px and
reads at roughly 1:1, which is the entire reason this clip is on a phone.
**The email now supports dark mode for real.** It was light-only, so the dark clip cut to a full-bleed
white screen for four seconds. The first fix framed it in invented dark chrome; the better one was
`DARK_MODE_CSS` in `emails/utils.tsx` — a `prefers-color-scheme` block with `!important` overrides hung
off `cm-*` class hooks, since inline styles (which is what mail clients need) otherwise win. **This
changes real sent email** in clients that honour the query — Apple Mail, iOS Mail, Outlook.com. Clients
that ignore it keep the light version exactly as before, so it is additive. Only `new-search-alerts` uses
it so far; the other templates could adopt it the same way.
**The interstitial.** A dissolve alone said "something changed", not "days went by" — which left the email
looking like it arrived the instant the search was saved, precisely the wrong idea about a feature whose
point is that it works while you are not looking. So the saved-search screen recedes behind a scrim,
three days tick across it, and the email arrives out of that. Synthesised in Remotion and injected by
`calculateMetadata` rather than emitted by the capture script, so the manifest stays a pure record of what
was photographed.
**The scrim does not lift before the email arrives.** It used to, and the result was visible: the veil
faded out, the saved-search screen returned at full strength for a few frames, and only then did the email
begin dissolving in — so the clip appeared to bounce back to where it had just been. The scrim now holds
through the handover and the email fades up over exactly the state the interstitial ended in. The gap is
also a little shorter (54 frames), which brings the email forward.
Its caption is light ink in **both** themes: the scrim has to be dark either way, or the screenshot
behind it still reads as the live screen, and taking the ink from the theme put dark brown text on a dark
scrim in light mode.
This clip is **English-only**, unavoidably — its middle beat is a screenshot of an English email, and the
interstitial says "3 days later". The hero clip has no text for the opposite reason: one render, every
locale. The surrounding caption is translated normally.
**Proving the match.** The profile beat used to scroll straight past the fields the search asked for, so
the viewer had to take the match on trust. Two changes fixed that, and the first is nearly free:
- **Pacing.** The two beats that carry the proof — the header (age, gender, city) and the details row
where religion and diet land together — hold for 96 frames instead of the scroll's 3. They are the only
moments in the clip meant to be _read_ rather than watched. That is exactly what per-frame `hold` in
the manifest is for.
- **Spotlight.** The rest of the frame dims and each matching value gets a hand-drawn amber ellipse,
stroked on over about a third of a second and staggered so the eye is walked through them one at a
time. Geometry is measured off the live DOM, so a profile restyle moves the circles with it rather than
leaving them pointing at blank space.
Two things that had to be got right:
- **Measure the text, not the element.** `boundingBox()` returns the layout box, and in the details list
each value sits in a full-width row — circling that produced a flat oval spanning the whole card. A
`Range` over the text node gives the glyphs' own bounds.
- **Frame both values together.** Centring on religion alone left diet at y≈762, half under the fixed
bottom nav. The script re-centres on the midpoint of the two against the viewport minus that nav.
An earlier version captioned each circle with the criterion it satisfied ("✓ Vegan"). Dropped: the fields
are about 25px apart, so every caption landed on the next value and buried what it was pointing at — and
the criteria have just been read out in the email a beat earlier, so the labels were repetition as well as
clutter. The circles alone carry it.
**This is editorial annotation, not product UI**, and it is drawn to look like it. Compass has no
"matches your search" highlighting on profiles; chrome implying otherwise would claim a feature that does
not exist. A hand-drawn ring is unmistakably someone marking up a screen. (The tap indicator is different
in kind — it depicts a finger, not a feature.)
**Where it sits.** Inside "What makes us different", as a card in the column beside the three cards it
is evidence for — "Keyword Search the Database", "Get Notified About Searches", "Personality-Centered".
It first went in as a section of its own below the grid, which put the claim and its proof a full screen
apart; that is the one arrangement in which the clip does no work. Two columns on desktop (cards stacked
left, clip right, which the clip's phone aspect ratio suits); on mobile the cards simply stack above it.
**Files**: `media-creator/scripts/capture-alert.mjs`, `media-creator/src/scenes/SearchAlert.tsx`
(compositions `SearchAlertLight` / `SearchAlertDark`),
`backend/email/emails/functions/render-search-alert.tsx` (`yarn --cwd=backend/email render:alert`), and
the `web/components/about/search-alert-demo.tsx` embed. `TapIndicator` and `shotAt` are imported from
`SearchDemo` rather than duplicated.
```bash
npm run capture:alert[:dark] -- --phase before|after
npm run render:alert[:dark] # -> out/compass-search-alert-{light,dark}.mp4 (2.5 MB, 31s, 916 frames)
npm run still:alert[:dark] # -> out/compass-alert-poster-{light,dark}.png (frame 0)
```
**Still to do: `npm run upload:media`.** The four new assets are wired into `upload-media.sh` and into the
`ASSETS` list in `web/scripts/fetch-media.mjs`, and they exist locally — but until they are in R2, a
Vercel build will fail on them by design. Uploading is publishing, so it is Martin's call.
### A5 — Stat band + country spread — **done**
Two numbers-not-assets blocks, both off the existing `stats` endpoint, which already has a 1-hour
server-side cache.
- **Stat band** under the page header — members, messages, countries. The page currently opens with a
wall of identical cards; this gives it a first beat that is not one.
- **Country spread** on **`/stats`**, as the right column of the **Community** group
(`web/components/widgets/country-spread.tsx`,
in `widgets/` because it is no longer an about-page component). It evidences "worldwide", asserted in
the home copy and shown nowhere. It started beside `MemberGrowth` on the about page and moved: it is a
distribution readout for someone who came to read numbers, and the about page already makes its one
claim about reach in the stat band. It belongs _inside_ Community because it answers the same question
as the four numbers beside it — who the members are — split by place rather than counted; on `lg` the
cards drop to a 2×2 block on the left and it takes the right half, and below `lg` it stacks under them
(its bars need the horizontal room). `/stats` fetches the whole `stats` payload for the page and passes
`countries`/`countryCount` in as props, so there is no second request. The page gates the column on
`MIN_COUNTRIES` rather than letting the component return null into it: a hidden child still leaves its
grid track behind, so hiding it would have left half a row blank instead of giving the width back.
`stats` gained `countries` (top 8 by `profiles.country`) and `countryCount` rather than a new endpoint:
same query batch, same cache, one round trip. Both blocks render **nothing** when the data is missing,
per A3. Country bars are scaled against the _largest_ country rather than the total — against the total
the long tail collapses into slivers of identical apparent length and the ranking stops being readable.
Kept away from the vote tally — 12 voters next to a large member count is exactly the turnout tension A1
was placed on this page to avoid.
One number in the band is not a measurement: "$0 / Cost to join" is a policy, and it is the whole
argument of the "Completely Free" card. Everything beside it is queried.
Both live in `components/about/platform-stats.tsx`. Extracting `SectionLabel` / `Divider` into
`components/about/section.tsx` was part of this: several blocks on the page now return null, and a
heading or rule left behind by an absent block is worse than no section, so the label has to sit inside
the component that decides whether to render.
### A6 — Repo activity (the unblocked half of A2) — **done**
A2 assumes a human photo and stays yours. But "Community Owned / no VC" can be evidenced without anyone's
consent: contributor count, commits, stars, last commit. New `repo-stats` endpoint, long server-side cache,
rendered in **our own components** — not a screenshot of GitHub's contributor wall, which H2 already
rejected as stale on capture and not Compass pixels. Client-side calls to `api.github.com` are out: 60/hr
per IP unauthenticated is not viable on a public page, so `repo-stats` proxies it with a 6-hour
server-side cache.
Each field is independently nullable and independently omitted, and the section renders nothing at all
when every call fails — a zeroed contributor count would undercut the exact claim being made. A wholly
empty result is never cached either, or one GitHub outage would pin the page to "nothing" for six hours.
Currently reads 4 contributors, 38 stars, last change yesterday. **Worth a decision from Martin**: that
is honest but modest, and it is the same tension as A1's twelve voters. It reads as a real early
open-source project, which is what it is. Inflating it is not an option; dropping the block if it reads
as weak is.
## Cross-cutting constraints
- **Dark mode doubles the assets.** The site is fully tokenised (`bg-canvas-*`, `text-ink-*`); a light-mode
@@ -430,9 +623,21 @@ members this wants an aggregate endpoint returning daily totals.
| A1 | Vote-tally screenshot | — | Claude ✅ |
| A2 | Community photo | — | **Martin** |
| A3 | Growth chart | — | Claude ✅ |
| A4 | Search-alert demo video | W0c | Claude ✅ |
| A5 | Stat band + country spread | — | Claude ✅ |
| A6 | Repo activity | — | Claude ✅ |
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.
A4 is captured, rendered and mounted; the one step left is `npm run upload:media`, which is publishing
and so Martin's. Until that runs, a Vercel build fails on the four missing assets — deliberately.
The home page is finished. On the about page, A2 needs a photo only Martin can supply; A6 covers the half
of that claim that does not. H3 option 1 (real member photos, opt-in) stays available but is not blocking
anything.
**Why three at once**: the about page reads flat because ten of its twelve blocks are the same card
(`FeatureCard` ×6, `HelpCard` ×4, identical surface and icon tile). A4/A5/A6 are as much about breaking
that rhythm as about adding proof, so they are designed together — a clip, a number band, and a data
block, none of which is another beige card.
---

View File

@@ -16,3 +16,4 @@ npm-debug.log*
# Captured marketing stills (regenerate with the capture scripts).
public/search/
public/alert/

View File

@@ -5,11 +5,15 @@
"description": "Standalone Remotion studio for Compass media creation. Intentionally NOT part of the root yarn workspaces — install and run it on its own with npm.",
"license": "MIT",
"scripts": {
"capture:alert": "node scripts/capture-alert.mjs --theme light",
"capture:alert:dark": "node scripts/capture-alert.mjs --theme dark",
"capture:profile": "node scripts/capture-profile.mjs",
"capture:search": "node scripts/capture-search.mjs --theme light",
"capture:search:dark": "node scripts/capture-search.mjs --theme dark",
"capture:vote": "node scripts/capture-vote.mjs",
"render": "npm run render:post",
"render:alert": "remotion render SearchAlertLight out/compass-search-alert-light.mp4 --crf 30",
"render:alert:dark": "remotion render SearchAlertDark out/compass-search-alert-dark.mp4 --crf 30",
"render:carousel": "node scripts/render-carousel.mjs",
"render:post": "remotion render IntroPost out/compass-intro-post.mp4",
"render:search": "remotion render SearchDemoLight out/compass-search-demo-light.mp4 --crf 30",
@@ -17,6 +21,8 @@
"render:story": "remotion render IntroStory out/compass-intro-story.mp4",
"render:tour": "remotion render ProfileTourStory out/compass-profile-tour-story.mp4",
"render:tour:post": "remotion render ProfileTourPost out/compass-profile-tour-post.mp4",
"still:alert": "remotion still SearchAlertLight out/compass-alert-poster-light.png --frame=0",
"still:alert:dark": "remotion still SearchAlertDark out/compass-alert-poster-dark.png --frame=0",
"still:search": "remotion still SearchDemoLight out/compass-search-poster-light.png --frame=0",
"still:search:dark": "remotion still SearchDemoDark out/compass-search-poster-dark.png --frame=0",
"studio": "remotion studio",

View File

@@ -1,9 +1,9 @@
// Remotion CLI configuration. Applies to `remotion studio` and `remotion render`.
// Docs: https://www.remotion.dev/docs/config
import {Config} from '@remotion/cli/config';
import {Config} from '@remotion/cli/config'
Config.setVideoImageFormat('jpeg');
Config.setOverwriteOutput(true);
Config.setVideoImageFormat('jpeg')
Config.setOverwriteOutput(true)
// High quality for social platforms (Reels / Shorts / TikTok re-encode aggressively).
Config.setCodec('h264');
Config.setCrf(18);
Config.setCodec('h264')
Config.setCrf(18)

View File

@@ -0,0 +1,544 @@
/**
* Captures the saved-search alert loop for the about page (A4 in docs/marketing-visuals.md).
*
* The about page claims "Get Notified About Searches" and shows nothing. It is the hardest claim on
* that page to evidence with a still, because the whole point of it is something that happens *later*:
* you search, nobody fits, and days afterwards the platform tells you somebody does.
*
* The flow, in order:
* 1. /people, filtered to man / 24-40 / atheist / vegan, near Grenoble
* 2. no results — which is the honest reason to save a search, and the app's own empty state says so
* 3. "Get notified for selected filters", and the saved-search modal confirming it
* 4. the alert email that arrives (dissolved into — see SearchAlert.tsx on why there is no title card)
* 5. the match's profile
* 6. the composer
*
* Beats 1-3 and 5-6 are the live app. Beat 4 is the real email template rendered by
* `yarn --cwd=backend/email render:alert`, shown in invented mail-client chrome — the one seam in the
* clip, because the product has no mail surface of its own to photograph.
*
* Mobile for the same reason as the hero clip: at 390px the email's container (maxWidth 600px, no
* fixed width) reflows fluid and stays legible at roughly 1:1, where a desktop capture scaled into a
* phone column would be unreadable.
*
* Usage:
* npm run capture:alert # light theme
* npm run capture:alert:dark # dark theme
*
* Needs `yarn dev` running and the SHOWCASE=1 seed applied, including the `juliensarr` persona — he is
* the only profile that matches this search, and without him beat 5 has nobody to open.
*
* Borrows Playwright from the monorepo root by absolute path, deliberately — this package stays
* Remotion-only. Same trick as capture-search.mjs.
*/
import {execFileSync} from 'node:child_process'
import {mkdirSync, readFileSync, rmSync, writeFileSync} from 'node:fs'
import {tmpdir} from 'node:os'
import {dirname, join} from 'node:path'
import {fileURLToPath} from 'node:url'
import pw from '../../node_modules/@playwright/test/index.js'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = join(HERE, '../..')
const ALERT_DIR = join(HERE, '../public/alert')
const BASE = 'http://localhost:3000'
const VIEWER_EMAIL = 'viewer@compass.showcase'
const VIEWER_PASSWORD = 'showcase-viewer-pass'
/** The persona this search is built to find. See tests/e2e/utils/showcase-profiles.ts. */
const MATCH_SLUG = 'juliensarr'
const MATCH_NAME = 'Julien Sarr'
const VIEWPORT = {width: 390, height: 844}
const SCALE = 2
const args = process.argv.slice(2)
const argOf = (name, fallback) => {
const i = args.indexOf(`--${name}`)
return i === -1 ? fallback : args[i + 1]
}
const THEME = argOf('theme', 'light')
if (THEME !== 'light' && THEME !== 'dark') {
throw new Error(`--theme must be "light" or "dark", got "${THEME}"`)
}
const OUT_DIR = join(ALERT_DIR, THEME)
/**
* Which half of the clip to shoot.
*
* The two halves cannot be captured in one run, and not for a technical reason. The saved search has to
* *match* Julien for the alert email to mean anything, and *not* match him for the "nobody fits this
* yet" beat that motivates saving it at all. Both cannot be true of one database at one moment, so the
* first half is shot while he does not exist and the second after he is seeded:
*
* SHOWCASE_SKIP=juliensarr SHOWCASE=1 ./scripts/dev_db_seed.sh
* npm run capture:alert -- --phase before
* SHOWCASE=1 ./scripts/dev_db_seed.sh
* npm run capture:alert -- --phase after # merges both halves into manifest.json
*
* Staging the empty state instead — hiding him from the results with the card's own hide control —
* would have been one pass and a fiction, and an invisible one to anybody watching. This costs a seed
* run and stays true.
*/
const PHASE = argOf('phase', 'before')
if (!['before', 'after'].includes(PHASE)) {
throw new Error(`--phase must be "before" or "after", got "${PHASE}"`)
}
const PART_MANIFEST = (phase) => join(OUT_DIR, `frames-${phase}.json`)
// The city the location filter is set to. The viewer persona and the match both live here, so the
// beat cannot fail on wherever the radius slider happens to sit.
const CITY = argOf('city', 'Grenoble')
// ─── Pacing (video frames @ 30fps) ──────────────────────────────────────────
const HOLD = {
establish: 34,
tap: 20,
panel: 34,
empty: 50, // "no results" — needs long enough to register as a dead end, not a loading state
settle: 46,
email: 70, // the payoff of the first half; it is dense and people need to read it
scroll: 3,
// The two beats that prove the match. Long, because they are the only moments in the clip that are
// meant to be *read* rather than watched: the viewer is checking five facts against five criteria.
criteria: 96,
finale: 60,
}
const pad = (n) => String(n).padStart(2, '0')
/**
* First *visible* match for a locator.
*
* Both filter UIs are always in the DOM — desktop rail and mobile sheet — with one hidden by `lg:`
* classes. Playwright's `.first()` is DOM order, not visibility, so on a phone viewport it returns the
* hidden desktop control and then times out. Same helper, same reason, as capture-search.mjs.
*/
async function visibleMatches(locator) {
const out = []
const n = await locator.count()
for (let i = 0; i < n; i++) {
const el = locator.nth(i)
if (await el.isVisible().catch(() => false)) out.push(el)
}
return out
}
async function firstVisible(locator, label) {
const found = await visibleMatches(locator)
if (!found.length) {
throw new Error(`no visible element for ${label} (${await locator.count()} hidden match(es))`)
}
return found[0]
}
async function main() {
// Only the first phase clears the directory; the second has to find the first half's frames still
// there to merge with.
if (PHASE === 'before') rmSync(OUT_DIR, {recursive: true, force: true})
mkdirSync(OUT_DIR, {recursive: true})
// Rendered from the real template as part of the run rather than by hand, so the email and the frames
// around it cannot drift apart — it is supposed to describe the very search just saved.
const emailPath = join(tmpdir(), `compass-alert-${THEME}.html`)
if (PHASE === 'after') {
execFileSync('yarn', ['--cwd=backend/email', 'render:alert', emailPath, BASE, THEME], {
cwd: REPO,
stdio: 'inherit',
})
}
const browser = await pw.chromium.launch()
const context = await browser.newContext({
...pw.devices['iPhone 13'],
viewport: VIEWPORT,
deviceScaleFactor: SCALE,
isMobile: true,
hasTouch: true,
colorScheme: THEME,
locale: 'en-GB',
})
const page = await context.newPage()
// Playwright's 30s default is not enough against a cold `next dev`: the first hit on a route pays
// for compiling it, and this script visits several it has never touched. Same class of problem as
// the fixed waits H1 had to replace with explicit conditions — don't let compile time decide
// whether the capture succeeds.
page.setDefaultNavigationTimeout(120000)
page.setDefaultTimeout(45000)
const shots = []
// Hoisted: it is read in the `before` phase but written into the manifest at the end, outside it.
let count = null
const parkPointer = () => page.mouse.move(2, 2)
const quieten = () =>
page.addStyleTag({
content: `
*, *::before, *::after { animation: none !important; transition: none !important; }
nextjs-portal, #__next-build-watcher { display: none !important; }
`,
})
const shoot = async (kind, hold, extra = {}) => {
await parkPointer()
await page.waitForTimeout(120)
// Phase-prefixed so the two runs cannot overwrite each other's frames. Playback order comes from
// the manifest, not from these names.
const name = `${PHASE}-${pad(shots.length)}-${kind}.png`
await page.screenshot({path: join(OUT_DIR, name)})
shots.push({name, kind, hold, ...extra})
}
const centreOf = async (locator) => {
const box = await locator.boundingBox()
if (!box) return undefined
return {x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2)}
}
/**
* Click something, then capture the result. The tap marker goes on the *previous* frame — the one
* still showing the pre-click state — because clicking reflows the page, so a position measured
* before the click does not describe the frame after it. Press, then result.
*/
const tapAndShoot = async (locator, kind, hold, settleMs = 900) => {
const tap = await centreOf(locator)
const previous = shots[shots.length - 1]
if (tap && previous) previous.tap = tap
await locator.click()
await page.waitForTimeout(settleMs)
await quieten()
await shoot(kind, hold)
}
/**
* Set one filter: open its group if it has one, open its section, then pick the option.
*
* Most filters are nested. `FilterGroup` renders `{isOpen && children}`, so a collapsed group keeps
* its sections out of the DOM entirely — not merely hidden — and looking for "Religion" before
* opening "Values & Beliefs" finds nothing at all. Only gender and location sit at the top level.
*
* Both groups and sections are accordions (`openGroup === title` / `openFilter == title`), so each
* of these closes the previous one. That is fine: the *selections* persist, and the closing is
* visible in the clip as the rail folding back up, which reads as progress through a form.
*/
/**
* The filter controls to drive: the mobile sheet if it is open, otherwise the page.
*
* Both filter UIs are always mounted — the desktop rail and the mobile sheet — and `firstVisible`
* alone is not enough to tell them apart, because the desktop rail is not reliably `display: none`
* at this viewport. It is merely behind the sheet, so Playwright calls it visible, clicks it, and
* then times out with the sheet's own overlay "intercepting pointer events". Scoping every query to
* the sheet's portal removes the ambiguity instead of guessing at it.
*/
const panel = () => {
const portal = page.locator('#headlessui-portal-root')
return portal
}
const pickFilter = async (group, sectionRe, optionText, kind) => {
const root = panel()
if (group) {
const groupHeader = await firstVisible(
root.getByRole('button').filter({hasText: group}),
`${group} group header`,
)
await groupHeader.evaluate((el) => el.scrollIntoView({block: 'center'}))
await page.waitForTimeout(250)
await tapAndShoot(groupHeader, `${kind}-group`, HOLD.tap, 600)
}
const section = await firstVisible(root.getByText(sectionRe), `${kind} section header`)
await section.evaluate((el) => el.scrollIntoView({block: 'center'}))
await page.waitForTimeout(250)
await tapAndShoot(section, `${kind}-open`, HOLD.tap, 600)
const option = await firstVisible(root.getByText(optionText, {exact: true}), `${kind} option`)
await option.evaluate((el) => el.scrollIntoView({block: 'center'}))
await page.waitForTimeout(250)
await tapAndShoot(option, `${kind}-set`, HOLD.panel, 1200)
}
// ── Sign in ────────────────────────────────────────────────────────────────
// Both phases need a session: the first to search and save, the second because the profile grid and
// the composer only render for a signed-in user.
await page.goto(`${BASE}/signin`, {waitUntil: 'networkidle'})
await page.fill('input[type="email"]', VIEWER_EMAIL)
await page.fill('input[type="password"]', VIEWER_PASSWORD)
await page.click('button[type="submit"]')
await page.waitForURL(`${BASE}/`, {timeout: 20000}).catch(() => {})
await page.waitForTimeout(4000)
if (page.url().includes('/signin')) {
throw new Error(
'still on /signin — is the viewer account seeded? Run: SHOWCASE=1 ./scripts/seed.sh',
)
}
const dismiss = page.getByText('Dismiss', {exact: true})
if (await dismiss.count()) {
await dismiss.first().click()
await page.waitForTimeout(600)
}
await quieten()
await page.waitForTimeout(1200)
if (PHASE === 'before') {
// ── 1. The unfiltered list ─────────────────────────────────────────────────
await shoot('idle', HOLD.establish)
// ── 2. Build the search ────────────────────────────────────────────────────
const filterButton = await firstVisible(page.locator('button.lg\\:hidden'), 'filter button')
await tapAndShoot(filterButton, 'filters-open', HOLD.panel)
// Group titles are from filters.tsx: Gender is top level, the other two are nested.
//
// "Wants kids" was in the original brief and is deliberately absent. It lives in the Relationship
// group, which `filters.tsx` renders only when the *signed-in viewer's* own `pref_relation_styles`
// includes 'relationship' — and the showcase viewer seeks friendship and collaboration. Adding it
// would mean changing the viewer persona, which is also what the H1 hero clip is captured as. The
// remaining four facets already narrow to exactly one person, so the beat was not worth that.
await pickFilter(null, /gender$/i, 'Man', 'gender')
await pickFilter('Values & Beliefs', /religion$/i, 'Atheist', 'religion')
await pickFilter('Lifestyle', /diet$/i, 'Vegan', 'diet')
// Location is a city autocomplete backed by an external geo service, so it is the one beat that can
// fail for reasons unrelated to the app. Treated as optional: warn and carry on rather than losing
// the whole capture, since the search is already specific enough without it.
try {
// Headers read as label-plus-current-selection — "Living anywhere", "Any gender" — so the section
// is "Living", never "Location", and never bare. Anchoring the regex at the end (/location$/i,
// /^living$/i) matched nothing; match the leading word instead.
const locationSection = await firstVisible(panel().getByText(/^living/i), 'Living section')
await locationSection.evaluate((el) => el.scrollIntoView({block: 'center'}))
await tapAndShoot(locationSection, 'location-open', HOLD.tap, 600)
const cityInput = await firstVisible(
panel().locator('input[placeholder*="city" i]'),
'city search box',
)
await cityInput.fill(CITY)
await page.waitForTimeout(2500)
const cityResult = await firstVisible(
panel().getByText(CITY, {exact: false}),
`${CITY} result`,
)
await tapAndShoot(cityResult, 'location-set', HOLD.panel, 1500)
} catch (err) {
console.warn(`warning: location beat skipped (${err.message})`)
}
// ── 3. No results ──────────────────────────────────────────────────────────
await page.keyboard.press('Escape')
await page.waitForTimeout(1800)
await quieten()
await shoot('no-results', HOLD.empty)
count = await page
.locator('text=/\\d+ (people|person)/')
.first()
.textContent()
.catch(() => null)
// ── 4. Save the search ─────────────────────────────────────────────────────
const notify = await firstVisible(page.getByText(/get notified/i), 'get-notified button')
await notify.evaluate((el) => el.scrollIntoView({block: 'center'}))
await page.waitForTimeout(400)
await quieten()
await shoot('notify-visible', HOLD.tap)
await tapAndShoot(notify, 'saved', HOLD.settle, 2500)
}
if (PHASE === 'after') {
// ── 5. The email ───────────────────────────────────────────────────────────
// Dissolved into rather than cut to: this is where the clip stops being one continuous session.
await page.goto(`file://${emailPath}`, {waitUntil: 'networkidle'})
await page.waitForTimeout(1500)
await quieten()
await shoot('email', HOLD.email, {fadeIn: true})
// Scroll the email far enough to reach the match card, in small steps — a large jump followed by
// stillness reads as a stutter however long it is held.
for (let i = 0; i < 8; i++) {
await page.mouse.wheel(0, 90)
await page.waitForTimeout(90)
await shoot(`email-scroll-${i}`, HOLD.scroll)
}
const matchLink = page.getByText(MATCH_NAME, {exact: false}).first()
if (await matchLink.count()) {
const tap = await centreOf(matchLink)
if (tap) shots[shots.length - 1].tap = tap
} else {
console.warn(`warning: "${MATCH_NAME}" not found in the email — is the persona seeded?`)
}
// ── 6. The profile ─────────────────────────────────────────────────────────
// Navigated directly rather than by clicking the email link. The link points at the production
// domain (the template builds absolute https://compassmeet.com URLs), so clicking it would leave the
// dev server and shoot the wrong database. The tap indicator above still shows the press.
await page.goto(`${BASE}/${MATCH_SLUG}`, {waitUntil: 'networkidle'})
await page.waitForTimeout(1500)
await quieten()
/**
* Bounding box of a locator in viewport CSS px, tagged with the search facet it satisfies.
*
* Measured live rather than hand-placed against a screenshot, so a profile restyle moves the
* spotlight with it instead of silently leaving it pointing at empty space. Returns null when the
* element is missing or off-screen, and the caller drops it — a spotlight on nothing is worse than
* one fewer spotlight.
*/
const highlight = async (locator, label) => {
const el = await visibleMatches(locator)
if (!el.length) {
console.warn(`warning: no element to spotlight for "${label}"`)
return null
}
// Measure the *text*, not the element. `boundingBox()` returns the layout box, and in the profile's
// details list each value sits in a full-width row — so circling that gave a flat oval spanning the
// whole card rather than a ring round the word. A Range over the text node gives the glyphs' own
// bounds. Falls back to the layout box if the node has no text (it always does here, but the
// fallback costs one line and beats returning nothing).
const box =
(await el[0].evaluate((node) => {
const range = document.createRange()
range.selectNodeContents(node)
const r = range.getBoundingClientRect()
return r.width > 0 && r.height > 0
? {x: r.x, y: r.y, width: r.width, height: r.height}
: null
})) ?? (await el[0].boundingBox())
if (!box || box.y < 0 || box.y + box.height > VIEWPORT.height) {
console.warn(`warning: "${label}" is outside the viewport; not spotlighting it`)
return null
}
return {
x: Math.round(box.x),
y: Math.round(box.y),
w: Math.round(box.width),
h: Math.round(box.height),
label,
}
}
// Beat one: who and where. Age, gender and city sit together in the profile header.
const topSpots = [
await highlight(page.getByText(/^32 years old$/), 'Age 2440'),
await highlight(page.getByText('Man', {exact: true}), 'Man'),
await highlight(page.getByText(/^Grenoble, France$/), 'Near Grenoble'),
].filter(Boolean)
await shoot('profile-top', HOLD.criteria, {fadeIn: true, highlights: topSpots})
// Beat two: the two facets that are the whole point of the search. They land in one frame, so the
// scroll stops here deliberately instead of sweeping past at three frames a step.
const religionRow = page.getByText('Atheist', {exact: true}).first()
const dietRow = page.getByText('Vegan', {exact: true}).first()
await religionRow.evaluate((el) => el.scrollIntoView({block: 'center'}))
await page.waitForTimeout(700)
// Centring on religion alone left diet down at y≈762, half under the fixed bottom nav. Re-centre on
// the midpoint of the two, against the viewport minus that nav, so both sit clear in the frame.
const NAV_H = 90
const relBox = await religionRow.boundingBox()
const dietBox = await dietRow.boundingBox()
if (relBox && dietBox) {
const mid = (relBox.y + dietBox.y + dietBox.height) / 2
const delta = Math.round(mid - (VIEWPORT.height - NAV_H) / 2)
if (Math.abs(delta) > 8) {
await page.mouse.wheel(0, delta)
await page.waitForTimeout(600)
}
}
await quieten()
await shoot('profile-scroll-approach', HOLD.scroll * 4)
const detailSpots = [
await highlight(page.getByText('Atheist', {exact: true}), 'Atheist'),
await highlight(page.getByText('Vegan', {exact: true}), 'Vegan'),
].filter(Boolean)
await shoot('profile-criteria', HOLD.criteria, {highlights: detailSpots})
for (let i = 0; i < 10; i++) {
await page.mouse.wheel(0, 100)
await page.waitForTimeout(90)
await shoot(`profile-scroll-${i}`, HOLD.scroll)
}
// ── 7. Write to him ────────────────────────────────────────────────────────
// Opens the composer only. Nothing is sent, and the recipient is a fictional persona regardless.
const contact = page.getByText(/thoughtful message/i).first()
if (await contact.count()) {
await contact.evaluate((el) => el.scrollIntoView({block: 'center'}))
await page.waitForTimeout(700)
await quieten()
await shoot('contact-visible', HOLD.panel)
await tapAndShoot(contact, 'contact-open', HOLD.finale, 2000)
} else {
console.warn('warning: no "Send them a thoughtful message" button — clip ends on the scroll')
await shoot('profile-end', HOLD.finale)
}
}
await browser.close()
// ── Manifest ───────────────────────────────────────────────────────────────
// Each phase writes its own frame list. The second run reads the first's and concatenates, so the
// scene sees one continuous clip and knows nothing about how it was shot.
writeFileSync(PART_MANIFEST(PHASE), JSON.stringify({frames: shots, resultCount: count}, null, 2))
if (PHASE === 'before') {
console.log(`captured ${shots.length} ${THEME} "before" frames`)
if (Number(count?.match(/^(\d+)/)?.[1] ?? NaN) > 0) {
console.warn(
`warning: the search matched ${count}. This half is supposed to find nobody — re-seed with ` +
`SHOWCASE_SKIP=${MATCH_SLUG} so the match does not exist yet.`,
)
}
console.log(`next: re-seed without SHOWCASE_SKIP, then npm run capture:alert -- --phase after`)
return
}
let before
try {
before = JSON.parse(readFileSync(PART_MANIFEST('before'), 'utf8'))
} catch {
throw new Error(
`no "before" frames in ${OUT_DIR}. Run --phase before first (see the header of this file).`,
)
}
const frames = [...before.frames, ...shots]
const durationInFrames = frames.reduce((sum, f) => sum + f.hold, 0)
writeFileSync(
join(OUT_DIR, 'manifest.json'),
JSON.stringify(
{
theme: THEME,
capturedAt: new Date().toISOString(),
viewport: {width: VIEWPORT.width * SCALE, height: VIEWPORT.height * SCALE},
cssViewport: VIEWPORT,
scale: SCALE,
resultCount: before.resultCount,
durationInFrames,
frames,
},
null,
2,
) + '\n',
)
console.log(
`merged ${before.frames.length} + ${shots.length} = ${frames.length} ${THEME} frames -> ` +
`media-creator/public/alert/${THEME}/`,
)
console.log(`clip length: ${durationInFrames} frames (${(durationInFrames / 30).toFixed(1)}s)`)
}
main().catch((err) => {
console.error(err.message)
process.exit(1)
})

View File

@@ -20,20 +20,20 @@
//
// That opens a real window; sign in yourself, and the session is kept in
// .auth-profile/ (gitignored) for every later headless run.
import {existsSync, mkdirSync} from 'node:fs';
import {dirname, join} from 'node:path';
import {fileURLToPath} from 'node:url';
import {existsSync, 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 pw from '../../node_modules/@playwright/test/index.js'
const {chromium} = pw;
const {chromium} = pw
const HERE = dirname(fileURLToPath(import.meta.url));
const args = process.argv.slice(2);
const LOGIN = args.includes('--login');
const URL = args.find((a) => !a.startsWith('--')) ?? 'http://localhost:3000/Martin';
const OUT_DIR = join(HERE, '..', 'public', 'profile');
const PROFILE_DIR = join(HERE, '..', '.auth-profile');
const HERE = dirname(fileURLToPath(import.meta.url))
const args = process.argv.slice(2)
const LOGIN = args.includes('--login')
const URL = args.find((a) => !a.startsWith('--')) ?? 'http://localhost:3000/Martin'
const OUT_DIR = join(HERE, '..', 'public', 'profile')
const PROFILE_DIR = join(HERE, '..', '.auth-profile')
// Card title -> output file. Titles come from the ProfileCard headings in
// web/components/profile/profile-info.tsx. The last two need a signed-in
@@ -46,11 +46,11 @@ const CARDS = [
['About Me', '06-bio.png'],
['Compatibility Prompts', '08-prompts.png'],
['Endorsements', '09-endorsements.png'],
];
]
const PAD = 12; // CSS px of breathing room around each clip
const PAD = 12 // CSS px of breathing room around each clip
mkdirSync(OUT_DIR, {recursive: true});
mkdirSync(OUT_DIR, {recursive: true})
// One persistent context for both modes, so --login and the captures share a
// session. Headed only while logging in.
@@ -59,38 +59,38 @@ const context = await chromium.launchPersistentContext(PROFILE_DIR, {
viewport: {width: 430, height: 932},
deviceScaleFactor: 2,
colorScheme: 'light',
});
const page = context.pages()[0] ?? (await context.newPage());
})
const page = context.pages()[0] ?? (await context.newPage())
if (LOGIN) {
console.log('\nA browser window is open. Sign in there yourself — this script never');
console.log('handles your credentials. It waits until you land back on a signed-in page.\n');
await page.goto('http://localhost:3000/signin', {timeout: 60000});
await page.waitForURL((u) => !u.pathname.includes('signin'), {timeout: 300000});
await page.waitForTimeout(3000);
await context.close();
console.log(`Session saved to ${PROFILE_DIR}. Re-run without --login to capture.`);
process.exit(0);
console.log('\nA browser window is open. Sign in there yourself — this script never')
console.log('handles your credentials. It waits until you land back on a signed-in page.\n')
await page.goto('http://localhost:3000/signin', {timeout: 60000})
await page.waitForURL((u) => !u.pathname.includes('signin'), {timeout: 300000})
await page.waitForTimeout(3000)
await context.close()
console.log(`Session saved to ${PROFILE_DIR}. Re-run without --login to capture.`)
process.exit(0)
}
if (!existsSync(join(PROFILE_DIR, 'Default'))) {
console.log('No saved session — prompts and endorsements will be skipped.');
console.log('Run with --login once to capture them.\n');
console.log('No saved session — prompts and endorsements will be skipped.')
console.log('Run with --login once to capture them.\n')
}
await page.goto(URL, {waitUntil: 'networkidle', timeout: 60000});
await page.waitForSelector('[data-testid="profile-content"]', {timeout: 30000});
await page.goto(URL, {waitUntil: 'networkidle', timeout: 60000})
await page.waitForSelector('[data-testid="profile-content"]', {timeout: 30000})
// Photos are lazy next/image — walk the page top to bottom so every one of them
// starts loading, then come back up and let them decode.
await page.evaluate(async () => {
const step = innerHeight * 0.8;
const step = innerHeight * 0.8
for (let y = 0; y < document.body.scrollHeight; y += step) {
scrollTo(0, y);
await new Promise((r) => setTimeout(r, 150));
scrollTo(0, y)
await new Promise((r) => setTimeout(r, 150))
}
scrollTo(0, 0);
});
await page.waitForTimeout(2500);
scrollTo(0, 0)
})
await page.waitForTimeout(2500)
// Anything pinned to the viewport (bottom nav, sticky top bar, sign-up CTA) would
// smear across a full-page capture — hide it, plus the CSS animations.
@@ -101,58 +101,55 @@ await page.addStyleTag({
*, *::before, *::after { animation: none !important; transition: none !important; }
nextjs-portal, #__next-build-watcher { display: none !important; }
`,
});
})
await page.evaluate(() => {
for (const el of document.querySelectorAll('body *')) {
const pos = getComputedStyle(el).position;
if (pos === 'fixed' || pos === 'sticky') el.style.display = 'none';
const pos = getComputedStyle(el).position
if (pos === 'fixed' || pos === 'sticky') el.style.display = 'none'
}
});
})
// Absolute page rect for a node, padded, clamped to the document.
const rectOf = (selectorFn, arg) =>
page.evaluate(
([fn, a, pad]) => {
const el = new Function('a', `return (${fn})(a)`)(a);
if (!el) return null;
const r = el.getBoundingClientRect();
const x = Math.max(0, r.left + scrollX - pad);
const y = Math.max(0, r.top + scrollY - pad);
const el = new Function('a', `return (${fn})(a)`)(a)
if (!el) return null
const r = el.getBoundingClientRect()
const x = Math.max(0, r.left + scrollX - pad)
const y = Math.max(0, r.top + scrollY - pad)
return {
x,
y,
width: Math.min(r.width + pad * 2, document.documentElement.scrollWidth - x),
height: r.height + pad * 2,
};
}
},
[selectorFn.toString(), arg, PAD],
);
)
const shoot = async (file, clip) => {
if (!clip) {
console.warn(`${file} — element not found, skipped`);
return;
console.warn(`${file} — element not found, skipped`)
return
}
const path = join(OUT_DIR, file);
await page.screenshot({path, fullPage: true, clip});
console.log(`${file} ${Math.round(clip.width)}×${Math.round(clip.height)} CSS px`);
};
const path = join(OUT_DIR, file)
await page.screenshot({path, fullPage: true, clip})
console.log(`${file} ${Math.round(clip.width)}×${Math.round(clip.height)} CSS px`)
}
// 1 — header: avatar, name, location, chips, pull-quote.
await shoot(
'01-header.png',
await rectOf(() => document.querySelector('.animate-profile-appear')),
);
await shoot('01-header.png', await rectOf(() => document.querySelector('.animate-profile-appear')))
// 2..6 — one card per section, found by its heading text.
for (const [title, file] of CARDS) {
await shoot(
file,
await rectOf((t) => {
const cards = [...document.querySelectorAll('.bg-canvas-50.border-canvas-300.border')];
return cards.find((c) => c.innerText.trim().startsWith(t));
const cards = [...document.querySelectorAll('.bg-canvas-50.border-canvas-300.border')]
return cards.find((c) => c.innerText.trim().startsWith(t))
}, title),
);
)
}
// 7 — photo carousel. Grab the scrolling row, not one slide: the clip is clamped
@@ -160,11 +157,11 @@ for (const [title, file] of CARDS) {
await shoot(
'07-photos.png',
await rectOf(() => document.querySelector('.snap-start')?.closest('[class*="overflow"]') ?? null),
);
)
// 8 — the whole page, for the establishing slow-scroll shot.
await page.screenshot({path: join(OUT_DIR, 'full.png'), fullPage: true});
console.log(' ✓ full.png (full page, for the scroll shot)');
await page.screenshot({path: join(OUT_DIR, 'full.png'), fullPage: true})
console.log(' ✓ full.png (full page, for the scroll shot)')
await context.close();
console.log(`\nWrote ${OUT_DIR}`);
await context.close()
console.log(`\nWrote ${OUT_DIR}`)

View File

@@ -5,12 +5,12 @@
// If you change scene timings there, update the `frame` values here to match.
//
// Run with: npm run render:carousel -> out/carousel/01-logo.jpg … 06-cta.jpg
import {execFileSync} from 'node:child_process';
import {mkdirSync} from 'node:fs';
import {join} from 'node:path';
import {execFileSync} from 'node:child_process'
import {mkdirSync} from 'node:fs'
import {join} from 'node:path'
const OUT_DIR = join('out', 'carousel');
mkdirSync(OUT_DIR, {recursive: true});
const OUT_DIR = join('out', 'carousel')
mkdirSync(OUT_DIR, {recursive: true})
// name (drives upload order via the numeric prefix) -> absolute frame in IntroPost
const SLIDES = [
@@ -20,13 +20,13 @@ const SLIDES = [
{name: '04-features', frame: 495},
{name: '05-vision', frame: 602},
{name: '06-cta', frame: 720},
];
]
const bin = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const bin = process.platform === 'win32' ? 'npx.cmd' : 'npx'
for (const {name, frame} of SLIDES) {
const output = join(OUT_DIR, `${name}.jpg`);
console.log(`${output} (frame ${frame})`);
const output = join(OUT_DIR, `${name}.jpg`)
console.log(`${output} (frame ${frame})`)
execFileSync(
bin,
[
@@ -39,7 +39,7 @@ for (const {name, frame} of SLIDES) {
'--jpeg-quality=95',
],
{stdio: 'inherit'},
);
)
}
console.log(`\nDone — ${SLIDES.length} carousel slides in ${OUT_DIR}/`);
console.log(`\nDone — ${SLIDES.length} carousel slides in ${OUT_DIR}/`)

View File

@@ -91,6 +91,12 @@ upload "$OUT_DIR/compass-search-demo-light.mp4" "videos/search-demo-light.mp4" "
upload "$OUT_DIR/compass-search-demo-dark.mp4" "videos/search-demo-dark.mp4" "video/mp4"
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 saved-search alert clip (A4). Same shape as the hero: clip in R2, poster converted to
# JPEG into web/public by the step above.
upload "$OUT_DIR/compass-search-alert-light.mp4" "videos/search-alert-light.mp4" "video/mp4"
upload "$OUT_DIR/compass-search-alert-dark.mp4" "videos/search-alert-dark.mp4" "video/mp4"
upload "$WEB_IMAGES/search-alert-poster-light.jpg" "images/search-alert-poster-light.jpg" "image/jpeg"
upload "$WEB_IMAGES/search-alert-poster-dark.jpg" "images/search-alert-poster-dark.jpg" "image/jpeg"
# About-page vote card (A1). Written straight to web/public by capture-vote.mjs — no render step.
# Two widths per theme: the desktop shot is illegible scaled into a phone column, so the page picks
# with a <picture> media query.

View File

@@ -3,6 +3,7 @@ import {FORMATS} from './theme'
import {Intro, INTRO_DURATION} from './scenes/Intro'
import {ProfileTour, PROFILE_TOUR_DURATION} from './scenes/ProfileTour'
import {SearchDemo, SearchDemoProps, calculateSearchDemoMetadata} from './scenes/SearchDemo'
import {SearchAlert, SearchAlertProps, calculateSearchAlertMetadata} from './scenes/SearchAlert'
// Register every video composition here. The same Intro scenes render into two
// Instagram-ready canvases; render with:
@@ -70,6 +71,30 @@ export const RemotionRoot: React.FC = () => {
defaultProps={{manifest: null, theme: 'dark'} as SearchDemoProps}
calculateMetadata={calculateSearchDemoMetadata}
/>
{/* The about-page saved-search clip. Canvas and duration come from
public/alert/<theme>/manifest.json via calculateMetadata, so re-capturing with different
filters or an extra beat needs no change here.
npm run capture:alert && npm run render:alert */}
<Composition
id="SearchAlertLight"
component={SearchAlert}
durationInFrames={480}
fps={30}
width={780}
height={1688}
defaultProps={{manifest: null, theme: 'light'} as SearchAlertProps}
calculateMetadata={calculateSearchAlertMetadata}
/>
<Composition
id="SearchAlertDark"
component={SearchAlert}
durationInFrames={480}
fps={30}
width={780}
height={1688}
defaultProps={{manifest: null, theme: 'dark'} as SearchAlertProps}
calculateMetadata={calculateSearchAlertMetadata}
/>
</>
)
}

View File

@@ -1,43 +1,36 @@
import React from 'react';
import {interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion';
import React from 'react'
import {interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion'
// Fade + rise, driven by a spring so motion feels physical rather than linear.
// `delay` is in frames, relative to the start of the enclosing <Sequence>.
export const FadeUp: React.FC<{
delay?: number;
distance?: number;
children: React.ReactNode;
style?: React.CSSProperties;
delay?: number
distance?: number
children: React.ReactNode
style?: React.CSSProperties
}> = ({delay = 0, distance = 60, children, style}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const frame = useCurrentFrame()
const {fps} = useVideoConfig()
const progress = spring({
frame: frame - delay,
fps,
config: {damping: 200, mass: 0.6},
});
})
const opacity = interpolate(progress, [0, 1], [0, 1]);
const translateY = interpolate(progress, [0, 1], [distance, 0]);
const opacity = interpolate(progress, [0, 1], [0, 1])
const translateY = interpolate(progress, [0, 1], [distance, 0])
return (
<div style={{...style, opacity, transform: `translateY(${translateY}px)`}}>
{children}
</div>
);
};
return <div style={{...style, opacity, transform: `translateY(${translateY}px)`}}>{children}</div>
}
// Fade a scene out over its final `fadeFrames` frames so cuts never pop.
export const useSceneFade = (
durationInFrames: number,
fadeFrames = 12,
): number => {
const frame = useCurrentFrame();
export const useSceneFade = (durationInFrames: number, fadeFrames = 12): number => {
const frame = useCurrentFrame()
return interpolate(
frame,
[0, fadeFrames, durationInFrames - fadeFrames, durationInFrames],
[0, 1, 1, 0],
{extrapolateLeft: 'clamp', extrapolateRight: 'clamp'},
);
};
)
}

View File

@@ -1,14 +1,14 @@
import {AbsoluteFill, useCurrentFrame} from 'remotion';
import {colors} from '../theme';
import {AbsoluteFill, useCurrentFrame} from 'remotion'
import {colors} from '../theme'
// Warm espresso gradient with a slow-drifting amber glow. Used behind every scene
// so cuts feel like one continuous piece rather than isolated slides.
export const Background: React.FC = () => {
const frame = useCurrentFrame();
const frame = useCurrentFrame()
// Gentle vertical drift of the glow, looping calmly over ~8s.
const drift = Math.sin(frame / 70) * 120;
const drift2 = Math.cos(frame / 90) * 100;
const drift = Math.sin(frame / 70) * 120
const drift2 = Math.cos(frame / 90) * 100
return (
<AbsoluteFill
@@ -35,10 +35,9 @@ export const Background: React.FC = () => {
{/* Subtle grain-free vignette to focus the center */}
<AbsoluteFill
style={{
background:
'radial-gradient(80% 80% at 50% 45%, transparent 55%, rgba(0,0,0,0.45) 100%)',
background: 'radial-gradient(80% 80% at 50% 45%, transparent 55%, rgba(0,0,0,0.45) 100%)',
}}
/>
</AbsoluteFill>
);
};
)
}

View File

@@ -1,5 +1,5 @@
import {Img, staticFile} from 'remotion';
import {colors} from '../theme';
import {Img, staticFile} from 'remotion'
import {colors} from '../theme'
// The Compass rose (public/logo.svg is a copy of web/public/favicon.svg),
// framed in a soft cream disc so it reads on the dark background.
@@ -14,16 +14,11 @@ export const Logo: React.FC<{size: number}> = ({size}) => {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: `0 30px 90px ${colors.amberDeep}55, 0 0 0 ${
size * 0.03
}px ${colors.amber}33`,
boxShadow: `0 30px 90px ${colors.amberDeep}55, 0 0 0 ${size * 0.03}px ${colors.amber}33`,
overflow: 'hidden',
}}
>
<Img
src={staticFile('logo.svg')}
style={{width: size, height: size}}
/>
<Img src={staticFile('logo.svg')} style={{width: size, height: size}} />
</div>
);
};
)
}

View File

@@ -1,5 +1,5 @@
// Remotion entry point. Registered compositions live in ./Root.
import {registerRoot} from 'remotion';
import {RemotionRoot} from './Root';
import {registerRoot} from 'remotion'
import {RemotionRoot} from './Root'
registerRoot(RemotionRoot);
registerRoot(RemotionRoot)

View File

@@ -1,9 +1,16 @@
import React from 'react';
import {AbsoluteFill, Sequence, interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion';
import {Background} from '../components/Background';
import {Logo} from '../components/Logo';
import {FadeUp, useSceneFade} from '../components/Animations';
import {colors, DESIGN_HEIGHT, fonts} from '../theme';
import React from 'react'
import {
AbsoluteFill,
Sequence,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from 'remotion'
import {Background} from '../components/Background'
import {Logo} from '../components/Logo'
import {FadeUp, useSceneFade} from '../components/Animations'
import {colors, DESIGN_HEIGHT, fonts} from '../theme'
// ─── Scene schedule (frames @ 30fps) ────────────────────────────────────────
const S = {
@@ -13,12 +20,12 @@ const S = {
features: {from: 340, dur: 185},
vision: {from: 520, dur: 105},
cta: {from: 620, dur: 130},
};
export const INTRO_DURATION = S.cta.from + S.cta.dur; // 750 frames ≈ 25s
}
export const INTRO_DURATION = S.cta.from + S.cta.dur // 750 frames ≈ 25s
// Wraps a scene so its whole content cross-fades at the edges — no hard pops.
const Scene: React.FC<{dur: number; children: React.ReactNode}> = ({dur, children}) => {
const opacity = useSceneFade(dur);
const opacity = useSceneFade(dur)
return (
<AbsoluteFill
style={{
@@ -31,8 +38,8 @@ const Scene: React.FC<{dur: number; children: React.ReactNode}> = ({dur, childre
>
{children}
</AbsoluteFill>
);
};
)
}
const Eyebrow: React.FC<{children: React.ReactNode}> = ({children}) => (
<div
@@ -47,15 +54,15 @@ const Eyebrow: React.FC<{children: React.ReactNode}> = ({children}) => (
>
{children}
</div>
);
)
// ─── Scene 1 — logo + wordmark ──────────────────────────────────────────────
const LogoScene: React.FC = () => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const pop = spring({frame, fps, config: {damping: 12, mass: 0.8}});
const scale = interpolate(pop, [0, 1], [0.6, 1]);
const spin = interpolate(pop, [0, 1], [-25, 0]);
const frame = useCurrentFrame()
const {fps} = useVideoConfig()
const pop = spring({frame, fps, config: {damping: 12, mass: 0.8}})
const scale = interpolate(pop, [0, 1], [0.6, 1])
const spin = interpolate(pop, [0, 1], [-25, 0])
return (
<Scene dur={S.logo.dur}>
@@ -89,8 +96,8 @@ const LogoScene: React.FC = () => {
</div>
</FadeUp>
</Scene>
);
};
)
}
// ─── Scene 2 — the hook ─────────────────────────────────────────────────────
const HookScene: React.FC = () => (
@@ -105,8 +112,7 @@ const HookScene: React.FC = () => (
lineHeight: 1.12,
}}
>
Tired of endless{' '}
<span style={{color: colors.amberLight}}>swiping</span>,{' '}
Tired of endless <span style={{color: colors.amberLight}}>swiping</span>,{' '}
<span style={{color: colors.amberLight}}>ads</span>, and{' '}
<span style={{color: colors.amberLight}}>algorithms</span>?
</div>
@@ -125,7 +131,7 @@ const HookScene: React.FC = () => (
</div>
</FadeUp>
</Scene>
);
)
// ─── Scene 3 — what Compass is ──────────────────────────────────────────────
const WhatScene: React.FC = () => (
@@ -143,8 +149,7 @@ const WhatScene: React.FC = () => (
lineHeight: 1.18,
}}
>
A free, open platform for{' '}
<span style={{color: colors.amberLight}}>deep, authentic</span>{' '}
A free, open platform for <span style={{color: colors.amberLight}}>deep, authentic</span>{' '}
1-on-1 connections.
</div>
</FadeUp>
@@ -164,7 +169,7 @@ const WhatScene: React.FC = () => (
</div>
</FadeUp>
</Scene>
);
)
// ─── Scene 4 — features ─────────────────────────────────────────────────────
const FEATURES: {title: string; text: string}[] = [
@@ -173,13 +178,13 @@ const FEATURES: {title: string; text: string}[] = [
{title: 'Depth over swipes', text: 'Values and ideas first. Photos stay secondary.'},
{title: 'Always free', text: 'No ads. No subscriptions. Your data is never sold.'},
{title: 'Open & democratic', text: 'Community-owned, open source, run by a public constitution.'},
];
]
// The five feature cards are the tallest scene, so they compact on the shorter
// 4:5 post canvas to keep comfortable breathing room top and bottom.
const useFeatureSizing = () => {
const {height} = useVideoConfig();
const compact = height < DESIGN_HEIGHT; // true for the 4:5 post format
const {height} = useVideoConfig()
const compact = height < DESIGN_HEIGHT // true for the 4:5 post format
return {
compact,
cardPad: compact ? '22px 32px' : '30px 36px',
@@ -188,15 +193,15 @@ const useFeatureSizing = () => {
textSize: compact ? 30 : 34,
rowGap: compact ? 16 : 22,
headerGap: compact ? 28 : 40,
};
};
}
}
const FeatureRow: React.FC<{title: string; text: string; delay: number}> = ({
title,
text,
delay,
}) => {
const s = useFeatureSizing();
const s = useFeatureSizing()
return (
<FadeUp delay={delay} distance={40}>
<div
@@ -244,11 +249,11 @@ const FeatureRow: React.FC<{title: string; text: string; delay: number}> = ({
</div>
</div>
</FadeUp>
);
};
)
}
const FeaturesScene: React.FC = () => {
const s = useFeatureSizing();
const s = useFeatureSizing()
return (
<Scene dur={S.features.dur}>
<div style={{width: '100%'}}>
@@ -262,8 +267,8 @@ const FeaturesScene: React.FC = () => {
</div>
</div>
</Scene>
);
};
)
}
// ─── Scene 5 — vision ───────────────────────────────────────────────────────
const VisionScene: React.FC = () => (
@@ -280,20 +285,16 @@ const VisionScene: React.FC = () => (
lineHeight: 1.28,
}}
>
What{' '}
<span style={{color: colors.amberLight, fontWeight: 700}}>Linux</span> is
to software and{' '}
<span style={{color: colors.amberLight, fontWeight: 700}}>Wikipedia</span>{' '}
is to knowledge —
What <span style={{color: colors.amberLight, fontWeight: 700}}>Linux</span> is to software
and <span style={{color: colors.amberLight, fontWeight: 700}}>Wikipedia</span> is to
knowledge —
<br />
Compass is to{' '}
<span style={{color: colors.amberBright, fontStyle: 'italic'}}>
human connection.
</span>
<span style={{color: colors.amberBright, fontStyle: 'italic'}}>human connection.</span>
</div>
</FadeUp>
</Scene>
);
)
// ─── Scene 6 — call to action ───────────────────────────────────────────────
const CtaScene: React.FC = () => (
@@ -344,7 +345,7 @@ const CtaScene: React.FC = () => (
</div>
</FadeUp>
</Scene>
);
)
// ─── Composition ────────────────────────────────────────────────────────────
export const Intro: React.FC = () => {
@@ -370,5 +371,5 @@ export const Intro: React.FC = () => {
<CtaScene />
</Sequence>
</AbsoluteFill>
);
};
)
}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React from 'react'
import {
AbsoluteFill,
Img,
@@ -8,11 +8,11 @@ import {
staticFile,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
import {Background} from '../components/Background';
import {Logo} from '../components/Logo';
import {FadeUp, useSceneFade} from '../components/Animations';
import {colors, fonts} from '../theme';
} from 'remotion'
import {Background} from '../components/Background'
import {Logo} from '../components/Logo'
import {FadeUp, useSceneFade} from '../components/Animations'
import {colors, fonts} from '../theme'
// A guided tour of a real Compass profile, section by section.
//
@@ -34,7 +34,7 @@ const SHOTS = {
photos: {src: 'profile/07-photos.png', w: 422, h: 324},
prompts: {src: 'profile/08-prompts.png', w: 422, h: 2152},
full: {src: 'profile/full.png', w: 422, h: 9948},
} as const;
} as const
// ─── Scene schedule (frames @ 30fps) ────────────────────────────────────────
// Scenes overlap by a few frames so the cross-fades in useSceneFade meet cleanly.
@@ -49,28 +49,28 @@ const S = {
prompts: {from: 635, dur: 120},
links: {from: 750, dur: 70},
cta: {from: 815, dur: 105},
};
export const PROFILE_TOUR_DURATION = S.cta.from + S.cta.dur; // 920 frames ≈ 30.7s
}
export const PROFILE_TOUR_DURATION = S.cta.from + S.cta.dur // 920 frames ≈ 30.7s
// ─── Layout ─────────────────────────────────────────────────────────────────
// The phone-ish frame the screenshots live in. Its height is what decides whether
// a shot fits or has to pan, so it adapts to the canvas: the 9:16 story gets a tall
// frame, the 4:5 post a short one.
const FRAME_WIDTH = 840;
const FRAME_WIDTH = 840
const useFrameBox = () => {
const {height} = useVideoConfig();
const captionBlock = height > 1600 ? 430 : 330; // room for eyebrow + caption
const {height} = useVideoConfig()
const captionBlock = height > 1600 ? 430 : 330 // room for eyebrow + caption
return {
width: FRAME_WIDTH,
height: height - captionBlock - 200,
captionSize: height > 1600 ? 52 : 46,
eyebrowSize: height > 1600 ? 30 : 26,
};
};
}
}
const Scene: React.FC<{dur: number; children: React.ReactNode}> = ({dur, children}) => {
const opacity = useSceneFade(dur);
const opacity = useSceneFade(dur)
return (
<AbsoluteFill
style={{
@@ -83,11 +83,11 @@ const Scene: React.FC<{dur: number; children: React.ReactNode}> = ({dur, childre
>
{children}
</AbsoluteFill>
);
};
)
}
const Eyebrow: React.FC<{children: React.ReactNode}> = ({children}) => {
const {eyebrowSize} = useFrameBox();
const {eyebrowSize} = useFrameBox()
return (
<div
style={{
@@ -101,8 +101,8 @@ const Eyebrow: React.FC<{children: React.ReactNode}> = ({children}) => {
>
{children}
</div>
);
};
)
}
// ─── The shot ───────────────────────────────────────────────────────────────
// A screenshot inside a rounded frame. Shots taller than the frame pan downward
@@ -112,33 +112,33 @@ const Eyebrow: React.FC<{children: React.ReactNode}> = ({children}) => {
// `pan` caps how far a tall shot travels, as a fraction of its overscroll — the
// bio is ~4 frames tall, and racing through all of it would just be a blur.
const Shot: React.FC<{
shot: {src: string; w: number; h: number};
dur: number;
pan?: number;
shot: {src: string; w: number; h: number}
dur: number
pan?: number
}> = ({shot, dur, pan = 1}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const box = useFrameBox();
const frame = useCurrentFrame()
const {fps} = useVideoConfig()
const box = useFrameBox()
const scale = box.width / shot.w;
const scaledHeight = shot.h * scale;
const overscroll = Math.max(0, scaledHeight - box.height);
const scale = box.width / shot.w
const scaledHeight = shot.h * scale
const overscroll = Math.max(0, scaledHeight - box.height)
// Hold still for a beat at each end before travelling, so the top of a card
// (its title) is readable on arrival and the bottom is readable on the cut.
const HOLD = 12;
const HOLD = 12
const progress = interpolate(frame, [HOLD, dur - HOLD], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const eased = progress * progress * (3 - 2 * progress); // smoothstep
})
const eased = progress * progress * (3 - 2 * progress) // smoothstep
const translateY = -overscroll * pan * eased;
const zoom = overscroll > 0 ? 1 : interpolate(eased, [0, 1], [1, 1.05]);
const translateY = -overscroll * pan * eased
const zoom = overscroll > 0 ? 1 : interpolate(eased, [0, 1], [1, 1.05])
// Frame itself springs in, so the cut lands with a little weight.
const entrance = spring({frame, fps, config: {damping: 200, mass: 0.7}});
const frameScale = interpolate(entrance, [0, 1], [0.94, 1]);
const entrance = spring({frame, fps, config: {damping: 200, mass: 0.7}})
const frameScale = interpolate(entrance, [0, 1], [0.94, 1])
return (
<div
@@ -163,20 +163,20 @@ const Shot: React.FC<{
}}
/>
</div>
);
};
)
}
// A titled beat: eyebrow + one-line caption above the framed screenshot.
const SectionScene: React.FC<{
eyebrow: string;
caption: React.ReactNode;
shot: {src: string; w: number; h: number};
dur: number;
pan?: number;
eyebrow: string
caption: React.ReactNode
shot: {src: string; w: number; h: number}
dur: number
pan?: number
}> = ({eyebrow, caption, shot, dur, pan}) => {
const box = useFrameBox();
const opacity = useSceneFade(dur);
const {height} = useVideoConfig();
const box = useFrameBox()
const opacity = useSceneFade(dur)
const {height} = useVideoConfig()
// The caption block is pinned to a fixed offset rather than centred with the
// shot: a short card (Links) would otherwise drag the text down the frame while
@@ -212,13 +212,13 @@ const SectionScene: React.FC<{
<Shot shot={shot} dur={dur} pan={pan} />
</div>
</AbsoluteFill>
);
};
)
}
// ─── Scene 1 — title, over the whole page scrolling by ──────────────────────
const TitleScene: React.FC = () => {
const frame = useCurrentFrame();
const {height} = useVideoConfig();
const frame = useCurrentFrame()
const {height} = useVideoConfig()
return (
<Scene dur={S.title.dur}>
@@ -275,8 +275,8 @@ const TitleScene: React.FC = () => {
</FadeUp>
</div>
</Scene>
);
};
)
}
// ─── Scene 9 — call to action ───────────────────────────────────────────────
const CtaScene: React.FC = () => (
@@ -330,7 +330,7 @@ const CtaScene: React.FC = () => (
</div>
</FadeUp>
</Scene>
);
)
// ─── Composition ────────────────────────────────────────────────────────────
export const ProfileTour: React.FC = () => {
@@ -447,5 +447,5 @@ export const ProfileTour: React.FC = () => {
<CtaScene />
</Sequence>
</AbsoluteFill>
);
};
)
}

View File

@@ -0,0 +1,358 @@
import React from 'react'
import {AbsoluteFill, Img, staticFile, useCurrentFrame} from 'remotion'
import {SearchFrame, SearchManifest, shotAt, TapIndicator} from './SearchDemo'
// The about-page clip: the saved-search loop, end to end.
//
// Filter the people page down to something specific, find nobody, ask to be told when someone turns
// up, and days later open the email that says someone has — then their profile, then the composer.
//
// This is the one differentiator on the about page that a screenshot cannot carry, because the whole
// claim is about something that happens *later*. It needs elapsed time, so it needs a clip.
//
// Like SearchDemo this is a dumb sequencer over frames captured by scripts/capture-alert.mjs, with
// pacing carried per-frame in the manifest. It adds exactly one thing that scene does not: a dissolve
// at the moment the email arrives.
/**
* Frames spent dissolving into a shot marked `fadeIn`.
*/
const GAP_FADE = 22
/**
* The time-passing interstitial, in video frames (~2s at 30fps).
*
* A dissolve alone was not enough. It said "something changed", not "days went by" — and without that,
* the email looks like it arrived the instant the search was saved, which is precisely the wrong idea
* about a feature whose whole point is that it works while you are not looking.
*
* So there is a real beat here: the saved-search screen recedes and dims, three days tick across, and
* the email arrives out of it. Synthesised in Remotion rather than captured, because it is the one
* moment in the clip that is not a thing the product renders.
*
* This does bake English ("3 days later") into the clip. Acceptable here and not elsewhere: the middle
* of this clip is a screenshot of an English email, so it is already English-only. The hero clip has no
* text for exactly the opposite reason — one render serves every locale.
*/
const GAP_HOLD = 54
const GAP_DAYS = 3
/** Brand amber — the same accent the tap indicator and the app's primary controls use. */
const ACCENT = '#C17F3E'
/**
* Spotlights the fields on the profile that satisfy the saved search.
*
* The clip's whole argument is "the platform found someone who fits", and without this the profile
* beat just scrolls past — the viewer is asked to take the match on trust. This dims everything else
* and rings the fields that actually matched, each captioned with the criterion it satisfies.
*
* **This is editorial annotation, not product UI, and it is drawn to look like it.** Compass has no
* "matches your search" highlighting on profiles; inventing chrome that implied otherwise would be
* claiming a feature that does not exist, which is the one thing these visuals are not allowed to do.
* So: a scrim and a ring in the brand accent, captions in the clip's own voice — deliberately unlike
* anything the app renders. (The tap indicator is different in kind: it depicts a finger, not a
* feature.)
*
* Geometry comes from the capture script measuring the live DOM, so a profile restyle moves these
* with it rather than leaving them pointing at blank space.
*/
const Spotlight: React.FC<{
// `label` rides along on each highlight but is not drawn — it names the facet in the capture
// script's warnings and keeps the manifest self-describing.
highlights: NonNullable<SearchFrame['highlights']>
css: {width: number; height: number}
since: number
hold: number
}> = ({highlights, css, since, hold}) => {
const fadeIn = Math.min(1, since / 12)
const fadeOut = Math.max(0, (since - (hold - 14)) / 14)
const opacity = Math.max(0, Math.min(fadeIn, 1 - fadeOut))
if (opacity <= 0) return null
const PAD = 6
return (
<svg
viewBox={`0 0 ${css.width} ${css.height}`}
style={{position: 'absolute', inset: 0, width: '100%', height: '100%', opacity}}
>
<defs>
<mask id="spotlight-mask">
<rect x="0" y="0" width={css.width} height={css.height} fill="white" />
{highlights.map((h, i) => (
<rect
key={i}
x={h.x - PAD}
y={h.y - PAD}
width={h.w + PAD * 2}
height={h.h + PAD * 2}
rx="9"
fill="black"
/>
))}
</mask>
</defs>
{/* One scrim with the matched fields punched out of it, rather than a box drawn over each —
the point is that everything else recedes, not that these get decorated. */}
<rect
x="0"
y="0"
width={css.width}
height={css.height}
fill="rgba(22,17,11,0.55)"
mask="url(#spotlight-mask)"
/>
{highlights.map((h, i) => {
// Staggered, so the eye is walked through the matches one at a time rather than handed all
// of them at once. Each circle takes about a third of a second to draw.
const at = Math.max(0, Math.min(1, (since - 8 - i * 11) / 11))
if (at <= 0) return null
// An ellipse round the field, drawn on rather than appearing — the gesture someone makes with
// a pen when they are checking items off a list, which is exactly what the viewer is doing.
// No caption: the criteria were just read out in the email a beat earlier, and repeating them
// here crowded the fields they were pointing at.
const rx = h.w / 2 + PAD + 5
const ry = h.h / 2 + PAD + 3
// Ramanujan's approximation — close enough to run the dash offset off, and cheap.
const perimeter = Math.PI * (3 * (rx + ry) - Math.sqrt((3 * rx + ry) * (rx + 3 * ry)))
return (
<ellipse
key={i}
cx={h.x + h.w / 2}
cy={h.y + h.h / 2}
rx={rx}
ry={ry}
fill="none"
stroke={ACCENT}
strokeWidth="3"
strokeLinecap="round"
strokeDasharray={perimeter}
// Slightly past a full turn, so the stroke overshoots its start the way a hand-drawn ring
// does instead of closing on a seam.
strokeDashoffset={perimeter * (1 - at) - perimeter * 0.04 * at}
// A degree or two off-axis keeps it from reading as a UI element the app drew.
transform={`rotate(${i % 2 === 0 ? -2.2 : 1.8} ${h.x + h.w / 2} ${h.y + h.h / 2})`}
/>
)
})}
</svg>
)
}
export type SearchAlertProps = Record<string, unknown> & {
manifest: SearchManifest | null
theme: 'light' | 'dark'
}
/**
* The interstitial: the last app frame pushed back and dimmed, with days ticking over it.
*
* Drawn on top of the outgoing screenshot rather than on a flat colour, so the clip never fully cuts
* away — the phone is still there, just receding, which is what makes it read as elapsed time on the
* same screen rather than as a scene change.
*/
/** The scrim the interstitial lays over the app. Shared, because the email dissolves in over exactly
* this — see the note in the component below. */
const veilOf = (theme: 'light' | 'dark') =>
theme === 'dark' ? 'rgba(12,10,8,0.86)' : 'rgba(24,19,12,0.80)'
const TimeGap: React.FC<{since: number; hold: number; theme: 'light' | 'dark'}> = ({
since,
hold,
theme,
}) => {
const t = Math.min(1, since / 12)
// The dots and caption fade at the end; the scrim itself does not. It used to, and that was a bug
// you could see: the veil lifted, the saved-search screen came back at full strength for a few
// frames, and only then did the email start dissolving in — so the clip appeared to return to where
// it had just been. The scrim now holds until the email takes over on top of it.
const out = Math.max(0, (since - (hold - 14)) / 14)
// Light in both themes. The veil is dark either way — it has to be, or the app screenshot behind it
// still reads as the live screen — so taking the ink from the theme put dark brown text on a dark
// scrim in light mode and the caption nearly disappeared.
const ink = '#F7F2EA'
const veil = veilOf(theme)
return (
<div
style={{
position: 'absolute',
inset: 0,
background: veil,
backdropFilter: 'blur(6px)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 34,
opacity: t,
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 34,
opacity: 1 - out,
}}
>
<div style={{display: 'flex', gap: 22}}>
{Array.from({length: GAP_DAYS}).map((_, i) => {
// Each day lights in turn across the middle of the beat, so the count is something you
// watch happen rather than read.
const at = 0.18 + i * 0.22
const lit = Math.max(0, Math.min(1, (since / hold - at) * 9))
return (
<div
key={i}
style={{
width: 26,
height: 26,
borderRadius: '50%',
border: `3px solid ${theme === 'dark' ? '#5A4A33' : '#C7B49A'}`,
background: `rgba(193,127,62,${lit})`,
transform: `scale(${0.82 + lit * 0.18})`,
}}
/>
)
})}
</div>
<div
style={{
color: ink,
fontFamily: 'Georgia, "Times New Roman", serif',
fontSize: 46,
letterSpacing: '0.01em',
opacity: Math.min(1, Math.max(0, (since / hold - 0.34) * 4)),
}}
>
3 days later
</div>
</div>
</div>
)
}
export const SearchAlert: React.FC<SearchAlertProps> = ({manifest, theme}) => {
const frame = useCurrentFrame()
const backdrop = theme === 'dark' ? '#1A1713' : '#EDE8E0'
if (!manifest) return <AbsoluteFill style={{backgroundColor: backdrop}} />
const {shot, since} = shotAt(manifest, frame)
const css = manifest.cssViewport ?? manifest.viewport
const index = manifest.frames.indexOf(shot)
const previous = index > 0 ? manifest.frames[index - 1] : null
// While a `fadeIn` shot is dissolving in, the previous shot is still painted underneath it.
const dissolving = shot.fadeIn && since < GAP_FADE
const opacity = dissolving ? since / GAP_FADE : 1
const first = manifest.frames[0]
const fadeStart = manifest.durationInFrames - GAP_FADE
const loopOpacity = frame < fadeStart ? 0 : (frame - fadeStart) / GAP_FADE
const img = (name: string, style: React.CSSProperties = {}) => (
<Img
src={staticFile(`alert/${theme}/${name}`)}
style={{
// Children of AbsoluteFill are in normal flow — it positions only itself — so anything meant
// to stack has to say so explicitly or it lays out below the frame above it and never shows.
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
...style,
}}
/>
)
// The synthetic gap beat carries no screenshot of its own — it holds the previous frame and draws
// the day counter over it.
if (shot.gap) {
return (
<AbsoluteFill style={{backgroundColor: backdrop}}>
{previous ? img(previous.name) : null}
<TimeGap since={since} hold={shot.hold} theme={theme} />
</AbsoluteFill>
)
}
return (
<AbsoluteFill style={{backgroundColor: backdrop}}>
{dissolving && previous ? (
previous.gap ? (
// Coming out of the interstitial: underlay the pre-gap shot *with the scrim still on it*, so
// the email fades up from exactly the state the gap ended in. Underlaying the bare
// screenshot is what let the saved-search screen flash back for a fraction of a second.
<>
{img(manifest.frames[index - 2].name)}
<div style={{position: 'absolute', inset: 0, background: veilOf(theme)}} />
</>
) : (
img(previous.name)
)
) : null}
{img(shot.name, {opacity})}
{/* Suppressed mid-dissolve: a press indicator over a half-faded frame reads as a glitch, and
the shot being dissolved into is an arrival, not something anybody tapped. */}
{shot.tap && !dissolving && (
<TapIndicator tap={shot.tap} css={css} since={since} hold={shot.hold} />
)}
{shot.highlights?.length && !dissolving ? (
<Spotlight highlights={shot.highlights} css={css} since={since} hold={shot.hold} />
) : null}
{loopOpacity > 0 && first ? img(first.name, {opacity: loopOpacity}) : null}
</AbsoluteFill>
)
}
/**
* Sizes the composition from the capture manifest — canvas and duration both — so re-capturing with a
* different filter set or an extra beat needs no edit here.
*
* public/alert/ is gitignored and regenerated by the capture script, so a missing manifest is the
* ordinary "not captured yet" state. Fail with the command that fixes it rather than rendering blank.
*/
export const calculateSearchAlertMetadata = async ({props}: {props: SearchAlertProps}) => {
const theme = props.theme ?? 'light'
const path = `alert/${theme}/manifest.json`
const res = await fetch(staticFile(path))
if (!res.ok) {
throw new Error(
`public/${path} is missing (HTTP ${res.status}). Run: npm run capture:alert` +
(theme === 'dark' ? ':dark' : ''),
)
}
const raw: SearchManifest = await res.json()
// Insert the time-passing beat immediately before the email, rather than asking the capture script
// to emit a placeholder frame for something it cannot photograph. The manifest stays a pure record
// of what was captured; this is presentation.
const frames: SearchFrame[] = []
for (const f of raw.frames) {
if (f.fadeIn && f.kind === 'email') {
frames.push({name: '', kind: 'time-gap', hold: GAP_HOLD, gap: true})
}
frames.push(f)
}
const manifest: SearchManifest = {
...raw,
frames,
durationInFrames: frames.reduce((sum, f) => sum + f.hold, 0),
}
return {
durationInFrames: manifest.durationInFrames,
width: manifest.viewport.width,
height: manifest.viewport.height,
props: {...props, manifest},
}
}

View File

@@ -23,6 +23,27 @@ export interface SearchFrame {
hold: number
/** Viewport position in CSS px of the control that produced this shot, when it came from a tap. */
tap?: {x: number; y: number}
/**
* Dissolve into this shot from the one before it instead of cutting.
*
* Only honoured by the SearchAlert scene, where it marks the point at which the clip stops being one
* continuous session — the email arrives days later, in another app, and a hard cut there reads as a
* mistake rather than as elapsed time.
*/
fadeIn?: boolean
/**
* A synthetic beat with no screenshot behind it, injected by the SearchAlert scene rather than
* captured. Marks the "days later" interstitial, which is the one moment in that clip the product
* does not render and so cannot be photographed.
*/
gap?: boolean
/**
* Regions of this shot to spotlight, in CSS px, each with the search facet it satisfies.
*
* Measured by the capture script off the live DOM — same mechanism as `tap` — so they track the real
* layout instead of being hand-placed against a screenshot that will move on the next restyle.
*/
highlights?: {x: number; y: number; w: number; h: number; label: string}[]
}
export interface SearchManifest {
@@ -73,7 +94,7 @@ const TAP_FRAMES = 14
*
* Positioned in percentages of the CSS viewport so it lands correctly whatever the capture scale.
*/
const TapIndicator: React.FC<{
export const TapIndicator: React.FC<{
tap: {x: number; y: number}
css: {width: number; height: number}
since: number

View File

@@ -17,14 +17,13 @@ export const colors = {
// Accents pulled straight from the compass logo
logoBlue: '#1D384B',
logoRed: '#E94734',
} as const;
} as const
// System-font stack only — no network fetch at render time, so renders are reproducible offline.
export const fonts = {
display:
'"Segoe UI", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif',
display: '"Segoe UI", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif',
serif: 'Georgia, "Times New Roman", serif',
} as const;
} as const
// Output formats. Same scenes, different canvas — layouts adapt via useVideoConfig().
// - post: 4:5 portrait, the tallest ratio Instagram allows in-feed (primary).
@@ -32,8 +31,8 @@ export const fonts = {
export const FORMATS = {
post: {width: 1080, height: 1350, fps: 30},
story: {width: 1080, height: 1920, fps: 30},
} as const;
} as const
// Design reference height (the story format). Scenes are tuned against this;
// the shorter post format compacts where needed.
export const DESIGN_HEIGHT = FORMATS.story.height;
export const DESIGN_HEIGHT = FORMATS.story.height

View File

@@ -171,6 +171,18 @@ const PORTRAITS: Record<string, PortraitSpec> = {
'reading in a park on a bench, book propped on one knee, city noise implied, sunlight through leaves',
],
},
juliensarr: {
appearance:
// Alpine fieldwork cues drag the apparent age up the way the coastal ones do for sofiacosta, so
// the early thirties are asserted rather than left to the scene: no grey, no weathering.
'32-year-old French-Senegalese man, dark brown skin, short black hair, neatly kept short beard ' +
'with no grey, tall and lean, unlined early-thirties face, warm and unhurried expression',
scenes: [
'standing in deep snow on an Alpine slope above Grenoble in a well-used shell jacket, a snow depth probe planted beside him, mountains behind, looking straight at the camera',
'cooking in a small bright flat kitchen, chopping herbs on a crowded board with pans and jars around him, sleeves pushed up, absorbed in the work',
'crouched beside a fast mountain stream in summer lowering a sensor into the water, notebook open on the rock next to him',
],
},
}
// ─── API ──────────────────────────────────────────────────────────────────────

View File

@@ -221,16 +221,49 @@ async function seedShowcaseProfile(profile: ShowcaseProfile, userId: string) {
})
}
/**
* Personas to delete and then not re-create, as a comma-separated list of slugs.
*
* Two uses, and the second is the reason it deletes rather than merely skipping:
*
* - The A4 alert clip is captured in two passes (docs/marketing-visuals.md). Its first half has to be
* shot against a database where the match does not exist yet, because the story is "nobody fits this
* search — tell me when someone does". Skipping alone would not do it: an already-seeded row would
* simply stay.
* - Re-seeding is otherwise a no-op for anyone who already exists, so edits to a persona — or portraits
* generated *after* the first seed, which is easy to do in that order — never take effect. Deleting
* and re-running is the way to pick them up.
*
* `users` cascades to `profiles`, `private_users` and the join tables, so removal is clean.
*
* SHOWCASE_SKIP=juliensarr SHOWCASE=1 ./scripts/dev_db_seed.sh # remove, then seed the rest
* SHOWCASE=1 ./scripts/dev_db_seed.sh # put them back, fresh
*/
const SKIPPED = (process.env.SHOWCASE_SKIP ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
/** Seeds every showcase persona. Returns how many were newly created. */
export async function seedShowcaseUsers() {
if (SKIPPED.length) {
const pg = createSupabaseDirectClient()
for (const slug of SKIPPED) {
await pg.none(`delete from users where username = $1`, [slug])
}
console.log(`[showcase] removed and skipping: ${SKIPPED.join(', ')}`)
}
const wanted = SHOWCASE_PROFILES.filter((p) => !SKIPPED.includes(p.slug))
let created = 0
for (const profile of SHOWCASE_PROFILES) {
for (const profile of wanted) {
if (await seedShowcaseProfile(profile, showcaseUserId(profile.slug))) {
created++
debug('Showcase profile created:', profile.slug)
}
}
console.log(`[showcase] ${created}/${SHOWCASE_PROFILES.length} personas created`)
console.log(`[showcase] ${created}/${wanted.length} personas created`)
await seedShowcaseViewer()
return created

View File

@@ -693,4 +693,74 @@ export const SHOWCASE_PROFILES: ShowcaseProfile[] = [
},
photoCount: 2,
},
/**
* The search-alert persona (A4 in docs/marketing-visuals.md).
*
* Every field here is chosen so that he is the *only* profile matching the saved search the alert
* clip demonstrates — man, 2440, atheist, vegan, wants kids, near Grenoble. Before he exists that
* search returns nothing, which is exactly the situation saved searches are for, and is the story
* the clip tells: you search, nobody fits yet, you ask to be told when someone does.
*
* Check before editing anyone above: making another man vegan *and* atheist *and* 2440 would give
* the clip two results and cost it its point.
*
* Grenoble deliberately — the viewer persona (`SHOWCASE_VIEWER`) lives there, so any radius the
* location filter is set to includes him and the beat cannot fail on a slider position.
*/
{
slug: 'juliensarr',
name: 'Julien Sarr',
email: 'julien.sarr@compass.showcase',
age: 32,
gender: 'male',
orientation: ['straight'],
headline: 'Hydrologist, vegan cook, wants a family and says so on the first date',
bio: [
'I model snowmelt for a living, which means I spend half the year in the Alps with instruments and the other half explaining to people why the numbers matter. Climate work is not abstract to me — I watch it in a dataset every winter.',
'I cook properly, all of it vegan, and I will feed anyone who lets me. Not evangelical about it; I just stopped being able to justify the alternative and the cooking got better. Ask me about lentils at your own risk.',
'I want kids. I would rather say that early than discover in year three that we wanted different lives. Beyond that I am fairly easy: long walks, bad films taken seriously, and someone who argues with me properly.',
],
keywords: ['hydrology', 'sustainable living', 'vegan cooking', 'mountaineering'],
city: 'Grenoble',
region_code: 'ARA',
country: 'France',
city_latitude: 45.1885,
city_longitude: 5.7245,
born_in_location: 'Senegal',
occupation_title: 'Hydrologist',
company: 'Institut des Géosciences',
university: 'Université Grenoble Alpes',
education_level: 'doctorate',
languages: ['french', 'english', 'wolof'],
ethnicity: ['african'],
height_in_inches: 73,
religion: ['atheist'],
political_beliefs: ['green'],
diet: ['vegan'],
mbti: 'infj',
big5: {
openness: 81,
conscientiousness: 76,
extraversion: 44,
agreeableness: 79,
neuroticism: 31,
},
relationship_status: ['single'],
pref_relation_styles: ['relationship'],
pref_romantic_styles: ['mono'],
pref_gender: ['female'],
pref_age_min: 27,
pref_age_max: 40,
has_kids: 0,
// The point of the persona: the saved search filters on wanting children, so this has to be
// unambiguous rather than merely open to it.
wants_kids_strength: 3,
is_smoker: false,
drinks_per_month: 3,
interests: ['Mountaineering', 'Vegan cooking', 'Climate science', 'Trail running'],
causes: ['Climate', 'Animal welfare'],
work: ['Science', 'Research'],
links: {site: 'juliensarr.fr', github: 'jsarr'},
photoCount: 3,
},
]

View File

@@ -0,0 +1,74 @@
import clsx from 'clsx'
import {ReactNode} from 'react'
import {eyebrow} from 'web/components/widgets/surface'
import {useAPIGetter} from 'web/hooks/use-api-getter'
import {useT} from 'web/lib/locale'
/**
* The live number band at the top of the about page (A5 in docs/marketing-visuals.md).
*
* It opens the page with something that is not a card — ten of the page's blocks are the same bordered
* tile, which is what made it read as flat.
*
* The country breakdown that used to sit beside it now lives on /stats
* (`widgets/country-spread.tsx`): it is a distribution readout for someone who came to read numbers,
* and this page only needed one claim about reach, which the member count already makes.
*
* **Never hardcoded.** Same reasoning as `MemberGrowth`: a number that quietly goes stale on a page
* arguing for transparency is the failure worth engineering against. And for the same reason it
* renders *nothing* on an empty or failed response rather than a zero or a skeleton — an empty frame
* claims more than it shows.
*
* Deliberately kept away from the vote tally further down the page. Twelve voters next to a large
* member count is the turnout tension A1 was placed on this page to manage, not to advertise.
*/
function formatCount(n: number) {
return n >= 10000 ? `${Math.floor(n / 1000)}k` : n.toLocaleString('en-US')
}
function Stat({value, label}: {value: ReactNode; label: string}) {
return (
<div className="flex-1 min-w-[110px]">
<div className="font-heading text-[clamp(30px,4vw,46px)] font-bold text-ink-900 leading-none tracking-tight tabular-nums">
{value}
</div>
<div className={clsx(eyebrow, 'text-ink-700 mt-2.5')}>{label}</div>
</div>
)
}
/**
* A rule-bounded band rather than a row of cards. The point is to break the card rhythm, so borrowing
* the card treatment would defeat it.
*/
export function StatBand() {
const t = useT()
const {data} = useAPIGetter('stats', {})
if (!data?.profiles) return null
return (
<div className="border-y border-canvas-200 py-8 flex flex-wrap gap-x-10 gap-y-7">
<Stat value={formatCount(data.profiles)} label={t('about.stat.members', 'Members')} />
{data.countryCount > 1 && (
<Stat
value={formatCount(data.countryCount)}
label={t('about.stat.countries', 'Countries')}
/>
)}
{data.conversations > 0 && (
<Stat
value={formatCount(data.conversations)}
label={t('about.stat.conversations', 'Conversations')}
/>
)}
{/* Not a queried number, and deliberately so: it is the one claim here that is a policy rather
than a measurement, and it is the whole argument of the "Completely Free" card. */}
<Stat
value={t('about.stat.price_value', '$0')}
label={t('about.stat.price', 'Cost to use')}
/>
</div>
)
}

View File

@@ -0,0 +1,129 @@
import {CodeBracketIcon, StarIcon, UserGroupIcon} from '@heroicons/react/24/outline'
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 {useAPIGetter} from 'web/hooks/use-api-getter'
import {useT} from 'web/lib/locale'
/**
* Open-source activity as evidence for "Community Owned / no VC" (A6 in docs/marketing-visuals.md).
*
* A2 — a genuine photo of the people behind the project — is the better version of this claim and is
* still open, because it needs consent only Martin can collect. This is the half that needs nobody's
* consent: the repo is public, so its contributor count, stars and commit recency are already facts a
* reader can check, and they say the thing the card asserts.
*
* **Our components, not a screenshot of GitHub.** H2 rejected embedding a third-party contributor
* graph for two reasons that still hold: it is stale the moment it is captured, and it is not Compass
* pixels on a page whose other visuals are. Rendering live numbers in our own type also survives a
* restyle and translates properly.
*
* **Proxied through our API** (`repo-stats`), not fetched from the browser. Unauthenticated GitHub
* allows 60 requests/hour per IP; a public page calling it directly would spend that budget on its
* own visitors and start rendering nothing. The endpoint caches for six hours.
*
* Renders nothing when the upstream call fails, and each number is independently omitted when only
* that one is missing — a fabricated or zeroed count would undercut the exact claim being made.
*/
type IconType = ComponentType<SVGProps<SVGSVGElement>>
function Metric({icon: Icon, value, label}: {icon: IconType; value: ReactNode; label: string}) {
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">
{value}
</div>
<div className="text-xs text-ink-600 truncate">{label}</div>
</div>
</div>
)
}
/** "3 days ago" / "today" — enough to show the project is alive without implying more precision. */
function daysAgoLabel(date: Date, t: ReturnType<typeof useT>) {
const days = Math.floor((Date.now() - date.getTime()) / (24 * 60 * 60 * 1000))
if (days <= 0) return t('about.repo.today', 'today')
if (days === 1) return t('about.repo.yesterday', 'yesterday')
return t('about.repo.days_ago', '{count} days ago', {count: days})
}
export function RepoActivity() {
const t = useT()
const {data} = useAPIGetter('repo-stats', {})
if (!data) return null
// 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
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>
<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')}
/>
)}
</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>
)
}

View File

@@ -0,0 +1,197 @@
import clsx from 'clsx'
import Image from 'next/image'
import {useEffect, useState} from 'react'
import {useT} from 'web/lib/locale'
/**
* The saved-search alert clip on the about page (A4 in docs/marketing-visuals.md).
*
* "Get Notified About Searches" is the most distinctive claim the page makes and the hardest to prove
* with a still, because the whole point of it happens *later*: you search for something specific, find
* nobody, ask to be told when someone turns up, and days afterwards the platform tells you. Only a clip
* can show elapsed time.
*
* It renders as a card *inside* "What makes us different", in the column beside the three cards it is
* evidence for ("Keyword Search the Database", "Get Notified About Searches", "Personality-Centered"),
* rather than as a section of its own further down. The claim and its proof were a full screen apart,
* which is the one arrangement that stops the clip from doing any work.
*
* Generated from the live app plus the real email template — see
* `media-creator/scripts/capture-alert.mjs`; regenerate with `npm run capture:alert` then
* `npm run render:alert` / `npm run still:alert`.
*
* Loading shape mirrors the home hero (`components/home/search-demo.tsx`, which carries the longer
* note): CSS picks the poster so the first paint is never the wrong theme, the <video> mounts only
* after `load`, and reduced-motion visitors keep the poster and get no video at all.
*
* Unlike the hero clip this one is **English-only**, and unavoidably so: its middle beat is a
* screenshot of an email, and the email's text is baked into the image. The surrounding caption is
* translated normally, so the point still lands in every locale — same trade-off as the vote card.
*/
const MEDIA_BASE = (process.env.NEXT_PUBLIC_MEDIA_BASE_URL ?? '').replace(/\/$/, '')
const videoUrl = (name: string) => `${MEDIA_BASE}/videos/${name}`
const ASSETS = {
light: {
poster: '/images/search-alert-poster-light.jpg',
video: videoUrl('search-alert-light.mp4'),
},
dark: {
poster: '/images/search-alert-poster-dark.jpg',
video: videoUrl('search-alert-dark.mp4'),
},
} as const
// The capture canvas from capture-alert.mjs (390x844 CSS at DPR 2).
const WIDTH = 780
const HEIGHT = 1688
/**
* Tracks the app's resolved theme via the `dark` class on <html>, which `use-theme.ts` sets from both
* the system preference and the manual toggle. Only the <video> uses it; the poster is chosen in CSS
* because it is the first paint.
*/
function useIsDarkTheme() {
const [isDark, setIsDark] = useState(false)
useEffect(() => {
const root = document.documentElement
const read = () => setIsDark(root.classList.contains('dark'))
read()
const observer = new MutationObserver(read)
observer.observe(root, {attributes: true, attributeFilter: ['class']})
return () => observer.disconnect()
}, [])
return isDark
}
function useShouldPlay() {
const [shouldPlay, setShouldPlay] = useState(false)
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const start = () => setShouldPlay(true)
if (document.readyState === 'complete') {
start()
return
}
window.addEventListener('load', start)
return () => window.removeEventListener('load', start)
}, [])
return shouldPlay
}
function Poster({src, alt, className}: {src: string; alt: string; className: string}) {
return (
<Image
src={src}
alt={alt}
width={WIDTH}
height={HEIGHT}
// Not `priority`: this sits well below the fold on the about page, unlike the home hero where
// the equivalent poster *is* the LCP element.
sizes="280px"
className={clsx('absolute inset-0 w-full h-full object-cover', className)}
/>
)
}
export function AlertDemo({
width = 'min(280px, 72vw)',
className,
}: {
/** CSS width of the device frame. The frame is 780x1688, so height follows at ~2.16x this. */
width?: string
className?: string
} = {}) {
const t = useT()
const label = t(
'about.alert.alt',
'Filtering Compass to a vegan atheist man aged 24 to 40 near Grenoble, finding nobody, saving the search, then receiving the email days later when someone matching it joins',
)
const shouldPlay = useShouldPlay()
const isDark = useIsDarkTheme()
const {poster, video} = ASSETS[isDark ? 'dark' : 'light']
return (
<div className={clsx('flex-1 flex items-center justify-center', className)}>
{/* Deeper, more offset shadow than the surrounding cards on purpose: this is a physical object
sitting on the panel, not another panel. It is the one place on the page where a heavy shadow
is doing representational work rather than signalling elevation. */}
<div
className="
relative rounded-[2.25rem] p-2
bg-canvas-100 ring-1 ring-canvas-200
shadow-[0_8px_16px_-8px_rgb(44_36_22/0.25),0_40px_80px_-32px_rgb(44_36_22/0.45)]
"
style={{width}}
>
<div
className="relative overflow-hidden rounded-[1.75rem] bg-canvas-50"
style={{aspectRatio: `${WIDTH} / ${HEIGHT}`}}
>
{/* Both posters ship and CSS picks one — `init-theme.js` sets the `dark` class before
first paint, so resolving the theme in JS here would paint the light one first and
visibly swap. `display: none` keeps the hidden one out of the accessibility tree, so
the shared alt text is announced once. */}
<Poster src={ASSETS.light.poster} alt={label} className="dark:hidden" />
<Poster src={ASSETS.dark.poster} alt={label} className="hidden dark:block" />
{shouldPlay && (
<video
key={video}
src={video}
poster={poster}
autoPlay
muted
loop
playsInline
aria-hidden
className="absolute inset-0 w-full h-full object-cover"
/>
)}
</div>
</div>
</div>
)
}
export function SearchAlertDemo() {
const t = useT()
return (
<figure
className="
h-full flex flex-col
bg-canvas-50 border-[1.5px] border-canvas-200 rounded-2xl p-5 sm:p-7
"
>
<h3 className="font-bold text-ink-900 mb-2.5">
{t('about.alert.title', 'What happens after you search')}
</h3>
{/*<p className="text-sm text-ink-500 leading-relaxed mb-5">*/}
{/* {t(*/}
{/* 'about.alert.claim',*/}
{/* 'Search for something specific enough that nobody matches it yet — and be told the day somebody does.',*/}
{/* )}*/}
{/*</p>*/}
{/* Grows to fill the column so the phone centres against the stack of cards beside it rather
than pinning to the top of a taller card. */}
<AlertDemo />
{/*<figcaption className="text-sm text-ink-500 leading-relaxed mt-5">*/}
{/* {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.',*/}
{/* )}*/}
{/*</figcaption>*/}
</figure>
)
}

View File

@@ -0,0 +1,32 @@
import clsx from 'clsx'
import {ReactNode} from 'react'
import {eyebrow} from 'web/components/widgets/surface'
/**
* Section chrome for the about page, extracted so that blocks which may render nothing can own their
* own heading.
*
* That is the whole reason this is not still inline in `pages/about.tsx`: several blocks on that page
* are live-queried and return null when the data is missing (`MemberGrowth`, `StatBand`,
* `RepoActivity`). A heading and a divider left behind by an absent block is worse
* than no section at all, so the label has to be inside the thing that decides whether to render.
*
* The surface/rhythm tokens this used to own now live in `widgets/surface.tsx`, because `/home` uses
* them too. What is left here is the chrome specific to the about page's left-aligned, rule-trailing
* section headings.
*/
export function SectionLabel({children}: {children: ReactNode}) {
return (
<div className="flex items-center gap-3 mb-5">
<span className={clsx(eyebrow, 'text-ink-700 shrink-0')}>{children}</span>
<div className="flex-1 h-px bg-gradient-to-r from-canvas-200 to-transparent" />
</div>
)
}
export function Divider() {
return (
<div className="h-px bg-gradient-to-r from-transparent via-canvas-200 to-transparent my-10" />
)
}

View File

@@ -1,5 +1,6 @@
import clsx from 'clsx'
import Link from 'next/link'
import {surface} from 'web/components/widgets/surface'
import {useT} from 'web/lib/locale'
/**
@@ -81,12 +82,12 @@ export function VoteEvidence() {
)
return (
<figure className="bg-canvas-50 border-[1.5px] border-canvas-200 rounded-2xl p-5 sm:p-7">
<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
card, so the existing fr/de strings apply as they are. */}
<p className="text-base text-ink-900 leading-relaxed mb-3">
<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(
@@ -99,7 +100,7 @@ export function VoteEvidence() {
{t('about.block.democratic.suffix', '.')}
</p>
<p className="text-sm text-ink-600 leading-relaxed mb-5">
<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:',
@@ -113,12 +114,12 @@ export function VoteEvidence() {
<Shot theme="dark" alt={alt} className="hidden dark:block" />
</div>
<figcaption className="text-sm text-ink-500 leading-relaxed mt-4">
<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="text-primary-500 hover:underline">
<Link href="/vote" className="font-medium text-primary-700 hover:underline">
{t('about.vote.link', 'See all proposals →')}
</Link>
</figcaption>

View File

@@ -118,7 +118,7 @@ export function AuthSubmitButton({
type="submit"
disabled={isLoading}
className={clsx(
'group relative w-full flex justify-center py-3.5 px-4 text-[15px] font-bold rounded-xl text-white bg-primary-500 shadow-[0_4px_16px_rgba(193,127,62,0.35)] transition-all duration-150 hover:bg-primary-600 hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(193,127,62,0.4)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500',
'group relative w-full flex justify-center py-3.5 px-4 text-[15px] font-bold rounded-xl text-white bg-cta shadow-[0_4px_16px_rgba(193,127,62,0.35)] transition-all duration-150 hover:bg-cta-hover hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(193,127,62,0.4)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500',
isLoading && 'opacity-70 cursor-not-allowed hover:translate-y-0',
)}
>

View File

@@ -22,6 +22,7 @@ export type ColorType =
| 'gold'
| 'none'
| 'primary'
| 'cta'
const sizeClasses = {
'2xs': 'px-2 py-1 text-xs',
@@ -53,12 +54,21 @@ export function buttonClass(size: SizeType, color: ColorType) {
color === 'yellow-outline' && [outline, 'text-yellow-500 hover:bg-yellow-500'],
color === 'blue' && [solid, 'bg-blue-400 hover:bg-blue-500'],
color === 'sky-outline' && [outline, 'text-sky-500 hover:bg-sky-500'],
color === 'indigo' && [solid, 'bg-primary-500 hover:bg-primary-600'],
color === 'indigo' && [solid, 'bg-cta hover:bg-cta-hover'],
// The conversion CTA (sign-up). Distinct from 'primary', which is the *tinted* secondary
// (amber-on-tan) and is used as such in three other places — the sign-up button was borrowing it
// and so rendered as a low-emphasis pill. It also hardcodes `px-4 py-2 text-sm`, which silently
// fought the `size="xl"` its callers were passing.
color === 'cta' && [
solid,
'!rounded-xl bg-cta hover:bg-cta-hover shadow-[0_6px_20px_-8px_rgb(var(--color-cta)/0.75)]',
],
color === 'indigo-outline' && [outline, 'text-primary-500 hover:bg-primary-500'],
color === 'gray' &&
'bg-canvas-200 text-ink-900 disabled:bg-ink-200 disabled:text-ink-500 hover:bg-canvas-300 hover:text-ink-1000',
color === 'gray-outline' && [outline, 'text-ink-600 hover:bg-canvas-25'],
color === 'gradient' && [gradient, 'from-primary-500 to-primary-800'],
// ink-600 on the page canvas is 4.2:1 at 14px, just under AA; ink-700 clears it.
color === 'gray-outline' && [outline, 'text-ink-700 hover:bg-canvas-25'],
color === 'gradient' && [gradient, 'from-cta to-cta-deep'],
color === 'gradient-pink' && [gradient, 'from-primary-500 to-fuchsia-500'],
color === 'gray-white' &&
'text-ink-500 hover:text-ink-900 disabled:text-ink-300 disabled:bg-transparent',

View File

@@ -12,7 +12,7 @@ export const SidebarSignUpButton = (props: {className?: string}) => {
return (
<Col className={clsx('mt-4', className)}>
<Button color="gradient" size="xl" onClick={firebaseLogin} className="w-full">
<Button size="xl" onClick={firebaseLogin} className="w-full">
Sign up
</Button>
</Col>

View File

@@ -24,7 +24,7 @@ export function StickyFormatMenu(props: {
const [iframeOpen, setIframeOpen] = useState(false)
return (
<Row className="text-ink-600 h-8 items-center">
<Row className="text-ink-700 h-9 shrink-0 items-center border-t border-canvas-200 bg-canvas-50 px-1">
<UploadButton key={'upload-button'} upload={upload} />
{!hideEmbed && (
<ToolbarButton

View File

@@ -326,7 +326,7 @@ export function CreateEventModal(props: {
<button
type="submit"
disabled={loading}
className="bg-primary-500 hover:bg-primary-600 text-white rounded-md px-4 py-2 text-sm font-medium disabled:opacity-50"
className="bg-cta hover:bg-cta-hover text-white rounded-md px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{loading
? isEditing

View File

@@ -231,7 +231,7 @@ export function EventCard(props: {
{!isRsvped && (
<button
onClick={() => onRsvp?.(event.id, 'going')}
className="bg-primary-500 hover:bg-primary-600 text-white rounded-md px-3 py-1.5 text-sm font-medium"
className="bg-cta hover:bg-cta-hover text-white rounded-md px-3 py-1.5 text-sm font-medium"
>
{t('events.going', 'Going')}
</button>

View File

@@ -234,9 +234,7 @@ export const Search = forwardRef<
}}
// size={'xs'}
color={'none'}
className={
'text-white bg-primary-500 hover:bg-primary-400 rounded-xl text-xs transition-colors'
}
className={'text-white bg-cta hover:bg-cta-hover rounded-xl text-xs transition-colors'}
>
<Bell className="h-4 w-4 mr-1 hidden sm:flex" />{' '}
{bookmarked

View File

@@ -1,12 +1,14 @@
import {AdjustmentsHorizontalIcon, EyeIcon, UsersIcon} from '@heroicons/react/24/outline'
import clsx from 'clsx'
import {discordLink, githubRepo} from 'common/constants'
import Link from 'next/link'
import {ComponentType, ReactNode, SVGProps, useEffect, useRef} from 'react'
import {FaDiscord, FaGithub} from 'react-icons/fa'
import {SearchDemo} from 'web/components/home/search-demo'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {SignUpButton} from 'web/components/nav/sidebar'
import {Reveal} from 'web/components/widgets/reveal'
import {eyebrow, Section, surface, surfaceHover} from 'web/components/widgets/surface'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
@@ -29,7 +31,7 @@ interface SocialAvatarProps {
function EyebrowBadge({children}: {children: React.ReactNode}) {
return (
<div className="inline-flex items-center gap-2 bg-canvas-200 text-primary-700 border border-primary-300 rounded-full px-4 py-1.5 text-sm font-semibold mb-8 animate-fade-up">
<div className="inline-flex items-center gap-2 bg-canvas-200 text-primary-700 ring-1 ring-primary-300 rounded-full px-4 py-1.5 text-sm font-semibold mb-8 animate-fade-up">
{/*<span className="w-2 h-2 rounded-full bg-[#6B8F71] inline-block" />*/}
{children}
</div>
@@ -38,15 +40,8 @@ function EyebrowBadge({children}: {children: React.ReactNode}) {
function FeatureCard({icon: Icon, title, text}: FeatureCardProps) {
return (
<div
className="
group relative overflow-hidden
bg-canvas-50 border-[1.5px] border-canvas-200 rounded-2xl p-7
transition-all duration-200
hover:-translate-y-1 hover:shadow-[0_12px_32px_rgba(44,36,22,0.10)] hover:border-primary-500
"
>
<div className="w-11 h-11 rounded-xl bg-primary-100 border border-primary-200 flex items-center justify-center mb-5">
<div className={clsx(surface, surfaceHover, 'h-full p-6 sm:p-7')}>
<div className="w-11 h-11 rounded-xl bg-primary-100 ring-1 ring-primary-200 flex items-center justify-center mb-5">
<Icon className="w-5 h-5 text-primary-600" strokeWidth={1.8} />
</div>
<h3 className="font-bold text-ink-1000 mb-2.5">{title}</h3>
@@ -90,18 +85,37 @@ function SocialProof({label}: {label: React.ReactNode}) {
)
}
/**
* The manifesto line, promoted to the page's one statement block.
*
* It was a 16px italic paragraph in a card the same weight as the three feature tiles above it, which
* is an odd way to treat the sentence that states what the project is *for*. It is now the only body
* copy on the page set at display size, on the only tinted surface — the same role "One Mission" plays
* on /about, and deliberately the same treatment so the two pages read as one product.
*/
function QuoteBlock({children}: {children: React.ReactNode}) {
return (
<div className="relative bg-canvas-50 border-[1.5px] border-canvas-200 rounded-2xl px-10 py-9 text-center max-w-2xl mx-auto mb-14">
<div
className={clsx(
'relative overflow-hidden rounded-2xl px-8 py-10 sm:px-14 sm:py-14',
'bg-gradient-to-br from-primary-100 via-canvas-50 to-canvas-50',
'dark:from-primary-900/25 dark:via-canvas-50 dark:to-canvas-50',
'ring-1 ring-primary-200',
'shadow-[0_1px_2px_rgb(44_36_22/0.04),0_16px_40px_-24px_rgb(44_36_22/0.35)]',
'dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05)]',
)}
>
<svg
aria-hidden
viewBox="0 0 24 24"
fill="currentColor"
className="absolute top-2 left-3 w-9 h-9 text-primary-500/30 select-none"
className="absolute -top-2 left-4 w-20 h-20 text-primary-500/15 select-none"
>
<path d="M9.5 5C6.46 5 4 7.46 4 10.5c0 2.9 2.24 5.27 5.08 5.48-.34 1.2-1.2 2.3-2.58 3.02a.6.6 0 0 0 .3 1.13c3.9-.5 6.7-3.78 6.7-8.13V10.5C13.5 7.46 11.04 5 9.5 5Zm9 0C15.46 5 13 7.46 13 10.5c0 2.9 2.24 5.27 5.08 5.48-.34 1.2-1.2 2.3-2.58 3.02a.6.6 0 0 0 .3 1.13c3.9-.5 6.7-3.78 6.7-8.13V10.5C22.5 7.46 20.04 5 18.5 5Z" />
</svg>
<p className="relative z-10 text-base text-ink-600 italic leading-relaxed">{children}</p>
<p className="relative z-10 font-heading text-ink-900 text-[clamp(20px,2.6vw,32px)] leading-[1.3] tracking-tight max-w-3xl text-balance">
{children}
</p>
</div>
)
}
@@ -116,30 +130,40 @@ function OpenSourceStrip({
badges: {label: string; url: string; primary?: boolean; icon?: ReactNode}[]
}) {
return (
<div className="w-full max-w-3xl bg-canvas-950 dark:bg-canvas-300 rounded-2xl px-10 py-8 flex items-center justify-between gap-6 flex-wrap">
<div>
<h3 className="text-white text-xl font-bold mb-1.5">{title}</h3>
<p className="text-white/55 text-sm leading-relaxed">{description}</p>
</div>
<Row className="flex flex-wrap gap-4">
{badges.map((b) => (
<Link
href={b.url}
key={b.label}
className={`
inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold border transition-all duration-150
${
// `bg-canvas-950` in both themes rather than the old `dark:bg-canvas-300`. Inverting it made the
// closing block a *pale* slab on a dark page — the one element meant to read as the page's ending
// instead read as a hole punched in it. Espresso-on-cream and near-black-on-dark both land as the
// same gesture. Matches the closing strip on /about.
<div className="relative overflow-hidden w-full bg-canvas-950 rounded-3xl px-7 py-10 sm:px-12 sm:py-14">
<div
aria-hidden
className="pointer-events-none absolute -top-32 -right-20 w-[440px] h-[440px] rounded-full bg-primary-500/20 blur-3xl"
/>
<div className="relative flex items-center justify-between gap-8 flex-wrap">
<div className="max-w-[520px]">
<h3 className="font-heading text-white text-[clamp(22px,2.4vw,32px)] font-bold leading-tight tracking-tight mb-3 text-balance">
{title}
</h3>
<p className="text-white/70 text-base leading-relaxed">{description}</p>
</div>
<Row className="flex flex-wrap gap-3">
{badges.map((b) => (
<Link
href={b.url}
key={b.label}
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',
b.primary
? 'bg-primary-500 text-white border-primary-500 hover:bg-primary-600'
: 'bg-primary-500/30 text-white/75 border-primary-500/30 hover:bg-primary-500/50'
}
`}
>
{b.icon}
{b.label}
</Link>
))}
</Row>
? 'bg-cta text-white border-cta hover:bg-cta-hover shadow-[0_6px_20px_-6px_rgba(193,127,62,0.6)]'
: 'bg-white/[0.06] text-white/70 border-white/10 hover:bg-white/[0.12] hover:text-white',
)}
>
{b.icon}
{b.label}
</Link>
))}
</Row>
</div>
</div>
)
}
@@ -247,14 +271,18 @@ export function LoggedOutHome() {
return (
<>
{/* Mobile sign-up CTA */}
{/* Mobile sign-up CTA.
The fixed positioning now lives on a wrapper rather than on the button itself. Previously the
button carried `w-full left-0 right-0 px-4`, so it spanned edge to edge and the `px-4` became
padding *inside* it instead of a gutter — a full-bleed slab with its corners cut off by the
viewport. The wrapper owns the gutter, the button keeps its radius.
The gradient backdrop fades page content out behind the bar as it scrolls under; it is
`pointer-events-none` so the transparent upper half does not swallow taps, with the button
itself opting back in. */}
{user === null && (
<Col className="mb-4 gap-2 lg:hidden">
<SignUpButton
className="mt-4 flex-1 fixed bottom-[calc(55px+env(safe-area-inset-bottom))] w-full left-0 right-0 z-10 mx-auto px-4"
size="xl"
/>
</Col>
<div className="lg:hidden fixed left-0 right-0 bottom-[calc(55px+env(safe-area-inset-bottom))] z-20 px-4 pb-3 pt-8 pointer-events-none bg-gradient-to-t from-canvas-100 via-canvas-100/90 to-transparent">
<SignUpButton className="pointer-events-auto" size="xl" />
</div>
)}
<div className="flex flex-col items-center w-full px-4 pb-16">
@@ -286,7 +314,11 @@ export function LoggedOutHome() {
// <div>, so the global h1h6 rule does not reach it — without this the two lines render in
// different faces.
className="animate-fade-up font-heading font-semibold text-[clamp(52px,8vw,96px)] lg:text-[clamp(44px,5.4vw,84px)] leading-none tracking-tight text-primary-500 mb-9 flex items-center justify-center lg:justify-start min-h-[1.1em]"
// primary-600, not -500: at 69px this counts as large text, which needs 3:1, and the
// brand base measured 2.70:1 on the light canvas. One step down clears it at 3.71:1,
// and because the ramp inverts, the dark theme gets a *brighter* amber here (6.8:1)
// rather than a dimmer one.
className="animate-fade-up font-heading font-semibold text-[clamp(52px,8vw,96px)] lg:text-[clamp(44px,5.4vw,84px)] leading-none tracking-tight text-primary-600 mb-9 flex items-center justify-center lg:justify-start min-h-[1.1em]"
style={{animationDelay: '80ms'}}
>
<span ref={typewriterRef} />
@@ -316,14 +348,18 @@ export function LoggedOutHome() {
{user === null && (
<Link
href={'/register'}
className="hidden sm:inline-flex items-center justify-center px-8 py-3.5 rounded-xl bg-primary-500 text-white font-bold text-[15px] shadow-[0_4px_16px_rgba(193,127,62,0.35)] hover:bg-primary-600 hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(193,127,62,0.4)] transition-all duration-150"
className="hidden sm:inline-flex items-center justify-center px-8 py-3.5 rounded-xl bg-cta text-white font-bold text-[15px] shadow-[0_4px_16px_rgba(193,127,62,0.35)] hover:bg-cta-hover hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(193,127,62,0.4)] transition-all duration-150"
>
{t('home.cta.primary', 'Get started — free')}
</Link>
)}
{/* Was a hardcoded `text-[#8B5E3C]`, which is a light-mode value: on the dark theme it
rendered dark-brown-on-dark and all but disappeared (2.7:1). `ink-900` rather than a
`primary-*` step because the ink ramp inverts reliably between themes, and it matches
the outline buttons on /about. The accent stays on the border and the hover. */}
<Link
href={'/about'}
className="px-7 py-3.5 rounded-xl bg-transparent text-[#8B5E3C] font-semibold text-[15px] border-2 border-canvas-200 hover:border-primary-500 hover:text-primary-500 hover:-translate-y-0.5 transition-all duration-150"
className="px-7 py-3.5 rounded-xl bg-transparent text-ink-900 font-semibold text-[15px] border-2 border-canvas-200 hover:border-primary-500 hover:text-primary-500 hover:-translate-y-0.5 transition-all duration-200 ease-out"
>
{t('home.cta.secondary', 'Learn how it works')}
</Link>
@@ -348,47 +384,63 @@ export function LoggedOutHome() {
<SearchDemo />
</section>
{/* Divider */}
<div className="w-full max-w-3xl h-px bg-gradient-to-r from-transparent via-[#D8D0C4] to-transparent mb-14" />
{/* Every section below the hero shares the hero's container instead of stepping through
max-w-3xl / 4xl / 2xl / 3xl as it used to. Those four different widths gave the page a
ragged left and right edge all the way down; the measure is now capped on the text inside
each block rather than on the blocks themselves. The gradient divider that used to sit here
is gone with it — `Section`'s rhythm separates them without a drawn rule. */}
<div className="w-full max-w-6xl">
{/* ── Features ── */}
<Section>
<p className={clsx(eyebrow, 'text-center text-primary-700 mb-3')}>
{t('home.features.label', 'Why Compass')}
</p>
<h2 className="text-center text-[clamp(26px,3.4vw,40px)] text-ink-1000 tracking-tight mb-12 text-balance">
{t('home.features.title', 'Built different. On purpose.')}
</h2>
<div className="grid md:grid-cols-3 gap-4 sm:gap-5">
{features.map((f, i) => (
<Reveal key={f.title} delay={i * 70}>
<FeatureCard {...f} />
</Reveal>
))}
</div>
</Section>
{/* ── Features ── */}
<section className="w-full max-w-4xl mb-14">
<p className="text-center text-xs font-bold tracking-[1.5px] uppercase text-primary-500 mb-3">
{t('home.features.label', 'Why Compass')}
</p>
<h2 className="text-center text-[clamp(24px,3vw,32px)] text-ink-1000 tracking-tight mb-10">
{t('home.features.title', 'Built different. On purpose.')}
</h2>
<div className="grid md:grid-cols-3 gap-5">
{features.map((f) => (
<FeatureCard key={f.title} {...f} />
))}
</div>
</section>
{/* ── Quote ── */}
<Section>
<Reveal>
<QuoteBlock>
{t('home.quote.prefix', 'Compass is to human connection what ')}
<strong className="text-primary-700">{t('home.quote.linux', 'Linux')}</strong>
{t('home.quote.linux_suffix', ' is to software, ')}
<strong className="text-primary-700">
{t('home.quote.wikipedia', 'Wikipedia')}
</strong>
{t('home.quote.wikipedia_suffix', ' is to knowledge, and ')}
<strong className="text-primary-700">{t('home.quote.firefox', 'Firefox')}</strong>
{t('home.quote.end', ' is to browsing — a public digital good designed to ')}
<strong className="text-primary-700">
{t('home.quote.mission', 'serve people, not profit.')}
</strong>
</QuoteBlock>
</Reveal>
</Section>
{/* ── Quote ── */}
<QuoteBlock>
{t('home.quote.prefix', 'Compass is to human connection what ')}
<strong className="text-ink-900">{t('home.quote.linux', 'Linux')}</strong>
{t('home.quote.linux_suffix', ' is to software, ')}
<strong className="text-ink-900">{t('home.quote.wikipedia', 'Wikipedia')}</strong>
{t('home.quote.wikipedia_suffix', ' is to knowledge, and ')}
<strong className="text-ink-900">{t('home.quote.firefox', 'Firefox')}</strong>
{t('home.quote.end', ' is to browsing — a public digital good designed to ')}
<strong className="text-ink-900">
{t('home.quote.mission', 'serve people, not profit.')}
</strong>
</QuoteBlock>
{/* ── Open source strip ── */}
<OpenSourceStrip
title={t('home.strip.title', 'Open Source & Free Forever')}
description={t(
'home.strip.description',
'No venture capital. No ads. No subscription fees. Built transparently by the community, for the community.',
)}
badges={openSourceBadges}
/>
{/* ── Open source strip ── */}
<Section>
<Reveal>
<OpenSourceStrip
title={t('home.strip.title', 'Open Source & Free Forever')}
description={t(
'home.strip.description',
'No venture capital. No ads. No subscription fees. Built transparently by the community, for the community.',
)}
badges={openSourceBadges}
/>
</Reveal>
</Section>
</div>
</div>
{/* Mobile bottom spacing */}

View File

@@ -263,122 +263,147 @@ export const SendMessageButton = (props: {
why the top of a long message and the image/emoji toolbar became unreachable. Keeping
children at their natural height makes the panel itself overflow, which `overflow-y-auto`
then scrolls. */}
<Col
ref={scrollRef}
className={clsx(MODAL_CLASS, '!h-auto max-h-[85vh] overflow-y-auto pb-5 [&>*]:shrink-0')}
>
{/* Header. Follows the Compose Modal design: portrait, an uppercase "writing to" eyebrow,
<Col className={clsx(MODAL_CLASS, '!h-auto max-h-[85vh] overflow-hidden pb-5 !gap-0')}>
{/* Split into a scrolling region and a pinned footer.
Everything used to live in one scrolling column, so the progress bar and the send button
— the two things telling you whether you may send yet — drifted off the bottom as soon as
the message got long. They are now outside the scroller entirely and cannot move.
`min-h-0` on the scroller is load-bearing: a flex child's default `min-height: auto`
refuses to shrink below its content, so without it the region would grow past the panel
and push the footer out again — the same class of bug as the `[&>*]:shrink-0` note. */}
<Col
ref={scrollRef}
className={'w-full flex-1 min-h-0 overflow-y-auto gap-4 [&>*]:shrink-0'}
>
{/* Header. Follows the Compose Modal design: portrait, an uppercase "writing to" eyebrow,
then the serif title and a hairline. The earlier full-bleed gradient band competed with
the content it was introducing. */}
<Col className={'w-full'}>
<Row className={'items-center gap-3.5 mb-6'}>
{profile.pinned_url && (
<img
src={profile.pinned_url}
alt=""
className={'h-13 w-13 rounded-full object-cover border border-canvas-200'}
style={{width: 52, height: 52}}
/>
)}
<Col className={'gap-0.5'}>
<span
className={
'text-primary-600 text-[11px] font-semibold uppercase tracking-[0.09em]'
}
>
{t('send_message.writing_to', 'Writing to')}
</span>
<span className={'text-ink-900 text-[17px] font-semibold leading-tight'}>
{toUser.name}
</span>
</Col>
</Row>
<Col className={'w-full'}>
<Row className={'items-center gap-3.5 mb-6'}>
{profile.pinned_url && (
<img
src={profile.pinned_url}
alt=""
className={'h-13 w-13 rounded-full object-cover border border-canvas-200'}
style={{width: 52, height: 52}}
/>
)}
<Col className={'gap-0.5'}>
<span
className={
'text-primary-600 text-[11px] font-semibold uppercase tracking-[0.09em]'
}
>
{t('send_message.writing_to', 'Writing to')}
</span>
<span className={'text-ink-900 text-[17px] font-semibold leading-tight'}>
{toUser.name}
</span>
</Col>
</Row>
<h2
className={
'font-heading text-[32px] font-medium leading-[1.15] text-ink-1000 !mt-0 mb-3'
}
>
{t('send_message.title', 'Start a meaningful conversation')}
</h2>
<p className={'text-ink-500 text-[15px] leading-[1.55] max-w-[92%]'}>
{t(
'send_message.guidance',
'Compass is about depth. Take a moment to write something genuine.',
)}
</p>
<div className={'h-px bg-canvas-200 mt-6'} />
</Col>
<h2
className={
'font-heading text-[32px] font-medium leading-[1.15] text-ink-1000 !mt-0 mb-3'
}
>
{t('send_message.title', 'Start a meaningful conversation')}
</h2>
<p className={'text-ink-500 text-[15px] leading-[1.55] max-w-[92%]'}>
{t(
'send_message.guidance',
'Compass is about depth. Take a moment to write something genuine.',
)}
</p>
<div className={'h-px bg-canvas-200 mt-6'} />
</Col>
{/* skipEmailVerification is dev-only and cannot be enabled in a production build; the API
{/* skipEmailVerification is dev-only and cannot be enabled in a production build; the API
still rejects unverified senders. See web/lib/dev-flags.ts. */}
{firebaseUser?.emailVerified || skipEmailVerification ? (
<>
{/* Topics box: a 40% tint rather than solid canvas-100. At full strength the fill sat a
{firebaseUser?.emailVerified || skipEmailVerification ? (
<>
{/* Topics box: a 40% tint rather than solid canvas-100. At full strength the fill sat a
whole step away from the modal's canvas-50 surface and read as a separate panel. The
border is what defines the box; the fill only needs to hint at it. Works in both
themes, since canvas-100 sits on the far side of canvas-50 either way. */}
{!!profile.keywords?.length && (
<div
className={
'w-full bg-canvas-100/80 border border-canvas-200 rounded-2xl p-[18px]'
}
>
<p className={'text-primary-600 mb-3 text-[12.5px] font-semibold'}>
{t('send_message.keywords_hint', `Insert some of {name}'s topics`, {
name: firstName,
})}
</p>
<div className={'flex flex-wrap gap-[9px]'}>
{profile.keywords.map((k) => (
<button
key={k}
type={'button'}
onClick={() => toggleChip(k)}
// Same chip language as the filter rail, so selecting a topic here reads the
// same as narrowing a search there.
className={clsx(
'inline-flex items-center gap-1.5 text-[13.5px] font-medium px-[15px] py-2 rounded-full border transition-colors duration-150 whitespace-nowrap',
insertedChips.includes(k)
? 'bg-primary-500 border-primary-500 text-white'
: 'bg-canvas-0 border-canvas-300 text-ink-700 hover:bg-primary-50 hover:border-primary-400',
)}
>
{insertedChips.includes(k) && (
<CheckIcon className={'h-3 w-3'} strokeWidth={3} />
)}
{k}
</button>
))}
{!!profile.keywords?.length && (
<div
className={
'w-full bg-canvas-100/80 border border-canvas-200 rounded-2xl p-[18px]'
}
>
<p className={'text-primary-600 mb-3 text-[12.5px] font-semibold'}>
{t('send_message.keywords_hint', `Insert some of {name}'s topics`, {
name: firstName,
})}
</p>
<div className={'flex flex-wrap gap-[9px]'}>
{profile.keywords.map((k) => (
<button
key={k}
type={'button'}
onClick={() => toggleChip(k)}
// Same chip language as the filter rail, so selecting a topic here reads the
// same as narrowing a search there.
className={clsx(
'inline-flex items-center gap-1.5 text-[13.5px] font-medium px-[15px] py-2 rounded-full border transition-colors duration-150 whitespace-nowrap',
insertedChips.includes(k)
? 'bg-cta border-cta text-white'
: 'bg-canvas-0 border-canvas-300 text-ink-700 hover:bg-primary-50 hover:border-primary-400',
)}
>
{insertedChips.includes(k) && (
<CheckIcon className={'h-3 w-3'} strokeWidth={3} />
)}
{k}
</button>
))}
</div>
</div>
</div>
)}
{/* No wrapper here. TextEditor already draws its own bordered shell
)}
{/* No wrapper here. TextEditor already draws its own bordered shell
(`border-ink-300 ... rounded-lg` in editor.tsx), so adding one produced two nested
outlines. Restyle that single shell instead — `!` because clsx just concatenates and
the base classes would otherwise win by stylesheet order. */}
{/* Plain wrapper purely so the effect above has something whose height it can observe;
{/* Plain wrapper purely so the effect above has something whose height it can observe;
it stretches like any flex child, so it doesn't change the layout. */}
<div ref={editorWrapRef} className={'w-full'}>
<CommentInputTextArea
editor={editor}
user={currentUser}
submit={sendMessage}
isSubmitting={!editor || submitting}
isDisabled={charCount < MIN_CHARS}
hideSubmitButton
submitOnEnter={false}
// max-h-none: let the editor grow and let the *modal* do the scrolling instead of
// trapping the message in a ~25vh inner scroller. That inner box made it awkward to
// scroll back to the top of a long message, and pushed the image/emoji toolbar —
// which sits below it — out of reach on shorter windows.
maxHeight={'max-h-none'}
className={
'!rounded-2xl !border-[1.5px] !border-canvas-200 !bg-canvas-0/50 !shadow-none focus-within:!border-primary-400 focus-within:!ring-0'
}
/>
</div>
<div ref={editorWrapRef} className={'w-full'}>
<CommentInputTextArea
editor={editor}
user={currentUser}
submit={sendMessage}
isSubmitting={!editor || submitting}
isDisabled={charCount < MIN_CHARS}
hideSubmitButton
submitOnEnter={false}
// max-h-none: let the editor grow and let the *modal* do the scrolling instead of
// trapping the message in a ~25vh inner scroller. That inner box made it awkward to
// scroll back to the top of a long message, and pushed the image/emoji toolbar —
// which sits below it — out of reach on shorter windows.
maxHeight={'max-h-none'}
className={
'!rounded-2xl !border-[1.5px] !border-canvas-200 !bg-canvas-0/50 !shadow-none focus-within:!border-primary-400 focus-within:!ring-0'
}
/>
</div>
</>
) : (
<EmailVerificationPrompt t={t} className="max-w-xl" />
)}
{error && (
<span
className={
'text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 text-sm mt-2'
}
>
{error}
</span>
)}
</Col>
{/* Pinned footer — outside the scroller, so it is visible no matter how long the message is. */}
{(firebaseUser?.emailVerified || skipEmailVerification) && (
<Col className={'w-full shrink-0 gap-3.5 pt-4'}>
{/* Progress toward the minimum length, with the count beside it. */}
<div className={'w-full flex items-center gap-3.5'}>
<div className={'h-[5px] flex-1 rounded-full bg-canvas-200 overflow-hidden'}>
@@ -409,18 +434,7 @@ export const SendMessageButton = (props: {
count: MIN_CHARS - charCount,
})}
</button>
</>
) : (
<EmailVerificationPrompt t={t} className="max-w-xl" />
)}
{error && (
<span
className={
'text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 text-sm mt-2'
}
>
{error}
</span>
</Col>
)}
</Col>
</Modal>

View File

@@ -34,7 +34,7 @@ function OptionChip(props: {
'focus-within:ring-2 focus-within:ring-primary-400 focus-within:ring-offset-1 focus-within:ring-offset-canvas-50',
disabled && 'cursor-not-allowed opacity-50',
checked
? 'border-primary-500 bg-primary-500 text-white shadow-[0_2px_8px_rgba(193,127,62,0.28)]'
? 'border-cta bg-cta text-white shadow-[0_2px_8px_rgba(193,127,62,0.28)]'
: 'border-canvas-300 bg-canvas-0 text-ink-600 hover:border-primary-400 hover:text-primary-700',
)}
>

View File

@@ -142,7 +142,7 @@ export const SignUpButton = (props: {
return (
<Button
color={color ?? 'gradient'}
color={color ?? 'cta'}
size={size ?? 'xl'}
onClick={startSignup}
className={clsx('w-full', className)}

View File

@@ -1,3 +1,4 @@
import {InformationCircleIcon} from '@heroicons/react/24/outline'
import * as Sentry from '@sentry/node'
import {Editor} from '@tiptap/react'
import clsx from 'clsx'
@@ -60,6 +61,14 @@ import {AddPhotosWidget} from './widgets/add-photos'
const DEFAULT_PREF_GENDER_VALUES = ['female', 'male']
const BIG5_KEYS = [
'big5_openness',
'big5_conscientiousness',
'big5_extraversion',
'big5_agreeableness',
'big5_neuroticism',
] as const
function PrefGenderCheckbox(props: {
profile: ProfileWithoutUser
setProfile: <K extends keyof ProfileWithoutUser>(key: K, value: ProfileWithoutUser[K]) => void
@@ -87,7 +96,7 @@ function PrefGenderCheckbox(props: {
{!showAll && (
<button
type="button"
className="text-primary-600 mt-1 text-sm"
className="text-primary-700 mt-1 text-sm font-medium hover:underline"
onClick={() => setShowAll(true)}
>
{t('profile.gender.show_more', 'Show more options')}
@@ -134,6 +143,9 @@ export const OptionalProfileUserForm = (props: {
const heightInches =
typeof profile.height_in_inches === 'number' ? profile.height_in_inches % 12 : undefined
const anyBig5Set = BIG5_KEYS.some((k) => typeof profile[k] === 'number')
const resetBig5 = () => BIG5_KEYS.forEach((k) => setProfile(k, null))
const [isExtracting, setIsExtracting] = useState(false)
const [parsingEditor, setParsingEditor] = useState<any>(null)
const [extractionProgress, setExtractionProgress] = useState(0)
@@ -358,13 +370,26 @@ export const OptionalProfileUserForm = (props: {
return (
<>
<Col className={'gap-8 max-w-3xl'}>
<p className={'guidance'}>
{t(
'profile.optional.subtitle',
'Although all the fields below are optional, they will help people better understand you and connect with you.',
)}
</p>
{/* `pb-32`: the bio editor is the last element on the page, so when it is focused there is
nothing below it to scroll into — the document simply ends, and no amount of
`scrollIntoView` can lift its toolbar off the bottom edge. This reserves scrollable room
below the form so the toolbar can clear the viewport edge (and the floating submit button). */}
<Col className={'gap-8 max-w-3xl pb-32'}>
{/* This sentence is the single most abandonment-reducing thing on the page — it tells you the
eighteen sections below are all skippable. It was rendered in `.guidance`: 14px at 70%
opacity, i.e. styled like fine print, which is exactly backwards. */}
<div className="flex items-start gap-3 rounded-xl bg-primary-100/60 ring-1 ring-primary-200 p-4">
<InformationCircleIcon
className="w-5 h-5 text-primary-700 shrink-0 mt-0.5"
strokeWidth={1.8}
/>
<p className="text-sm text-ink-700 leading-relaxed">
{t(
'profile.optional.subtitle',
'Although all the fields below are optional, they will help people better understand you and connect with you.',
)}
</p>
</div>
<Category title={t('profile.llm.extract.title', 'Auto-fill')} className={'mt-0'} />
<LLMExtractSection
parsingEditor={parsingEditor}
@@ -379,8 +404,8 @@ export const OptionalProfileUserForm = (props: {
{extractionError}
</p>
)}
<hr className="border border-b my-4" />
{/* The rule that used to sit here (`border border-b`, which draws two) is now owned by
`Category` itself, so every section boundary is drawn the same way. */}
<Category
title={t('profile.optional.category.personal_info', 'Personal Information')}
className={'mt-0'}
@@ -437,7 +462,7 @@ export const OptionalProfileUserForm = (props: {
{!showAllGenders && (
<button
type="button"
className="text-primary-600 mt-1 text-sm"
className="text-primary-700 mt-1 text-sm font-medium hover:underline"
onClick={() => setShowAllGenders(true)}
>
{t('profile.gender.show_more', 'Show more options')}
@@ -797,7 +822,7 @@ export const OptionalProfileUserForm = (props: {
{!showAllOrientations && (
<button
type="button"
className="text-primary-600 mt-1 text-sm"
className="text-primary-700 mt-1 text-sm font-medium hover:underline"
onClick={() => setShowAllOrientations(true)}
>
{t('profile.orientation.show_more', 'Show more options')}
@@ -1038,15 +1063,36 @@ export const OptionalProfileUserForm = (props: {
{/* Big Five personality traits (0100) */}
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.big5', 'Big Five Personality Traits')}
</label>
<Row className="w-full items-center justify-between gap-3">
<label className={clsx(labelClassName)}>
{t('profile.big5', 'Big Five Personality Traits')}
</label>
{/* A slider cannot express "unset": drag one and there is no gesture that puts it back,
so before this the first accidental touch permanently committed a score. Clearing
writes `null` rather than `undefined` on purpose — the edit path
(`pages/profile.tsx`) strips undefined but persists null, so undefined would silently
leave the old value in the database. Signup strips both, which is also correct there:
an unset trait should not be sent at all. */}
{anyBig5Set && (
<button
type="button"
onClick={resetBig5}
className="text-sm font-medium text-primary-700 hover:underline shrink-0"
>
{t('profile.big5_reset', 'Reset')}
</button>
)}
</Row>
<p className="guidance custom-link">
{t(
'profile.big5_guidance',
'The Big Five personality trait model is a scientific model that groups variation in personality into five separate factors (',
)}
<CustomLink href={'https://en.wikipedia.org/wiki/Big_Five_personality_traits'}>
{/* CustomLink ships no colour of its own, so this inherited a 2.70:1 amber. */}
<CustomLink
href={'https://en.wikipedia.org/wiki/Big_Five_personality_traits'}
className="text-primary-700 font-medium hover:underline"
>
{t('profile.big5_wikipedia_link', 'Wikipedia article')}
</CustomLink>
{t(
@@ -1062,30 +1108,35 @@ export const OptionalProfileUserForm = (props: {
<Big5Slider
label={t('profile.big5_openness', 'Openness')}
value={profile.big5_openness ?? 50}
isSet={typeof profile.big5_openness === 'number'}
onChange={(v) => setProfile('big5_openness', v)}
/>
<Big5Slider
label={t('profile.big5_conscientiousness', 'Conscientiousness')}
value={profile.big5_conscientiousness ?? 50}
isSet={typeof profile.big5_conscientiousness === 'number'}
onChange={(v) => setProfile('big5_conscientiousness', v)}
/>
<Big5Slider
label={t('profile.big5_extraversion', 'Extraversion')}
value={profile.big5_extraversion ?? 50}
isSet={typeof profile.big5_extraversion === 'number'}
onChange={(v) => setProfile('big5_extraversion', v)}
/>
<Big5Slider
label={t('profile.big5_agreeableness', 'Agreeableness')}
value={profile.big5_agreeableness ?? 50}
isSet={typeof profile.big5_agreeableness === 'number'}
onChange={(v) => setProfile('big5_agreeableness', v)}
/>
<Big5Slider
label={t('profile.big5_neuroticism', 'Neuroticism')}
value={profile.big5_neuroticism ?? 50}
isSet={typeof profile.big5_neuroticism === 'number'}
onChange={(v) => setProfile('big5_neuroticism', v)}
/>
</div>
<p className="text-sm text-ink-500">
<p className="text-sm text-ink-700">
{t(
'profile.big5_hint',
'Drag each slider to set where you see yourself on these traits (0 = low, 100 = high).',
@@ -1305,7 +1356,10 @@ export const OptionalProfileUserForm = (props: {
loading={isSubmitting}
onClick={handleSubmit}
size={'xl'}
color={'primary'}
// Was `primary` — the *tinted secondary* variant. This is the button that actually
// creates the account at the end of a 7,500px form; it should not be the quietest
// control on screen.
color={'cta'}
>
{buttonLabel ?? t('common.next', 'Next')}
</Button>
@@ -1360,18 +1414,56 @@ const CitySearchBox = (props: {onCitySelected: (city: City | undefined) => void}
)
}
/**
* Section header for the long optional-profile form.
*
* This form is ~7,500px of a single flat column holding eighteen of these. Previously each was a bare
* `text-xl` heading pulled *closer* to its fields by a `mb-[-8px]` hack, so there was no visual
* boundary between one section and the next — "Work" ended and "Political beliefs" began with nothing
* between them but a slightly bolder line of text, and a filler had no way to see how the form was
* organised or where they were in it.
*
* A rule plus real leading space does the grouping without restructuring 1,300 lines of field markup:
* the header now closes the previous section as much as it opens its own. The negative margin is gone
* — the parent `Col` already supplies `gap-8`, which is the spacing this was fighting.
*/
function Category({title, className}: {title: string; className?: string}) {
return <h3 className={clsx('text-xl font-semibold mb-[-8px]', className)}>{title}</h3>
return (
// `mt-6` on top of the parent's `gap-8`, against `pt-5` below: the rule has to sit closer to the
// heading it introduces than to the section it closes, or it reads as a trailing underline for the
// previous block. Even spacing on both sides is the default mistake here.
<div className={clsx('w-full border-t border-canvas-200 mt-6 pt-5', className)}>
{/* `!mt-0`: the global h1h6 rule puts a 24px margin on this, which silently doubled the space
below the rule and made the spacing symmetric again. It is also what the old `mb-[-8px]` on
this component was compensating for. */}
<h3 className="font-heading text-2xl font-bold tracking-tight text-ink-900 !mt-0">{title}</h3>
</div>
)
}
const Big5Slider = (props: {label: string; value: number; onChange: (v: number) => void}) => {
const {label, value, onChange} = props
/**
* `isSet` distinguishes "I am a 50" from "I have not answered this".
*
* The slider has no null position — an untouched trait has to render *somewhere*, and it renders at
* the midpoint. Without a readout that says otherwise, an untouched form looks like five deliberate
* 50s, and submitting it would claim five personality scores the user never gave.
*/
const Big5Slider = (props: {
label: string
value: number
isSet: boolean
onChange: (v: number) => void
}) => {
const {label, value, isSet, onChange} = props
return (
<div>
<div className="mb-1 flex items-center justify-between text-sm text-ink-600">
<div className="mb-1 flex items-center justify-between text-sm text-ink-700">
<span>{label}</span>
<span className="font-semibold text-ink-700" data-testid={`${label.toLowerCase()}-value`}>
{Math.round(value)}
<span
className={clsx('font-semibold', isSet ? 'text-ink-700' : 'text-ink-600')}
data-testid={`${label.toLowerCase()}-value`}
>
{isSet ? Math.round(value) : ''}
</span>
</div>
<Slider

View File

@@ -5,10 +5,10 @@ import {useT} from 'web/lib/locale'
const colorClasses = {
'indigo-dark':
'text-ink-500 hover:bg-primary-100 hover:text-ink-700 aria-checked:bg-primary-500 aria-checked:text-white',
indigo: 'text-ink-500 hover:bg-ink-50 aria-checked:bg-primary-100 aria-checked:text-primary-900',
green: 'text-ink-500 hover:bg-ink-50 aria-checked:bg-teal-500 aria-checked:text-ink-0',
red: 'text-ink-500 hover:bg-ink-50 aria-checked:bg-scarlet-500 aria-checked:text-ink-0',
'text-ink-700 hover:bg-primary-100 hover:text-ink-700 aria-checked:bg-cta aria-checked:text-white',
indigo: 'text-ink-700 hover:bg-ink-50 aria-checked:bg-primary-100 aria-checked:text-primary-900',
green: 'text-ink-700 hover:bg-ink-50 aria-checked:bg-teal-500 aria-checked:text-ink-0',
red: 'text-ink-700 hover:bg-ink-50 aria-checked:bg-scarlet-500 aria-checked:text-ink-0',
}
export type ColorType = keyof typeof colorClasses

View File

@@ -0,0 +1,77 @@
import {CountryCount} from 'common/stats'
import {useAPIGetter} from 'web/hooks/use-api-getter'
import {useT} from 'web/lib/locale'
/**
* Where members are, as a ranked bar list (A5 in docs/marketing-visuals.md).
*
* Lives in `widgets/` rather than `components/about/` because it is used from `/stats`, which is where
* it ended up: it is a distribution readout for someone who came to read numbers, and the about page
* only needed one claim about reach, which the member count already carries.
*
* Takes its data as props when the caller already has it — `/stats` fetches the whole `stats` payload
* for the page — and falls back to fetching for standalone use. Passing `undefined` props to
* `useAPIGetter` makes it skip the request rather than firing a duplicate.
*
* Renders **nothing** below a handful of countries, and nothing at all when the data is missing: an
* empty frame claims more than it shows.
*/
/**
* Below this there is not enough of a spread to be worth drawing bars for. Exported because a caller
* that puts this in a column of its own has to know whether the column will be empty *before* laying
* it out — a hidden child still leaves the grid track behind.
*/
export const MIN_COUNTRIES = 3
function CountryBar({row, max}: {row: CountryCount; max: number}) {
// Widths are relative to the largest country, not to the total. Against the total the long tail
// collapses into slivers of identical apparent length and the ranking stops being readable.
const pct = Math.max(2, Math.round((row.count / max) * 100))
return (
<div className="flex items-center gap-3">
<div className="w-28 shrink-0 text-sm text-ink-600 truncate" title={row.country}>
{row.country}
</div>
<div className="flex-1 h-2 rounded-full bg-canvas-200 overflow-hidden">
<div className="h-full rounded-full bg-primary-500" style={{width: `${pct}%`}} />
</div>
<div className="w-10 shrink-0 text-right text-sm text-ink-500 tabular-nums">{row.count}</div>
</div>
)
}
export function CountrySpread(props: {countries?: CountryCount[]; countryCount?: number}) {
const t = useT()
// Skips the request entirely when the caller supplied the data — `useAPIGetter` bails on undefined
// props — so /stats, which already fetches the whole payload, does not fetch it twice.
const {data} = useAPIGetter('stats', props.countries ? undefined : {})
const countries = props.countries ?? data?.countries ?? []
const total = props.countryCount ?? data?.countryCount ?? 0
if (countries.length < MIN_COUNTRIES) return null
const max = countries[0].count
const remaining = total - countries.length
return (
<div>
<h3 className="text-sm font-bold text-ink-900 mb-1">
{t('stats.countries.title', 'Where members are')}
</h3>
<p className="text-sm text-ink-500 leading-relaxed mb-4">
{t('stats.countries.subtitle', 'Compass is not tied to one city or one country.')}
</p>
<div className="flex flex-col gap-2.5">
{countries.map((row) => (
<CountryBar key={row.country} row={row} max={max} />
))}
</div>
{remaining > 0 && (
<p className="text-xs text-ink-500 mt-3">
{t('stats.countries.remaining', '+ {count} more countries', {count: remaining})}
</p>
)}
</div>
)
}

View File

@@ -15,7 +15,7 @@ import {richTextToString} from 'common/util/parse'
import Iframe from 'common/util/tiptap-iframe'
import {debounce} from 'lodash'
import Image from 'next/image'
import {createElement, ReactNode, useCallback, useEffect, useMemo, useState} from 'react'
import {createElement, ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {Modal, MODAL_CLASS} from 'web/components/layout/modal'
import {CustomLink} from 'web/components/links'
import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state'
@@ -214,6 +214,53 @@ export function useTextEditor(props: {
const getImages = (data: DataTransfer | null) =>
Array.from(data?.files ?? []).filter((file) => file.type.startsWith('image'))
/** Breathing room left below the format toolbar when we scroll it back into view. */
const TOOLBAR_VIEWPORT_GAP = 16
/**
* The lowest y coordinate that is actually *visible*, which is not the same as the viewport bottom.
*
* On mobile the bottom nav bar is `fixed inset-x-0 bottom-0 z-50`, so it sits on top of the page —
* scrolling something to `innerHeight - gap` parks it underneath the nav, which is exactly as hidden
* as being off-screen. Measuring the obstruction rather than hardcoding its height keeps this correct
* on the pages that render no bottom nav (signup), across the `lg` breakpoint where it disappears, and
* over the safe-area inset on notched devices.
*
* `nav` covers the bottom bar; `[data-bottom-overlay]` is an opt-in for any other fixed bottom
* furniture that gets added later. Both are cheap to query on each keystroke.
*/
function getUsableViewportBottom(): number {
const viewportBottom = window.visualViewport?.height ?? window.innerHeight
let usable = viewportBottom
document.querySelectorAll<HTMLElement>('nav, [data-bottom-overlay]').forEach((el) => {
if (getComputedStyle(el).position !== 'fixed') return
const r = el.getBoundingClientRect()
if (r.height === 0 || r.width === 0) return // `lg:hidden` etc.
// Anchored to the bottom edge and covering it.
if (r.bottom >= viewportBottom - 2 && r.top < usable) usable = r.top
})
return usable
}
/**
* Nearest ancestor that actually scrolls vertically, or null when the page itself is the scroller.
*
* Needed because this editor is used both inline on a page and inside a modal, and a modal is its own
* scroll container with the page behind it locked — so scrolling the window there does nothing at all.
* The editor's own content area is a *sibling* of the toolbar, not an ancestor, so it is never picked
* up by this walk.
*/
function getScrollParent(el: HTMLElement | null): HTMLElement | null {
let node = el?.parentElement ?? null
while (node && node !== document.body && node !== document.documentElement) {
const {overflowY} = getComputedStyle(node)
const scrolls = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'
if (scrolls && node.scrollHeight > node.clientHeight) return node
node = node.parentElement
}
return null
}
export function TextEditor(props: {
editor: Editor | null
simple?: boolean // show heading in toolbar
@@ -232,9 +279,70 @@ export function TextEditor(props: {
className,
onBlur,
onChange,
maxHeight = 'max-h-[69vh]',
// The content area scrolls internally once it hits this; below it, the box just grows. At the
// previous 69vh the box could become two-thirds of the viewport tall before anything scrolled,
// which pushed the format toolbar (image / embed / emoji) off the bottom of the screen as soon as
// you typed a few lines — the toolbar sits *after* the content in flow, so its position is
// whatever the content height puts it at. Capping lower means new lines scroll within the box
// instead of growing it, and the toolbar stays a fixed ~32px below wherever you are typing.
maxHeight = 'max-h-[60vh]',
} = props
const toolbarRef = useRef<HTMLDivElement>(null)
/**
* Keep the format toolbar on screen while typing.
*
* Capping the content area's height is not sufficient on its own: the box grows *downwards*, so an
* editor that starts near the bottom of the viewport pushes its toolbar past the bottom edge long
* before the content ever reaches the height cap. The bio editor at the end of the signup form is
* the worst case — it is the last thing on the page, so it always starts there.
*
* `scrollIntoView({block: 'nearest'})` on the toolbar scrolls by the minimum needed and does nothing
* when it is already visible, so it neither fights the user's own scrolling nor jumps the page. It
* also walks whatever scrollable ancestor actually exists rather than assuming the window scrolls.
*
* Guarded on `isFocused` so that programmatic content changes (loading an existing bio, the
* auto-fill extraction writing its result) cannot yank the page around while the user is elsewhere.
*/
useEffect(() => {
if (!editor) return
const keepToolbarInView = () => {
if (!editor.isFocused) return
const el = toolbarRef.current
if (!el) return
// Explicit rather than `scrollIntoView({block: 'nearest'})`. 'nearest' considers an element
// that is flush with the edge to be already visible and does nothing — and in that no-op case
// Chrome does not apply `scroll-margin` either, so the toolbar ends up hard against the bottom
// edge with its border clipped. Computing the overshoot ourselves guarantees the gap.
// The containing scroller first (a modal body, say). Inside a modal this is the only thing that
// moves: the page behind it is locked, so the window branch below is a no-op there.
const scroller = getScrollParent(el)
if (scroller) {
const overshoot =
el.getBoundingClientRect().bottom +
TOOLBAR_VIEWPORT_GAP -
scroller.getBoundingClientRect().bottom
if (overshoot > 0) scroller.scrollTop += overshoot
}
// Then the window, for the inline case — and for a modal that is itself taller than the screen.
// Re-measure: the scroller above may already have moved it.
const rect = el.getBoundingClientRect()
const overshoot = rect.bottom + TOOLBAR_VIEWPORT_GAP - getUsableViewportBottom()
if (overshoot > 0) window.scrollBy(0, overshoot)
else if (rect.top < 0) el.scrollIntoView({block: 'nearest'})
}
editor.on('update', keepToolbarInView)
editor.on('selectionUpdate', keepToolbarInView)
return () => {
editor.off('update', keepToolbarInView)
editor.off('selectionUpdate', keepToolbarInView)
}
}, [editor])
return (
// matches input styling
<div
@@ -248,9 +356,11 @@ export function TextEditor(props: {
<EditorContent editor={editor} onBlur={onBlur} onChange={onChange} />
</div>
<StickyFormatMenu editor={editor} hideEmbed={hideEmbed}>
{children}
</StickyFormatMenu>
<div ref={toolbarRef}>
<StickyFormatMenu editor={editor} hideEmbed={hideEmbed}>
{children}
</StickyFormatMenu>
</div>
</div>
)
}

View File

@@ -0,0 +1,75 @@
import clsx from 'clsx'
import {ReactNode, useEffect, useRef, useState} from 'react'
import {useSafeLayoutEffect} from 'web/hooks/use-safe-layout-effect'
/**
* Fades and lifts its children into place the first time they scroll into view.
*
* **Why not `useIsVisible`.** That hook exists and is the first thing to reach for, but its threshold
* semantics are wrong for a reveal: it fires at 90% visibility for anything shorter than the viewport,
* so a card would finish scrolling into place before it started animating. A reveal wants the opposite —
* fire as soon as the top edge crosses in. Hence a local observer with `rootMargin` instead of a second
* general-purpose visibility hook.
*
* **Degrades to visible, never to blank.** The hidden state is applied by `armed`, which is set in a
* layout effect — so the server-rendered HTML and any no-JS client render the content plainly visible,
* and the arm happens before first paint on the client, so there is no flash of the un-hidden state.
* Getting this backwards (hiding by default, revealing in JS) is how a reveal turns into an invisible
* page when the observer never runs.
*
* Reduced-motion visitors never arm it at all, so they get the content with no transition rather than a
* transition we merely shortened.
*/
export function Reveal({
children,
className,
delay = 0,
}: {
children: ReactNode
className?: string
/** Stagger, in ms, for siblings revealed as a group. Keep under ~150ms — beyond that it reads as lag. */
delay?: number
}) {
const ref = useRef<HTMLDivElement | null>(null)
const [armed, setArmed] = useState(false)
const [shown, setShown] = useState(false)
useSafeLayoutEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
setArmed(true)
}, [])
useEffect(() => {
const el = ref.current
if (!armed || !el) return
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return
setShown(true)
observer.disconnect()
},
// Negative bottom margin so the reveal starts a little after the element enters, rather than
// the instant its first pixel does — that reads as intentional instead of twitchy.
{rootMargin: '0px 0px -10% 0px', threshold: 0.01},
)
observer.observe(el)
return () => observer.disconnect()
}, [armed])
const hidden = armed && !shown
return (
<div
ref={ref}
style={delay && armed ? {transitionDelay: `${delay}ms`} : undefined}
className={clsx(
'transition-[opacity,transform] duration-500 ease-out',
hidden ? 'opacity-0 translate-y-4' : 'opacity-100 translate-y-0',
className,
)}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,58 @@
import clsx from 'clsx'
import {ReactNode} from 'react'
import {eyebrow} from 'web/components/widgets/surface'
/**
* Segmented progress for the multi-step flows (`/onboarding`, `/signup`).
*
* Both flows previously showed no progress at all: three onboarding screens and a two-step signup
* that each looked exactly like the last, so the only way to know how much was left was to keep
* clicking. That is the single biggest reason a signup flow gets abandoned midway, and it costs one
* component to fix.
*
* Segments rather than a continuous bar because the step count is small and known — discrete marks
* answer "how many more" directly, where a 66%-filled bar only implies it.
*
* `bg-cta` for the filled segments rather than `primary-500`: this is a small, purely decorative mark
* against `canvas-200`, and using the CTA token keeps every "this is the active/committed thing"
* signal on one colour across the flow.
*/
export function StepProgress({
current,
total,
label,
className,
}: {
/** 1-based. */
current: number
total: number
/** Already-translated, e.g. t('common.step_progress', 'Step {current} of {total}', {...}). */
label?: ReactNode
className?: string
}) {
return (
<div className={clsx('w-full', className)}>
<div
className="flex items-center gap-1.5"
role="progressbar"
aria-valuemin={1}
aria-valuemax={total}
aria-valuenow={current}
// The visible label is decorative text; the bar needs its own accessible name because a
// progressbar's contents are not announced.
aria-valuetext={typeof label === 'string' ? label : `${current} / ${total}`}
>
{Array.from({length: total}, (_, i) => (
<div
key={i}
className={clsx(
'h-1 flex-1 rounded-full transition-colors duration-300 ease-out',
i < current ? 'bg-cta' : 'bg-canvas-200',
)}
/>
))}
</div>
{label && <p className={clsx(eyebrow, 'text-ink-700 mt-3')}>{label}</p>}
</div>
)
}

View File

@@ -0,0 +1,61 @@
import clsx from 'clsx'
import {ReactNode} from 'react'
/**
* The shared surface vocabulary for the marketing pages (`/about`, `/home`).
*
* It lives in `widgets/` rather than beside either page because both use it, and a landing page
* importing its card treatment from `components/about/` would be the kind of dependency that quietly
* becomes load-bearing.
*/
/**
* The one card treatment.
*
* Every block used to be `border-[1.5px] border-canvas-200` and nothing else, which is why these pages
* read as a flat sheet: a hairline is the only depth cue, so a run of identical rectangles all sit at
* exactly the same elevation and nothing can be more important than anything else. The border is now a
* `ring` at lower opacity — so it defines the edge without drawing it — and the elevation comes from a
* two-layer shadow: a tight 1px contact shadow plus a wide, heavily-offset ambient one.
*
* Dark mode gets an inset top highlight instead. Drop shadows do essentially nothing on a near-black
* surface, whereas a 1px light edge along the top reads as a lit surface and costs nothing.
*/
export const surface = clsx(
'rounded-2xl bg-canvas-50 ring-1 ring-canvas-200/60',
'shadow-[0_1px_2px_rgb(44_36_22/0.04),0_12px_32px_-20px_rgb(44_36_22/0.30)]',
'dark:ring-canvas-200 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.04)]',
)
/**
* Hover for cards that are themselves links or contain one.
*
* The old hover lit the entire perimeter `primary-500`, which on an otherwise calm surface reads as an
* alert rather than an affordance. Depth does the same job quietly: lift 2px, deepen the ambient shadow,
* and let only a hint of warmth into the ring. Also `ease-out`, not `ease-in` — `ease-in` starts slow,
* which on a 200ms hover feels like the page is lagging behind the cursor.
*/
export const surfaceHover = clsx(
'transition-[transform,box-shadow,--tw-ring-color] duration-200 ease-out',
'hover:-translate-y-0.5 hover:ring-primary-500/40',
'hover:shadow-[0_2px_4px_rgb(44_36_22/0.05),0_22px_48px_-24px_rgb(44_36_22/0.45)]',
'dark:hover:shadow-[inset_0_1px_0_rgb(255_255_255/0.06)]',
)
/** The one spec for the small uppercase labels. There used to be several, at 11/11/12px and
* 1.1/1.2/1.5px tracking, which is close enough to look like a mistake rather than a distinction.
* Pair it with `text-ink-700` or `text-primary-700` — at 11px the lighter ramp steps fail AA. */
export const eyebrow = 'text-[11px] font-bold uppercase tracking-[1.2px]'
/**
* Vertical rhythm between sections.
*
* These pages used to run `gap-4` inside blocks against `my-10`/`mb-14` between sections — a 2.5x
* ratio, which is not enough separation for the eye to group anything, so the whole page read as one
* undifferentiated run of cards. At `py-14`/`py-20` against a 16-20px inner gap the ratio is 5-6x and
* the sections separate on their own, which is also why the gradient dividers are no longer needed
* between them.
*/
export function Section({children, className}: {children: ReactNode; className?: string}) {
return <section className={clsx('py-14 sm:py-20 first:pt-0', className)}>{children}</section>
}

View File

@@ -15,6 +15,10 @@ 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 {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'
@@ -22,6 +26,8 @@ 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 {useT} from 'web/lib/locale'
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -44,92 +50,164 @@ interface HelpCardProps {
id?: string
}
// ─── Feature Card ─────────────────────────────────────────────────────────────
// ─── Icon chip ────────────────────────────────────────────────────────────────
function FeatureCard({icon: Icon, title, text}: FeatureCardProps) {
/**
* Shared so the hero blocks and the ordinary cards can differ in *size* while staying the same object.
* Ten identically-sized chips down the page was a large part of why nothing read as more important than
* anything else.
*/
function IconChip({icon: Icon, large}: {icon: IconType; large?: boolean}) {
return (
<div
className="
group relative overflow-hidden
bg-canvas-50 border-[1.5px] border-canvas-200 rounded-2xl p-7
transition-all duration-[120ms] ease-in
hover:shadow-[0_10px_30px_rgba(44,36,22,0.09)]
hover:border-primary-500
"
className={clsx(
'rounded-xl bg-primary-100 ring-1 ring-primary-200 flex items-center justify-center flex-shrink-0',
large ? 'w-14 h-14' : 'w-11 h-11',
)}
>
<div className="w-11 h-11 rounded-xl bg-primary-100 border border-primary-200 flex items-center justify-center mb-5 flex-shrink-0">
<Icon className="w-5 h-5 text-primary-600" strokeWidth={1.8} />
<Icon className={clsx('text-primary-600', large ? 'w-7 h-7' : 'w-5 h-5')} strokeWidth={1.8} />
</div>
)
}
// ─── Feature Card ─────────────────────────────────────────────────────────────
function FeatureCard({icon, title, text}: FeatureCardProps) {
return (
<div className={clsx(surface, surfaceHover, 'h-full p-6 sm:p-7')}>
<div className="mb-5">
<IconChip icon={icon} />
</div>
<h3 className="font-bold text-ink-900 mb-2.5">{title}</h3>
<p className="text-sm text-ink-500 leading-relaxed">{text}</p>
<p className="text-sm text-ink-600 leading-relaxed">{text}</p>
</div>
)
}
// ─── Full-width Feature Card ──────────────────────────────────────────────────
function FeatureCardWide({icon: Icon, title, text}: FeatureCardProps) {
function FeatureCardWide({icon, title, text}: FeatureCardProps) {
return (
<div
className="
group relative overflow-hidden col-span-1 md:col-span-2
bg-canvas-50 border-[1.5px] border-canvas-200 rounded-2xl p-7
flex flex-col sm:flex-row items-start sm:items-center gap-0 sm:gap-6
transition-all duration-[120ms] ease-in
hover:shadow-[0_10px_30px_rgba(44,36,22,0.09)]
hover:border-primary-500
"
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',
)}
>
<div className="w-11 h-11 rounded-xl bg-primary-100 border border-primary-200 flex items-center justify-center flex-shrink-0">
<Icon className="w-5 h-5 text-primary-600" strokeWidth={1.8} />
</div>
<div className={'min-w-0'}>
<IconChip icon={icon} />
<div className="min-w-0">
<h3 className="font-bold text-ink-900 mb-2">{title}</h3>
<p className="text-sm text-ink-500 leading-relaxed">{text}</p>
{/* 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" ─────────────────────────────────
/**
* The lead block of the page.
*
* This is the most distinctive claim Compass makes and the one with an actual recording behind it, so it
* gets the room: the claim at display size on the left, the clip that proves it at full height on the
* right. Previously it was one of three identical tiles stacked beside the phone, which meant the single
* strongest thing on the page was styled exactly like "Completely Free".
*
* The warm radial behind the phone is the only gradient on a content block. It exists to stop the device
* 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}) {
return (
<div className={clsx(surface, 'relative overflow-hidden p-6 sm:p-10')}>
<div
aria-hidden
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">
<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>
</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
and below the copy. Instead it bottom-bleeds past the panel edge (clipped by the panel's
own `overflow-hidden`), which is both the conventional device-showcase treatment and the
one that makes the height mismatch disappear. Only from `md` up: stacked on mobile the
phone is the whole point of its own row and must not be cropped. */}
<AlertDemo width="min(270px, 68vw)" className="md:self-end md:-mb-24" />
</div>
</div>
)
}
// ─── Statement: "One Mission" ─────────────────────────────────────────────────
/**
* The page's thesis, given the weight of a thesis.
*
* It is the only block set on a tinted surface and the only body copy on the page at display size — both
* deliberately unique, because the point of promoting it is that a reader who skims the whole page
* should still land on this. Repeating this treatment anywhere else would cost it exactly the emphasis
* it was promoted to have.
*/
function MissionStatement({title, text}: {title: string; text: string}) {
return (
<div
className={clsx(
'relative overflow-hidden rounded-2xl p-8 sm:p-12',
'bg-gradient-to-br from-primary-100 via-canvas-50 to-canvas-50',
'dark:from-primary-900/25 dark:via-canvas-50 dark:to-canvas-50',
'ring-1 ring-primary-200',
'shadow-[0_1px_2px_rgb(44_36_22/0.04),0_16px_40px_-24px_rgb(44_36_22/0.35)]',
'dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05)]',
)}
>
<IconChip icon={FlagIcon} large />
<h3 className={clsx(eyebrow, 'text-primary-700 mt-6 mb-3')}>{title}</h3>
<p className="font-heading text-ink-900 text-[clamp(22px,3vw,36px)] leading-[1.25] tracking-tight max-w-3xl text-balance">
{text}
</p>
</div>
)
}
// ─── Help Card ────────────────────────────────────────────────────────────────
function HelpCard({
icon: Icon,
title,
text,
buttonLabel,
buttonUrl,
buttonPrimary,
id,
}: HelpCardProps) {
function HelpCard({icon, title, text, buttonLabel, buttonUrl, buttonPrimary, id}: HelpCardProps) {
return (
<div
className="
group relative overflow-hidden
bg-canvas-50 border-[1.5px] border-canvas-200 rounded-2xl p-7
transition-all duration-[120ms] ease-in
hover:shadow-[0_10px_30px_rgba(44,36,22,0.09)]
hover:border-primary-500
"
className={clsx(surface, surfaceHover, 'h-full flex flex-col p-6 sm:p-7')}
// NOTE: Abandoned the left accent bar due to a known Firefox rendering bug.
// Firefox fails to correctly apply overflow-hidden on rounded containers with borders,
// causing the absolute/flex-item to bleed past the corner radius.
// Removed for cross-browser consistency.
>
<div className="w-10 h-10 rounded-xl bg-primary-100 border border-primary-200 flex items-center justify-center mb-4 flex-shrink-0">
<Icon className="w-[18px] h-[18px] text-primary-600" strokeWidth={1.8} />
<div className="mb-4">
<IconChip icon={icon} />
</div>
<h3 id={id} className="font-bold text-ink-900 mb-2">
{title}
</h3>
<p className="text-sm text-ink-500 leading-relaxed flex-1 mb-5">{text}</p>
<div>
<p className="text-sm text-ink-600 leading-relaxed mb-5">{text}</p>
{/* `mt-auto` on the button rather than `flex-1` on the paragraph, and the parent is now actually
`flex flex-col`. It was not, so the old `flex-1` did nothing and the four buttons in this grid
sat wherever their card's text happened to end — measured 20-23px out of line with each other. */}
<div className="mt-auto -ml-3 -mb-3">
<GeneralButton
url={buttonUrl}
content={buttonLabel}
color={
buttonPrimary
? 'bg-primary-500 hover:bg-primary-600 text-white border-primary-500 shadow-[0_3px_12px_rgba(193,127,62,0.3)]'
? 'bg-cta hover:bg-cta-hover text-white border-cta shadow-[0_3px_12px_rgba(193,127,62,0.3)]'
: 'bg-canvas-100 hover:border-primary-600 hover:text-primary-600 border-canvas-300 text-ink-900'
}
/>
@@ -138,72 +216,75 @@ function HelpCard({
)
}
// ─── Section Label ────────────────────────────────────────────────────────────
function SectionLabel({children}: {children: ReactNode}) {
return (
<div className="flex items-center gap-3 mb-4">
<span className="text-[11px] font-bold tracking-[1.2px] uppercase text-ink-500">
{children}
</span>
<div className="flex-1 h-px bg-canvas-200" />
</div>
)
}
// ─── Share Strip ──────────────────────────────────────────────────────────────
/**
* The closing ask, and the page's visual climax.
*
* It is the only dark block, which is what makes it land — so it is also the reason the "One Mission"
* 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.
*/
function ShareStrip({title, text}: {title: string; text: string}) {
const t = useT()
return (
<div className="bg-canvas-950 rounded-2xl px-9 py-8 flex items-center justify-between gap-6 flex-wrap">
<div className={'max-w-[450px]'}>
<h3 className="text-white text-lg font-bold mb-1.5 flex items-center gap-2.5">
<MegaphoneIcon className="w-5 h-5 text-primary-500 flex-shrink-0" strokeWidth={1.8} />
{title}
</h3>
<p className="text-primary-500 text-sm leading-relaxed">{text}</p>
<div className="relative overflow-hidden bg-canvas-950 rounded-3xl px-7 py-10 sm:px-12 sm:py-14">
<div
aria-hidden
className="pointer-events-none absolute -top-32 -right-20 w-[440px] h-[440px] rounded-full bg-primary-500/20 blur-3xl"
/>
<div className="relative flex items-center justify-between gap-8 flex-wrap">
<div className="max-w-[520px]">
<div className="flex items-center gap-2.5 mb-3">
<MegaphoneIcon className="w-5 h-5 text-primary-500 flex-shrink-0" strokeWidth={1.8} />
<span className={clsx(eyebrow, 'text-primary-500')}>
{t('about.final.label', 'Spread the word')}
</span>
</div>
<h3 className="font-heading text-white text-[clamp(22px,2.4vw,32px)] font-bold leading-tight tracking-tight mb-3 text-balance">
{title}
</h3>
{/* Was `text-primary-500` — amber body copy on espresso. The accent belongs on the eyebrow
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>
</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-4 py-2 rounded-xl text-sm font-semibold border transition-all duration-150',
'bg-white/[0.06] !text-white/60 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-4 py-2 rounded-xl text-sm font-semibold border transition-all duration-150',
'bg-primary-500 text-white hover:text-white border-primary-500 hover:bg-primary-600',
)}
/>
</Row>
</div>
)
}
// ─── Divider ──────────────────────────────────────────────────────────────────
function Divider() {
return (
<div className="h-px bg-gradient-to-r from-transparent via-canvas-200 to-transparent my-10" />
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function About() {
const t = useT()
const features: (FeatureCardProps & {wide?: boolean})[] = [
// The two cards that support the spotlight claim above them. "Get Notified About Searches" used to be
// 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'),
@@ -212,14 +293,6 @@ export default function About() {
'"Meditation", "Hiking", "Neuroscience", "Nietzsche". Access any profile and get niche.',
),
},
{
icon: BellIcon,
title: t('about.block.notify.title', 'Get Notified About Searches'),
text: t(
'about.block.notify.text',
"No need to constantly check the app! We'll contact you when new users fit your searches.",
),
},
{
icon: SparklesIcon,
title: t('about.block.personality.title', 'Personality-Centered'),
@@ -230,29 +303,16 @@ export default function About() {
title: t('about.block.free.title', 'Completely Free'),
text: t('about.block.free.text', 'Subscription-free. Paywall-free. Ad-free.'),
},
// 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'),
text: t(
'about.block.mission.text',
'Our only mission is to create more genuine human connections, and every decision must serve that goal.',
),
},
{
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.',
),
wide: true,
},
]
// 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).
//
// "One Mission" has likewise been promoted out of this grid into the statement block below, leaving
// "Vision" to support it rather than to sit alongside it as an equal.
const helpCards: HelpCardProps[] = [
{
icon: LightBulbIcon,
@@ -309,16 +369,19 @@ export default function About() {
url="/about"
/>
<div className="max-w-4xl mx-auto px-6 py-12 pb-20">
{/* `max-w-6xl` rather than `max-w-4xl`: at 1900px the old column filled about half the available
area and left the rest empty beige. Prose inside is still capped at a reading measure, so the
wider container buys layout room without producing 1100px-long lines. */}
<div className="max-w-6xl mx-auto px-5 sm:px-8 pt-12 pb-24">
{/* ── Page header ── */}
<div className="mb-10">
<p className="text-xs font-bold tracking-[1.5px] uppercase text-primary-500 mb-3">
<div className="mb-12">
<p className={clsx(eyebrow, 'text-primary-700 mb-4')}>
{t('about.eyebrow', 'About Compass')}
</p>
<h1 className="text-[clamp(28px,4vw,40px)] text-ink-900 tracking-tight leading-tight mb-3">
<h1 className="text-[clamp(34px,5vw,56px)] text-ink-900 tracking-tight leading-[1.08] mb-5 max-w-3xl text-balance">
{t('about.title', 'Why Choose Compass?')}
</h1>
<p className="text-lg text-ink-500 max-w-lg leading-relaxed">
<p className="text-lg sm:text-xl text-ink-700 max-w-xl leading-relaxed">
{t(
'about.subtitle',
'To find your people with ease — based on who they are, not how they look.',
@@ -326,54 +389,115 @@ export default function About() {
</p>
</div>
{/* Opens the page with something that is not a card — the run of identical bordered tiles
below is what made this page read as flat. Renders nothing if the stats call comes back
empty, in which case the header simply meets the feature grid as it did before. */}
<StatBand />
{/* ── Features ── */}
<SectionLabel>{t('about.features.label', 'What makes us different')}</SectionLabel>
<Section>
<SectionLabel>{t('about.features.label', 'What makes us different')}</SectionLabel>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{features.map((f) =>
f.wide ? (
<FeatureCardWide key={f.title} icon={f.icon} title={f.title} text={f.text} />
) : (
<FeatureCard key={f.title} icon={f.icon} title={f.title} text={f.text} />
),
)}
</div>
{/* The claim and the recording that proves it are now the same block (A4). They used to be a
full screen apart, which is the one arrangement that stops the clip from doing any work;
then they shared a row as equals, which still styled the strongest claim on the page as one
tile of three. The clip and posters are not in the repo; web/scripts/fetch-media.mjs pulls
them from R2 at build time, so a deploy without them fails loudly rather than shipping an
empty frame. */}
<Reveal>
<NotifySpotlight
title={t('about.block.notify.title', 'Get Notified About Searches')}
text={t(
'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>
<Divider />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-5 mt-4 sm:mt-5">
{searchFeatures.map((f, i) => (
<Reveal key={f.title} delay={i * 70}>
<FeatureCard icon={f.icon} title={f.title} text={f.text} />
</Reveal>
))}
</div>
</Section>
{/* ── Mission ── */}
<Section>
<SectionLabel>{t('about.mission.label', 'Why we exist')}</SectionLabel>
<Reveal>
<MissionStatement
title={t('about.block.mission.title', 'One Mission')}
text={t(
'about.block.mission.text',
'Our only mission is to create more genuine human connections, and every decision must serve that goal.',
)}
/>
</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>
</Section>
{/* ── How a decision gets made ── */}
<SectionLabel>{t('about.vote.label', 'How a decision gets made')}</SectionLabel>
<div className="mb-4">
<VoteEvidence />
</div>
<Section>
<SectionLabel>{t('about.vote.label', 'How a decision gets made')}</SectionLabel>
<Reveal>
<VoteEvidence />
</Reveal>
</Section>
<Divider />
{/* 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 ── */}
<SectionLabel>{t('about.help.label', 'Help Compass grow')}</SectionLabel>
<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. */}
<div className="mb-4">
<MemberGrowth />
</div>
{/* 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 ── */}
<ShareStrip
title={t('about.final.title', 'Tell Your Friends and Family')}
text={t(
'about.final.text',
'The best way to grow Compass is word of mouth. Thank you for supporting our mission.',
)}
/>
{/* ── Share strip ── */}
<Reveal>
<ShareStrip
title={t('about.final.title', 'Tell Your Friends and Family')}
text={t(
'about.final.text',
'The best way to grow Compass is word of mouth. Thank you for supporting our mission.',
)}
/>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
{helpCards.map((card) => (
<HelpCard key={card.id} {...card} />
))}
</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}>
<HelpCard {...card} />
</Reveal>
))}
</div>
</Section>
</div>
</PageBase>
)

View File

@@ -1,16 +1,23 @@
import {CheckIcon} from '@heroicons/react/24/outline'
import clsx from 'clsx'
import Router from 'next/router'
import {useEffect, useState} from 'react'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
import {StepProgress} from 'web/components/widgets/step-progress'
import {surface} from 'web/components/widgets/surface'
import {useT} from 'web/lib/locale'
const TOTAL_STEPS = 3
interface OnboardingStepProps {
onNext: () => void
onSkip: () => void
}
interface OnboardingScreenProps {
step: number
title: string
content: React.ReactNode
footerText?: string
@@ -21,7 +28,41 @@ interface OnboardingScreenProps {
welcomeTitle?: string
}
/**
* Bulleted list for the onboarding screens.
*
* The steps used to render bare `<li>`s inside a `space-y-2` `<ul>` with the browser's list markers
* suppressed by the CSS reset — so they read as loose unrelated sentences rather than a list. A ticked
* marker also says something the plain text does not: these are things you get, not things you must do.
*/
function Bullets({items}: {items: string[]}) {
return (
<ul className="space-y-3">
{items.map((item) => (
<li key={item} className="flex items-start gap-3">
<span className="mt-0.5 w-5 h-5 rounded-full bg-primary-100 ring-1 ring-primary-200 flex items-center justify-center flex-shrink-0">
<CheckIcon className="w-3 h-3 text-primary-700" strokeWidth={2.5} />
</span>
<span className="text-ink-700">{item}</span>
</li>
))}
</ul>
)
}
/**
* The shell every onboarding step renders into.
*
* **Left-aligned body, centred nothing.** Every screen here is multi-line paragraphs plus a list, and
* the old layout centred the paragraphs while left-aligning the lists inside them — so the text block
* changed alignment mid-screen. Centred ragged-both-edges copy at this length is hard to read anyway;
* left-aligning all of it resolves the inconsistency in the direction that reads better.
*
* The screen is now a `surface` card on the page background rather than bare text floating in the
* viewport, which gives the flow the same material as /home and /about.
*/
function OnboardingScreen({
step,
title,
content,
footerText,
@@ -44,41 +85,73 @@ function OnboardingScreen({
}, [welcomeTitle])
return (
<Col className="max-w-2xl mx-auto text-center px-6 sm:py-12 py-2">
{onBack && (
<div className="self-start mb-0">
<button onClick={onBack} className="text-ink-500 hover:text-ink-700 text-sm">
{t('common.back', 'Back')}
</button>
</div>
)}
<div className={clsx(surface, 'w-full max-w-2xl p-6 sm:p-10')}>
<StepProgress
current={step}
total={TOTAL_STEPS}
label={t('common.step_progress', 'Step {current} of {total}', {
current: step,
total: TOTAL_STEPS,
})}
className="mb-8"
/>
{/* Keyed on the swap so the welcome-to-title change animates rather than snapping. */}
<h1
className={`text-4xl font-bold mb-4 mt-2 transition-all duration-500 ${showWelcome ? 'text-5xl text-gray-500' : 'text-ink-900'}`}
key={showWelcome ? 'welcome' : 'title'}
className={clsx(
'animate-fade-up font-bold tracking-tight text-balance mb-5',
showWelcome
? 'text-[clamp(30px,5vw,44px)] text-primary-700'
: 'text-[clamp(26px,4vw,36px)] text-ink-900',
)}
>
{showWelcome && welcomeTitle ? welcomeTitle : title}
</h1>
<div className="text-lg text-ink-700 leading-relaxed mb-4">{content}</div>
<div className="text-base sm:text-lg text-ink-700 leading-relaxed space-y-4">{content}</div>
{footerText && <p className="text-sm text-ink-500 italic mb-8">{footerText}</p>}
{footerText && (
// Was `italic text-ink-500` — 3.5:1, and italic at 14px is the least legible thing on the
// screen. Now a bounded aside that reads as a footnote through position and rule, not slant.
<p className="mt-7 pl-4 border-l-2 border-primary-200 text-sm text-ink-600 leading-relaxed">
{footerText}
</p>
)}
<Col className="gap-4">
<Button onClick={onNext} size="lg" className="w-full max-w-xs mx-auto">
<Col className="gap-4 mt-9">
<Button onClick={onNext} size="lg" className="w-full">
{continueText ?? t('common.continue', 'Continue')}
</Button>
<button onClick={onSkip} className="text-sm text-ink-500 hover:text-ink-700 underline">
{t('onboarding.skip', 'Skip onboarding')}
</button>
<div className="flex items-center justify-between gap-4">
{/* Back moved down here beside Skip. It used to sit alone above the title, where it read as
page chrome rather than as one of this screen's two secondary actions. */}
{onBack ? (
<button
onClick={onBack}
className="text-sm font-medium text-ink-700 hover:text-ink-900 transition-colors"
>
{t('common.back', 'Back')}
</button>
) : (
<span />
)}
<button
onClick={onSkip}
className="text-sm font-medium text-ink-700 hover:text-ink-900 underline underline-offset-4 transition-colors"
>
{t('onboarding.skip', 'Skip onboarding')}
</button>
</div>
</Col>
</Col>
</div>
)
}
function Step1NoHiddenAlgorithms({onNext, onSkip}: OnboardingStepProps) {
const t = useT()
const content = (
<div className="space-y-4">
<>
<p>{t('onboarding.step1.body1', 'Compass does not decide who you should see.')}</p>
<p>
{t(
@@ -86,14 +159,15 @@ function Step1NoHiddenAlgorithms({onNext, onSkip}: OnboardingStepProps) {
'There is no engagement algorithm, no swipe-ranking, no boosted profiles, and no attention optimization. You can browse the full database, apply your own filters, and see exactly why someone matches with you.',
)}
</p>
<p className="font-semibold">
<p className="font-semibold text-ink-900">
{t('onboarding.step1.body3', 'You stay in control of discovery. Always.')}
</p>
</div>
</>
)
return (
<OnboardingScreen
step={1}
title={t('onboarding.step1.title', 'No black box. No manipulation.')}
content={content}
footerText={t('onboarding.step1.footer', 'Transparency is a core principle, not a feature.')}
@@ -111,33 +185,34 @@ function Step2SearchBeatsSwiping({
}: OnboardingStepProps & {onBack: () => void}) {
const t = useT()
const content = (
<div className="space-y-4">
<>
<p>
{t(
'onboarding.step2.body1',
'Instead of endless swiping, Compass lets you search intentionally.',
)}
</p>
<p className="text-left max-w-md mx-auto">
{t('onboarding.step2.body2', 'Look for people by:')}
</p>
<ul className="text-left max-w-md mx-auto space-y-2">
<li>{t('onboarding.step2.item1', 'Interests and keywords')}</li>
<li>{t('onboarding.step2.item2', 'Values and ideas')}</li>
<li>{t('onboarding.step2.item3', 'Compatibility answers')}</li>
<li>{t('onboarding.step2.item4', 'Location and intent')}</li>
</ul>
<p>{t('onboarding.step2.body2', 'Look for people by:')}</p>
<Bullets
items={[
t('onboarding.step2.item1', 'Interests and keywords'),
t('onboarding.step2.item2', 'Values and ideas'),
t('onboarding.step2.item3', 'Compatibility answers'),
t('onboarding.step2.item4', 'Location and intent'),
]}
/>
<p>
{t(
'onboarding.step2.body3',
'You can save searches and get notified when new people match them. No need to check the app every day.',
)}
</p>
</div>
</>
)
return (
<OnboardingScreen
step={2}
title={t('onboarding.step2.title', "Search, don't scroll.")}
content={content}
footerText={t('onboarding.step2.footer', 'Less noise. More signal.')}
@@ -155,27 +230,30 @@ function Step3CompatibilityInspect({
}: OnboardingStepProps & {onBack: () => void}) {
const t = useT()
const content = (
<div className="space-y-4">
<>
<p>{t('onboarding.step3.body1', "Matches aren't magic or mysterious.")}</p>
<p>
{t('onboarding.step3.body2', 'Your compatibility score comes from explicit questions:')}
</p>
<ul className="text-left max-w-md mx-auto space-y-2">
<li>{t('onboarding.step3.item1', 'Your answer')}</li>
<li>{t('onboarding.step3.item2', 'What answers you accept from others')}</li>
<li>{t('onboarding.step3.item3', 'How important each question is to you')}</li>
</ul>
<Bullets
items={[
t('onboarding.step3.item1', 'Your answer'),
t('onboarding.step3.item2', 'What answers you accept from others'),
t('onboarding.step3.item3', 'How important each question is to you'),
]}
/>
<p>
{t(
'onboarding.step3.body3',
'You can inspect, question, and improve the system. The full math is open source.',
)}
</p>
</div>
</>
)
return (
<OnboardingScreen
step={3}
title={t('onboarding.step3.title', 'Compatibility you can understand.')}
content={content}
footerText={t(
@@ -239,7 +317,13 @@ export default function OnboardingPage() {
// title="Welcome to Compass - Onboarding"
// description="Get started with Compass - transparent dating without algorithms"
// />
<Col className="min-h-screen items-center justify-center">{renderStep()}</Col>
<Col className="min-h-screen items-center justify-center px-5 py-10">
{/* Keyed on the step so each screen enters rather than swapping in place — the three screens are
near-identical in shape, and without the transition a click reads as "nothing happened". */}
<div key={currentStep} className="animate-fade-up w-full flex justify-center">
{renderStep()}
</div>
</Col>
// </PageBase>
)
}

View File

@@ -1,3 +1,5 @@
import {CheckIcon} from '@heroicons/react/24/outline'
import clsx from 'clsx'
import Router from 'next/router'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
@@ -5,10 +7,27 @@ import {PageBase} from 'web/components/page-base'
import {ProfileCardViewer} from 'web/components/profile-card-viewer'
import {SEO} from 'web/components/SEO'
import {ShareProfileButtons} from 'web/components/widgets/share-profile-button'
import {surface} from 'web/components/widgets/surface'
import {useProfile} from 'web/hooks/use-profile'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
/** Same marker treatment as the onboarding steps, so the end of the flow matches its start. */
function Bullets({items}: {items: string[]}) {
return (
<ul className="space-y-3">
{items.map((item) => (
<li key={item} className="flex items-start gap-3">
<span className="mt-0.5 w-5 h-5 rounded-full bg-primary-100 ring-1 ring-primary-200 flex items-center justify-center flex-shrink-0">
<CheckIcon className="w-3 h-3 text-primary-700" strokeWidth={2.5} />
</span>
<span className="text-ink-700">{item}</span>
</li>
))}
</ul>
)
}
export default function SoftGatePage() {
const user = useUser()
const t = useT()
@@ -31,16 +50,16 @@ export default function SoftGatePage() {
'Get started with Compass - transparent connections without algorithms',
)}
/>
<Col className="min-h-screen items-center justify-center p-6">
<Col className="max-w-xl w-full gap-6 text-center">
<h1 className="text-4xl font-semibold text-ink-900">
<Col className="min-h-screen items-center justify-center px-5 py-10">
<div className={clsx(surface, 'w-full max-w-xl p-6 sm:p-10')}>
<h1 className="text-[clamp(26px,4vw,36px)] font-bold tracking-tight text-ink-900 text-balance mb-6">
{t('onboarding.soft-gate.title', "You're ready to explore")}
</h1>
{profile && user && (
<Col className="gap-4 text-ink-700 items-center">
<Col className="gap-4 items-center mb-7">
<ProfileCardViewer user={user} profile={profile} />
<p>
<p className="text-base text-ink-700 leading-relaxed">
{t(
'onboarding.soft-gate.profile_card',
'You created a public profile card and values-based profile. Share them to attract people who think like you. Shared profiles are discovered much more often.',
@@ -50,53 +69,47 @@ export default function SoftGatePage() {
</Col>
)}
<Col className="gap-4 text-ink-700">
<Col className="gap-4 text-base text-ink-700 leading-relaxed">
<p>
{t(
'onboarding.soft-gate.intro',
"You've answered your first compatibility questions and shared your top interests.",
)}
</p>
<p className="text-left space-y-2 text-ink-600">
{t('onboarding.soft-gate.what_it_means', "Here's what that means for you:")}
</p>
<p>{t('onboarding.soft-gate.what_it_means', "Here's what that means for you:")}</p>
<ul className="text-left mx-auto space-y-2">
{/*<li>*/}
{/* {t("onboarding.soft-gate.bullet1", "Compatibility scores now reflect your values and preferences")}*/}
{/*</li>*/}
<li>
<span>
{t(
'onboarding.soft-gate.bullet2',
"You'll see match percentages that align closely with what you care about",
)}
</span>
</li>
<li>
<span>
{t(
'onboarding.soft-gate.bullet3',
'You can update your profile anytime to increase the chances of the right people reaching out.',
)}
</span>
</li>
</ul>
{/*<li>*/}
{/* {t("onboarding.soft-gate.bullet1", "Compatibility scores now reflect your values and preferences")}*/}
{/*</li>*/}
<Bullets
items={[
t(
'onboarding.soft-gate.bullet2',
"You'll see match percentages that align closely with what you care about",
),
t(
'onboarding.soft-gate.bullet3',
'You can update your profile anytime to increase the chances of the right people reaching out.',
),
]}
/>
</Col>
<Col className="gap-3 items-center">
<Button onClick={handleExplore} size="lg">
<Col className="gap-4 mt-9">
<Button onClick={handleExplore} size="lg" className="w-full">
{t('onboarding.soft-gate.explore_button', 'Explore Profiles Now')}
</Button>
{/* Was `text-ink-500` at 3.5:1. This is the escape hatch from a screen whose primary
action sends you away from your own profile, so it has to be legible. */}
<button
onClick={handleRefine}
className="text-sm text-ink-500 hover:text-ink-700 transition-colors"
className="text-sm font-medium text-ink-700 hover:text-ink-900 underline underline-offset-4 transition-colors"
>
{t('onboarding.soft-gate.refine_button', 'Refine Profile')}
</button>
</Col>
</Col>
</div>
</Col>
</PageBase>
)

View File

@@ -58,7 +58,7 @@ function SectionCard({icon: Icon, title, description, links}: SectionCardProps)
content={label}
color={
primary
? 'bg-primary-500 hover:bg-primary-600 text-white border-primary-500 shadow-[0_3px_12px_rgba(193,127,62,0.3)] text-sm'
? 'bg-cta hover:bg-cta-hover text-white border-cta shadow-[0_3px_12px_rgba(193,127,62,0.3)] text-sm'
: 'bg-canvas-100 border-canvas-300 text-ink-900 hover:border-primary-500 hover:text-primary-500 text-sm'
}
/>

View File

@@ -13,6 +13,7 @@ import {Col} from 'web/components/layout/col'
import {OptionalProfileUserForm} from 'web/components/optional-profile-form'
import {initialRequiredState, RequiredProfileUserForm} from 'web/components/required-profile-form'
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
import {StepProgress} from 'web/components/widgets/step-progress'
import {useTracking} from 'web/hooks/use-tracking'
import {api} from 'web/lib/api'
import {auth, CACHED_REFERRAL_USERNAME_KEY} from 'web/lib/firebase/users'
@@ -123,19 +124,35 @@ export default function SignupPage() {
}
}
const TOTAL_STEPS = 2
return (
<Col className="items-center">
<Toaster position={'top-center'} containerClassName="!bottom-[70px]" />
{isSubmitting ? (
<Col className="flex-1 items-center justify-center py-20">
<CompassLoadingIndicator />
<div className="mt-4 text-gray-500">
{/* Was `text-gray-500`, an off-palette literal that does not flip with the theme. */}
<div className="mt-4 text-base text-ink-700">
{t('signup.creating_profile', 'Creating your profile...')}
</div>
</Col>
) : (
<Col className={'w-full max-w-4xl px-6 py-4'}>
<BackButton className="-ml-2 mb-2 self-start" />
{/* The flow had no progress indicator at all: two substantial form steps that looked alike,
so the only way to learn there were two was to finish the first. The bar is deliberately
the same component as /onboarding — those three screens lead directly here, and a reader
who has just watched a 3-segment bar fill should recognise this as the next one. */}
<StepProgress
current={step + 1}
total={TOTAL_STEPS}
label={t('common.step_progress', 'Step {current} of {total}', {
current: step + 1,
total: TOTAL_STEPS,
})}
className="mb-7"
/>
{step === 0 ? (
<RequiredProfileUserForm
data={baseUser}
@@ -161,8 +178,12 @@ export default function SignupPage() {
)
}
export const colClassName = 'items-start gap-2'
export const labelClassName = 'font-semibold text-md'
// Field-group and label classes shared with `optional-profile-form.tsx`.
// `gap-1.5` rather than `gap-2`: the label, its help text and the input are one unit, and at gap-2
// they sat as far apart from each other as consecutive fields did. The vertical rhythm between
// fields comes from the parent `Col`'s `gap-8`, not from here.
export const colClassName = 'items-start gap-1.5 w-full'
export const labelClassName = 'font-semibold text-md text-ink-900'
function getInitialBaseUser() {
const emailName = auth.currentUser?.email?.replace(/@.*$/, '')

View File

@@ -47,7 +47,7 @@ function SocialLinkButton({url, label, icon, primary}: SocialLink) {
hover:-translate-y-0.5
${
primary
? 'bg-primary-500 border-primary-500 text-white hover:bg-primary-600 shadow-[0_3px_12px_rgba(193,127,62,0.3)]'
? 'bg-cta border-cta text-white hover:bg-cta-hover shadow-[0_3px_12px_rgba(193,127,62,0.3)]'
: 'bg-canvas-100 border-canvas-300 text-ink-900 hover:border-primary-500 hover:text-primary-500'
}
`}

View File

@@ -21,6 +21,7 @@ import {ComponentType, ReactNode, SVGProps, useEffect, useState} from 'react'
import {PageBase} from 'web/components/page-base'
import {SEO} from 'web/components/SEO'
import ChartMembers from 'web/components/widgets/charts'
import {CountrySpread, MIN_COUNTRIES} from 'web/components/widgets/country-spread'
import {api} from 'web/lib/api'
import {useT} from 'web/lib/locale'
import {getCount} from 'web/lib/supabase/users'
@@ -40,6 +41,8 @@ interface StatCardProps {
interface StatGroupProps {
icon: IconType
title: string
/** Optional block sharing the group's row — the right column on large screens. */
aside?: ReactNode
children: ReactNode
}
@@ -99,7 +102,7 @@ function StatCard({value, label, icon: Icon, accent = 'amber', large}: StatCardP
// ─── Stat Group ───────────────────────────────────────────────────────────────
function StatGroup({icon: Icon, title, children}: StatGroupProps) {
function StatGroup({icon: Icon, title, aside, children}: StatGroupProps) {
return (
<div className="mb-10">
<div className="flex items-center gap-3 mb-4">
@@ -109,7 +112,20 @@ function StatGroup({icon: Icon, title, children}: StatGroupProps) {
</span>
<div className="flex-1 h-px bg-canvas-200" />
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">{children}</div>
{/* With an `aside`, the cards give up half the width and drop to a 2×2 block beside it on large
screens; without one they keep the full-width 4-up row. The aside stacks underneath below
`lg` rather than squeezing — its country bars need the horizontal room. */}
<div className={clsx('grid gap-3', aside && 'lg:grid-cols-2 items-start')}>
<div
className={clsx(
'grid grid-cols-2 gap-3',
aside ? 'md:grid-cols-2' : 'md:grid-cols-3 lg:grid-cols-4',
)}
>
{children}
</div>
{aside}
</div>
</div>
)
}
@@ -260,6 +276,8 @@ export default function Stats() {
void load()
}, [])
const hasCountrySpread = (statsData?.countries?.length ?? 0) >= MIN_COUNTRIES
const genderRatioLabel = statsData?.genderRatio
? `${statsData.genderRatio.male ?? 0} / ${statsData.genderRatio.female ?? 0}`
: null
@@ -321,7 +339,31 @@ export default function Stats() {
<ChartCard />
{/* ── Community ── */}
<StatGroup icon={UsersIcon} title={t('stats.group.community', 'Community')}>
{/* "Where members are" moved here from the about page, and sits inside Community rather
than in a section of its own: it answers "who are the members" like the four numbers
beside it, just split by place instead of counted. Fed from the payload this page
already fetched, so it does not fire a second `stats` request. Gated on the same
threshold the component uses: dropping the aside gives the four cards the full width
back, where merely hiding it would leave half the row blank. */}
<StatGroup
icon={UsersIcon}
title={t('stats.group.community', 'Community')}
aside={
hasCountrySpread && (
<div
className="
bg-canvas-50 border-[1.5px] border-canvas-200 rounded-2xl p-6
shadow-[0_2px_8px_rgba(44,36,22,0.05)]
"
>
<CountrySpread
countries={statsData?.countries}
countryCount={statsData?.countryCount}
/>
</div>
)
}
>
<StatCard
value={data.profiles}
label={t('stats.members', 'Members')}

View File

@@ -41,6 +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: 'videos/search-alert-light.mp4', dest: 'videos/search-alert-light.mp4'},
{key: 'videos/search-alert-dark.mp4', dest: 'videos/search-alert-dark.mp4'},
{key: 'images/search-alert-poster-light.jpg', dest: 'images/search-alert-poster-light.jpg'},
{key: 'images/search-alert-poster-dark.jpg', dest: 'images/search-alert-poster-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'},

View File

@@ -134,6 +134,19 @@
--color-primary-900: 71 39 16; /* #472710 */
--color-primary-950: 43 21 8; /* #2B1508 */
/* Filled-button background — the amber a *white label* is allowed to sit on.
The brand base (primary-500, #C17F3E) carries white at only 3.30:1, below the 4.5:1 AA needs
for normal-size text, and it is the background of every primary CTA in the app. This is one
ramp step down (#A6682E) at 4.52:1.
It is a token of its own rather than "just use primary-600" because the primary ramp *inverts*
between themes — in dark mode 600 is a step *lighter*, so `bg-primary-600` would clear AA in
light and drop dark to 2.64:1. Both themes therefore pin the same value here: a solid brand
block does not need to shift with the theme, and white has to stay legible on it either way.
Hover goes darker (not lighter) in both themes for the same reason. */
--color-cta: 166 104 46; /* #A6682E — white on this: 4.52:1 */
--color-cta-hover: 133 80 34; /* #855022 — white on this: 6.29:1 */
--color-cta-deep: 101 58 24; /* #653A18 — the far stop of the gradient CTA, ~9:1 */
--color-ink-0: 255 255 255; /* white */
--color-ink-50: 255 255 255;
--color-ink-100: 255 255 255;
@@ -270,6 +283,15 @@
--color-primary-900: 243 228 206; /* Near-Cream Tint */
--color-primary-950: 250 243 233; /* Glow Tint */
/* Same values as the light theme — see the note there. The ramp inverts in dark mode, so the
token cannot be expressed as a ramp step that works in both; white legibility is the
constraint, and it does not care which theme is active. */
--color-cta: 166 104 46; /* = primary-400 in this (dark) ramp */
--color-cta-hover: 133 80 34; /* = primary-300 in this (dark) ramp */
--color-cta-deep: 101 58 24; /* pinned dark: the gradient's far stop used to be primary-800,
which in this ramp is near-cream (232 201 157) — white text ran
off the end of it at about 1.3:1. */
--color-no-950: 255 255 255; /* white */
--color-no-900: 242 242 242;
--color-no-800: 217 217 217;
@@ -463,16 +485,20 @@ ul {
margin-top: 0.5rem;
}
/* `primary-700`, not the raw #c17f3e brand base: that hex is 3.30:1 on the canvas and this rule
beats any `text-*` utility on the anchor (0,2,0 vs 0,1,0 specificity), so call sites could not
opt out of it. As a var it also flips with the theme instead of being a light-mode literal
duplicated into the .dark block below. */
.custom-link a,
.custom-link button {
color: #c17f3e;
color: rgb(var(--color-primary-700));
text-decoration: none;
font-family: var(--font-main), serif;
}
.dark .custom-link a,
.dark .custom-link button {
color: #c17f3e;
color: rgb(var(--color-primary-700));
text-decoration: none;
font-family: var(--font-main), serif;
}
@@ -589,8 +615,11 @@ ol > li::marker {
}
}
/* Was `opacity: 0.7`, which dimmed whatever it inherited to roughly 3.2:1 — and dimmed any link
inside it along with the text. A real colour token hits the same "secondary" register while
staying legible, and leaves nested links at their own contrast. */
.guidance {
opacity: 0.7;
color: rgb(var(--color-ink-700));
font-size: 14px;
}

View File

@@ -351,6 +351,14 @@ module.exports = {
900: 'rgb(var(--color-primary-900) / <alpha-value>)',
950: 'rgb(var(--color-primary-950) / <alpha-value>)',
},
// Filled-button background. Use `bg-cta hover:bg-cta-hover text-white` for any solid amber
// button — `bg-primary-500 text-white` is 3.30:1 and fails AA. See globals.css for why this
// is a token rather than a ramp step.
cta: {
DEFAULT: 'rgb(var(--color-cta) / <alpha-value>)',
hover: 'rgb(var(--color-cta-hover) / <alpha-value>)',
deep: 'rgb(var(--color-cta-deep) / <alpha-value>)',
},
gray: {
50: 'hsl(0, 0%, 95%)',
100: 'hsl(0, 0%, 90%)',