mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-08-04 12:15:12 -04:00
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:
@@ -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, 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.
|
||||
"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
|
||||
|
||||
@@ -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 24–40'),
|
||||
await highlight(page.getByText(/^32$/), 'Age 24–40'),
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user