diff --git a/avian/frontend/apt.js b/avian/frontend/apt.js
index 5acf486..35a4087 100644
--- a/avian/frontend/apt.js
+++ b/avian/frontend/apt.js
@@ -412,6 +412,12 @@
function renderCollage(items, animate) {
collage.innerHTML = '';
+ // Drop the previous render's hit-test tiles up front so a click or hover on
+ // the empty-nest state (or a collage that hasn't laid out yet) resolves to
+ // nothing, not to a stale bird from the last populated render. The populated
+ // path repopulates collagePlaced once the new tiles are placed.
+ collagePlaced = [];
+ collageHovered = null;
if (!items.length) {
// No birds heard yet: show an empty nest where the collage would be, with
// the status line beneath it. The frame (shoot.py) overrides the .empty
@@ -419,6 +425,16 @@
collage.innerHTML = '
' +
'

' +
'
no birds heard in this window.
';
+ // Bloom the nest in on the same cues as the collage (first load, window
+ // change, view switch); a silent poll/resize renders without animate. The
+ // class self-clears after the worst case so a throttled tab still ends
+ // with the nest visible, mirroring the tile entrance's safety net.
+ if (animate) {
+ var enest = collage.firstChild;
+ enest.classList.add('entering');
+ clearTimeout(collageEntranceT);
+ collageEntranceT = setTimeout(function () { enest.classList.remove('entering'); }, 900);
+ }
return;
}
var W = collage.clientWidth, H = collage.clientHeight;
diff --git a/avian/frontend/styles.css b/avian/frontend/styles.css
index ae494fa..8243a1d 100644
--- a/avian/frontend/styles.css
+++ b/avian/frontend/styles.css
@@ -602,6 +602,18 @@
}
.empty-nest .nest-img { width: 78%; max-width: 470px; height: auto; }
.empty-nest .empty { padding: 0; margin: 0; }
+ /* Bloom the empty nest in on the same cues as the collage, reusing the tile
+ keyframe (gtile-in): the nest fades + scales, the status line trails it. */
+ .empty-nest.entering .nest-img {
+ animation: gtile-in 420ms cubic-bezier(.2, .7, .3, 1) backwards;
+ }
+ .empty-nest.entering .empty {
+ animation: gtile-in 420ms cubic-bezier(.2, .7, .3, 1) 130ms backwards;
+ }
+ @media (prefers-reduced-motion: reduce) {
+ .empty-nest.entering .nest-img,
+ .empty-nest.entering .empty { animation: none; }
+ }
/* Page header is now shared at .stage > .static-head - see top of CSS. */
/* Stats grid: charts stack on the LEFT, text sections on the RIGHT.