diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index bb7c7f88..a526b717 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -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} = { 'update-event': updateEvent, health: health, stats: stats, + 'repo-stats': repoStats, 'unsubscribe/:token': unsubscribe, me: getMe, 'get-user-and-profile': getUserAndProfileHandler, diff --git a/backend/api/src/repo-stats.ts b/backend/api/src/repo-stats.ts new file mode 100644 index 00000000..4ef25ab2 --- /dev/null +++ b/backend/api/src/repo-stats.ts @@ -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 +} diff --git a/backend/api/src/stats.ts b/backend/api/src/stats.ts index 3e67cb85..17330247 100644 --- a/backend/api/src/stats.ts +++ b/backend/api/src/stats.ts @@ -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 diff --git a/backend/email/emails/functions/render-search-alert.tsx b/backend/email/emails/functions/render-search-alert.tsx new file mode 100644 index 00000000..fd21987b --- /dev/null +++ b/backend/email/emails/functions/render-search-alert.tsx @@ -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 [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 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( + , + ) + + // 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 = `` + writeFileSync(out, html.replace('', head)) + console.log(`wrote ${out} (${theme}, assets resolved against ${base})`) +} + +main().catch((err) => { + console.error(err.message) + process.exit(1) +}) diff --git a/backend/email/emails/new-search-alerts.tsx b/backend/email/emails/new-search-alerts.tsx index c74727f9..3685123e 100644 --- a/backend/email/emails/new-search-alerts.tsx +++ b/backend/email/emails/new-search-alerts.tsx @@ -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 ( - + + + {t('email.search_alerts.preview', 'New people share your values — reach out and connect')} - + -
+
{t('email.search_alerts.greeting', 'Hi {name},', {name})} @@ -62,6 +64,7 @@ export const NewSearchAlertsEmail = ({ {(matches || []).map((match) => (
( . + * + * 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; } + } +` diff --git a/backend/email/package.json b/backend/email/package.json index cdea7607..73fa0c46 100644 --- a/backend/email/package.json +++ b/backend/email/package.json @@ -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" }, diff --git a/common/messages/de.json b/common/messages/de.json index 72a1b55a..f0139d51 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -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...", diff --git a/common/messages/fr.json b/common/messages/fr.json index f4feba5a..ff6cb661 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -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...", diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index d6818bc2..71994fcf 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -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 diff --git a/common/src/stats.ts b/common/src/stats.ts index 0643b96b..ee619fde 100644 --- a/common/src/stats.ts +++ b/common/src/stats.ts @@ -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 genderCounts: Record + /** 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 } diff --git a/docs/marketing-visuals.md b/docs/marketing-visuals.md index 3c643f90..f8ff86fe 100644 --- a/docs/marketing-visuals.md +++ b/docs/marketing-visuals.md @@ -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. --- diff --git a/media-creator/.gitignore b/media-creator/.gitignore index 96524c01..62a48368 100644 --- a/media-creator/.gitignore +++ b/media-creator/.gitignore @@ -16,3 +16,4 @@ npm-debug.log* # Captured marketing stills (regenerate with the capture scripts). public/search/ +public/alert/ diff --git a/media-creator/package.json b/media-creator/package.json index f0b3cd2c..0dc047e4 100644 --- a/media-creator/package.json +++ b/media-creator/package.json @@ -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", diff --git a/media-creator/remotion.config.ts b/media-creator/remotion.config.ts index 82ef67bc..1e1dc3a6 100644 --- a/media-creator/remotion.config.ts +++ b/media-creator/remotion.config.ts @@ -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) diff --git a/media-creator/scripts/capture-alert.mjs b/media-creator/scripts/capture-alert.mjs new file mode 100644 index 00000000..e8a323a9 --- /dev/null +++ b/media-creator/scripts/capture-alert.mjs @@ -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) +}) diff --git a/media-creator/scripts/capture-profile.mjs b/media-creator/scripts/capture-profile.mjs index 68521881..432d3dc6 100644 --- a/media-creator/scripts/capture-profile.mjs +++ b/media-creator/scripts/capture-profile.mjs @@ -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}`) diff --git a/media-creator/scripts/render-carousel.mjs b/media-creator/scripts/render-carousel.mjs index 96c9229c..44b062a7 100644 --- a/media-creator/scripts/render-carousel.mjs +++ b/media-creator/scripts/render-carousel.mjs @@ -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}/`) diff --git a/media-creator/scripts/upload-media.sh b/media-creator/scripts/upload-media.sh index 254ba76a..50750d0f 100755 --- a/media-creator/scripts/upload-media.sh +++ b/media-creator/scripts/upload-media.sh @@ -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 media query. diff --git a/media-creator/src/Root.tsx b/media-creator/src/Root.tsx index 7d2e8411..3536b819 100644 --- a/media-creator/src/Root.tsx +++ b/media-creator/src/Root.tsx @@ -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//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 */} + + ) } diff --git a/media-creator/src/components/Animations.tsx b/media-creator/src/components/Animations.tsx index dcdafd47..d7cd5244 100644 --- a/media-creator/src/components/Animations.tsx +++ b/media-creator/src/components/Animations.tsx @@ -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 . 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 ( -
- {children} -
- ); -}; + return
{children}
+} // 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'}, - ); -}; + ) +} diff --git a/media-creator/src/components/Background.tsx b/media-creator/src/components/Background.tsx index e1a5bce5..83193ae5 100644 --- a/media-creator/src/components/Background.tsx +++ b/media-creator/src/components/Background.tsx @@ -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 ( { {/* Subtle grain-free vignette to focus the center */} - ); -}; + ) +} diff --git a/media-creator/src/components/Logo.tsx b/media-creator/src/components/Logo.tsx index 7150763d..02c8f092 100644 --- a/media-creator/src/components/Logo.tsx +++ b/media-creator/src/components/Logo.tsx @@ -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', }} > - +
- ); -}; + ) +} diff --git a/media-creator/src/index.ts b/media-creator/src/index.ts index 6da20a49..899db00d 100644 --- a/media-creator/src/index.ts +++ b/media-creator/src/index.ts @@ -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) diff --git a/media-creator/src/scenes/Intro.tsx b/media-creator/src/scenes/Intro.tsx index 26d05433..fbf13cdc 100644 --- a/media-creator/src/scenes/Intro.tsx +++ b/media-creator/src/scenes/Intro.tsx @@ -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 ( = ({dur, childre > {children} - ); -}; + ) +} const Eyebrow: React.FC<{children: React.ReactNode}> = ({children}) => (
= ({children}) => ( > {children}
-); +) // ─── 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 ( @@ -89,8 +96,8 @@ const LogoScene: React.FC = () => { - ); -}; + ) +} // ─── Scene 2 — the hook ───────────────────────────────────────────────────── const HookScene: React.FC = () => ( @@ -105,8 +112,7 @@ const HookScene: React.FC = () => ( lineHeight: 1.12, }} > - Tired of endless{' '} - swiping,{' '} + Tired of endless swiping,{' '} ads, and{' '} algorithms? @@ -125,7 +131,7 @@ const HookScene: React.FC = () => ( -); +) // ─── 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{' '} - deep, authentic{' '} + A free, open platform for deep, authentic{' '} 1-on-1 connections. @@ -164,7 +169,7 @@ const WhatScene: React.FC = () => ( -); +) // ─── 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 (
= ({
- ); -}; + ) +} const FeaturesScene: React.FC = () => { - const s = useFeatureSizing(); + const s = useFeatureSizing() return (
@@ -262,8 +267,8 @@ const FeaturesScene: React.FC = () => {
- ); -}; + ) +} // ─── Scene 5 — vision ─────────────────────────────────────────────────────── const VisionScene: React.FC = () => ( @@ -280,20 +285,16 @@ const VisionScene: React.FC = () => ( lineHeight: 1.28, }} > - What{' '} - Linux is - to software and{' '} - Wikipedia{' '} - is to knowledge — + What Linux is to software + and Wikipedia is to + knowledge —
Compass is to{' '} - - human connection. - + human connection. -); +) // ─── Scene 6 — call to action ─────────────────────────────────────────────── const CtaScene: React.FC = () => ( @@ -344,7 +345,7 @@ const CtaScene: React.FC = () => ( -); +) // ─── Composition ──────────────────────────────────────────────────────────── export const Intro: React.FC = () => { @@ -370,5 +371,5 @@ export const Intro: React.FC = () => { - ); -}; + ) +} diff --git a/media-creator/src/scenes/ProfileTour.tsx b/media-creator/src/scenes/ProfileTour.tsx index 5edc6480..7e595da6 100644 --- a/media-creator/src/scenes/ProfileTour.tsx +++ b/media-creator/src/scenes/ProfileTour.tsx @@ -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 ( = ({dur, childre > {children} - ); -}; + ) +} const Eyebrow: React.FC<{children: React.ReactNode}> = ({children}) => { - const {eyebrowSize} = useFrameBox(); + const {eyebrowSize} = useFrameBox() return (
= ({children}) => { > {children}
- ); -}; + ) +} // ─── 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 (
- ); -}; + ) +} // 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<{ - ); -}; + ) +} // ─── 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 ( @@ -275,8 +275,8 @@ const TitleScene: React.FC = () => { - ); -}; + ) +} // ─── Scene 9 — call to action ─────────────────────────────────────────────── const CtaScene: React.FC = () => ( @@ -330,7 +330,7 @@ const CtaScene: React.FC = () => ( -); +) // ─── Composition ──────────────────────────────────────────────────────────── export const ProfileTour: React.FC = () => { @@ -447,5 +447,5 @@ export const ProfileTour: React.FC = () => { - ); -}; + ) +} diff --git a/media-creator/src/scenes/SearchAlert.tsx b/media-creator/src/scenes/SearchAlert.tsx new file mode 100644 index 00000000..74ffc30e --- /dev/null +++ b/media-creator/src/scenes/SearchAlert.tsx @@ -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 + 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 ( + + + + + {highlights.map((h, i) => ( + + ))} + + + + {/* 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. */} + + + {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 ( + + ) + })} + + ) +} + +export type SearchAlertProps = Record & { + 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 ( +
+
+
+ {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 ( +
+ ) + })} +
+
+ 3 days later +
+
+
+ ) +} + +export const SearchAlert: React.FC = ({manifest, theme}) => { + const frame = useCurrentFrame() + const backdrop = theme === 'dark' ? '#1A1713' : '#EDE8E0' + if (!manifest) return + + 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 = {}) => ( + + ) + + // 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 ( + + {previous ? img(previous.name) : null} + + + ) + } + + return ( + + {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)} +
+ + ) : ( + 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 && ( + + )} + {shot.highlights?.length && !dissolving ? ( + + ) : null} + {loopOpacity > 0 && first ? img(first.name, {opacity: loopOpacity}) : null} + + ) +} + +/** + * 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}, + } +} diff --git a/media-creator/src/scenes/SearchDemo.tsx b/media-creator/src/scenes/SearchDemo.tsx index 277f54e8..d58857c7 100644 --- a/media-creator/src/scenes/SearchDemo.tsx +++ b/media-creator/src/scenes/SearchDemo.tsx @@ -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 diff --git a/media-creator/src/theme.ts b/media-creator/src/theme.ts index c8dc93a1..6db006cb 100644 --- a/media-creator/src/theme.ts +++ b/media-creator/src/theme.ts @@ -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 diff --git a/scripts/generate-showcase-portraits.ts b/scripts/generate-showcase-portraits.ts index 7585fa92..920a42e1 100644 --- a/scripts/generate-showcase-portraits.ts +++ b/scripts/generate-showcase-portraits.ts @@ -171,6 +171,18 @@ const PORTRAITS: Record = { '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 ────────────────────────────────────────────────────────────────────── diff --git a/tests/e2e/utils/seed-showcase.ts b/tests/e2e/utils/seed-showcase.ts index b1afc874..defd843e 100644 --- a/tests/e2e/utils/seed-showcase.ts +++ b/tests/e2e/utils/seed-showcase.ts @@ -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 diff --git a/tests/e2e/utils/showcase-profiles.ts b/tests/e2e/utils/showcase-profiles.ts index 956da2c4..3ead8418 100644 --- a/tests/e2e/utils/showcase-profiles.ts +++ b/tests/e2e/utils/showcase-profiles.ts @@ -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, + }, ] diff --git a/web/components/about/platform-stats.tsx b/web/components/about/platform-stats.tsx new file mode 100644 index 00000000..e7586795 --- /dev/null +++ b/web/components/about/platform-stats.tsx @@ -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 ( +
+
+ {value} +
+
{label}
+
+ ) +} + +/** + * 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 ( +
+ + {data.countryCount > 1 && ( + + )} + {data.conversations > 0 && ( + + )} + {/* 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. */} + +
+ ) +} diff --git a/web/components/about/repo-activity.tsx b/web/components/about/repo-activity.tsx new file mode 100644 index 00000000..90908b48 --- /dev/null +++ b/web/components/about/repo-activity.tsx @@ -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> + +function Metric({icon: Icon, value, label}: {icon: IconType; value: ReactNode; label: string}) { + return ( +
+
+ +
+
+
+ {value} +
+
{label}
+
+
+ ) +} + +/** "3 days ago" / "today" — enough to show the project is alive without implying more precision. */ +function daysAgoLabel(date: Date, t: ReturnType) { + 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 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. */} + {t('about.repo.label', 'Built in the open')} + +

+ {t( + 'about.repo.claim', + 'Community owned. No investors, no acquisition, nothing to sell you.', + )} +

+

+ {t( + 'about.repo.intro', + 'The whole thing is built in public — every line of code, every change, open to read and to challenge:', + )} +

+ +
+ {data.contributors !== null && ( + + )} + {data.stars !== null && ( + + )} + {lastCommit && !isNaN(lastCommit.getTime()) && ( + + )} +
+ +

+ + {t('about.repo.link', 'Read the source →')} + +

+
+
+ ) +} diff --git a/web/components/about/search-alert-demo.tsx b/web/components/about/search-alert-demo.tsx new file mode 100644 index 00000000..1468cfe2 --- /dev/null +++ b/web/components/about/search-alert-demo.tsx @@ -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
- {/* Divider */} -
+ {/* 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. */} +
+ {/* ── Features ── */} +
+

+ {t('home.features.label', 'Why Compass')} +

+

+ {t('home.features.title', 'Built different. On purpose.')} +

+
+ {features.map((f, i) => ( + + + + ))} +
+
- {/* ── Features ── */} -
-

- {t('home.features.label', 'Why Compass')} -

-

- {t('home.features.title', 'Built different. On purpose.')} -

-
- {features.map((f) => ( - - ))} -
-
+ {/* ── Quote ── */} +
+ + + {t('home.quote.prefix', 'Compass is to human connection what ')} + {t('home.quote.linux', 'Linux')} + {t('home.quote.linux_suffix', ' is to software, ')} + + {t('home.quote.wikipedia', 'Wikipedia')} + + {t('home.quote.wikipedia_suffix', ' is to knowledge, and ')} + {t('home.quote.firefox', 'Firefox')} + {t('home.quote.end', ' is to browsing — a public digital good designed to ')} + + {t('home.quote.mission', 'serve people, not profit.')} + + + +
- {/* ── Quote ── */} - - {t('home.quote.prefix', 'Compass is to human connection what ')} - {t('home.quote.linux', 'Linux')} - {t('home.quote.linux_suffix', ' is to software, ')} - {t('home.quote.wikipedia', 'Wikipedia')} - {t('home.quote.wikipedia_suffix', ' is to knowledge, and ')} - {t('home.quote.firefox', 'Firefox')} - {t('home.quote.end', ' is to browsing — a public digital good designed to ')} - - {t('home.quote.mission', 'serve people, not profit.')} - - - - {/* ── Open source strip ── */} - + {/* ── Open source strip ── */} +
+ + + +
+
{/* Mobile bottom spacing */} diff --git a/web/components/messaging/send-message-button.tsx b/web/components/messaging/send-message-button.tsx index 8b054d3a..bf0694f0 100644 --- a/web/components/messaging/send-message-button.tsx +++ b/web/components/messaging/send-message-button.tsx @@ -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. */} - *]:shrink-0')} - > - {/* Header. Follows the Compose Modal design: portrait, an uppercase "writing to" eyebrow, + + {/* 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. */} + *]: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. */} - - - {profile.pinned_url && ( - - )} - - - {t('send_message.writing_to', 'Writing to')} - - - {toUser.name} - - - + + + {profile.pinned_url && ( + + )} + + + {t('send_message.writing_to', 'Writing to')} + + + {toUser.name} + + + -

- {t('send_message.title', 'Start a meaningful conversation')} -

-

- {t( - 'send_message.guidance', - 'Compass is about depth. Take a moment to write something genuine.', - )} -

-
- +

+ {t('send_message.title', 'Start a meaningful conversation')} +

+

+ {t( + 'send_message.guidance', + 'Compass is about depth. Take a moment to write something genuine.', + )} +

+
+ - {/* 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 && ( -
-

- {t('send_message.keywords_hint', `Insert some of {name}'s topics`, { - name: firstName, - })} -

-
- {profile.keywords.map((k) => ( - - ))} + {!!profile.keywords?.length && ( +
+

+ {t('send_message.keywords_hint', `Insert some of {name}'s topics`, { + name: firstName, + })} +

+
+ {profile.keywords.map((k) => ( + + ))} +
-
- )} - {/* 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. */} -
- -
+
+ +
+ + ) : ( + + )} + {error && ( + + {error} + + )} + + {/* Pinned footer — outside the scroller, so it is visible no matter how long the message is. */} + {(firebaseUser?.emailVerified || skipEmailVerification) && ( + {/* Progress toward the minimum length, with the count beside it. */}
@@ -409,18 +434,7 @@ export const SendMessageButton = (props: { count: MIN_CHARS - charCount, })} - - ) : ( - - )} - {error && ( - - {error} - + )} diff --git a/web/components/multi-checkbox.tsx b/web/components/multi-checkbox.tsx index 00a678c2..bce5bbb2 100644 --- a/web/components/multi-checkbox.tsx +++ b/web/components/multi-checkbox.tsx @@ -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', )} > diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index c62ed7ca..f51a9b0d 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -142,7 +142,7 @@ export const SignUpButton = (props: { return ( + )} +

{t( 'profile.big5_guidance', 'The Big Five personality trait model is a scientific model that groups variation in personality into five separate factors (', )} - + {/* CustomLink ships no colour of its own, so this inherited a 2.70:1 amber. */} + {t('profile.big5_wikipedia_link', 'Wikipedia article')} {t( @@ -1062,30 +1108,35 @@ export const OptionalProfileUserForm = (props: { setProfile('big5_openness', v)} /> setProfile('big5_conscientiousness', v)} /> setProfile('big5_extraversion', v)} /> setProfile('big5_agreeableness', v)} /> setProfile('big5_neuroticism', v)} />

-

+

{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')} @@ -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

{title}

+ 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. +
+ {/* `!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. */} +

{title}

+
+ ) } -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 (
-
+
{label} - - {Math.round(value)} + + {isSet ? Math.round(value) : '–'}
) } diff --git a/web/components/widgets/reveal.tsx b/web/components/widgets/reveal.tsx new file mode 100644 index 00000000..387e185a --- /dev/null +++ b/web/components/widgets/reveal.tsx @@ -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(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 ( + + ) +} diff --git a/web/components/widgets/step-progress.tsx b/web/components/widgets/step-progress.tsx new file mode 100644 index 00000000..dd517a9b --- /dev/null +++ b/web/components/widgets/step-progress.tsx @@ -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 ( +
+
+ {Array.from({length: total}, (_, i) => ( +
+ ))} +
+ {label &&

{label}

} +
+ ) +} diff --git a/web/components/widgets/surface.tsx b/web/components/widgets/surface.tsx new file mode 100644 index 00000000..f68f6612 --- /dev/null +++ b/web/components/widgets/surface.tsx @@ -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
{children}
+} diff --git a/web/pages/about.tsx b/web/pages/about.tsx index 1990f03c..4061f627 100644 --- a/web/pages/about.tsx +++ b/web/pages/about.tsx @@ -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 (
-
- + +
+ ) +} + +// ─── Feature Card ───────────────────────────────────────────────────────────── + +function FeatureCard({icon, title, text}: FeatureCardProps) { + return ( +
+
+

{title}

-

{text}

+

{text}

) } // ─── Full-width Feature Card ────────────────────────────────────────────────── -function FeatureCardWide({icon: Icon, title, text}: FeatureCardProps) { +function FeatureCardWide({icon, title, text}: FeatureCardProps) { return (
-
- -
-
+ +

{title}

-

{text}

+ {/* 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. */} +

{text}

) } +// ─── 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 ( +
+
+
+
+ +

+ {title} +

+

{text}

+

{note}

+
+ {/* 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. */} + +
+
+ ) +} + +// ─── 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 ( +
+ +

{title}

+

+ {text} +

+
+ ) +} + // ─── Help Card ──────────────────────────────────────────────────────────────── -function HelpCard({ - icon: Icon, - title, - text, - buttonLabel, - buttonUrl, - buttonPrimary, - id, -}: HelpCardProps) { +function HelpCard({icon, title, text, buttonLabel, buttonUrl, buttonPrimary, id}: HelpCardProps) { return (
-
- +
+

{title}

-

{text}

-
+

{text}

+ {/* `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. */} +
@@ -138,72 +216,75 @@ function HelpCard({ ) } -// ─── Section Label ──────────────────────────────────────────────────────────── - -function SectionLabel({children}: {children: ReactNode}) { - return ( -
- - {children} - -
-
- ) -} - // ─── 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 ( -
-
-

- - {title} -

-

{text}

+
+
+
+
+
+ + + {t('about.final.label', 'Spread the word')} + +
+

+ {title} +

+ {/* 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. */} +

{text}

+
+ + {/*// */} + {/*// ${*/} + {/*// 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'*/} + {/*// }*/} + + +
- - {/*// */} - {/*// ${*/} - {/*// 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'*/} - {/*// }*/} - - -
) } -// ─── Divider ────────────────────────────────────────────────────────────────── - -function Divider() { - return ( -
- ) -} - // ─── 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" /> -
+ {/* `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. */} +
{/* ── Page header ── */} -
-

+

+

{t('about.eyebrow', 'About Compass')}

-

+

{t('about.title', 'Why Choose Compass?')}

-

+

{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() {

+ {/* 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. */} + + {/* ── Features ── */} - {t('about.features.label', 'What makes us different')} +
+ {t('about.features.label', 'What makes us different')} -
- {features.map((f) => - f.wide ? ( - - ) : ( - - ), - )} -
+ {/* 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. */} + + + - +
+ {searchFeatures.map((f, i) => ( + + + + ))} +
+
+ + {/* ── Mission ── */} +
+ {t('about.mission.label', 'Why we exist')} + + + + {/* 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. */} + + + +
{/* ── How a decision gets made ── */} - {t('about.vote.label', 'How a decision gets made')} -
- -
+
+ {t('about.vote.label', 'How a decision gets made')} + + + +
- + {/* Owns its own heading, because it renders nothing when GitHub is unreachable and a label with + nothing under it is worse than no section. */} + {/* ── Help ── */} - {t('about.help.label', 'Help Compass grow')} +
+ {t('about.help.label', 'Help Compass grow')} - {/* 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. */} -
- -
+ {/* 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. */} +
+ +
- {/* ── Share strip ── */} - + {/* ── Share strip ── */} + + + -
- {helpCards.map((card) => ( - - ))} -
+
+ {helpCards.map((card, i) => ( + + + + ))} +
+
) diff --git a/web/pages/onboarding/index.tsx b/web/pages/onboarding/index.tsx index f920632b..d9736eca 100644 --- a/web/pages/onboarding/index.tsx +++ b/web/pages/onboarding/index.tsx @@ -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 `
  • `s inside a `space-y-2` `
      ` 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 ( +
        + {items.map((item) => ( +
      • + + + + {item} +
      • + ))} +
      + ) +} + +/** + * 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 ( - - {onBack && ( -
      - -
      - )} +
      + + {/* Keyed on the swap so the welcome-to-title change animates rather than snapping. */}

      {showWelcome && welcomeTitle ? welcomeTitle : title}

      -
      {content}
      +
      {content}
      - {footerText &&

      {footerText}

      } + {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. +

      + {footerText} +

      + )} - - - +
      + {/* 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 ? ( + + ) : ( + + )} + +
      - +
      ) } function Step1NoHiddenAlgorithms({onNext, onSkip}: OnboardingStepProps) { const t = useT() const content = ( -
      + <>

      {t('onboarding.step1.body1', 'Compass does not decide who you should see.')}

      {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.', )}

      -

      +

      {t('onboarding.step1.body3', 'You stay in control of discovery. Always.')}

      -
      + ) return ( void}) { const t = useT() const content = ( -
      + <>

      {t( 'onboarding.step2.body1', 'Instead of endless swiping, Compass lets you search intentionally.', )}

      -

      - {t('onboarding.step2.body2', 'Look for people by:')} -

      -
        -
      • {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')}
      • -
      +

      {t('onboarding.step2.body2', 'Look for people by:')}

      +

      {t( 'onboarding.step2.body3', 'You can save searches and get notified when new people match them. No need to check the app every day.', )}

      -
      + ) return ( void}) { const t = useT() const content = ( -
      + <>

      {t('onboarding.step3.body1', "Matches aren't magic or mysterious.")}

      {t('onboarding.step3.body2', 'Your compatibility score comes from explicit questions:')}

      -
        -
      • {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')}
      • -
      +

      {t( 'onboarding.step3.body3', 'You can inspect, question, and improve the system. The full math is open source.', )}

      -
      + ) return ( - {renderStep()} + + {/* 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". */} +
      + {renderStep()} +
      + // ) } diff --git a/web/pages/onboarding/soft-gate.tsx b/web/pages/onboarding/soft-gate.tsx index 0da8cefd..6bf83d84 100644 --- a/web/pages/onboarding/soft-gate.tsx +++ b/web/pages/onboarding/soft-gate.tsx @@ -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 ( +
        + {items.map((item) => ( +
      • + + + + {item} +
      • + ))} +
      + ) +} + 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', )} /> - - -

      + +
      +

      {t('onboarding.soft-gate.title', "You're ready to explore")}

      {profile && user && ( - + -

      +

      {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() { )} - +

      {t( 'onboarding.soft-gate.intro', "You've answered your first compatibility questions and shared your top interests.", )}

      -

      - {t('onboarding.soft-gate.what_it_means', "Here's what that means for you:")} -

      +

      {t('onboarding.soft-gate.what_it_means', "Here's what that means for you:")}

      -
        - {/*
      • */} - {/* {t("onboarding.soft-gate.bullet1", "Compatibility scores now reflect your values and preferences")}*/} - {/*
      • */} -
      • - - {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.', - )} - -
      • -
      + {/*
    • */} + {/* {t("onboarding.soft-gate.bullet1", "Compatibility scores now reflect your values and preferences")}*/} + {/*
    • */} + - - + {/* 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. */} - +
      ) diff --git a/web/pages/organization.tsx b/web/pages/organization.tsx index 8d534934..89e7ed59 100644 --- a/web/pages/organization.tsx +++ b/web/pages/organization.tsx @@ -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' } /> diff --git a/web/pages/signup.tsx b/web/pages/signup.tsx index ec41726f..c79c2e93 100644 --- a/web/pages/signup.tsx +++ b/web/pages/signup.tsx @@ -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 ( {isSubmitting ? ( -
      + {/* Was `text-gray-500`, an off-palette literal that does not flip with the theme. */} +
      {t('signup.creating_profile', 'Creating your profile...')}
      ) : ( + {/* 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. */} + {step === 0 ? (
      @@ -109,7 +112,20 @@ function StatGroup({icon: Icon, title, children}: StatGroupProps) {
      -
      {children}
      + {/* 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. */} +
      +
      + {children} +
      + {aside} +
      ) } @@ -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() { {/* ── 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. */} + + +
      + ) + } + > 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; } diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 47e92857..9da6803d 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -351,6 +351,14 @@ module.exports = { 900: 'rgb(var(--color-primary-900) / )', 950: 'rgb(var(--color-primary-950) / )', }, + // 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) / )', + hover: 'rgb(var(--color-cta-hover) / )', + deep: 'rgb(var(--color-cta-deep) / )', + }, gray: { 50: 'hsl(0, 0%, 95%)', 100: 'hsl(0, 0%, 90%)',