mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 17:59:13 -04:00
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:
@@ -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,
|
||||
|
||||
81
backend/api/src/repo-stats.ts
Normal file
81
backend/api/src/repo-stats.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
130
backend/email/emails/functions/render-search-alert.tsx
Normal file
130
backend/email/emails/functions/render-search-alert.tsx
Normal 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, 24–40, 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)
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
`
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, 24–40, 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
1
media-creator/.gitignore
vendored
1
media-creator/.gitignore
vendored
@@ -16,3 +16,4 @@ npm-debug.log*
|
||||
|
||||
# Captured marketing stills (regenerate with the capture scripts).
|
||||
public/search/
|
||||
public/alert/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
544
media-creator/scripts/capture-alert.mjs
Normal file
544
media-creator/scripts/capture-alert.mjs
Normal 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 24–40'),
|
||||
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)
|
||||
})
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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}/`)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'},
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
358
media-creator/src/scenes/SearchAlert.tsx
Normal file
358
media-creator/src/scenes/SearchAlert.tsx
Normal 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},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, 24–40, 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* 24–40 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,
|
||||
},
|
||||
]
|
||||
|
||||
74
web/components/about/platform-stats.tsx
Normal file
74
web/components/about/platform-stats.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
129
web/components/about/repo-activity.tsx
Normal file
129
web/components/about/repo-activity.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
197
web/components/about/search-alert-demo.tsx
Normal file
197
web/components/about/search-alert-demo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
web/components/about/section.tsx
Normal file
32
web/components/about/section.tsx
Normal 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" />
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 h1–h6 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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 (0–100) */}
|
||||
<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 h1–h6 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
|
||||
|
||||
@@ -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
|
||||
|
||||
77
web/components/widgets/country-spread.tsx
Normal file
77
web/components/widgets/country-spread.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
75
web/components/widgets/reveal.tsx
Normal file
75
web/components/widgets/reveal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
web/components/widgets/step-progress.tsx
Normal file
58
web/components/widgets/step-progress.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
web/components/widgets/surface.tsx
Normal file
61
web/components/widgets/surface.tsx
Normal 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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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(/@.*$/, '')
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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'},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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%)',
|
||||
|
||||
Reference in New Issue
Block a user