Refactor and enhance search and alert capture scripts: ensure accurate filter state resets, improve reliability of test selectors, add frame re-capture for consistent tap markers, and handle redesigned UI elements.

This commit is contained in:
MartinBraquet
2026-08-04 15:11:14 +02:00
parent f5fd64ef53
commit cdb7a0730d
4 changed files with 295 additions and 55 deletions

View File

@@ -35,8 +35,8 @@ coherent field combinations, real photos, real locations, populated interests/ca
### H1 — Hero search demo ✅
The page said "Don't Swipe. / Search." and then never showed a search. It now does, directly under the
hero: one real mobile session, filter to women → type `meditation` → 10 people narrow to 2 → open Sofia's
profile → scroll it → write to her. That covers both halves of the pitch (keyword search _and_ filtering)
hero: one real mobile session, filter to women → type `meditation` → 11 people narrow to 5, then to 2 →
open Sofia's profile → scroll it → write to her. That covers both halves of the pitch (keyword search _and_ filtering)
plus the payoff, which a static grid shot cannot.
**Mobile, not desktop.** A desktop capture rendered into a phone-width column puts the app's 14px type at
@@ -52,10 +52,12 @@ debounce schedule and the clip cannot drift from the product.
Two things that bite when scripting this page:
- Both filter UIs are always in the DOM, one hidden by `lg:` classes. Playwright's `.first()` is DOM
order, not visibility, so on a phone viewport it returns the _desktop_ control and times out. Hence the
`firstVisible()` helper — use it for anything that exists in both layouts.
- Filter sections start collapsed, so "Any gender" must be expanded before "Woman" exists in the DOM.
- Playwright's `.first()` is DOM order, not visibility, so anything mounted more than once can hand back
an offscreen copy that then times out. Hence the `firstVisible()` helper. (It was written for the
mobile/desktop `lg:` split the filter redesign removed, but the hazard outlived the split.)
- Filter sections start collapsed, so Gender must be expanded before "Woman" exists in the DOM. Target
the section by test id: an active `FilterSection` renders its _selection_ in place of its title, and
gender's selection is icons only, so there is no text to match on for any state but the empty one.
Each frame carries its own `hold` in the manifest, so pacing lives next to the interaction it describes
and the Remotion scene stays a dumb sequencer.
@@ -69,6 +71,11 @@ clicking often reflows the page (choosing a gender inserts the "Woman ×" summar
everything down), so a position measured pre-click does not describe the post-click frame and the ripple
lands on the wrong control; and press-then-result is the truthful order.
The corollary is that nothing may move between the held frame and the measurement either — scrolling a
control into view before pressing it is enough to put every marker off by the scroll delta. Both capture
scripts re-shoot the held frame immediately before measuring (`refreshLast`); see A4, where this went
unnoticed across several renders.
**Smooth scrolling.** The profile scroll is 30 steps of 100px held 3 frames each, not 7 steps of 420px
held 14. Total distance and duration are about the same, but a large jump followed by stillness reads as
a stutter however long you hold it; ~33px per frame at 30fps is continuous motion. Smoothness here is
@@ -110,15 +117,39 @@ Two knock-on details:
a separate cut-short composition; that needed `--frame=-1` to dodge the loop dissolve, and it made
the poster and the first video frame disagree.)
**Gender beat.** `GenderFilter` auto-expands its full ~20-option list when the _signed-in viewer's own_
gender is an extended one (`gender-filter.tsx`). The viewer persona is therefore `female`: Woman and Man
are the two defaults, so the beat shows a short list with a "Show more genders" link rather than a wall
of checkboxes. Nothing in the capture clicks that link.
**The filters are reset off camera, before the first frame.** "Who I'm looking for" seeds the search from
the signed-in viewer's saved preferences, so the app opens on a list already narrowed by three of them —
a filter badge reading 3, six people. The clip is an argument about what filtering does, and that only
lands if the "before" is genuinely before. Starting mid-narrowing gave the establishing frame a count the
viewer had no reason for and made the beat that follows look like it achieved nothing. Reset, the clip
walks 11 → 5 → 2 on screen.
It is deliberately not a beat of its own: nobody arriving at Compass presses reset. It undoes state this
capture inherits from its seeded account, not something the product asks of anyone. The script also blurs
the filter button afterwards — closing the sheet returns focus to it, and an amber focus ring on an
untouched control would sit in the establishing frame, which is also the poster.
**Gender beat.** Because of that reset the beat selects Woman on an empty filter, which is the obvious
reading. Watch this one if the filter defaults change again: with preferences pre-applied, Woman and Man
both arrive selected and this same tap _deselected_ women — the clip's payoff rendered as "No profiles
found", and nothing failed. The script now asserts the resulting selection (each chip is a real hidden
checkbox underneath) and warns rather than passing quietly.
`GenderFilter` auto-expands its full ~20-option list when an extended gender is _selected_, or when the
signed-in viewer's own gender is one (`gender-filter.tsx`). The reset closes the first route and the
viewer persona's `gender: 'female'` closes the second, so the beat shows Woman, Man and a "Show more
genders" link rather than a wall of chips. Nothing in the capture clicks that link.
**Query**`meditation`, matching the home-page copy. Two results, matching for different reasons: Priya
on her visible _Meditation_ keyword chip, Sofia only through her bio prose. That shows the search reads
bios, not just tags. `--query` overrides.
**Selectors.** The capture reaches for test ids over text or Tailwind classes wherever the redesign showed
text to be unstable: `open-filters-button`, the `gender` filter section (its header renders _icons only_
once active, so there is no text to match), and `people-profile-count`. The banner's "Dismiss" survives
only as an `aria-label` on an ✕, and the profile CTA is now `Message <name>` inside `#connect`
scoped, because `profile-header.tsx` renders a second button with the same label off-screen.
**Scene**`media-creator/src/scenes/SearchDemo.tsx`, composition `SearchDemo`. `calculateMetadata`
reads the manifest and derives canvas size _and_ duration from it, so a re-capture with a different query
or an extra beat needs no edit there. Frames are held on a step function, not cross-faded: they are
@@ -410,8 +441,10 @@ It is also the hardest to evidence with a still, because the whole point of it h
1. `/people`, filtered down — Man, 2440, atheist, vegan, near Grenoble.
2. **Nobody matches.** Not a compromise — this is the premise. The app's own empty state says it:
"No profiles found. Feel free to click on Get Notified…". The clip follows the product's instruction.
3. Press **Get notified for selected filters**; the saved-search modal confirms it.
"No profiles found", over the four facets just set and an offer to be told when that changes.
3. Press **Get notified** — the full button the empty state puts in the middle of the page, where
someone who has just hit a dead end is already looking, not the icon-sized bell up in the toolbar.
The button relabels itself **Saved!** and that is the whole confirmation.
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.
@@ -430,6 +463,27 @@ npm run capture:alert -- --phase after # merges both halves into manifes
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.
**Tap markers are a coordinate painted onto a screenshot, so nothing may move between the two.** Plenty
did: `pickFilter` scrolls each control to the centre of the panel before pressing it, and the location
beat types a city and waits for a dropdown — all after the held frame was shot. Every marker in the
filter sequence then sat high by the scroll delta, pointing at the group header above the control it
meant. Both capture scripts now re-shoot the held frame immediately before measuring (`refreshLast`), so
the frame _is_ the state the tap describes. It runs unconditionally: when nothing moved it rewrites
identical pixels, and a marker that is silently right most of the time is how this survived several
renders.
**The save beat has a 2-second window.** The empty-state button is the one `GetNotifiedButton` that gets
no `onSaved` callback (`profile-grid.tsx`), so nothing opens behind it — the only evidence the save
happened is the button relabelling itself, and it reverts after 2000ms. The capture settles for 900ms and
then asserts the label; the previous 2.5s wait predated this button and shot a "saved" frame pixel-
identical to the one before it, so the clip reached its payoff and appeared to do nothing.
**The filters are reset off camera first**, for the same reason as H1 but with sharper stakes: this clip
is an argument that one _specific_ four-facet search matches nobody, and the email then lists those four
facets back. Preferences inherited from the viewer's account would join the saved search silently, turn
up in the email as criteria nothing on screen ever set, and make "no results" evidence about the account
rather than about the search.
`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

View File

@@ -8,7 +8,7 @@
* 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
* 3. "Get notified" — the empty state's own full-size button, which relabels itself "Saved!"
* 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
@@ -25,8 +25,11 @@
* 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.
* Needs `yarn dev:isolated` running — not `yarn dev`. The viewer account is a Firebase *emulator* user
* and `scripts/seed.sh` reads its connection details from a local `supabase status`, so neither the
* sign-in nor the seed works against the shared remote dev DB. The `juliensarr` persona has to be seeded
* before the `after` phase: 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.
@@ -75,9 +78,9 @@ const OUT_DIR = join(ALERT_DIR, THEME)
* 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
* SHOWCASE_SKIP=juliensarr SHOWCASE=1 ./scripts/seed.sh
* npm run capture:alert -- --phase before
* SHOWCASE=1 ./scripts/dev_db_seed.sh
* SHOWCASE=1 ./scripts/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 —
@@ -200,12 +203,36 @@ async function main() {
return {x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2)}
}
/**
* Re-capture the frame currently on hold, overwriting its PNG.
*
* A tap marker is a viewport coordinate painted onto a specific screenshot, so the two are only
* consistent if nothing moved between them. Plenty does: `pickFilter` scrolls each control to the
* centre of the panel before pressing it, and the location beat types a city and waits for a results
* dropdown — all *after* the held frame was shot. The coordinates then described a viewport the frame
* never showed, and every marker in the filter sequence landed high by the scroll delta, on the group
* header above the control it was meant to point at.
*
* Re-shooting is the fix rather than compensating arithmetic: the held frame simply becomes the state
* the tap is measured against. When nothing moved it rewrites identical pixels and costs one
* screenshot, which is why it is unconditional — a marker that is silently right most of the time is
* how this went unnoticed in the first place.
*/
const refreshLast = async () => {
const previous = shots[shots.length - 1]
if (!previous) return
await parkPointer()
await page.waitForTimeout(120)
await page.screenshot({path: join(OUT_DIR, previous.name)})
}
/**
* 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) => {
await refreshLast()
const tap = await centreOf(locator)
const previous = shots[shots.length - 1]
if (tap && previous) previous.tap = tap
@@ -278,7 +305,9 @@ async function main() {
)
}
const dismiss = page.getByText('Dismiss', {exact: true})
// The affordance is an ✕ in the banner's corner (profiles/profiles-home.tsx), so "Dismiss" survives
// only as its aria-label — matching on visible text silently found nothing and left the banner in.
const dismiss = page.getByLabel('Dismiss', {exact: true})
if (await dismiss.count()) {
await dismiss.first().click()
await page.waitForTimeout(600)
@@ -286,12 +315,44 @@ async function main() {
await quieten()
await page.waitForTimeout(1200)
// One filter button at every width now — it opens the same right-hand sheet on desktop and mobile
// (filters/search.tsx). By test id rather than by class: the old `button.lg:hidden` selector described
// a mobile/desktop split the redesign removed, and broke silently with it.
const openFilterSheet = () =>
firstVisible(page.locator('[data-testid="open-filters-button"]'), 'filter button')
if (PHASE === 'before') {
// ── 0. Clear the inherited filter state ────────────────────────────────────
// Off camera, before the first frame. "Who I'm looking for" seeds the search from the signed-in
// viewer's saved preferences, so the app opens already narrowed by three of them.
//
// This matters more here than it does for the hero clip. The whole point of this one is that a
// *specific* four-facet search matches nobody, and the alert email then lists those four facets
// back. Inherited preferences would silently join the saved search and turn up in the email as
// criteria nothing in the clip ever set — and the "no results" beat would no longer be evidence
// that this search is demanding, only that the account is.
await (await openFilterSheet()).click()
await page.waitForTimeout(900)
const reset = page.getByText('Reset filters', {exact: true})
if (await reset.count()) {
await reset.first().click()
await page.waitForTimeout(1600)
} else {
console.warn('warning: no "Reset filters" control — the clip may open on a pre-filtered list')
}
await page.keyboard.press('Escape')
await page.waitForTimeout(1600)
// Closing the sheet returns focus to the filter button, which would then wear a focus ring through
// the establishing frame.
await page.evaluate(() => document.activeElement?.blur())
await page.waitForTimeout(200)
await quieten()
// ── 1. The unfiltered list ─────────────────────────────────────────────────
await shoot('idle', HOLD.establish)
// ── 2. Build the search ────────────────────────────────────────────────────
const filterButton = await firstVisible(page.locator('button.lg\\:hidden'), 'filter button')
const filterButton = await openFilterSheet()
await tapAndShoot(filterButton, 'filters-open', HOLD.panel)
// Group titles are from filters.tsx: Gender is top level, the other two are nested.
@@ -309,11 +370,17 @@ async function main() {
// 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')
// The section is titled "Location" again. It read "Living anywhere" when headers were
// label-plus-current-selection; the redesign shows the bare title until something is picked (and
// the selection *instead of* the title once something is), so `/^living/i` now matches nothing.
const locationSection = await firstVisible(
panel().getByText(/^location$/i),
'Location section',
)
await locationSection.evaluate((el) => el.scrollIntoView({block: 'center'}))
// Let the scroll land before `tapAndShoot` re-shoots the held frame and measures against it —
// the other beats already wait 250ms here, and this one was reading a viewport still in motion.
await page.waitForTimeout(250)
await tapAndShoot(locationSection, 'location-open', HOLD.tap, 600)
const cityInput = await firstVisible(
@@ -337,19 +404,45 @@ async function main() {
await quieten()
await shoot('no-results', HOLD.empty)
// Null here is the expected result, not a failure: the count only renders when something matched,
// and this beat exists precisely because nothing did. Recorded for the manifest either way.
count = await page
.locator('text=/\\d+ (people|person)/')
.locator('[data-testid="people-profile-count"]')
.first()
.textContent()
.catch(() => null)
// ── 4. Save the search ─────────────────────────────────────────────────────
const notify = await firstVisible(page.getByText(/get notified/i), 'get-notified button')
// The empty state now offers this as a full "Get notified" button in the middle of the page
// (profile-grid.tsx), which is where someone who has just hit a dead end is already looking — not
// the icon-sized bell in the toolbar. Both are the same `GetNotifiedButton` and both carry the same
// aria-label, so neither role nor label tells them apart; only the big one renders visible text,
// which is what this matches on.
const notify = await firstVisible(
page.locator('button').filter({hasText: 'Get notified'}),
'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)
// 900ms, and the margin is the point. The empty-state button is the one `GetNotifiedButton` that
// gets no `onSaved` (profile-grid.tsx), so no confirmation modal opens behind it — the *only*
// evidence the save happened is the button relabelling itself "Saved!" with a filled bell, and
// `GetNotifiedButton` drops that back after 2000ms. The old 2.5s settle predates this button and
// now lands after the reset, which shot a "saved" frame pixel-identical to the one before it: the
// clip appeared to reach its payoff and do nothing.
await tapAndShoot(notify, 'saved', HOLD.settle, 900)
const savedLabel = await page
.locator('button')
.filter({hasText: /Saved!/})
.count()
if (!savedLabel) {
console.warn(
'warning: the "saved" frame did not catch the Saved! state — settle window missed',
)
}
}
if (PHASE === 'after') {
@@ -425,9 +518,11 @@ async function main() {
}
}
// Beat one: who and where. Age, gender and city sit together in the profile header.
// Beat one: who and where. Age, gender and city sit together in the profile header — now as one
// slash-separated editorial line (`profile-primary-info.tsx`), so the age is a bare "32" where it
// used to read "32 years old". Each of the three is a single visible node on this page.
const topSpots = [
await highlight(page.getByText(/^32 years old$/), 'Age 2440'),
await highlight(page.getByText(/^32$/), 'Age 2440'),
await highlight(page.getByText('Man', {exact: true}), 'Man'),
await highlight(page.getByText(/^Grenoble, France$/), 'Near Grenoble'),
].filter(Boolean)
@@ -470,7 +565,14 @@ async function main() {
// ── 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()
// The CTA is now "Message <first name>" inside the Connect section (profile/connect-actions.tsx),
// not the old "Send them a thoughtful message". Scoped to `#connect` deliberately: profile-header.tsx
// renders a second SendMessageButton with the same label at the top of the page, far off-screen by
// this point in the scroll.
const contact = page
.locator('#connect')
.getByText(/^Message /)
.first()
if (await contact.count()) {
await contact.evaluate((el) => el.scrollIntoView({block: 'center'}))
await page.waitForTimeout(700)
@@ -478,7 +580,7 @@ async function main() {
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')
console.warn('warning: no "Message <name>" button in #connect — clip ends on the scroll')
await shoot('profile-end', HOLD.finale)
}
}

View File

@@ -19,8 +19,10 @@
* No hand-made login needed. `SHOWCASE=1` seeding creates viewer@compass.showcase and this signs in as
* it headlessly, so the whole thing runs unattended.
*
* Prerequisites:
* yarn dev # from the repo root — needs http://localhost:3000
* Prerequisites — `dev:isolated`, not `dev`. The viewer account is a Firebase *emulator* user, and
* `scripts/seed.sh` reads its connection details from a local `supabase status`, so neither the sign-in
* nor the seed works against the shared remote dev DB:
* yarn dev:isolated # from the repo root — needs http://localhost:3000 and the emulators
* SHOWCASE=1 ./scripts/seed.sh # showcase personas + the viewer account
*
* Usage:
@@ -153,6 +155,23 @@ async function main() {
return {x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2)}
}
/**
* Re-capture the frame currently on hold, overwriting its PNG.
*
* A tap marker is a viewport coordinate painted onto a specific screenshot, so the two agree only if
* nothing moved between them — and `scrollIntoViewIfNeeded` before a press moves things. The marker
* then describes a viewport the frame never showed and lands high by the scroll delta. Re-shooting
* makes the held frame *be* the state the tap is measured against; when nothing moved it rewrites
* identical pixels, which is why it is unconditional.
*/
const refreshLast = async () => {
const previous = shots[shots.length - 1]
if (!previous) return
await parkPointer()
await page.waitForTimeout(120)
await page.screenshot({path: join(OUT_DIR, previous.name)})
}
/**
* Click something, then capture the result.
*
@@ -163,6 +182,7 @@ async function main() {
* before-frame is also the truthful order: press, then result.
*/
const tapAndShoot = async (locator, kind, hold, settleMs = 900) => {
await refreshLast()
const tap = await centreOf(locator)
const previous = shots[shots.length - 1]
if (tap && previous) previous.tap = tap
@@ -189,10 +209,14 @@ async function main() {
// ── Quieten the page ───────────────────────────────────────────────────────
// The growth-phase banner is timely copy that would date the clip, and on a phone it costs a third
// of the screen. Dismiss it rather than crop around it.
const dismiss = page.getByText('Dismiss', {exact: true})
// The affordance is an ✕ in the banner's corner (profiles/profiles-home.tsx), so "Dismiss" survives
// only as its aria-label — matching on visible text silently found nothing and left the banner in.
const dismiss = page.getByLabel('Dismiss', {exact: true})
if (await dismiss.count()) {
await dismiss.first().click()
await page.waitForTimeout(600)
} else {
console.warn('warning: no dismissable banner found — check it is not sitting in frame')
}
// Animations off so no frame lands mid-transition; the dev badge lives in a shadow-DOM portal, so it
@@ -207,17 +231,59 @@ async function main() {
await quieten()
await page.waitForTimeout(1200)
// One filter button at every width now — it opens the same right-hand sheet on desktop and mobile
// (filters/search.tsx). Targeted by test id rather than by class: the old `button.lg:hidden` selector
// described the mobile/desktop split that the redesign removed, and broke silently with it.
const openFilterSheet = () =>
firstVisible(page.locator('[data-testid="open-filters-button"]'), 'filter button')
// ── 0. Clear the inherited filter state ────────────────────────────────────
// Off camera, before the first frame.
//
// "Who I'm looking for" seeds the search from the signed-in viewer's saved preferences, so the app
// opens on a list already narrowed by three of them — a filter badge reading 3, and six people. The
// clip is about what filtering *does*, and that argument only lands if the "before" is genuinely
// before: an unfiltered list, narrowing to two, on screen. Starting mid-narrowing gave the opening
// frame a count the viewer had no reason for and made the beat that follows look like it did nothing.
//
// Deliberately not a beat of its own. Nobody arriving at Compass presses reset — it is undoing state
// this capture happens to inherit from its seeded account, not something the product asks of anyone.
await (await openFilterSheet()).click()
await page.waitForTimeout(900)
const reset = page.getByText('Reset filters', {exact: true})
if (await reset.count()) {
await reset.first().click()
await page.waitForTimeout(1600)
} else {
console.warn('warning: no "Reset filters" control — the clip may open on a pre-filtered list')
}
await page.keyboard.press('Escape')
await page.waitForTimeout(1600)
// Drop focus. Closing the sheet returns it to the filter button, which then wears a focus ring
// through the establishing frame — an amber halo on a control nobody has touched yet, in the one
// frame that also serves as the poster. Only needed here: the later focus ring, on the search box, is
// a beat of its own.
await page.evaluate(() => document.activeElement?.blur())
await page.waitForTimeout(200)
await quieten()
// ── 1. The unfiltered list ─────────────────────────────────────────────────
await shoot('idle', HOLD.establish)
// ── 2. Filter to women ─────────────────────────────────────────────────────
// The filter rail collapses to a single icon button on mobile (`lg:hidden` in filters/search.tsx).
const filterButton = await firstVisible(page.locator('button.lg\\:hidden'), 'filter button')
const filterButton = await openFilterSheet()
await tapAndShoot(filterButton, 'filters-open', HOLD.panel)
// Sections start collapsed, so gender has to be opened before "Woman" exists in the DOM. The header
// is labelled by its current state ("Any gender" until something is picked), not by the field name.
const genderSection = await firstVisible(page.getByText(/gender$/i), 'gender section header')
// Sections start collapsed, so gender has to be opened before "Woman" exists in the DOM.
//
// Targeted by test id, not by text. A `FilterSection` header renders its *selection* in place of its
// title once the filter is active, and gender's selection is a row of icons (♀ ♂ ⚧) with no text at
// all — so there is nothing to match on for any state but the empty one. The old `/gender$/i` match
// broke on exactly that.
const genderSection = await firstVisible(
page.locator('[data-testid="gender"] button').first(),
'gender section header',
)
await tapAndShoot(genderSection, 'gender-open', HOLD.panel)
const woman = await firstVisible(page.getByText('Woman', {exact: true}), 'Woman option')
@@ -225,6 +291,25 @@ async function main() {
await page.waitForTimeout(300)
await tapAndShoot(woman, 'filter-woman', HOLD.panel, 1800)
// The beat is only truthful if it ends on women alone, and the failure is silent — with the filters
// pre-seeded from the viewer's preferences (before the reset above), this same tap *deselected* Woman
// and the clip's payoff rendered as "No profiles found". Each chip is a real (visually hidden)
// checkbox, so read the selection rather than infer it from the icon-only section header.
const chipChecked = async (label) =>
page
.locator('label', {has: page.getByText(label, {exact: true})})
.locator('input[type="checkbox"]')
.first()
.isChecked()
.catch(() => null)
const [womanOn, manOn] = [await chipChecked('Woman'), await chipChecked('Man')]
if (womanOn !== true || manOn !== false) {
console.warn(
`warning: after the tap the gender filter is Woman=${womanOn}, Man=${manOn} — the beat is ` +
`meant to narrow to women only. Did the filter reset run?`,
)
}
// Close the sheet to reveal what the filter did. Escape is the reliable route — the close affordance
// moves around, and a stray click can land on a filter.
await page.keyboard.press('Escape')
@@ -245,8 +330,10 @@ async function main() {
await page.waitForTimeout(2500)
await shoot('result', HOLD.settle)
// The count is now its own labelled node ("12 people"). Read it by test id: the same string also
// appears in the filter sheet's header, so a text regex picks up whichever the DOM lists first.
const count = await page
.locator('text=/\\d+ (people|person)/')
.locator('[data-testid="people-profile-count"]')
.first()
.textContent()
.catch(() => null)
@@ -287,7 +374,14 @@ async function main() {
// ── 6. Contact her ─────────────────────────────────────────────────────────
// Opens the compose UI only. Nothing is ever sent: no submit is clicked, and the account is a
// fictional showcase persona regardless.
const contact = page.getByText(/thoughtful message/i).first()
// The CTA is now "Message <first name>" inside the Connect section (profile/connect-actions.tsx),
// not the old "Send them a thoughtful message". Scoped to `#connect` deliberately: profile-header.tsx
// renders a second SendMessageButton with the same label at the top of the page, and an unscoped
// match would tap that one — off-screen by this point in the scroll.
const contact = page
.locator('#connect')
.getByText(/^Message /)
.first()
if (await contact.count()) {
// Centre it rather than scrollIntoViewIfNeeded: that stops as soon as the button is technically
// in the viewport, which parks it flush against the bottom edge underneath the fixed nav bar —
@@ -299,9 +393,7 @@ async function main() {
await tapAndShoot(contact, 'contact-open', HOLD.finale, 2000)
} else {
console.warn(
'warning: no "Send them a thoughtful message" button found — clip ends on the scroll',
)
console.warn('warning: no "Message <name>" button found in #connect — clip ends on the scroll')
await shoot('profile-end', HOLD.finale)
}

View File

@@ -1,13 +1,5 @@
import React from 'react'
import {
AbsoluteFill,
Easing,
Img,
interpolate,
staticFile,
useCurrentFrame,
useVideoConfig,
} from 'remotion'
import {AbsoluteFill, Easing, Img, interpolate, staticFile, useCurrentFrame, useVideoConfig,} from 'remotion'
import {colors, fonts, FORMATS} from '../theme'
// A twelve-second silent b-roll scroll of a single profile, made to sit *under* the
@@ -24,19 +16,19 @@ import {colors, fonts, FORMATS} from '../theme'
// node scripts/capture-profile.mjs https://www.compassmeet.com/mhg1 --out profile-mhg1
// Re-run that after any profile-UI change; update FULL and STOPS below if the page moves.
const USERNAME = 'mhg1'
const USERNAME = 'martin'
const DIR = `profile-${USERNAME}`
const PROFILE_URL = `compassmeet.com/${USERNAME}`
// Pixel dimensions of the full-page PNG as written by the capture script (it shoots
// at DPR 2, so these are 2× the CSS numbers). We scale by width, so only the ratio
// matters.
const FULL = {src: `${DIR}/full.png`, w: 860, h: 8612}
const FULL = {src: `${DIR}/full.png`, w: 860, h: 10204}
// The page's own top bar, cropped off the head of the shot: it is chrome, not profile,
// and holding on it would spend the opening beat on nothing. In source-PNG px, so 100
// here is 50 CSS px.
const CROP_TOP = 200
const CROP_TOP = 100
// Blank canvas below the sign-up button, cropped off the tail so the clip doesn't
// spend its last beat resting on nothing.
const CROP_BOTTOM = 8460