diff --git a/index.html b/index.html
index ca20e5e..0d5cd6b 100644
--- a/index.html
+++ b/index.html
@@ -180,6 +180,69 @@
pointer-events: none;
}
+ .milestone {
+ position: fixed;
+ inset: 0;
+ z-index: 8;
+ display: grid;
+ place-items: center;
+ padding: 28px;
+ pointer-events: none;
+ }
+
+ .milestone-burst {
+ position: relative;
+ display: grid;
+ min-width: min(84vw, 390px);
+ min-height: 180px;
+ place-items: center;
+ animation: milestone-pop 2200ms ease-out forwards;
+ will-change: opacity, transform;
+ }
+
+ .milestone-count {
+ position: relative;
+ z-index: 2;
+ display: grid;
+ gap: 7px;
+ place-items: center;
+ padding: 22px 28px;
+ border: 1px solid rgba(21, 21, 21, 0.12);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.86);
+ box-shadow: 0 18px 50px rgba(21, 21, 21, 0.1);
+ backdrop-filter: blur(16px);
+ }
+
+ .milestone-number {
+ font-size: clamp(46px, 12vw, 78px);
+ font-weight: 780;
+ letter-spacing: 0;
+ line-height: 0.9;
+ }
+
+ .milestone-label {
+ color: var(--muted);
+ font-size: 13px;
+ font-weight: 650;
+ letter-spacing: 0.06em;
+ line-height: 1.2;
+ text-transform: uppercase;
+ }
+
+ .spark {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ font-size: 24px;
+ line-height: 1;
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.6);
+ animation: spark-fly 1600ms cubic-bezier(0.2, 0.78, 0.22, 1) forwards;
+ animation-delay: var(--delay);
+ will-change: opacity, transform;
+ }
+
@keyframes wiggle {
0%,
100% {
@@ -227,6 +290,49 @@
}
}
+ @keyframes milestone-pop {
+ 0% {
+ opacity: 0;
+ transform: translateY(18px) scale(0.82) rotate(-2deg);
+ }
+
+ 14% {
+ opacity: 1;
+ transform: translateY(0) scale(1.05) rotate(1deg);
+ }
+
+ 24%,
+ 72% {
+ opacity: 1;
+ transform: translateY(0) scale(1) rotate(0);
+ }
+
+ 100% {
+ opacity: 0;
+ transform: translateY(-28px) scale(0.96) rotate(0);
+ }
+ }
+
+ @keyframes spark-fly {
+ 0% {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.5) rotate(0);
+ }
+
+ 18% {
+ opacity: 1;
+ }
+
+ 100% {
+ opacity: 0;
+ transform: translate(
+ calc(-50% + var(--x)),
+ calc(-50% + var(--y))
+ )
+ scale(1.15) rotate(var(--spin));
+ }
+ }
+
@media (prefers-reduced-motion: reduce) {
.hero,
.train-emoji:hover {
@@ -244,6 +350,14 @@
animation: fade-blip 1500ms ease both !important;
}
+ .milestone-burst {
+ animation: milestone-fade 1800ms ease forwards;
+ }
+
+ .spark {
+ display: none;
+ }
+
@keyframes fade-blip {
0% {
opacity: 0;
@@ -261,6 +375,18 @@
transform: translateX(-50%) scale(1);
}
}
+
+ @keyframes milestone-fade {
+ 0%,
+ 100% {
+ opacity: 0;
+ }
+
+ 18%,
+ 82% {
+ opacity: 1;
+ }
+ }
}
@@ -294,6 +420,15 @@
const PUFF_COUNT = 4;
const MIN_DURATION_MS = 4500;
const MAX_DURATION_MS = 7500;
+ const MILESTONE_INTERVAL = 10;
+ const SPARKS = [
+ ["🚂", "-126px", "-54px", "-18deg", "0ms"],
+ ["⭐", "118px", "-76px", "24deg", "40ms"],
+ ["🎉", "-104px", "72px", "32deg", "90ms"],
+ ["✨", "132px", "58px", "-28deg", "130ms"],
+ ["🚃", "0px", "-104px", "18deg", "180ms"],
+ ["🎊", "0px", "98px", "-20deg", "220ms"],
+ ];
const dispatcher = document.querySelector("#dispatcher");
const hero = document.querySelector("#hero");
@@ -338,6 +473,44 @@
event.currentTarget.remove();
}
+ function showMilestone() {
+ const milestone = document.createElement("div");
+ const burst = document.createElement("div");
+ const panel = document.createElement("div");
+ const number = document.createElement("span");
+ const label = document.createElement("span");
+
+ milestone.className = "milestone";
+ milestone.setAttribute("aria-live", "polite");
+ burst.className = "milestone-burst";
+ panel.className = "milestone-count";
+ number.className = "milestone-number";
+ label.className = "milestone-label";
+ number.textContent = String(count);
+ label.textContent = "trains dispatched";
+
+ panel.append(number, label);
+ burst.append(panel);
+
+ for (const [char, x, y, spin, delay] of SPARKS) {
+ const spark = document.createElement("span");
+ spark.className = "spark";
+ spark.textContent = char;
+ spark.style.setProperty("--x", x);
+ spark.style.setProperty("--y", y);
+ spark.style.setProperty("--spin", spin);
+ spark.style.setProperty("--delay", delay);
+ spark.setAttribute("aria-hidden", "true");
+ burst.append(spark);
+ }
+
+ milestone.append(burst);
+ burst.addEventListener("animationend", (event) => {
+ if (event.target === burst) milestone.remove();
+ });
+ dispatcher.append(milestone);
+ }
+
function createSteam(train, delay) {
window.setTimeout(() => {
const elapsed = performance.now() - train.startedAt;
@@ -391,6 +564,9 @@
hero.classList.add("dispatched");
updateCounter();
playChoo();
+ if (count % MILESTONE_INTERVAL === 0) {
+ showMilestone();
+ }
for (let i = 0; i < PUFF_COUNT; i += 1) {
createSteam(train, (durationMs / (PUFF_COUNT + 1)) * (i + 1));