Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 180 additions & 16 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ffffff" />
<meta name="theme-color" content="#fbfbfa" />
<meta
name="description"
content="A tiny train dispatcher. Click anywhere to send a train chugging across the screen."
Expand All @@ -22,6 +22,20 @@
--paper: #fbfbfa;
--panel: rgba(255, 255, 255, 0.72);
--accent: #111;
--steam: #c9c9c7;
--sky-opacity: 0;
}

:root[data-theme="night"] {
color-scheme: dark;
--ink: #f3f6ff;
--muted: #aeb8d3;
--faint: rgba(195, 207, 238, 0.24);
--paper: #080b14;
--panel: rgba(13, 18, 32, 0.72);
--accent: #d9e5ff;
--steam: #677492;
--sky-opacity: 1;
}

* {
Expand All @@ -36,7 +50,9 @@
body {
margin: 0;
overflow: hidden;
background: var(--paper);
background:
radial-gradient(circle at 50% 115%, rgba(73, 103, 160, 0.18), transparent 42%),
var(--paper);
color: var(--ink);
font-family:
ui-sans-serif,
Expand All @@ -48,6 +64,9 @@
Arial,
sans-serif;
font-feature-settings: "kern";
transition:
background 260ms ease,
color 260ms ease;
}

main {
Expand All @@ -73,6 +92,79 @@
pointer-events: none;
}

.night-sky {
position: fixed;
inset: 0;
z-index: 0;
opacity: var(--sky-opacity);
pointer-events: none;
transition: opacity 300ms ease;
}

.moon {
position: absolute;
top: clamp(58px, 9vh, 90px);
left: clamp(28px, 8vw, 104px);
width: clamp(42px, 8vw, 74px);
aspect-ratio: 1;
border-radius: 50%;
background: #f2e9c7;
box-shadow:
0 0 26px rgba(242, 233, 199, 0.32),
inset -16px 4px 0 rgba(201, 211, 232, 0.42);
}

.star {
position: absolute;
width: 3px;
height: 3px;
border-radius: 999px;
background: #f8fbff;
box-shadow: 0 0 10px rgba(248, 251, 255, 0.75);
animation: twinkle 2400ms ease-in-out infinite;
}

.star:nth-child(2) {
top: 18%;
left: 30%;
}

.star:nth-child(3) {
top: 12%;
left: 68%;
animation-delay: 460ms;
}

.star:nth-child(4) {
top: 31%;
left: 82%;
animation-delay: 900ms;
}

.star:nth-child(5) {
top: 42%;
left: 16%;
animation-delay: 1280ms;
}

.star:nth-child(6) {
top: 66%;
left: 76%;
animation-delay: 1720ms;
}

.track-glow {
position: fixed;
right: -8vw;
bottom: clamp(70px, 12vh, 112px);
left: -8vw;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(167, 190, 235, 0.52), transparent);
box-shadow:
0 10px 0 rgba(167, 190, 235, 0.12),
0 -10px 0 rgba(167, 190, 235, 0.12);
}

.hero.dispatched {
opacity: 0;
}
Expand Down Expand Up @@ -127,18 +219,23 @@
.steam {
position: fixed;
z-index: 0;
color: #c9c9c7;
color: var(--steam);
font-size: 18px;
pointer-events: none;
animation: puff 1400ms ease-out forwards;
will-change: transform, opacity;
}

.mute-toggle {
.controls {
position: fixed;
top: 16px;
right: 16px;
z-index: 10;
display: flex;
gap: 8px;
}

.icon-toggle {
display: inline-flex;
width: 38px;
height: 38px;
Expand All @@ -154,16 +251,22 @@
transition:
border-color 150ms ease,
background 150ms ease,
color 150ms ease,
transform 150ms ease;
}

.mute-toggle:hover {
.icon-toggle:hover {
background: #fff;
border-color: #b8b8b8;
transform: translateY(-1px);
}

.mute-toggle:focus-visible {
:root[data-theme="night"] .icon-toggle:hover {
background: rgba(28, 37, 62, 0.92);
border-color: rgba(211, 223, 255, 0.34);
}

.icon-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
Expand Down Expand Up @@ -227,9 +330,23 @@
}
}

@keyframes twinkle {
0%,
100% {
opacity: 0.38;
transform: scale(0.82);
}

50% {
opacity: 1;
transform: scale(1.2);
}
}

@media (prefers-reduced-motion: reduce) {
.hero,
.train-emoji:hover {
.train-emoji:hover,
.star {
animation: none;
transition: none;
}
Expand Down Expand Up @@ -266,22 +383,44 @@
</head>
<body>
<main id="dispatcher">
<div class="night-sky" aria-hidden="true">
<span class="moon"></span>
<span class="star"></span>
<span class="star"></span>
<span class="star"></span>
<span class="star"></span>
<span class="star"></span>
<span class="track-glow"></span>
</div>

<div class="hero" id="hero">
<div class="hero-inner">
<span class="train-emoji" role="img" aria-label="train">🚂</span>
<span class="tagline">Click anywhere to dispatch a train.</span>
</div>
</div>

<button
class="mute-toggle"
id="mute"
type="button"
aria-label="Mute"
aria-pressed="false"
>
🔊
</button>
<div class="controls">
<button
class="icon-toggle"
id="theme"
type="button"
aria-label="Turn night mode on"
aria-pressed="false"
>
🌙
</button>

<button
class="icon-toggle"
id="mute"
type="button"
aria-label="Mute"
aria-pressed="false"
>
🔊
</button>
</div>

<div class="counter" id="counter" aria-live="polite">0 trains dispatched</div>
</main>
Expand All @@ -297,8 +436,10 @@

const dispatcher = document.querySelector("#dispatcher");
const hero = document.querySelector("#hero");
const themeButton = document.querySelector("#theme");
const muteButton = document.querySelector("#mute");
const counter = document.querySelector("#counter");
const themeMeta = document.querySelector('meta[name="theme-color"]');
const choo = new Audio("./public/choo-choo.mp3");
choo.preload = "auto";

Expand All @@ -308,6 +449,7 @@
let lastLane = -1;
let lastDirection = "rtl";
let muted = localStorage.getItem("wtc:muted") === "1";
let nightMode = localStorage.getItem("wtc:theme") === "night";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the system dark-mode preference initially

On a first visit where wtc:theme is not already in localStorage, this initializes nightMode to false; the later syncThemeButton() call then writes day, so users whose browser/OS prefers dark mode never get the night theme automatically. Initialize from matchMedia('(prefers-color-scheme: dark)') when no explicit saved theme exists, or avoid persisting the default until the user toggles it.

Useful? React with 👍 / 👎.


function pick(items) {
return items[Math.floor(Math.random() * items.length)];
Expand All @@ -320,6 +462,18 @@
localStorage.setItem("wtc:muted", muted ? "1" : "0");
}

function syncThemeButton() {
document.documentElement.dataset.theme = nightMode ? "night" : "day";
themeButton.textContent = nightMode ? "☀️" : "🌙";
themeButton.setAttribute(
"aria-label",
nightMode ? "Turn night mode off" : "Turn night mode on",
);
themeButton.setAttribute("aria-pressed", String(nightMode));
themeMeta.setAttribute("content", nightMode ? "#080b14" : "#fbfbfa");
localStorage.setItem("wtc:theme", nightMode ? "night" : "day");
}

function updateCounter() {
const noun = count === 1 ? "train" : "trains";
counter.textContent = `${count} ${noun} dispatched`;
Expand Down Expand Up @@ -398,6 +552,12 @@
}

dispatcher.addEventListener("click", dispatchTrain);
themeButton.addEventListener("click", (event) => {
event.stopPropagation();
nightMode = !nightMode;
syncThemeButton();
});

muteButton.addEventListener("click", (event) => {
event.stopPropagation();
muted = !muted;
Expand All @@ -411,9 +571,13 @@
} else if (event.key === "m" || event.key === "M") {
muted = !muted;
syncMuteButton();
} else if (event.key === "n" || event.key === "N") {
nightMode = !nightMode;
syncThemeButton();
}
});

syncThemeButton();
syncMuteButton();
updateCounter();
</script>
Expand Down