feat(portal): Make the World logo interactable - #2161
Conversation
…rld logomark Extends the homepage World logomark pixel field with a hover lens that scales/lightens nearby halftone dots, progressively reveals real World App icons (lazily fetched) as the cursor approaches, a depth-of-field blur ring, a click-triggered ripple wave, and a spring press animation on click. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Icon preprocessing pipeline (fetch, normalize to a consistent 256x256 WebP, optional S3 upload) plus a scheduled workflow to refresh existing icons in place without disturbing the cell<->app index mapping. Also hardens against icon-load retry storms, code-splits BasePixelStrip out of shared chunks so unrelated routes stop paying for it, and gates the /pixel-probe QA route to dev-only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pin all third-party GitHub Actions in the icon-refresh workflow to verified commit SHAs instead of mutable tags (CodeQL + Semgrep: supply-chain risk if a tag gets silently repointed). Also validate app_id against a strict identifier pattern before it's used in any file path or S3 key, rather than trusting the rankings API's shape (CodeQL: untrusted network data written to file). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…bors Replace the binary sharp-zone/blur-ring split with one continuous falloff: scale still drops off steeply so the focused icon pops disproportionately, but opacity and blur now share the same distance falloff at a gentler power, so neighbors fade/blur smoothly instead of snapping between fully opaque and hidden. This also makes z-order hand-offs between icons naturally smooth (two icons trading the frontmost slot are now nearly identical in opacity/blur at the crossover point), replacing an earlier opacity-dip hack that didn't look right. The click-triggered press spring now ripples outward to neighboring icons, scaled down by distance from the pressed one, instead of only animating the single pressed icon in isolation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…reverse ripple The random-displacement shockwave caused FPS issues, so it's gone in favor of a deterministic radial push (reusing state already computed for the press pyramid effect) that clears space around the pressed icon instead. The pressed icon now holds near 3x scale instead of shrinking away, shakes in place while held, and release fires a second ripple that's the time- reverse of the press ripple - collapsing back to the click point instead of expanding - and runs much faster so it reads as a snap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Profiling (CDP trace + rAF frame timing) showed the lens/ripple/icon-wave passes each walking the full 1,645-cell grid separately, plus rewriting unchanged halftone-dot styles every frame during a stationary press, and the ripple's band width, were driving browser style-recalc cost well past frame budget while holding a click. Merges the three passes into one, skips dot-style writes when the pointer hasn't moved, and narrows the ripple band so fewer dots are touched per frame - median frame time during a held click/ripple is back to a steady 16.7ms (60fps) in testing, down from routinely blowing past 20-33ms. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…second wave Releasing before the forward ripple finished animating used to fire an independent reverse ripple starting fresh from the outer edge, while the still-expanding original kept going - two rings on screen, reading as a second wave arriving from the edge. Tracks the forward ripple by reference and, on release, removes it and replaces it with a reverse ripple whose start time is backdated to match its current radius/intensity exactly, so it continues the same wave rather than resetting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A CDP invalidation trace showed appendChild-triggered layout invalidations (remove + reinsert) as a real chunk of style-recalc cost, and the revealed- icon z-stacking loop was re-appending every revealed icon every frame unconditionally, regardless of whether its stacking order actually changed. Tracks last frame's applied order and only re-stacks when it's different. Verified z-order stays correct (most-zoomed icon still paints last) both stationary and while moving the cursor. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every icon is reused 2-3x across the grid's 1,645 cells, but the loading bookkeeping was keyed by cell index, not href - so a second cell sharing an already-(or currently-)fetched icon still spun up its own new Image() and network request. Caches the load promise by href instead, shared across the page's lifetime, so every cell after the first for a given href reuses the same in-flight or settled promise. A failed fetch is cached too, matching the existing per-cell "don't retry a known-broken icon" behavior. Verified live: hovering 3 cells that share the same icon now fires exactly 1 network request total instead of 3, while all 3 still display it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…hanged The "settled, outside ICON_REVEAL_RADIUS but still inside LENS_RADIUS" branch rewrote transform/opacity/filter/fillOpacity for every such icon on every single frame unconditionally, even though every value it computes is a pure function of (cell, pointer position, ripple state). Since revealed icons never un-reveal, the number of cells that can land in this branch grows with how much of the grid has been explored - so a fuller grid meant more redundant per-frame writes, independent of clicking/holding. Skips the whole branch when the pointer hasn't moved and no ripple is currently affecting icons, reusing the pointerMoved/iconWaveActive checks already computed for the raw dot styling. Verified: settled icons still render correctly at rest, still catch ripple blur while a wave is passing through, still clear cleanly afterward, and the underlying dot dimming is still applied. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ency Each ambient "pop and spin" tile now sends out a slow, quiet ring to its neighbors - a soft scale pulse plus a lightening toward the same warm gray the lens/click-ripple already use - so it reads as a gentle ripple radiating from the spinning tile. Kept cheap despite the slow/wide travel: each pop's neighbor list is computed once via direct grid-lattice lookups (findRingNeighbors/CELL_INDEX_BY_KEY), never a scan of all 1,645 cells, and at any instant only the thin band of neighbors the wavefront is currently crossing gets written to. Also dialed back how many tiles pop concurrently (8 -> 4, slower interval) so the new ring has room to be the calmer, more prominent motion instead of competing with a lot of simultaneous rotation. Verified live: the ring's leading/trailing edges grow steadily over a multi-second window (not a quick snap), the lightened color is present, concurrent rotating tiles dropped from ~2.9 avg to ~1.5 avg, and idle frame timing stays a flat 16.7ms/60fps with multiple overlapping rings in flight. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cuts amplitude roughly in half again (1.5px -> 0.8px) and slows both axes' frequencies to match, continuing the earlier dampening pass now that it's been tried alongside the rest of the click/press feedback. Verified live: tracking the specific pressed icon shows continued oscillation at a visibly smaller peak-to-peak range than before. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fbd35eeb4d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| Key: `${S3_PREFIX}/${fileName}`, | ||
| Body: normalized, | ||
| ContentType: "image/webp", | ||
| CacheControl: "public, max-age=31536000, immutable", |
There was a problem hiding this comment.
Version refreshed icons instead of overwriting live keys
In the scheduled refresh, every successful download is uploaded directly over the existing pixel-strip-icons/<appId>.webp object before create-pull-request runs. After the initial manifest migration, these stable URLs produce no tracked manifest change, so changed or corrupted bytes can reach the live asset bucket without any PR or CI review; moreover, the one-year immutable directive allows browsers and intermediary caches to continue serving the old bytes. Upload each refresh under versioned keys and change the manifest URLs in the reviewable PR, or otherwise stage the objects until approval.
Useful? React with 👍 / 👎.
| const progress = (now - pop.start) / POP_DURATION_MS; | ||
|
|
||
| if (progress >= 1) { | ||
| const rect = rectsRef.current.get(pop.key); | ||
|
|
||
| if (rect) { | ||
| rect.style.transform = ""; | ||
| } | ||
|
|
||
| continue; |
There was a problem hiding this comment.
Keep pop rings alive through their full travel
Every pop is removed after POP_DURATION_MS (1100 ms), and only stillPopping entries drive the ring pass below. At POP_RING_SPEED = 0.025, the wavefront has traveled only 27.5 px when this removal occurs, despite POP_RING_RADIUS being 70 px (which requires 2800 ms), so most configured neighbors are never reached and any active ring is cleared prematurely. Track the ring until its radius plus fade duration has elapsed rather than tying its lifetime to the spin animation.
Useful? React with 👍 / 👎.
| for (const key of popRingActiveRef.current) { | ||
| if (!nextPopRingActive.has(key)) { | ||
| const rect = rectsRef.current.get(key); | ||
|
|
||
| if (rect) { | ||
| rect.style.transform = ""; | ||
| rect.style.fill = ""; | ||
| } |
There was a problem hiding this comment.
Preserve styles when another effect claims a ring cell
When the pointer lens or a click ripple reaches a cell that was active in the previous pop ring, the ring loop correctly skips that claimed cell, but this cleanup then clears the transform and fill that the lens/ripple wrote earlier in the same frame. With a stationary pointer, the pointerMoved optimization prevents those lens styles from being restored on subsequent frames, leaving the cell visually unscaled until the pointer moves again. Only clear properties when the cell is not currently claimed, or perform this cleanup before the claiming effects write their styles.
Useful? React with 👍 / 👎.
A pop's entry was retired as soon as its 1100ms spin finished, but the ring it emits travels at POP_RING_SPEED and only reaches POP_RING_RADIUS after 2800ms - so the wavefront was cut off 27.5px out of a configured 70px, and any ring still in flight was cleared early. Measured against /pixel-probe, a ring reached at most 26.8px and lit 12.8 cells per pop; it now reaches the full 70.0px and lights 40.4. An entry's lifetime is now the union of its spin and its ring (ringEndsAt, derived from the farthest real neighbor so a pop at the edge of the logomark isn't kept alive waiting on a wavefront with nothing left to reach). The spin hands its cell back once, on the frame it settles, rather than rewriting the same empty transform for the remaining ~2.4s. POP_MAX_CONCURRENT now counts spinning entries, since it exists to bound simultaneous rotation - counting every live entry would have starved pops to a third of the intended cadence. Also stop the ring's cleanup pass from wiping styles it doesn't own. A cell leaves the ring either because the wavefront passed (reset it) or because the lens/reveal/ripple claimed it - and in that second case the claiming pass already wrote the cell's transform/fill earlier in the same frame, so clearing them there clobbered a live write. With the pointer stationary the lens skips rewriting styles, so the cell stayed visibly unscaled until the cursor moved again. A style-mutation trace over an 18s pointer sweep recorded 4 such same-frame wipes before and 0 after. Frame timing over 15s idle holds a 16.7ms median (p95 18.3ms, 3/900 frames over 20ms, none over 33ms) despite ~3x more ring cells lit per pop; a moving pointer stays at a 16.7ms median with a slightly heavier tail (40/1072 frames over 20ms, against 19/1082 before). Co-Authored-By: Claude <noreply@anthropic.com>
The weekly refresh uploaded every icon straight over the existing
pixel-strip-icons/<appId>.webp object. Once the manifest points at those
stable URLs, a refresh produces no manifest diff at all, so the "opens a
PR so a human reviews it first" gate was vacuous - changed or corrupt
upstream bytes reached the live asset bucket the moment the workflow
ran. The one-year `immutable` Cache-Control made it worse: caches would
keep serving the old bytes anyway, so the overwrite carried the risk
without the effect.
Keys now carry a hash of the exact bytes stored, so an object is only
ever created, never rewritten with different content:
- new bytes land under a key nothing references yet, and merging the
manifest PR is what puts them in front of visitors
- that PR always carries a visible diff, because the URL changes
whenever the bytes do
- reverting it is a complete rollback - the previous key is still
there, untouched
- `immutable` becomes truthful
An unchanged icon hashes to the key it already has, so the upload is
skipped (verified: a second --refresh-existing run over unchanged
upstream bytes leaves the manifest byte-identical, so no spurious PRs).
A HeadObject failure that isn't "missing" - credentials scoped to
PutObject only, say - warns once and falls through to uploading rather
than failing the refresh, which is safe precisely because the key is
derived from the content.
Note this hashes the normalized output, so a sharp/libvips upgrade that
re-encodes the same source differently will legitimately rotate every
key at once; the auto-PR body now says so, since it otherwise reads as
the "broken upstream response" signal it warns about.
Co-Authored-By: Claude <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e2d2853567
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ASSETS_S3_REGION: ${{ secrets.ASSETS_S3_REGION }} | ||
| ASSETS_S3_BUCKET_NAME: ${{ secrets.ASSETS_S3_BUCKET_NAME }} | ||
| run: | | ||
| npx tsx scripts/normalize-app-icons.ts \ |
There was a problem hiding this comment.
Pin tsx before executing the refresh
This workflow does not declare tsx in web/package.json or web/pnpm-lock.yaml, so after the frozen install, npx tsx resolves and installs a remote package at runtime—the npm exec documentation describes commands as coming from a local or remote package. Because this scheduled step runs after long-lived AWS credentials are configured, every refresh executes unpinned registry code with access to those credentials; add a pinned tsx dependency to the lockfile and invoke it through pnpm exec instead.
Useful? React with 👍 / 👎.
| svg.addEventListener("pointerdown", handlePointerDown); | ||
| svg.addEventListener("pointerup", handlePointerUp); |
There was a problem hiding this comment.
Capture pointer release outside the SVG
For a mouse/trackpad press that is dragged outside the logo before release, pointerup is dispatched outside this SVG because no pointer capture or window-level listener is installed. handlePointerLeave releases the icon spring but leaves pressRippleRef untouched, so the forward ripple completes without the promised reverse release wave and the stale reference remains until another in-SVG release; capture the pointer on pointerdown or listen for pointerup/pointercancel outside the SVG.
Useful? React with 👍 / 👎.
Pressing on the logo and releasing after the cursor has moved off it sent the pointerup to whatever element was under the cursor instead of the SVG, so handlePointerUp never ran: the forward wave just faded out with no reverse release wave, and pressRippleRef kept pointing at that finished ripple - which the next in-SVG release then reversed, sending a wave out from wherever the previous press had been. Capture the pointer on pointerdown so the release always lands here, and treat pointercancel (the browser taking the pointer back) as a release too. Driven in Playwright against /pixel-probe with a transparent element covering part of the SVG, standing in for the real homepage's copy laid over the pixel field: pressing on the mark, waiting out the forward wave, then releasing over that element lit 31 cells before (ambient pop rings only - no release wave at all) and 504 after. Also restore a hard bound on the live pop list. POP_MAX_CONCURRENT used to double as one, but it now counts only spinning pops, and pops are started by a timer while they're retired by the rAF loop - which is paused for as long as the page sits in a background tab while that timer keeps firing. POP_MAX_LIVE is derived from the ring's full lifetime and the fastest start interval, so it can't bind during normal playback: pop cadence, ring reach (70px) and frame timing are unchanged with it in place (23 pops per 15s, median 16.7ms, p95 17.4ms). Co-Authored-By: Claude <noreply@anthropic.com>
The scheduled refresh invoked `npx tsx`, and tsx wasn't declared anywhere in web/package.json or the lockfile - so after the frozen install, npx fell back to fetching it from the registry at runtime. That step runs with the long-lived AWS credentials configured for the upload already in its environment, which made every weekly run execute freshly-downloaded, unpinned third-party code with access to them. Declare tsx at an exact version so `--frozen-lockfile` pins it, and invoke it (plus the prettier call the script shells out to) through `pnpm exec`, which only ever resolves what's installed. Co-Authored-By: Claude <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8a7d1531e2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| logoUrl: | ||
| "https://world-id-assets.com/app_d2905e660b94ad24d6fc97816182ab35/da7a488a-d4b8-4f30-b5c6-be42df8de4cd.png", |
There was a problem hiding this comment.
Allow the manifest's icon host through every deployment CSP
When NEXT_PUBLIC_IMAGES_CDN_URL points to staging or any custom CDN, generateCsp() in web/proxy.ts permits only that configured hostname, while every newly added manifest entry requests world-id-assets.com. Those requests are therefore rejected by img-src, loadImage() records each cell as failed, and the icon-reveal feature silently shows only dots in that environment. Either generate URLs from the configured CDN or explicitly permit the manifest host.
Useful? React with 👍 / 👎.
| if ( | ||
| ripplesRef.current.length > 0 || | ||
| pressRef.current || | ||
| popsRef.current.length > 0 | ||
| ) { | ||
| scheduleFrame(); |
There was a problem hiding this comment.
Stop ambient frames when the hero is offscreen
After the first ambient pop, the 400–900 ms pop cadence is shorter than the roughly 3.5-second ring lifetime, so popsRef.current normally remains nonempty and this recursively schedules animation frames at display refresh rate for the rest of the page visit. Because there is no intersection/visibility guard, scrolling past the hero still runs the ring traversal and DOM style writes continuously for decoration that cannot be seen; pause the timer and frame loop while the component is outside the viewport.
Useful? React with 👍 / 👎.
| const base = | ||
| cdnBaseUrl ?? | ||
| `https://${process.env.NEXT_PUBLIC_IMAGES_CDN_URL ?? "world-id-assets.com"}`; |
There was a problem hiding this comment.
Preserve schemes when deriving the CDN base URL
When the documented --upload --write-manifest invocation omits --cdn-base-url, the normal environment format shown in web/.env.example is a complete URL such as https://your-cdn-url; prefixing it again produces https://https://your-cdn-url/... and writes unusable logoUrl values into the manifest. Normalize the configured value rather than unconditionally adding a scheme.
Useful? React with 👍 / 👎.
PR Type
Description
Extends the homepage World logomark pixel field (
BasePixelStrip) from a static halftone SVG into an interactive piece.Screen.Recording.2026-07-31.at.11.14.31.mov
world-id-assets.com, zero upfront requests, and cached by URL so an icon reused across multiple cells only triggers one network request) develop in place of the dots on one continuous falloff - scale pops steeply near the cursor, while opacity/blur share a gentler falloff so neighbors fade/blur smoothly and z-order hand-offs between icons look continuous instead of snapping. Icons stay part of the grid permanently once revealed, settling to rest size (never re-blurring) when the cursor moves away.icon-manifest.tsprovides 553 unique World App icons sourced from the public app-rankings API, mapped 1:1 to the pixel grid's 1,645 cells (each icon reused 2-3x, placements kept far enough apart that no two copies can ever appear in the same lens at once).scripts/normalize-app-icons.ts+.github/workflows/refresh-pixel-strip-icons.yml) fetches, normalizes to a consistent WebP, optionally uploads to S3, and patches the manifest in place on a weekly schedule - without disturbing the cell<->app index mapping - opening a PR rather than pushing directly so a broken upstream response can't silently reach production.app/pixel-probe/is a dev-only QA route that mountsBasePixelStripdirectly, bypassing the auth/env setup the real homepage needs, so this can be reviewed/tested without a full local environment. The real homepage shows the same component at/for logged-out visitors - no special setup needed there either, since that render path never touches auth/GraphQL and its one network call (network stats) falls back to static values if it fails.All of the above is layered as direct DOM style writes driven by
requestAnimationFrame, not React re-renders/state, since ~1,645 SVG elements re-rendering per pointermove would be far more expensive than mutating a handful of style properties per frame.Performance
Profiling (CDP traces with invalidation-reason attribution, rAF frame timing, JS self-time instrumentation, and CPU-throttled runs simulating mid/low-tier mobile) found the click/hold interaction dropping well below 60fps - driven by browser style-recalculation cost, not JS compute (
applyLensitself only ever took 1-2ms at full speed):appendChild, which is a real DOM move even when the result is unchanged) when the stacking order hasn't actually changed frame to frame.Median frame time during a held click/ripple is back to a steady 16.7ms (60fps) in local testing, down from routinely blowing past 20-33ms; idle ambient animation (pops + rings) holds the same flat 16.7ms with zero dropped frames even with several effects overlapping.
Security
Pinned third-party GitHub Actions in the icon-refresh workflow to verified commit SHAs instead of mutable tags, and validate
app_idagainst a strict identifier pattern before it's used in any file path or S3 key - addressing CodeQL/Semgrep findings from initial review (supply-chain risk and untrusted network data written to file, respectively).Verified end-to-end against both the live
/pixel-proberoute and the real homepage with Playwright throughout: zero icon requests before hover, correct lazy-fetch and dedup counts, ripple direction/timing (forward growing outward, reverse collapsing inward, seamless continuity when reversed mid-flight), press scale/push/shake behavior, ambient pop/ring timing and decay, clean state reset after every effect settles, and frame-timing measurements (including CPU-throttled) before/after each performance change.Checklist
🤖 Generated with Claude Code