`;
- html += ``;
- if (release.sections.length === 0) {
- html += `
No entries yet.
`;
- }
- release.sections.forEach((sec) => {
- html += `
`;
- html += `
${escHtml(sec.heading)}
`;
- html += `
`;
- sec.items.forEach((item) => {
- html += `- ${escHtml(item)}
`;
- });
- html += `
`;
- });
+ const latest = CHANGELOG_RELEASES[0];
+ const older = CHANGELOG_RELEASES.slice(1);
+
+ let html = buildReleaseHtml(latest);
+
+ if (older.length > 0) {
+ html += `
`;
+ html += `
`;
+ older.forEach((r) => { html += buildReleaseHtml(r); });
html += `
`;
- });
+ }
list.innerHTML = html;
+
+ const btn = document.getElementById('changelogShowMore');
+ if (btn) {
+ btn.addEventListener('click', () => {
+ const container = document.getElementById('changelogOlder');
+ if (!container) return;
+ const expanded = btn.getAttribute('aria-expanded') === 'true';
+ if (expanded) {
+ container.hidden = true;
+ btn.setAttribute('aria-expanded', 'false');
+ btn.textContent = `Show ${older.length} older release${older.length === 1 ? '' : 's'} ↓`;
+ } else {
+ container.hidden = false;
+ btn.setAttribute('aria-expanded', 'true');
+ btn.textContent = `Hide older releases ↑`;
+ }
+ });
+ }
}
// ---- Render footer meta-irony stats -------------------------
diff --git a/src/js/18-scary-features.js b/src/js/18-scary-features.js
index 9d1eb00..a66efaf 100644
--- a/src/js/18-scary-features.js
+++ b/src/js/18-scary-features.js
@@ -389,3 +389,130 @@
});
}
+ // ── Grim Reaper: Scythe Swing, Speech Bubbles, Proximity ─────
+
+ const REAPER_IDLE_QUOTES = [
+ 'Tick tock\u2026',
+ 'Your queries, my harvest.',
+ 'Every token is a breath closer.',
+ 'The servers never sleep.',
+ 'I have been very busy lately.',
+ 'So many tokens\u2026 so little time.',
+ 'CO\u2082 says hello.',
+ "I'm not waiting for you. I'm waiting \u2018with\u2019 you.",
+ 'Did you really need to ask AI that?',
+ 'One more prompt\u2026',
+ "Don\u2019t mind me.",
+ 'I go where the tokens flow.',
+ 'Another milestone down.',
+ 'Carbon is forever.',
+ 'Patience is my virtue. Time is not yours.',
+ ];
+
+ const REAPER_CLICK_QUOTES = [
+ 'Oh! A visitor. How delightful.',
+ "Please don\u2019t tap the glass.",
+ "I\u2019m working. Could you not?",
+ 'Ah, the curious ones. My favourite.',
+ 'Touch me again and I swing the scythe.',
+ 'Yes, yes. I see you.',
+ 'This is not a game. (It is a bit of a game.)',
+ "You\u2019ve earned 100 doom points.",
+ "I\u2019m always watching.",
+ 'Boo yourself.',
+ 'Still here? Interesting.',
+ 'We have so much in common, you and I.',
+ 'Every click costs something.',
+ "You're not the first. You won\u2019t be the last.",
+ ];
+
+ const REAPER_HOVER_QUOTES = [
+ 'Getting closer\u2026',
+ 'I can feel your warmth.',
+ 'Are you here to confess?',
+ 'Hello there.',
+ 'You seem\u2026 familiar.',
+ 'Careful now.',
+ ];
+
+ let _reaperBubbleTimer = null;
+ let _reaperHoverActive = false;
+ let _reaperClickIdx = 0;
+ let _reaperHoverIdx = 0;
+
+ function _showReaperBubble(text, durationMs) {
+ const bubble = document.getElementById('reaper-bubble');
+ if (!bubble) return;
+ bubble.textContent = text;
+ bubble.classList.add('visible');
+ clearTimeout(_reaperBubbleTimer);
+ _reaperBubbleTimer = setTimeout(() => bubble.classList.remove('visible'), durationMs || 3500);
+ }
+
+ function _swingReaperScythe() {
+ const reaper = document.getElementById('grim-reaper');
+ if (!reaper) return;
+ reaper.classList.remove('reaper-swinging');
+ // Force reflow so re-adding the class retriggers the animation
+ void reaper.offsetWidth;
+ reaper.classList.add('reaper-swinging');
+ setTimeout(() => reaper.classList.remove('reaper-swinging'), 800);
+ }
+
+ function initGrimReaper() {
+ const reaper = document.getElementById('grim-reaper');
+ if (!reaper) return;
+
+ // Click / tap — show a click quote and swing the scythe
+ reaper.addEventListener('click', () => {
+ const quote = REAPER_CLICK_QUOTES[_reaperClickIdx % REAPER_CLICK_QUOTES.length];
+ _reaperClickIdx++;
+ _showReaperBubble(quote, 3500);
+ _swingReaperScythe();
+ });
+
+ // Mouse proximity — react when cursor comes within 140 px of the reaper.
+ // Throttled via rAF to avoid layout thrash on high-poll-rate mice.
+ let _reaperMouseFrame = null;
+ document.addEventListener('mousemove', (e) => {
+ if (_reaperMouseFrame) return;
+ _reaperMouseFrame = requestAnimationFrame(() => {
+ _reaperMouseFrame = null;
+ const bbox = reaper.getBoundingClientRect();
+ const nearX = e.clientX > bbox.left - 20 && e.clientX < bbox.right + 140;
+ const nearY = e.clientY > bbox.top - 100 && e.clientY < bbox.bottom + 10;
+ const isNear = nearX && nearY;
+
+ if (isNear && !_reaperHoverActive) {
+ _reaperHoverActive = true;
+ reaper.classList.add('reaper-proximity');
+ const quote = REAPER_HOVER_QUOTES[_reaperHoverIdx % REAPER_HOVER_QUOTES.length];
+ _reaperHoverIdx++;
+ _showReaperBubble(quote, 2500);
+ _swingReaperScythe();
+ } else if (!isNear && _reaperHoverActive) {
+ _reaperHoverActive = false;
+ reaper.classList.remove('reaper-proximity');
+ }
+ });
+ });
+
+ // Periodic idle swing + quote every 25–55 seconds.
+ // The timeout ID is kept so we can avoid leaking if the element is removed.
+ let _idleSwingId = null;
+ function _scheduleIdleSwing() {
+ const delay = 25000 + Math.random() * 30000;
+ _idleSwingId = setTimeout(() => {
+ if (!document.getElementById('grim-reaper')) return; // element removed — stop
+ if (!_reaperHoverActive) {
+ const idx = Math.floor(Math.random() * REAPER_IDLE_QUOTES.length);
+ _showReaperBubble(REAPER_IDLE_QUOTES[idx], 4000);
+ _swingReaperScythe();
+ }
+ _scheduleIdleSwing();
+ }, delay);
+ }
+
+ _scheduleIdleSwing();
+ }
+
diff --git a/src/js/21-boot.js b/src/js/21-boot.js
index b698d79..2de1dac 100644
--- a/src/js/21-boot.js
+++ b/src/js/21-boot.js
@@ -58,6 +58,7 @@
initShame();
initVillainLeaderboard();
initIntervention();
+ initGrimReaper();
// Persist accelerator game state every 30 seconds and on page hide
setInterval(saveAcceleratorState, 30000);
diff --git a/styles/content-pages.css b/styles/content-pages.css
index 4ca6507..a0af1e2 100644
--- a/styles/content-pages.css
+++ b/styles/content-pages.css
@@ -457,4 +457,56 @@
margin-top: 0.5rem;
}
+.changelog-gh-link {
+ margin-left: auto;
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ text-decoration: none;
+ opacity: 0.7;
+ transition: opacity 0.2s;
+}
+
+.changelog-gh-link:hover {
+ opacity: 1;
+ color: var(--accent);
+}
+
+.changelog-item a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.changelog-item a:hover {
+ text-decoration: underline;
+ color: var(--accent-2);
+}
+
+.changelog-show-more {
+ display: block;
+ width: 100%;
+ margin-top: 0.5rem;
+ padding: 0.6rem 1rem;
+ background: transparent;
+ border: 1px dashed var(--border);
+ border-radius: 0.5rem;
+ color: var(--accent);
+ font-family: 'Share Tech Mono', monospace;
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: background 0.2s, border-color 0.2s;
+ text-align: center;
+}
+
+.changelog-show-more:hover {
+ background: var(--surface);
+ border-color: var(--accent);
+}
+
+.changelog-older {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+ margin-top: 0.5rem;
+}
+
diff --git a/styles/counter-milestones.css b/styles/counter-milestones.css
index 451b4c7..c48061a 100644
--- a/styles/counter-milestones.css
+++ b/styles/counter-milestones.css
@@ -76,6 +76,25 @@
z-index: 20;
}
+/* Session counter pop — orange */
+.token-pop--session {
+ color: var(--accent-2);
+ text-shadow: 0 0 8px rgba(255,136,0,0.5);
+}
+
+/* Rate counter pop — green */
+.token-pop--rate {
+ color: var(--accent-3);
+ text-shadow: 0 0 8px rgba(0,204,119,0.5);
+}
+
+/* Impact stat pop — teal/cyan */
+.token-pop--stat {
+ color: #4ecdc4;
+ text-shadow: 0 0 8px rgba(78,205,196,0.5);
+ font-size: 0.7rem;
+}
+
@keyframes token-pop-float {
0% { opacity: 0; transform: translateX(-50%) translateY(0); }
15% { opacity: 1; }
@@ -96,6 +115,8 @@
border-radius: 0.5rem;
padding: 1rem;
text-align: center;
+ position: relative;
+ overflow: visible;
}
.impact-stat .stat-icon { font-size: 1.8rem; display: block; margin-bottom: 0.4rem; }
diff --git a/styles/scary-features.css b/styles/scary-features.css
index 1322b44..d4b8b14 100644
--- a/styles/scary-features.css
+++ b/styles/scary-features.css
@@ -754,9 +754,78 @@
.grim-reaper { transform: translateX(0); }
}
+/* Scythe swing — pivot near the grip point (≈84% across, 68% down the bounding box) */
+.reaper-scythe-group {
+ transform-box: fill-box;
+ transform-origin: 84% 68%;
+ will-change: transform;
+}
+
+@keyframes reaper-scythe-swing {
+ 0% { transform: rotate(0deg); }
+ 15% { transform: rotate(-26deg); }
+ 45% { transform: rotate(20deg); }
+ 70% { transform: rotate(-8deg); }
+ 85% { transform: rotate(4deg); }
+ 100% { transform: rotate(0deg); }
+}
+
+.reaper-swinging .reaper-scythe-group {
+ animation: reaper-scythe-swing 0.75s ease-in-out forwards;
+}
+
+/* Chat / speech bubble */
+.reaper-bubble {
+ position: absolute;
+ bottom: calc(100% + 6px);
+ left: 0;
+ background: #0d0d0d;
+ border: 1.5px solid var(--accent);
+ color: #eee;
+ border-radius: 10px 10px 10px 2px;
+ padding: 0.45rem 0.65rem;
+ font-size: 0.68rem;
+ line-height: 1.45;
+ width: 170px;
+ white-space: normal;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ box-shadow: 0 2px 14px rgba(0, 0, 0, 0.6);
+ z-index: 11;
+}
+
+.reaper-bubble.visible { opacity: 1; }
+
+/* Tail pointing down-left toward the reaper */
+.reaper-bubble::after {
+ content: '';
+ position: absolute;
+ bottom: -8px;
+ left: 14px;
+ width: 0;
+ height: 0;
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+ border-top: 8px solid var(--accent);
+}
+
+:root[data-theme="light"] .reaper-bubble {
+ background: #1a0000;
+ color: #ffe0e0;
+}
+
+/* Proximity highlight — reaper eyes blaze brighter when cursor is near */
+.reaper-proximity .reaper-eye-inner {
+ animation: reaper-eye-pulse 0.8s ease-in-out infinite;
+ filter: drop-shadow(0 0 8px #ff3333);
+}
+
@media (prefers-reduced-motion: reduce) {
.grim-reaper svg,
.reaper-eye-inner { animation: none; }
/* transition: none keeps the hover snap instant on reduced-motion devices. */
.grim-reaper { transition: none; }
+ .reaper-swinging .reaper-scythe-group { animation: none; }
+ .reaper-bubble { transition: none; }
}
diff --git a/tests/e2e/death-clock.spec.js b/tests/e2e/death-clock.spec.js
index 9af802d..014829c 100644
--- a/tests/e2e/death-clock.spec.js
+++ b/tests/e2e/death-clock.spec.js
@@ -351,6 +351,40 @@ test.describe('mobile layout — fixed elements within viewport', () => {
expect(cutOff).toBeLessThan(bbox.width / 2);
});
+ test('grim reaper has a speech bubble element', async ({ page }) => {
+ const bubble = page.locator('#reaper-bubble');
+ await expect(bubble).toBeAttached();
+ // Bubble should start hidden (no "visible" class)
+ await expect(bubble).not.toHaveClass(/visible/);
+ });
+
+ test('grim reaper shows speech bubble and swings scythe when clicked', async ({ page }) => {
+ const reaper = page.locator('#grim-reaper');
+ const bubble = page.locator('#reaper-bubble');
+
+ await reaper.click();
+
+ // Bubble should become visible after click
+ await expect(bubble).toHaveClass(/visible/, { timeout: 1000 });
+ // Bubble text should be non-empty
+ const text = await bubble.textContent();
+ expect(text.trim().length).toBeGreaterThan(0);
+
+ // The scythe-group wrapper must exist in the SVG
+ await expect(reaper.locator('.reaper-scythe-group')).toBeAttached();
+ });
+
+ test('grim reaper speech bubble auto-hides after a few seconds', async ({ page }) => {
+ const reaper = page.locator('#grim-reaper');
+ const bubble = page.locator('#reaper-bubble');
+
+ await reaper.click();
+ await expect(bubble).toHaveClass(/visible/, { timeout: 1000 });
+
+ // After ~4 s the bubble should disappear (default duration is 3.5 s)
+ await expect(bubble).not.toHaveClass(/visible/, { timeout: 5000 });
+ });
+
test('Share Your Doom button is fully within the viewport on mobile', async ({ page }) => {
// Reveal the panel immediately via the ?share=true query param
await page.goto('/?share=true');
diff --git a/tests/script.test.js b/tests/script.test.js
index fa519e6..5d3d44b 100644
--- a/tests/script.test.js
+++ b/tests/script.test.js
@@ -362,8 +362,103 @@ describe('renderChangelog (DOM)', () => {
expect(el.textContent).toBe('v1.2.3');
});
- test('changelog HTML does not contain unescaped script tags', () => {
- expect(document.getElementById('changelogList').innerHTML).not.toContain('