Add script to generate AI-based showcase portraits for fictional profiles

- Introduced `generate-showcase-portraits.ts` using the Gemini API for synthetic portrait generation.
- Added `PORTRAITS` data structure for controlling persona appearances and scenes.
- Implemented functionality to ensure consistent identity across generated photos.
- Integrated `.env` API key support and optional modes for targeted regeneration (`--only`, `--force`, `--dry-run`).
- Updated related documentation (`marketing-visuals.md` and `README.md`) with generation guidelines and usage instructions.
- Seed script (`seed-showcase.ts`) enhanced to link generated photos with fictional profiles in the showcase dataset.
This commit is contained in:
MartinBraquet
2026-07-21 15:34:41 +02:00
parent b2bbb4f630
commit 7afa52252f
6 changed files with 1385 additions and 18 deletions

190
docs/marketing-visuals.md Normal file
View File

@@ -0,0 +1,190 @@
# Marketing visuals — home & about
Plan for adding visuals to the logged-out home page (`web/components/home/home.tsx`) and the about page
(`web/pages/about.tsx`). Both are currently 100% type + Heroicons; `web/public/images/` holds only logos and
`default-avatar.png`. Every differentiator ("keyword search any profile", "filter by values", "get notified
about searches", "democratic") is asserted in prose and never shown.
## Principles
- **Product visuals, not people visuals.** The home subtitle ends "— not just your photos". Stock imagery of
smiling couples would contradict the positioning and read as the thing we're arguing against.
- **Captured, not mocked.** Everything that _can_ be a screenshot of the live app should be, so it can be
regenerated when the UI changes instead of rotting. `media-creator/scripts/capture-profile.mjs` already
does this for the profile tour reel; the same approach extends to marketing stills.
- **Never fabricate social proof.** See H3 below.
- **Poster-first.** The LCP element stays a static image; video is lazy and decorative.
## Prerequisite: showcase seed data (W0)
Screenshots are only as good as the database behind them. The current seed
(`tests/e2e/utils/seedDatabase.ts` + `tests/e2e/backend/utils/userInformation.ts`) produces:
- `bio` = 200350 lorem-ipsum words, `headline` = lorem words, `keywords` = lorem words
- `age` uniform in 18100
- `pinned_url` = `/images/default-avatar.png` for _every_ medium/full profile
- `city` / `country` / `university` / `links` / `photo_urls` / interests / causes / work never set
Unusable for capture. W0 adds a curated set of hand-authored personas alongside the faker ones — real prose,
coherent field combinations, real photos, real locations, populated interests/causes/work and social links.
**Status: in progress.** See "Showcase profiles" below.
## Home page
### H1 — Hero search demo (highest value)
The page says "Don't Swipe. / Search." and then shows no search. A 1015s loop — typing `stoicism`, filter
chips appearing, result cards sliding in — proves the core differentiator at a glance.
- Build as a `SearchDemo` Remotion scene in `media-creator/`, fed by a `capture-search.mjs` companion to the
existing `capture-profile.mjs`.
- Place below the CTA row in `home.tsx`, above the divider, framed in browser chrome.
- Static poster is the LCP image (`next/image`, `priority`, AVIF); `<video muted autoplay loop playsinline
preload="none">` swaps in after load. Budget ~2 MB.
- `prefers-reduced-motion: reduce` → poster only, no video element mounted.
- Needs W0 (search results must show real people).
### H2 — Feature-card micro-screenshots
The three cards (`home.tsx:202-227`) each carry a generic Heroicon. Replace with a cropped ~400px-wide
fragment of the real UI:
| Card | Visual |
| --------------------- | ------------------------------------------- |
| Radically Transparent | search results grid, no "recommended" badge |
| Built for Depth | filter panel with value/interest chips |
| Community Owned | GitHub contributor graph |
Layout unchanged; the icon tile becomes a bordered image. Needs W0.
### H3 — Replace the fabricated avatars
`SocialProof` (`home.tsx:69-74`) renders four gradient circles lettered S/R/T/L next to "700+ **real** people
worldwide". Fabricated avatars next to the word _real_, on a platform selling radical transparency, is the one
visual on the page that actively costs trust.
Options, in order of preference:
1. 45 actual member photos, with explicit opt-in recorded.
2. Drop the avatars; keep the count.
Do **not** substitute showcase-seed photos here — the copy claims these are real members.
## About page
This page is a reference doc, not a pitch; it needs evidence, not a hero.
### A1 — Proof for "Democratic"
`about.tsx:235-252` links to `/vote` and `/constitution` in text. A small real screenshot of an actual vote
tally turns the assertion into evidence.
### A2 — Proof for "Community Owned / no VC"
The one place a genuine human photo belongs, because it evidences the governance claim rather than selling a
lifestyle: contributor photos, a Discord screenshot, or the GitHub contributor wall.
### A3 — Growth chart (optional)
A simple line of the member count over time near the `ShareStrip`. `web/public/md/financials.md` already
establishes the transparency framing.
## Cross-cutting constraints
- **Dark mode doubles the assets.** The site is fully tokenised (`bg-canvas-*`, `text-ink-*`); a light-mode
screenshot on a dark page looks broken. Capture scripts shoot both themes; serve via `<picture>` +
`prefers-color-scheme`. This is real ongoing maintenance — accept it deliberately.
- **i18n.** Text baked into a screenshot does not translate. Keep visible copy inside captures minimal, or
accept English-only visuals.
- **Privacy.** Nothing captured from the shared dev DB may contain real member data. Capture only against
showcase-seed profiles (W0), which are fictional by construction.
- **Photo licensing.** Showcase portraits must be licensed for this use — AI-generated, CC0, or explicitly
consented. Never scraped.
## Work order
| ID | Item | Blocked by | Who |
| --- | ------------------------------ | ---------- | ---------- |
| W0 | Showcase seed profiles | — | Claude ✅ |
| W0b | Showcase portrait photos | — | Claude ✅ |
| W0c | Run the seed | W0, W0b | **Martin** |
| H1 | Hero search demo | W0c | Claude |
| H2 | Feature-card micro-screenshots | W0c | Claude |
| H3 | Real member avatars | opt-in | **Martin** |
| A1 | Vote-tally screenshot | — | Claude |
| A2 | Community photo | — | **Martin** |
| A3 | Growth chart | — | Claude |
---
## Showcase profiles (W0)
Ten hand-authored personas seeded next to the faker profiles, designed so that any screenshot of search,
filters, or a profile page looks like a real product with real people in it.
### Design
- Data lives in `tests/e2e/utils/showcase-profiles.ts` — plain data, no faker.
- Seeded by `seedShowcaseUsers()` in `tests/e2e/utils/seed-showcase.ts`, called from `seed-test-data.ts`
under `SHOWCASE=1` (which seeds the personas _instead of_ the faker profiles).
- **No Firebase auth accounts.** `seedUser()` creates them because e2e tests sign in as those users; nobody
signs in as a showcase persona. `SPEC_CONFIG` hardcodes the Firebase emulator at `localhost:9099`, so
requiring auth would make this fail with ECONNREFUSED whenever it runs against the remote dev DB without
the local stack up. User ids are instead derived deterministically from the slug
(`showcase<sha256(slug)[:20]>`, 28 chars like a real uid).
- Usernames are the persona slug, so capture scripts can hard-code profile URLs
(`/mayaokonkwo`, `/rafaelmendes`, …). Re-seeding is idempotent: ids are stable and the insert
short-circuits if that user already exists.
- Personas cover the filter surface deliberately: a spread of ages (2461), genders, orientations,
relationship intents (collaboration / friendship / relationship), countries, religions, politics, and MBTI —
so a screenshot of the filter panel has something to match on whatever facet is demoed.
- Bios are 3-paragraph tiptap docs in a real human register, each seeded with the niche keywords the marketing
copy promises ("stoicism", "sustainable living", "neuroscience", "hiking", "meditation").
- Interests / causes / work rows are upserted into the lookup tables by name, then linked.
### Photos (W0b — done)
29 portraits in `web/public/images/showcase/`, named `<slug>-1.jpg` … `<slug>-N.jpg` where `N` is the
persona's `photoCount`. `-1` becomes `pinned_url` and the **rest** become `photo_urls` — the two are
disjoint, because `ProfileCarousel` renders `buildArray(pinned_url, photo_urls)` without deduping and a
shared entry would show twice. Missing files degrade gracefully — the seed falls back to
`default-avatar.png` and warns.
Generated by `scripts/generate-showcase-portraits.ts` against the Gemini image API
(`gemini-3-pro-image`, `GEMINI_API_KEY` from the repo-root `.env`):
```bash
npx tsx scripts/generate-showcase-portraits.ts # fill in anything missing
npx tsx scripts/generate-showcase-portraits.ts --only priyaraman --force
npx tsx scripts/generate-showcase-portraits.ts --dry-run # print prompts, call nothing
```
- **Licensing**: fully synthetic. No real person to consent, no license to track, nothing scraped. This is
the only sourcing route that is unambiguously safe for fictional profiles — a CC0 portrait of a real
person attached to an invented biography is a different and worse problem.
- **Output**: 896×1200 JPEG, re-compressed at q82 to 100250 KB each (4.4 MB total).
- **Identity consistency** is the hard part. Shot 1 is generated from text; shots 2..N pass shot 1 back as a
reference image with an explicit "same person" instruction. Text alone drifts — identical descriptions
produce different faces.
- **Prompt lessons**, encoded in the script as constants:
- The "candid / shot on film" register drags in a white film border and an orange date stamp. `STYLE`
rules them out by name.
- Left alone the model backs the camera off and the subject becomes a small figure in a room. `FRAMING`
forces a waist-up medium shot for shot 1, which is the avatar.
- Sun-weathered cues ("tanned", "sun-bleached", "freckles") age a subject up ~10 years. The 24-year-old
persona needed her youth asserted explicitly to stop reading as mid-thirties.
> **Deploy weight**: these live under `web/public`, so they ship with the Vercel build (4.4 MB) and are
> publicly reachable at `/images/showcase/*.jpg` even though no production page references them. That's the
> price of the dev server serving them same-origin for captures. If it becomes a problem, gitignore the
> directory and regenerate locally — the script is the source of truth, not the files.
### Running it
Scripts that mutate backend state are run by the human:
```bash
./scripts/dev_db_seed.sh # remote dev DB
./scripts/seed.sh # local supabase stack
```

View File

@@ -0,0 +1,297 @@
/**
* Generates the showcase-persona portraits with the Gemini image API.
*
* The personas in `tests/e2e/utils/showcase-profiles.ts` are fictional, so their photos have to be too:
* no real person to consent, no license to track, nothing scraped. See `docs/marketing-visuals.md` (W0b).
*
* Identity consistency across a persona's photos comes from generating shot 1 from text, then passing that
* image back as a reference for shots 2..N. Text alone drifts — same description, different face.
*
* Usage (needs GEMINI_API_KEY in the repo-root .env):
*
* npx tsx scripts/generate-showcase-portraits.ts # all missing portraits
* npx tsx scripts/generate-showcase-portraits.ts --only priyaraman
* npx tsx scripts/generate-showcase-portraits.ts --force # regenerate existing
* npx tsx scripts/generate-showcase-portraits.ts --dry-run # print prompts, call nothing
*/
import {execFileSync} from 'child_process'
import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'fs'
import {join} from 'path'
import {SHOWCASE_PROFILES, ShowcaseProfile} from '../tests/e2e/utils/showcase-profiles'
const REPO_ROOT = join(__dirname, '..')
const OUT_DIR = join(REPO_ROOT, 'web/public/images/showcase')
const MODEL = 'gemini-3-pro-image'
const API = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`
/** Target size after re-compression. Raw API output is ~700 KB. */
const JPEG_QUALITY = 82
/**
* Shared photographic direction. The negatives matter more than the positives here — without them the
* model reliably produces glossy stock-photo faces, which is precisely the register this product is
* positioned against.
*/
const STYLE =
'Candid documentary-style photograph, natural available light, shot on a 50mm lens, shallow depth of ' +
'field, realistic skin texture with visible pores and blemishes, unretouched. Not a stock photo, not a ' +
'studio portrait, no artificial lighting, no perfect teeth, no posed corporate smile. Slightly imperfect ' +
'framing, as if taken by a friend. ' +
// The "candid / shot on film" register reliably drags in a white film border and an orange date stamp,
// which look broken inside a profile card. Rule them out explicitly.
'Full-bleed digital photograph filling the entire frame: no white border, no film frame, no Polaroid ' +
'edge, no date stamp, no timestamp, no watermark, no text or lettering anywhere in the image.'
interface PortraitSpec {
/** Stable physical description — repeated in every shot so the reference has something to lock onto. */
appearance: string
/** One scene per photo. Length must equal the persona's `photoCount`. */
scenes: string[]
}
const PORTRAITS: Record<string, PortraitSpec> = {
mayaokonkwo: {
appearance:
'31-year-old Nigerian-British woman, medium-dark skin, long box braids often tied back, ' +
'tortoiseshell glasses pushed up on her head, warm and slightly amused expression',
scenes: [
'sitting in a cluttered university office in Edinburgh, whiteboard covered in diagrams behind her, holding a mug, looking off camera',
'on a windy Scottish hillside in a waterproof jacket, mid-hike, hair pulled back, laughing at something out of frame',
'in a small kitchen cooking, steam rising from a pot, sleeves pushed up, concentrating on the pan',
],
},
tomasleclerc: {
appearance:
'38-year-old French man, light skin, short dark hair going grey at the temples, close-cropped beard, ' +
'lean build, forearms marked from manual work, quiet serious face',
scenes: [
'in a woodworking workshop surrounded by oak offcuts and hand tools, sawdust in the air, wearing a worn canvas apron',
'planing a board at a workbench, hands in focus, face partly turned away',
'outdoors stacking split firewood beside a stone house, autumn light, breath visible',
'sitting on the workshop step drinking coffee, dog at his feet, looking tired and content',
],
},
priyaraman: {
appearance:
'26-year-old South Indian woman, brown skin, thick shoulder-length black hair usually loose, ' +
'small nose stud, animated open expression',
scenes: [
'in front of a wall of climate model output on monitors, gesturing mid-explanation to someone off camera',
'sitting cross-legged on a mat by a window in early morning light, eyes closed, meditating',
'crouched in a small allotment garden in Bangalore, hands in the soil, grinning up at the camera',
],
},
davidhirsch: {
appearance:
'61-year-old white American man, full head of grey hair, wire-rimmed glasses, deep laugh lines, ' +
'tall and slightly stooped, kind attentive face',
scenes: [
// The choir shot came out with singers crowding the foreground and his face half-hidden — fine as a
// second photo, wrong for the avatar. The trail shot leads instead.
'walking a wooded trail in Portland in a rain jacket, morning mist, pausing to look back at the camera',
'conducting a community choir in a church hall, arms raised mid-beat, singers blurred in the foreground',
],
},
aminahaddad: {
appearance:
'34-year-old Lebanese woman, olive skin, dark hair in a practical low ponytail, strong runners build, ' +
'direct confident gaze',
scenes: [
'on a construction site in a hard hat and hi-vis vest, holding rolled drawings, Beirut skyline behind her',
'mid-run on a coastal road at dawn, sweat visible, focused expression',
'sitting on a balcony in the evening with a book about Ottoman history, city lights behind',
],
},
joonpark: {
appearance:
'29-year-old Korean non-binary person, light skin, bleached-blond undercut, several ear piercings, ' +
'oversized clothes, wry playful expression',
scenes: [
'at a messy desk in a Berlin game studio, three monitors of spreadsheets and game builds, mid-thought',
'foraging in a damp beech forest, holding up a mushroom to the light, basket in the other hand',
'hosting a dinner party, leaning over a crowded table serving food, guests blurred and laughing',
'sitting on a windowsill in a Berlin flat at night, city outside, holding a controller',
],
},
sofiacosta: {
appearance:
// Sun-weathered outdoor cues ("tanned", "sun-bleached", "freckles") reliably age this one up a
// decade, so the youth has to be asserted harder than for the other personas.
'young woman of 24, clearly in her early twenties with a smooth youthful face and no lines, ' +
'Brazilian, lightly tanned skin, sun-bleached curly brown hair, freckles across the nose, ' +
'wiry athletic build, open enthusiastic student-aged face',
scenes: [
'on a research boat in a wetsuit, clipboard in hand, salt-wet hair, squinting into the sun',
'underwater free diving above a seagrass meadow, no mask fogging, calm and weightless',
'at a climbing gym chalking her hands, mid-conversation, gear scattered around',
],
},
marcusadeyemi: {
appearance:
'45-year-old Black British man, dark skin, shaved head, greying stubble beard, heavy-framed glasses, ' +
'broad build, calm watchful expression',
scenes: [
'in a dim film edit suite lit by monitors, timeline visible, headphones around his neck',
'in a home kitchen in Toronto cooking for a crowd, large pot, several dishes underway, sleeves rolled',
],
},
ellenostrom: {
appearance:
'52-year-old Swedish woman, weathered fair skin, short greying blonde hair, no makeup, ' +
'strong outdoor build, dry amused expression',
scenes: [
'in nurse scrubs in a small rural clinic, leaning on a doorframe, northern winter light through the window',
'cross-country skiing through birch forest in deep snow, poles planted, grinning',
'sitting on a wooden jetty in a towel after a cold-water swim, steam rising off her shoulders',
],
},
rafaelmendes: {
appearance:
'36-year-old Brazilian man, light brown skin, dark curly hair getting long, patchy beard, ' +
'thin build, slightly rumpled, thoughtful reserved expression',
scenes: [
'at a home desk in São Paulo late at night, terminal windows on screen, one lamp, mug of coffee',
'reading in a park on a bench, book propped on one knee, city noise implied, sunlight through leaves',
],
},
}
// ─── API ──────────────────────────────────────────────────────────────────────
function loadApiKey() {
const env = readFileSync(join(REPO_ROOT, '.env'), 'utf8')
const key = env.match(/^GEMINI_API_KEY=(.+)$/m)?.[1]?.trim()
if (!key) throw new Error('GEMINI_API_KEY not found in .env')
return key
}
/** Returns raw JPEG bytes. `reference` locks the face for shots after the first. */
async function generateImage(apiKey: string, prompt: string, reference?: Buffer) {
const parts: object[] = []
if (reference) {
parts.push({inlineData: {mimeType: 'image/jpeg', data: reference.toString('base64')}})
}
parts.push({text: prompt})
const res = await fetch(`${API}?key=${apiKey}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
contents: [{parts}],
generationConfig: {imageConfig: {aspectRatio: '3:4'}},
}),
})
const body = await res.json()
if (body.error) throw new Error(`${body.error.status}: ${body.error.message}`)
const candidate = body.candidates?.[0]
const imagePart = candidate?.content?.parts?.find((p: any) => p.inlineData)
if (!imagePart) {
const text = candidate?.content?.parts?.find((p: any) => p.text)?.text
throw new Error(`no image returned (finishReason=${candidate?.finishReason}) ${text ?? ''}`)
}
return Buffer.from(imagePart.inlineData.data, 'base64')
}
/** Re-encode to keep the repo light — raw output is ~700 KB per shot, 29 shots. */
function compress(path: string) {
execFileSync('convert', [path, '-strip', '-quality', String(JPEG_QUALITY), path])
}
// ─── Prompt assembly ──────────────────────────────────────────────────────────
/**
* Shot 1 becomes `pinned_url` — the avatar and the card thumbnail — so it needs the face big in frame.
* Left to itself the model backs the camera off and the subject reads as a small figure in a room.
*/
const FRAMING =
'Waist-up medium shot. The person is unmistakably the subject and their face is large and clearly visible, sharply in focus.'
function firstShotPrompt(profile: ShowcaseProfile, spec: PortraitSpec, scene: string) {
return [
`Photograph of a ${spec.appearance}.`,
`She/he/they work as a ${profile.occupation_title} and live in ${profile.city}, ${profile.country}.`,
`Scene: ${scene}.`,
FRAMING,
`They look their stated age of ${profile.age}, not older.`,
STYLE,
].join(' ')
}
function followUpPrompt(spec: PortraitSpec, scene: string) {
return [
'Generate a new photograph of the exact same person shown in the reference image —',
'same face, same bone structure, same hair, same age. Keep the identity strictly consistent.',
`The person is a ${spec.appearance}.`,
`New scene, different day, different clothes: ${scene}.`,
'The person remains the clear subject of the frame, face visible and in focus.',
STYLE,
].join(' ')
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
const args = process.argv.slice(2)
const dryRun = args.includes('--dry-run')
const force = args.includes('--force')
const only = args[args.indexOf('--only') + 1]
const targets = args.includes('--only')
? SHOWCASE_PROFILES.filter((p) => p.slug === only)
: SHOWCASE_PROFILES
if (targets.length === 0) throw new Error(`no persona matching --only ${only}`)
const apiKey = dryRun ? '' : loadApiKey()
mkdirSync(OUT_DIR, {recursive: true})
for (const profile of targets) {
const spec = PORTRAITS[profile.slug]
if (!spec) throw new Error(`no portrait spec for ${profile.slug}`)
if (spec.scenes.length !== profile.photoCount) {
throw new Error(
`${profile.slug}: ${spec.scenes.length} scenes but photoCount is ${profile.photoCount}`,
)
}
// The first shot defines the face; later shots reference it, so it must exist before they run.
let reference: Buffer | undefined
for (let i = 0; i < spec.scenes.length; i++) {
const path = join(OUT_DIR, `${profile.slug}-${i + 1}.jpg`)
const scene = spec.scenes[i]
const prompt = i === 0 ? firstShotPrompt(profile, spec, scene) : followUpPrompt(spec, scene)
if (dryRun) {
console.log(`\n── ${profile.slug}-${i + 1}\n${prompt}`)
continue
}
if (existsSync(path) && !force) {
console.log(`skip ${profile.slug}-${i + 1} (exists)`)
if (i === 0) reference = readFileSync(path)
continue
}
try {
const image = await generateImage(apiKey, prompt, i === 0 ? undefined : reference)
writeFileSync(path, image)
compress(path)
if (i === 0) reference = readFileSync(path)
console.log(`write ${profile.slug}-${i + 1}`)
} catch (err: any) {
console.error(`FAIL ${profile.slug}-${i + 1}: ${err.message}`)
// A failed first shot leaves nothing to reference, so the rest of this persona is pointless.
if (i === 0) break
}
}
}
}
main().catch((err) => {
console.error(err.message)
process.exit(1)
})

View File

@@ -0,0 +1,220 @@
import {debug} from 'common/logger'
import {PrivateUser} from 'common/user'
import {getDefaultNotificationPreferences} from 'common/user-notification-preferences'
import {randomString} from 'common/util/random'
import {createHash} from 'crypto'
import {existsSync} from 'fs'
import {join} from 'path'
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
import {getUser} from 'shared/utils'
import {SHOWCASE_PROFILES, ShowcaseProfile} from './showcase-profiles'
/**
* Seeds the hand-authored showcase personas (see `showcase-profiles.ts`).
*
* Separate from `seedDbUser()` because the goals differ: that path randomises everything so e2e tests get
* varied input, this one keeps every field exactly as written so marketing captures are reproducible.
*
* Deliberately does NOT create Firebase auth accounts. `seedUser()` does, because e2e tests sign in as
* those users; nobody ever signs in as a showcase persona — they exist to be looked at. Skipping Firebase
* means this runs against the remote dev DB without the emulator (SPEC_CONFIG hardcodes localhost:9099,
* so with no emulator every sign-up fails with ECONNREFUSED).
*
* Idempotent — ids are derived from the slug, and re-running skips personas that already exist.
*/
/**
* Deterministic stand-in for a Firebase uid: 28 chars, same shape as the real thing, stable across runs
* so re-seeding updates nothing and capture scripts can rely on the id.
*/
function showcaseUserId(slug: string) {
return 'showcase' + createHash('sha256').update(slug).digest('hex').slice(0, 20)
}
/** Portraits live in the web app's public dir so dev captures can load them same-origin. */
const PHOTO_PUBLIC_DIR = join(__dirname, '../../../web/public/images/showcase')
const PHOTO_URL_PREFIX = '/images/showcase'
const DEFAULT_AVATAR = '/images/default-avatar.png'
/**
* Resolves `<slug>-1.jpg` … `<slug>-N.jpg`, keeping only files that actually exist.
* Missing portraits are not fatal: the profile still seeds, it just wears the default avatar.
*
* `pinned_url` and `photo_urls` are disjoint: the pinned photo must NOT also appear in `photo_urls`.
* `ProfileCarousel` renders `buildArray(pinned_url, photo_urls)` without deduping, so a shared entry
* shows up twice. (The photo editor wraps the same call in `uniq`, which masks it while editing.)
*/
function resolvePhotos(profile: ShowcaseProfile) {
const urls: string[] = []
for (let i = 1; i <= profile.photoCount; i++) {
const file = `${profile.slug}-${i}.jpg`
if (existsSync(join(PHOTO_PUBLIC_DIR, file))) urls.push(`${PHOTO_URL_PREFIX}/${file}`)
}
if (urls.length === 0) {
console.warn(
`[showcase] no portraits found for ${profile.slug} in web/public/images/showcase — ` +
`falling back to the default avatar. See docs/marketing-visuals.md (W0b).`,
)
return {photo_urls: [], pinned_url: DEFAULT_AVATAR}
}
return {pinned_url: urls[0], photo_urls: urls.slice(1)}
}
/** Bios are stored as a tiptap doc; one paragraph node per entry. */
function toBioDoc(paragraphs: string[]) {
return {
type: 'doc',
content: paragraphs.map((text) => ({
type: 'paragraph',
content: [{type: 'text', text}],
})),
}
}
/**
* `interests` / `causes` / `work` are user-extensible lookup tables keyed by a unique name.
* Upsert by name and hand back the id, so re-seeding never duplicates an option.
*/
async function upsertOptionIds(
tx: SupabaseDirectClient,
table: 'interests' | 'causes' | 'work',
names: string[],
) {
const ids: number[] = []
for (const name of names) {
const row = await tx.one<{id: number}>(
`insert into ${table} (name) values ($1)
on conflict (name) do update set name = excluded.name
returning id`,
[name],
)
ids.push(row.id)
}
return ids
}
async function linkOptions(
tx: SupabaseDirectClient,
table: 'profile_interests' | 'profile_causes' | 'profile_work',
profileId: number,
optionIds: number[],
) {
for (const optionId of optionIds) {
await insert(tx, table, {profile_id: profileId, option_id: optionId})
}
}
async function seedShowcaseProfile(profile: ShowcaseProfile, userId: string) {
const pg = createSupabaseDirectClient()
const {photo_urls, pinned_url} = resolvePhotos(profile)
const bio = toBioDoc(profile.bio)
const bioText = profile.bio.join('\n\n')
const privateUser: PrivateUser = {
id: userId,
email: profile.email,
initialIpAddress: '127.0.0.1',
initialDeviceToken: randomString(),
notificationPreferences: getDefaultNotificationPreferences(),
blockedUserIds: [],
blockedByUserIds: [],
}
return pg.tx(async (tx: any) => {
if (await getUser(userId, tx)) return false
await insert(tx, 'users', {
id: userId,
name: profile.name,
username: profile.slug,
data: {},
})
await insert(tx, 'private_users', {id: userId, data: privateUser})
const inserted = await insert(tx, 'profiles', {
user_id: userId,
age: profile.age,
gender: profile.gender,
orientation: profile.orientation,
headline: profile.headline,
bio,
bio_length: bioText.length,
keywords: profile.keywords,
city: profile.city,
region_code: profile.region_code,
country: profile.country,
city_latitude: profile.city_latitude,
city_longitude: profile.city_longitude,
born_in_location: profile.born_in_location,
occupation_title: profile.occupation_title,
company: profile.company,
university: profile.university,
education_level: profile.education_level,
languages: profile.languages,
ethnicity: profile.ethnicity,
height_in_inches: profile.height_in_inches,
religion: profile.religion,
political_beliefs: profile.political_beliefs,
diet: profile.diet,
mbti: profile.mbti,
big5_openness: profile.big5.openness,
big5_conscientiousness: profile.big5.conscientiousness,
big5_extraversion: profile.big5.extraversion,
big5_agreeableness: profile.big5.agreeableness,
big5_neuroticism: profile.big5.neuroticism,
relationship_status: profile.relationship_status,
pref_relation_styles: profile.pref_relation_styles,
pref_romantic_styles: profile.pref_romantic_styles,
pref_gender: profile.pref_gender,
pref_age_min: profile.pref_age_min,
pref_age_max: profile.pref_age_max,
has_kids: profile.has_kids,
wants_kids_strength: profile.wants_kids_strength,
is_smoker: profile.is_smoker,
drinks_per_month: profile.drinks_per_month,
photo_urls,
pinned_url,
links: profile.links,
// Public so captures can run against a logged-out browser.
visibility: 'public',
})
const profileId = inserted.id
await linkOptions(
tx,
'profile_interests',
profileId,
await upsertOptionIds(tx, 'interests', profile.interests),
)
await linkOptions(
tx,
'profile_causes',
profileId,
await upsertOptionIds(tx, 'causes', profile.causes),
)
await linkOptions(
tx,
'profile_work',
profileId,
await upsertOptionIds(tx, 'work', profile.work),
)
return true
})
}
/** Seeds every showcase persona. Returns how many were newly created. */
export async function seedShowcaseUsers() {
let created = 0
for (const profile of SHOWCASE_PROFILES) {
if (await seedShowcaseProfile(profile, showcaseUserId(profile.slug))) {
created++
debug('Showcase profile created:', profile.slug)
}
}
console.log(`[showcase] ${created}/${SHOWCASE_PROFILES.length} personas created`)
return created
}

View File

@@ -3,6 +3,7 @@ import {createSomeNotifications} from 'shared/create-notification'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
import {seedShowcaseUsers} from './seed-showcase'
import {
seedUser,
TEST_USER_DISPLAY_NAME,
@@ -52,27 +53,33 @@ async function seedNotifications() {
type ProfileType = 'basic' | 'medium' | 'full'
;(async () => {
//Edit the count seedConfig to specify the amount of each profiles to create
const seedConfig = [
{count: 8, profileType: 'basic' as ProfileType},
{count: 8, profileType: 'medium' as ProfileType},
{count: 8, profileType: 'full' as ProfileType},
]
if (process.env.SHOWCASE === '1') {
// Hand-authored personas with real prose and portraits, for marketing captures.
// Needs no Firebase emulator, so it works against the remote dev DB on its own.
await seedShowcaseUsers()
} else {
//Edit the count seedConfig to specify the amount of each profiles to create
const seedConfig = [
{count: 8, profileType: 'basic' as ProfileType},
{count: 8, profileType: 'medium' as ProfileType},
{count: 8, profileType: 'full' as ProfileType},
]
for (const {count, profileType} of seedConfig) {
for (let i = 0; i < count; i++) {
await seedUser(undefined, undefined, profileType)
for (const {count, profileType} of seedConfig) {
for (let i = 0; i < count; i++) {
await seedUser(undefined, undefined, profileType)
}
}
}
// Used in some tests that require interaction with a permanent user
await seedUser(
TEST_USER_EMAIL,
TEST_USER_PASSWORD,
'full',
TEST_USER_DISPLAY_NAME,
TEST_USER_USERNAME,
)
// Used in some tests that require interaction with a permanent user
await seedUser(
TEST_USER_EMAIL,
TEST_USER_PASSWORD,
'full',
TEST_USER_DISPLAY_NAME,
TEST_USER_USERNAME,
)
}
await seedCompatibilityPrompts()
await seedNotifications()

View File

@@ -0,0 +1,631 @@
/**
* Hand-authored "showcase" profiles.
*
* The faker-generated profiles (`UserAccountInformationForSeeding`) are fine for e2e assertions but useless
* on camera: lorem-ipsum bios, lorem headlines, ages uniform over 18100, and the same `default-avatar.png`
* on every profile. Marketing captures (see `docs/marketing-visuals.md`) need a database that looks like a
* real product with real people in it.
*
* These ten personas are fictional by construction, so nothing captured from them can leak member data.
* They deliberately spread across the filter surface — age, gender, orientation, connection intent, country,
* religion, politics, MBTI — so that whatever facet a screenshot demonstrates, there is something to match.
*
* Bios embed the niche keywords the marketing copy promises ("stoicism", "sustainable living",
* "neuroscience", "hiking", "meditation") so keyword-search demos return sensible results.
*
* Photos are NOT in this repo by default — see `docs/marketing-visuals.md` (W0b) for the naming convention
* and licensing rules. Missing files degrade to the default avatar with a warning.
*/
export interface ShowcaseProfile {
/** Stable slug — drives the user id, username, and photo filenames. Lowercase, no spaces. */
slug: string
name: string
email: string
age: number
gender: string
orientation: string[]
headline: string
/** Paragraphs of the bio, in order. */
bio: string[]
keywords: string[]
city: string
region_code: string | null
country: string
city_latitude: number
city_longitude: number
born_in_location: string
occupation_title: string
company: string | null
university: string | null
education_level: string
languages: string[]
ethnicity: string[]
height_in_inches: number
religion: string[]
political_beliefs: string[]
diet: string[]
mbti: string
big5: {
openness: number
conscientiousness: number
extraversion: number
agreeableness: number
neuroticism: number
}
relationship_status: string[]
pref_relation_styles: string[]
pref_romantic_styles: string[]
pref_gender: string[]
pref_age_min: number
pref_age_max: number
has_kids: number
wants_kids_strength: number
is_smoker: boolean
drinks_per_month: number
/** Names upserted into the `interests` lookup table, then linked via `profile_interests`. */
interests: string[]
/** Names upserted into `causes` → `profile_causes`. */
causes: string[]
/** Names upserted into `work` → `profile_work`. */
work: string[]
/** Keys must be values of `SITE_ORDER` in `common/socials.ts`. */
links: Record<string, string>
/** How many portraits to expect at `/images/showcase/<slug>-<n>.jpg`. */
photoCount: number
}
export const SHOWCASE_PROFILES: ShowcaseProfile[] = [
{
slug: 'mayaokonkwo',
name: 'Maya Okonkwo',
email: 'maya.okonkwo@compass.showcase',
age: 31,
gender: 'female',
orientation: ['bisexual'],
headline: 'Cognitive scientist who reads too much Stoicism and hikes to think',
bio: [
'I spend my working hours on the neuroscience of attention — specifically what happens in the first 200 milliseconds after something grabs you. It has made me insufferable at parties and unexpectedly patient in traffic.',
'Outside the lab I read a lot of Stoicism, mostly Epictetus, mostly badly. I have a standing Sunday hiking habit that started as a way to think through problems and turned into the part of the week I protect hardest. I cook Nigerian food from my mothers recipes and I am still not as good at it as she is.',
'I am looking for people who argue in good faith and change their minds out loud. Friendship or something more — I am genuinely open on that, and I would rather find out slowly.',
],
keywords: ['neuroscience', 'stoicism', 'hiking', 'cooking'],
city: 'Edinburgh',
region_code: 'SCT',
country: 'United Kingdom',
city_latitude: 55.9533,
city_longitude: -3.1883,
born_in_location: 'Nigeria',
occupation_title: 'Cognitive Neuroscientist',
company: 'University of Edinburgh',
university: 'University College London',
education_level: 'doctorate',
languages: ['english', 'igbo', 'french'],
ethnicity: ['african'],
height_in_inches: 67,
religion: ['agnostic'],
political_beliefs: ['progressive'],
diet: ['pescetarian'],
mbti: 'intj',
big5: {
openness: 91,
conscientiousness: 78,
extraversion: 42,
agreeableness: 66,
neuroticism: 31,
},
relationship_status: ['single'],
pref_relation_styles: ['friendship', 'relationship'],
pref_romantic_styles: ['mono'],
pref_gender: ['female', 'male', 'non-binary'],
pref_age_min: 27,
pref_age_max: 42,
has_kids: 0,
wants_kids_strength: 2,
is_smoker: false,
drinks_per_month: 6,
interests: ['Neuroscience', 'Stoicism', 'Hiking', 'Cooking', 'Philosophy'],
causes: ['Open science', 'Mental health'],
work: ['Research', 'Academia'],
links: {site: 'mayaokonkwo.science', github: 'mokonkwo', substack: 'twohundredms'},
photoCount: 3,
},
{
slug: 'tomasleclerc',
name: 'Tomás Leclerc',
email: 'tomas.leclerc@compass.showcase',
age: 38,
gender: 'male',
orientation: ['straight'],
headline: 'Carpenter, slow-living convert, looking for people who build things',
bio: [
'I left a decade of software consulting to make furniture. Best decision I have made, financially the worst. I build mostly in oak and mostly to commission, and I am learning Japanese joinery from books and failure.',
'Sustainable living is the through-line: I heat with wood I cut, I fix rather than replace, and I have opinions about supply chains that I try to keep to myself for the first hour of knowing someone.',
'I have a nine-year-old daughter half the week. I am not in a hurry about anything. I would like to meet people who make things with their hands, or who want to learn.',
],
keywords: ['woodworking', 'sustainable living', 'permaculture', 'japanese joinery'],
city: 'Lyon',
region_code: 'ARA',
country: 'France',
city_latitude: 45.764,
city_longitude: 4.8357,
born_in_location: 'France',
occupation_title: 'Furniture Maker',
company: 'Atelier Leclerc',
university: null,
education_level: 'bachelors',
languages: ['french', 'english', 'spanish'],
ethnicity: ['caucasian'],
height_in_inches: 71,
religion: ['atheist'],
political_beliefs: ['green'],
diet: ['omnivore'],
mbti: 'istp',
big5: {
openness: 74,
conscientiousness: 83,
extraversion: 35,
agreeableness: 71,
neuroticism: 28,
},
relationship_status: ['single'],
pref_relation_styles: ['friendship', 'relationship'],
pref_romantic_styles: ['mono'],
pref_gender: ['female'],
pref_age_min: 30,
pref_age_max: 48,
has_kids: 1,
wants_kids_strength: 1,
is_smoker: false,
drinks_per_month: 12,
interests: ['Woodworking', 'Sustainable living', 'Permaculture', 'Cycling'],
causes: ['Climate', 'Right to repair'],
work: ['Craft', 'Design'],
links: {site: 'atelier-leclerc.fr', instagram: 'atelierleclerc'},
photoCount: 4,
},
{
slug: 'priyaraman',
name: 'Priya Raman',
email: 'priya.raman@compass.showcase',
age: 26,
gender: 'female',
orientation: ['queer'],
headline: 'Climate modeller. Meditation twice a day. Will talk about soil for hours.',
bio: [
'I model regional climate for a living, which means I spend most days being carefully precise about uncertainty and most evenings being asked whether we are doomed. (Short answer: the question is badly posed.)',
'I have had a daily meditation practice since I was nineteen — Vipassana, twenty minutes, twice. It is the reason I can do the job without burning out. I also keep an allotment that is more ambition than vegetable.',
'Here for collaborators and friends, mostly. I have more ideas than time and I like people who fix that problem for each other.',
],
keywords: ['climate', 'meditation', 'soil', 'gardening'],
city: 'Bangalore',
region_code: 'KA',
country: 'India',
city_latitude: 12.9716,
city_longitude: 77.5946,
born_in_location: 'India',
occupation_title: 'Climate Scientist',
company: 'Indian Institute of Science',
university: 'Indian Institute of Science',
education_level: 'masters',
languages: ['english', 'tamil', 'hindi'],
ethnicity: ['south_asian'],
height_in_inches: 63,
religion: ['hindu'],
political_beliefs: ['green'],
diet: ['veg'],
mbti: 'infj',
big5: {
openness: 88,
conscientiousness: 80,
extraversion: 48,
agreeableness: 79,
neuroticism: 36,
},
relationship_status: ['single'],
pref_relation_styles: ['collaboration', 'friendship'],
pref_romantic_styles: [],
pref_gender: ['female', 'non-binary'],
pref_age_min: 22,
pref_age_max: 38,
has_kids: 0,
wants_kids_strength: 1,
is_smoker: false,
drinks_per_month: 0,
interests: ['Meditation', 'Gardening', 'Climate science', 'Cycling'],
causes: ['Climate', 'Food security'],
work: ['Research', 'Climate'],
links: {site: 'priyaraman.in', github: 'praman', x: 'praman_climate'},
photoCount: 3,
},
{
slug: 'davidhirsch',
name: 'David Hirsch',
email: 'david.hirsch@compass.showcase',
age: 61,
gender: 'male',
orientation: ['straight'],
headline: 'Retired GP, second act as a choir conductor, still learning Bach',
bio: [
'Forty years of general practice taught me that most of what people need is someone who will actually listen for more than four minutes. I retired two years ago and I am still adjusting to having time.',
'I conduct a community choir of forty-odd amateurs. We are not good, but we are getting better, and Bach forgives a lot. I walk every morning and I have started reading philosophy properly rather than pretending I had.',
'Widowed. Not looking to replace anyone. I would like conversation, walking companions, and people who are unbothered by the age gap in either direction.',
],
keywords: ['choir', 'bach', 'medicine', 'walking'],
city: 'Portland',
region_code: 'OR',
country: 'United States',
city_latitude: 45.5152,
city_longitude: -122.6784,
born_in_location: 'United States',
occupation_title: 'Retired Physician',
company: null,
university: 'Oregon Health & Science University',
education_level: 'doctorate',
languages: ['english', 'german'],
ethnicity: ['caucasian'],
height_in_inches: 70,
religion: ['jewish'],
political_beliefs: ['liberal'],
diet: ['omnivore'],
mbti: 'enfj',
big5: {
openness: 77,
conscientiousness: 86,
extraversion: 63,
agreeableness: 88,
neuroticism: 25,
},
relationship_status: ['single'],
pref_relation_styles: ['friendship'],
pref_romantic_styles: [],
pref_gender: ['female', 'male'],
pref_age_min: 45,
pref_age_max: 75,
has_kids: 2,
wants_kids_strength: 0,
is_smoker: false,
drinks_per_month: 8,
interests: ['Choral music', 'Walking', 'Philosophy', 'Bach'],
causes: ['Healthcare access', 'Arts funding'],
work: ['Medicine', 'Music'],
links: {site: 'rosecitychoir.org'},
photoCount: 2,
},
{
slug: 'aminahaddad',
name: 'Amina Haddad',
email: 'amina.haddad@compass.showcase',
age: 34,
gender: 'female',
orientation: ['straight'],
headline: 'Civil engineer rebuilding water systems. Long-distance runner.',
bio: [
'I design municipal water infrastructure, which is the least glamorous and most consequential engineering there is. Two years in Jordan, now back in Beirut doing the same work at home.',
'I run — properly run, sixty kilometres a week, two marathons a year. It is the only time my head is quiet. I read history obsessively, mostly Ottoman, and I am teaching myself to sail badly.',
'Looking for a partner who has their own thing and takes it seriously. I travel for work and I need someone who finds that interesting rather than threatening.',
],
keywords: ['engineering', 'running', 'water', 'history'],
city: 'Beirut',
region_code: null,
country: 'Lebanon',
city_latitude: 33.8938,
city_longitude: 35.5018,
born_in_location: 'Lebanon',
occupation_title: 'Civil Engineer',
company: 'Beirut Water Authority',
university: 'American University of Beirut',
education_level: 'masters',
languages: ['arabic', 'french', 'english'],
ethnicity: ['middle_eastern'],
height_in_inches: 66,
religion: ['muslim'],
political_beliefs: ['moderate'],
diet: ['omnivore'],
mbti: 'estj',
big5: {
openness: 68,
conscientiousness: 92,
extraversion: 55,
agreeableness: 61,
neuroticism: 33,
},
relationship_status: ['single'],
pref_relation_styles: ['relationship'],
pref_romantic_styles: ['mono'],
pref_gender: ['male'],
pref_age_min: 30,
pref_age_max: 45,
has_kids: 0,
wants_kids_strength: 3,
is_smoker: false,
drinks_per_month: 2,
interests: ['Running', 'History', 'Sailing', 'Engineering'],
causes: ['Clean water', 'Rebuilding Beirut'],
work: ['Engineering', 'Infrastructure'],
links: {linkedin: 'aminahaddad', site: 'aminahaddad.eng'},
photoCount: 3,
},
{
slug: 'joonpark',
name: 'Joon Park',
email: 'joon.park@compass.showcase',
age: 29,
gender: 'non-binary',
orientation: ['pansexual'],
headline: 'Game designer, amateur mycologist, host of a very small dinner party',
bio: [
'I design systems for a mid-size indie studio — mostly economies and progression, the invisible plumbing that makes a game feel fair. Before that I studied economics, which turns out to be the same job with worse graphics.',
'I forage mushrooms, seriously and carefully, and I run a monthly dinner for eight people who mostly did not know each other beforehand. Seating chart is the whole art form.',
'Open to friendship, collaboration, or dating — I have never found it useful to decide in advance. I am poly and clear about it from the start.',
],
keywords: ['game design', 'mycology', 'foraging', 'economics'],
city: 'Berlin',
region_code: 'BE',
country: 'Germany',
city_latitude: 52.52,
city_longitude: 13.405,
born_in_location: 'South Korea',
occupation_title: 'Game Designer',
company: 'Kettle Studios',
university: 'Seoul National University',
education_level: 'bachelors',
languages: ['korean', 'english', 'german'],
ethnicity: ['asian'],
height_in_inches: 68,
religion: ['agnostic'],
political_beliefs: ['libertarian'],
diet: ['omnivore'],
mbti: 'entp',
big5: {
openness: 95,
conscientiousness: 58,
extraversion: 71,
agreeableness: 64,
neuroticism: 44,
},
relationship_status: ['open'],
pref_relation_styles: ['collaboration', 'friendship', 'relationship'],
pref_romantic_styles: ['poly'],
pref_gender: ['female', 'male', 'non-binary'],
pref_age_min: 24,
pref_age_max: 40,
has_kids: 0,
wants_kids_strength: 0,
is_smoker: false,
drinks_per_month: 14,
interests: ['Game design', 'Foraging', 'Cooking', 'Board games'],
causes: ['Open source', 'Digital rights'],
work: ['Games', 'Design'],
links: {
site: 'joonpark.games',
github: 'joonp',
discord: 'joonp',
bluesky: 'joonp.bsky.social',
},
photoCount: 4,
},
{
slug: 'sofiacosta',
name: 'Sofia Costa',
email: 'sofia.costa@compass.showcase',
age: 24,
gender: 'female',
orientation: ['lesbian'],
headline: 'Marine biology PhD student. Free diver. Terrible at sitting still.',
bio: [
'My PhD is on seagrass meadows — unglamorous, enormously important carbon sinks that everyone ignores in favour of coral. I am four months from fieldwork season and counting.',
'I free dive, which is the closest thing I have to meditation. I also climb, badly and enthusiastically. I read almost entirely non-fiction and I am trying to fix that.',
'I would like friends in my new city more than anything else right now. Dating is fine too but I moved here nine months ago and I know four people.',
],
keywords: ['marine biology', 'free diving', 'climbing', 'conservation'],
city: 'Lisbon',
region_code: 'LI',
country: 'Portugal',
city_latitude: 38.7223,
city_longitude: -9.1393,
born_in_location: 'Brazil',
occupation_title: 'PhD Researcher',
company: 'University of Lisbon',
university: 'Federal University of Rio de Janeiro',
education_level: 'masters',
languages: ['portuguese', 'english', 'spanish'],
ethnicity: ['hispanic'],
height_in_inches: 64,
religion: ['spiritual'],
political_beliefs: ['progressive'],
diet: ['pescetarian'],
mbti: 'enfp',
big5: {
openness: 89,
conscientiousness: 62,
extraversion: 76,
agreeableness: 82,
neuroticism: 47,
},
relationship_status: ['single'],
pref_relation_styles: ['friendship', 'relationship'],
pref_romantic_styles: ['mono'],
pref_gender: ['female'],
pref_age_min: 22,
pref_age_max: 34,
has_kids: 0,
wants_kids_strength: 1,
is_smoker: false,
drinks_per_month: 10,
interests: ['Free diving', 'Climbing', 'Marine conservation', 'Non-fiction'],
causes: ['Ocean conservation', 'Climate'],
work: ['Research', 'Conservation'],
links: {site: 'sofiacosta.bio', instagram: 'sofia.seagrass'},
photoCount: 3,
},
{
slug: 'marcusadeyemi',
name: 'Marcus Adeyemi',
email: 'marcus.adeyemi@compass.showcase',
age: 45,
gender: 'male',
orientation: ['gay'],
headline: 'Documentary editor. Twelve years sober. Cooks for a crowd.',
bio: [
'I cut documentaries — mostly long-form, mostly about people the news got bored of. The job is 90% patience and 10% knowing when a shot has earned its length.',
'Twelve years sober this March, which shapes more of my life than any other single fact. I cook for large numbers of people as often as I can get away with it and I have strong feelings about braises.',
'Married, and my husband and I are both here looking for friends — we moved for his work and starting over socially at forty-five is genuinely hard. Not looking for anything romantic.',
],
keywords: ['documentary', 'film editing', 'sobriety', 'cooking'],
city: 'Toronto',
region_code: 'ON',
country: 'Canada',
city_latitude: 43.6532,
city_longitude: -79.3832,
born_in_location: 'United Kingdom',
occupation_title: 'Documentary Editor',
company: 'Freelance',
university: 'National Film and Television School',
education_level: 'bachelors',
languages: ['english', 'yoruba'],
ethnicity: ['african'],
height_in_inches: 73,
religion: ['atheist'],
political_beliefs: ['socialist'],
diet: ['omnivore'],
mbti: 'isfj',
big5: {
openness: 81,
conscientiousness: 75,
extraversion: 44,
agreeableness: 85,
neuroticism: 30,
},
relationship_status: ['married'],
pref_relation_styles: ['friendship'],
pref_romantic_styles: [],
pref_gender: ['female', 'male', 'non-binary'],
pref_age_min: 30,
pref_age_max: 60,
has_kids: 0,
wants_kids_strength: 0,
is_smoker: false,
drinks_per_month: 0,
interests: ['Film', 'Cooking', 'Documentary', 'Cycling'],
causes: ['Addiction recovery', 'Press freedom'],
work: ['Film', 'Media'],
links: {site: 'marcusadeyemi.film', linkedin: 'marcusadeyemi'},
photoCount: 2,
},
{
slug: 'ellenostrom',
name: 'Elle Nyström',
email: 'elle.nystrom@compass.showcase',
age: 52,
gender: 'female',
orientation: ['straight'],
headline: 'Nurse practitioner, cross-country skier, deeply unserious about most things',
bio: [
'Twenty-six years in nursing, currently running a rural primary care clinic that covers an area the size of a small country and has three staff. I like the work more than I expected to at this stage.',
'I ski about a thousand kilometres a winter and swim in cold water year-round, which locally is not considered eccentric. I play accordion, badly, at a level I describe as "socially tolerated".',
'Divorced, kids grown and gone. Looking for a companion for long days outdoors and someone to be quiet with afterwards.',
],
keywords: ['nursing', 'skiing', 'cold water swimming', 'accordion'],
city: 'Umeå',
region_code: 'AC',
country: 'Sweden',
city_latitude: 63.8258,
city_longitude: 20.263,
born_in_location: 'Sweden',
occupation_title: 'Nurse Practitioner',
company: 'Västerbotten Regional Health',
university: 'Umeå University',
education_level: 'masters',
// NOTE: 'Finnish' is capitalised in LANGUAGE_CHOICES while every other value is lowercase.
// Matching the constant as it actually is — see the note in docs/marketing-visuals.md.
languages: ['swedish', 'english', 'Finnish'],
ethnicity: ['caucasian'],
height_in_inches: 68,
religion: ['atheist'],
political_beliefs: ['socialist'],
diet: ['omnivore'],
mbti: 'esfp',
big5: {
openness: 70,
conscientiousness: 84,
extraversion: 68,
agreeableness: 80,
neuroticism: 22,
},
relationship_status: ['single'],
pref_relation_styles: ['relationship', 'friendship'],
pref_romantic_styles: ['mono'],
pref_gender: ['male'],
pref_age_min: 44,
pref_age_max: 65,
has_kids: 3,
wants_kids_strength: 0,
is_smoker: false,
drinks_per_month: 6,
interests: ['Cross-country skiing', 'Cold water swimming', 'Music', 'Hiking'],
causes: ['Rural healthcare', 'Mental health'],
work: ['Medicine', 'Healthcare'],
links: {site: 'ellenystrom.se'},
photoCount: 3,
},
{
slug: 'rafaelmendes',
name: 'Rafael Mendes',
email: 'rafael.mendes@compass.showcase',
age: 36,
gender: 'male',
orientation: ['straight', 'demisexual'],
headline: 'Open-source maintainer looking for collaborators, not users',
bio: [
'I maintain a mid-sized open-source database tool that about four thousand companies depend on and roughly nine people contribute to. The maths of that is the most interesting problem in my life right now.',
'I care a lot about how public digital goods get funded and governed — the Wikipedia question, basically. I read political economy for fun and I have a half-written essay about it that will probably stay half-written.',
'Here mostly for collaboration. If you have ever wanted to work on infrastructure that outlives its funding round, talk to me.',
],
keywords: ['open source', 'databases', 'governance', 'political economy'],
city: 'São Paulo',
region_code: 'SP',
country: 'Brazil',
city_latitude: -23.5505,
city_longitude: -46.6333,
born_in_location: 'Brazil',
occupation_title: 'Open Source Maintainer',
company: 'Independent',
university: 'University of São Paulo',
education_level: 'bachelors',
languages: ['portuguese', 'english'],
ethnicity: ['hispanic'],
height_in_inches: 69,
religion: ['agnostic'],
political_beliefs: ['progressive'],
diet: ['veg'],
mbti: 'intp',
big5: {
openness: 93,
conscientiousness: 66,
extraversion: 33,
agreeableness: 69,
neuroticism: 40,
},
relationship_status: ['single'],
pref_relation_styles: ['collaboration', 'friendship'],
pref_romantic_styles: ['mono'],
pref_gender: ['female', 'non-binary'],
pref_age_min: 28,
pref_age_max: 44,
has_kids: 0,
wants_kids_strength: 2,
is_smoker: false,
drinks_per_month: 4,
interests: ['Open source', 'Political economy', 'Chess', 'Running'],
causes: ['Open source', 'Digital public goods'],
work: ['Software', 'Open source'],
links: {
github: 'rafaelmendes',
site: 'rafaelmendes.dev',
discord: 'rmendes',
substack: 'publicgoods',
},
photoCount: 2,
},
]

View File

@@ -0,0 +1,22 @@
# Showcase portraits
Portraits for the hand-authored seed personas in `tests/e2e/utils/showcase-profiles.ts`, used only for
marketing captures. See [`docs/marketing-visuals.md`](../../../../docs/marketing-visuals.md) (W0b).
These are **fully synthetic** — generated with the Gemini image API, no real person depicted. That is
deliberate: the personas are fictional, and attaching a real person's likeness to an invented biography
would be a different and worse problem than the licensing one.
Regenerate with:
```bash
npx tsx scripts/generate-showcase-portraits.ts # fill in anything missing
npx tsx scripts/generate-showcase-portraits.ts --only priyaraman --force
```
- Naming: `<slug>-1.jpg``<slug>-N.jpg`, where `<slug>` is the persona's `slug` and `N` its `photoCount`.
`-1` is the pinned photo, so it wants the face large in frame.
- 896×1200 JPEG, q82, 100250 KB each.
- Prompts live in the script, not here — edit `PORTRAITS` there and re-run with `--force`.
Missing files are not fatal: the seed falls back to `default-avatar.png` and warns.