diff --git a/script.js b/script.js
index 90bce8e..1a1c6b9 100644
--- a/script.js
+++ b/script.js
@@ -11,6 +11,7 @@
const {
BASE_TOKENS,
TOKENS_PER_SECOND,
+ BASE_DATE_ISO,
HISTORICAL_DATA,
MILESTONES,
PROMPT_SCORING,
@@ -28,13 +29,14 @@
} = window.DeathClockCore;
// ---- State -----------------------------------------------
+ const BASE_DATE_MS = new Date(BASE_DATE_ISO).getTime();
const pageLoadTime = Date.now();
let currentTheme = 'dark';
let chartInstance = null;
// ---- Helpers ---------------------------------------------
function getCurrentTokens() {
- const elapsed = (Date.now() - pageLoadTime) / 1000;
+ const elapsed = (Date.now() - BASE_DATE_MS) / 1000;
return BASE_TOKENS + TOKENS_PER_SECOND * elapsed;
}
@@ -381,6 +383,329 @@
.replace(/'/g, ''');
}
+ // ---- Life Blocks ----------------------------------------
+ const LB_LAST_MILESTONE = MILESTONES[MILESTONES.length - 1];
+
+ // Drill-down state
+ const lb = {
+ level: 'days', // 'days' | 'hours' | 'minutes' | 'seconds'
+ day: null, // day offset from today (0 = today)
+ hour: null, // 0-23
+ minute: null, // 0-59
+ rafId: null,
+ lastSec: -1,
+ lastMin: -1,
+ lastHr: -1,
+ lastDayMs: 0,
+ exploding: false,
+ };
+
+ function lbExtinctionMs() {
+ const tokens = getCurrentTokens();
+ if (tokens >= LB_LAST_MILESTONE.tokens) return Date.now();
+ const secsLeft = (LB_LAST_MILESTONE.tokens - tokens) / TOKENS_PER_SECOND;
+ return Date.now() + secsLeft * 1000;
+ }
+
+ function lbTotalDaysLeft() {
+ const ms = lbExtinctionMs() - Date.now();
+ return Math.max(0, Math.ceil(ms / 86400000));
+ }
+
+ function lbMidnight(now) {
+ const d = new Date(now);
+ d.setHours(0, 0, 0, 0);
+ return d.getTime();
+ }
+
+ // Returns 'dead' | 'dying' | 'future' for a block's state
+ function lbBlockState(unitStart, unitDuration, now) {
+ const unitEnd = unitStart + unitDuration;
+ if (now >= unitEnd) return 'dead';
+ if (now >= unitStart) return 'dying';
+ return 'future';
+ }
+
+ function lbDayOffsetToMs(dayOffset, now) {
+ return lbMidnight(now) + dayOffset * 86400000;
+ }
+
+ // ---- Rendering helpers ----
+
+ function lbMakeDyingBlock(dataAttr, progress, label, content) {
+ return `
${content}
`;
+ }
+
+ function lbMakeBlock(state, dataAttr, label, content) {
+ return `
${content}
`;
+ }
+
+ // ---- View renderers ----
+
+ function lbRenderDays(container, now) {
+ const total = lbTotalDaysLeft();
+ const todayMidnight = lbMidnight(now);
+ const todayProgress = ((now - todayMidnight) / 86400000) * 100;
+ const extDate = new Date(lbExtinctionMs());
+ const extStr = extDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
+
+ let html = `
`;
+ // Day 0 = today (dying), days 1..total = future
+ html += lbMakeDyingBlock('data-day="0"', todayProgress,
+ 'Today — burning away', '');
+ for (let i = 1; i <= total; i++) {
+ html += lbMakeBlock('future', `data-day="${i}"`,
+ `Day ${i} from now`, '');
+ }
+ html += '
';
+
+ container.innerHTML = html;
+ document.getElementById('lb-info').textContent =
+ `${total.toLocaleString()} days until extinction · predicted ${extStr}`;
+ }
+
+ function lbRenderHours(container, dayOffset, now) {
+ const dayStartMs = lbDayOffsetToMs(dayOffset, now);
+ const isToday = dayOffset === 0;
+
+ let html = '
';
+ for (let h = 0; h < 24; h++) {
+ const unitStart = dayStartMs + h * 3600000;
+ const state = isToday ? lbBlockState(unitStart, 3600000, now) : 'future';
+ const label = `${String(h).padStart(2, '0')}:00`;
+ const content = `${String(h).padStart(2, '0')}`;
+ if (state === 'dying') {
+ const prog = ((now - unitStart) / 3600000) * 100;
+ html += lbMakeDyingBlock(`data-hour="${h}"`, prog, label, content);
+ } else {
+ html += lbMakeBlock(state, `data-hour="${h}"`, label, content);
+ }
+ }
+ html += '
';
+ container.innerHTML = html;
+ const dayLabel = dayOffset === 0 ? 'Today' : `Day +${dayOffset}`;
+ document.getElementById('lb-info').textContent = `${dayLabel} — select an hour`;
+ }
+
+ function lbRenderMinutes(container, dayOffset, hour, now) {
+ const dayStartMs = lbDayOffsetToMs(dayOffset, now);
+ const hourStartMs = dayStartMs + hour * 3600000;
+ const isThisHour = dayOffset === 0 && now >= hourStartMs &&
+ now < hourStartMs + 3600000;
+
+ let html = '
';
+ for (let m = 0; m < 60; m++) {
+ const unitStart = hourStartMs + m * 60000;
+ const state = isThisHour ? lbBlockState(unitStart, 60000, now) : 'future';
+ const label = `${String(hour).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
+ const content = `${String(m).padStart(2, '0')}`;
+ if (state === 'dying') {
+ const prog = ((now - unitStart) / 60000) * 100;
+ html += lbMakeDyingBlock(`data-minute="${m}"`, prog, label, content);
+ } else {
+ html += lbMakeBlock(state, `data-minute="${m}"`, label, content);
+ }
+ }
+ html += '
';
+ container.innerHTML = html;
+ document.getElementById('lb-info').textContent =
+ `${String(hour).padStart(2, '0')}:xx — select a minute`;
+ }
+
+ function lbRenderSeconds(container, dayOffset, hour, minute, now) {
+ const dayStartMs = lbDayOffsetToMs(dayOffset, now);
+ const minStartMs = dayStartMs + hour * 3600000 + minute * 60000;
+ const isThisMinute = dayOffset === 0 && now >= minStartMs &&
+ now < minStartMs + 60000;
+
+ let html = '
';
+ for (let s = 0; s < 60; s++) {
+ const unitStart = minStartMs + s * 1000;
+ const state = isThisMinute ? lbBlockState(unitStart, 1000, now) : 'future';
+ const label =
+ `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
+ const content = `${String(s).padStart(2, '0')}`;
+ if (state === 'dying') {
+ const prog = ((now - unitStart) / 1000) * 100;
+ html += lbMakeDyingBlock(`data-second="${s}"`, prog, label, content);
+ } else {
+ html += lbMakeBlock(state, `data-second="${s}"`, label, content);
+ }
+ }
+ html += '
';
+ container.innerHTML = html;
+ document.getElementById('lb-info').textContent =
+ `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:xx`;
+ }
+
+ function lbRenderBreadcrumb() {
+ const el = document.getElementById('lb-breadcrumb');
+ if (!el) return;
+ const parts = [{ label: '💀 Days', level: 'days' }];
+ if (lb.level !== 'days') {
+ parts.push({ label: `Day ${lb.day === 0 ? 'Today' : '+' + lb.day}`, level: 'hours' });
+ }
+ if (lb.level === 'minutes' || lb.level === 'seconds') {
+ parts.push({ label: `Hour ${String(lb.hour).padStart(2, '0')}`, level: 'minutes' });
+ }
+ if (lb.level === 'seconds') {
+ parts.push({ label: `Min ${String(lb.minute).padStart(2, '0')}`, level: 'seconds' });
+ }
+ el.innerHTML = parts.map((p, i) => {
+ const isCurrent = i === parts.length - 1;
+ if (isCurrent) return `
${escHtml(p.label)}`;
+ return `
${escHtml(p.label)}
+
›`;
+ }).join('');
+ // Wire up back-nav clicks
+ el.querySelectorAll('[data-nav]').forEach((btn) => {
+ const navTo = btn.getAttribute('data-nav');
+ btn.addEventListener('click', () => lbNavigateTo(navTo));
+ btn.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') lbNavigateTo(navTo); });
+ });
+ }
+
+ function lbNavigateTo(level) {
+ lb.level = level;
+ if (level === 'days') { lb.day = null; lb.hour = null; lb.minute = null; }
+ else if (level === 'hours') { lb.hour = null; lb.minute = null; }
+ else if (level === 'minutes') { lb.minute = null; }
+ lbFullRender();
+ }
+
+ function lbFullRender() {
+ const container = document.getElementById('lb-container');
+ if (!container) return;
+ const now = Date.now();
+ const nowDate = new Date(now);
+
+ lb.lastSec = nowDate.getSeconds();
+ lb.lastMin = nowDate.getMinutes();
+ lb.lastHr = nowDate.getHours();
+ lb.lastDayMs = lbMidnight(nowDate);
+ lb.exploding = false;
+
+ if (lb.level === 'days') {
+ lbRenderDays(container, now);
+ } else if (lb.level === 'hours') {
+ lbRenderHours(container, lb.day, now);
+ } else if (lb.level === 'minutes') {
+ lbRenderMinutes(container, lb.day, lb.hour, now);
+ } else if (lb.level === 'seconds') {
+ lbRenderSeconds(container, lb.day, lb.hour, lb.minute, now);
+ }
+
+ lbRenderBreadcrumb();
+ lbAttachClicks(container);
+ }
+
+ function lbAttachClicks(container) {
+ container.querySelectorAll('.lb-block:not(.lb-dead)').forEach((block) => {
+ block.addEventListener('click', lbHandleBlockClick);
+ block.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') lbHandleBlockClick.call(block, e);
+ });
+ });
+ }
+
+ function lbHandleBlockClick(e) {
+ const block = e.currentTarget || this;
+ if (lb.level === 'days') {
+ lb.day = parseInt(block.getAttribute('data-day'), 10);
+ lb.level = 'hours';
+ } else if (lb.level === 'hours') {
+ lb.hour = parseInt(block.getAttribute('data-hour'), 10);
+ lb.level = 'minutes';
+ } else if (lb.level === 'minutes') {
+ lb.minute = parseInt(block.getAttribute('data-minute'), 10);
+ lb.level = 'seconds';
+ } else {
+ // At seconds level — no deeper drill-down exists
+ return;
+ }
+ lbFullRender();
+ }
+
+ // ---- RAF update loop ----
+
+ function lbTriggerExplosion(onDone) {
+ if (lb.exploding) { onDone(); return; }
+ const dyingEl = document.querySelector('#lb-container .lb-dying');
+ if (!dyingEl) { onDone(); return; }
+ lb.exploding = true;
+ dyingEl.classList.add('lb-exploding');
+ setTimeout(onDone, 560);
+ }
+
+ function lbUpdateFrame() {
+ const now = Date.now();
+ const nowDate = new Date(now);
+
+ // Smooth progress on the dying block (no re-render needed)
+ if (!lb.exploding) {
+ const dyingEl = document.querySelector('#lb-container .lb-dying');
+ if (dyingEl) {
+ let unitStart, unitDur;
+ if (lb.level === 'days') {
+ unitStart = lbMidnight(nowDate);
+ unitDur = 86400000;
+ } else if (lb.level === 'hours') {
+ const h = nowDate.getHours();
+ const dayMs = lbDayOffsetToMs(lb.day, now);
+ unitStart = dayMs + h * 3600000;
+ unitDur = 3600000;
+ } else if (lb.level === 'minutes') {
+ const m = nowDate.getMinutes();
+ const dayMs = lbDayOffsetToMs(lb.day, now);
+ unitStart = dayMs + lb.hour * 3600000 + m * 60000;
+ unitDur = 60000;
+ } else {
+ const s = nowDate.getSeconds();
+ const dayMs = lbDayOffsetToMs(lb.day, now);
+ unitStart = dayMs + lb.hour * 3600000 + lb.minute * 60000 + s * 1000;
+ unitDur = 1000;
+ }
+ const prog = Math.min(100, ((now - unitStart) / unitDur) * 100);
+ dyingEl.style.setProperty('--progress', prog.toFixed(2) + '%');
+ }
+ }
+
+ // Check for time-boundary crossings and trigger explosion + re-render
+ const sec = nowDate.getSeconds();
+ const min = nowDate.getMinutes();
+ const hr = nowDate.getHours();
+ const dayMs = lbMidnight(nowDate);
+
+ // Only the boundary relevant to the current view needs to trigger a re-render
+ // (e.g. watching 'hours': only a new hour matters; minute/second ticks are ignored).
+ // lb.lastSec/Min/Hr/DayMs are reset on every full re-render, so they stay accurate.
+ const secondsChanged = (lb.level === 'seconds') && sec !== lb.lastSec;
+ const minutesChanged = (lb.level === 'minutes') && min !== lb.lastMin;
+ const hoursChanged = (lb.level === 'hours') && hr !== lb.lastHr;
+ const dayChanged = (lb.level === 'days') && dayMs !== lb.lastDayMs;
+
+ if ((secondsChanged || minutesChanged || hoursChanged || dayChanged) && !lb.exploding) {
+ lb.lastSec = sec;
+ lb.lastMin = min;
+ lb.lastHr = hr;
+ lb.lastDayMs = dayMs;
+ lbTriggerExplosion(() => lbFullRender());
+ }
+
+ lb.rafId = requestAnimationFrame(lbUpdateFrame);
+ }
+
+ function initLifeBlocks() {
+ lbFullRender();
+ lb.rafId = requestAnimationFrame(lbUpdateFrame);
+ }
+
// ---- Bootstrap ------------------------------------------
function init() {
// Theme toggle
@@ -393,6 +718,7 @@
renderScoring();
initScoringToggle();
initChart();
+ initLifeBlocks();
// Kick off the live counter RAF loop
requestAnimationFrame(updateCounters);
diff --git a/styles.css b/styles.css
index 3e5bf0b..fac52af 100644
--- a/styles.css
+++ b/styles.css
@@ -571,7 +571,192 @@ tr:hover td { background: var(--surface-2); }
.rec-item:not(.done) .rec-impact { color: var(--text-muted); }
-/* ---- Footer ---- */
+/* ---- Life Blocks Section ---- */
+#life-blocks-section { background: var(--bg); }
+
+.lb-desc {
+ font-size: .85rem;
+ color: var(--text-dim);
+ margin-bottom: 1.25rem;
+ line-height: 1.6;
+}
+
+.lb-controls {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: .75rem;
+ margin-bottom: 1rem;
+ min-height: 2rem;
+}
+
+.lb-breadcrumb {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: .25rem;
+ font-size: .78rem;
+ color: var(--text-dim);
+}
+
+.lb-breadcrumb-item {
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: .3rem;
+ padding: .2rem .55rem;
+ cursor: pointer;
+ transition: background .15s, color .15s;
+ color: var(--accent-4);
+}
+.lb-breadcrumb-item:hover { background: var(--surface-3); color: var(--text); }
+.lb-breadcrumb-item.lb-bc-current {
+ color: var(--text-muted);
+ cursor: default;
+ background: transparent;
+ border-color: transparent;
+}
+.lb-breadcrumb-sep {
+ color: var(--text-muted);
+ font-size: .7rem;
+ user-select: none;
+}
+
+.lb-info {
+ font-family: 'Orbitron', monospace;
+ font-size: .75rem;
+ color: var(--accent);
+ letter-spacing: .06em;
+}
+
+/* ---- Grid ---- */
+.lb-container {
+ width: 100%;
+ overflow-x: auto;
+ padding-bottom: .5rem;
+}
+
+.lb-grid {
+ display: grid;
+ grid-template-columns: repeat(var(--lb-cols, 52), var(--lb-block-size, 11px));
+ gap: var(--lb-gap, 2px);
+ width: max-content;
+ animation: lb-grid-in .25s ease-out;
+}
+
+@keyframes lb-grid-in {
+ from { opacity: 0; transform: scale(.96); }
+ to { opacity: 1; transform: scale(1); }
+}
+
+/* ---- Blocks ---- */
+.lb-block {
+ width: var(--lb-block-size, 11px);
+ height: var(--lb-block-size, 11px);
+ border-radius: 2px;
+ cursor: pointer;
+ position: relative;
+ overflow: visible;
+ transition: transform .1s, box-shadow .15s;
+ outline: none;
+}
+.lb-block:focus-visible { box-shadow: 0 0 0 2px var(--accent-4); }
+
+/* Future — dim green, shows potential */
+.lb-block.lb-future {
+ background: #0f2a0f;
+ border: 1px solid #1a3a1a;
+}
+.lb-block.lb-future:hover {
+ background: #1e4a1e;
+ transform: scale(1.5);
+ box-shadow: 0 0 8px rgba(0, 200, 80, .45);
+ z-index: 10;
+}
+
+/* Dead — consumed, faded */
+.lb-block.lb-dead {
+ background: #0a0a0a;
+ border: 1px solid #111;
+ opacity: .35;
+ cursor: default;
+}
+
+/* Dying — currently being consumed */
+.lb-block.lb-dying {
+ background: #1a0800;
+ border: 1px solid var(--accent-2);
+ animation: lb-pulse-dying 1.4s ease-in-out infinite;
+ overflow: hidden;
+ cursor: pointer;
+ z-index: 5;
+}
+/* Fill representing elapsed fraction of the time unit */
+.lb-block.lb-dying::after {
+ content: '';
+ position: absolute;
+ bottom: 0; left: 0; right: 0;
+ height: var(--progress, 0%);
+ background: linear-gradient(to top, var(--accent) 0%, var(--accent-2) 70%, #ffff44 100%);
+ pointer-events: none;
+ transition: height .1s linear;
+}
+
+@keyframes lb-pulse-dying {
+ 0%,100% { border-color: var(--accent-2); box-shadow: 0 0 4px var(--accent-glow); }
+ 50% { border-color: var(--accent); box-shadow: 0 0 12px var(--accent-glow); }
+}
+
+/* Exploding — death animation */
+.lb-block.lb-exploding {
+ animation: lb-explode .55s ease-out forwards !important;
+ z-index: 20;
+ pointer-events: none;
+}
+
+@keyframes lb-explode {
+ 0% { transform: scale(1); background: var(--accent-2); box-shadow: 0 0 0 rgba(255,200,0,.0); opacity: 1; }
+ 15% { transform: scale(2.2); background: #ffff55; box-shadow: 0 0 18px rgba(255,220,0,.9); opacity: 1; }
+ 35% { transform: scale(1.6) rotate(12deg); background: var(--accent); box-shadow: 0 0 24px var(--accent-glow); opacity: .9; }
+ 60% { transform: scale(.9) rotate(-18deg); background: #660000; box-shadow: 0 0 8px rgba(100,0,0,.5); opacity: .6; }
+ 80% { transform: scale(.5) rotate(30deg); background: #220000; opacity: .3; }
+ 100% { transform: scale(0) rotate(60deg); background: transparent; opacity: 0; }
+}
+
+/* ---- Zoomed (hour/minute/second) blocks ---- */
+.lb-grid.lb-zoom {
+ --lb-cols: 12;
+ --lb-block-size: 38px;
+ --lb-gap: 4px;
+}
+.lb-zoom .lb-block {
+ border-radius: 4px;
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+ padding-bottom: 3px;
+}
+.lb-zoom .lb-block.lb-dying::after {
+ border-radius: 4px 4px 0 0;
+}
+.lb-zoom .lb-label {
+ font-family: 'Share Tech Mono', monospace;
+ font-size: .6rem;
+ color: rgba(255,255,255,.55);
+ line-height: 1;
+ position: relative;
+ z-index: 1;
+ pointer-events: none;
+ user-select: none;
+}
+.lb-zoom .lb-dead .lb-label { color: rgba(255,255,255,.15); }
+.lb-zoom .lb-dying .lb-label { color: rgba(255,255,255,.8); }
+.lb-zoom .lb-future:hover { transform: scale(1.15); }
+
+/* ---- Responsive ---- */
+@media (max-width: 600px) {
+ .lb-grid { --lb-block-size: 8px; --lb-gap: 1px; }
+ .lb-grid.lb-zoom { --lb-cols: 10; --lb-block-size: 28px; --lb-gap: 3px; }
+}
footer {
text-align: center;
padding: 2rem 1rem;
From 7360a895eb56e8cce223dd52bf4bf2f32ddd82a3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 14 Apr 2026 13:43:16 +0000
Subject: [PATCH 2/2] feat: add PR preview URL deployment + update deploy to
gh-pages branch
Agent-Logs-Url: https://github.com/nitrocode/token-deathclock/sessions/c323ba5c-3e79-4cbc-94c6-b2ee1b510351
Co-authored-by: nitrocode <7775707+nitrocode@users.noreply.github.com>
---
.github/workflows/deploy.yml | 29 +++++------
.github/workflows/preview-cleanup.yml | 31 ++++++++++++
.github/workflows/preview.yml | 69 +++++++++++++++++++++++++++
AGENTS.md | 18 +++++--
4 files changed, 127 insertions(+), 20 deletions(-)
create mode 100644 .github/workflows/preview-cleanup.yml
create mode 100644 .github/workflows/preview.yml
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index d3f10b8..324d75f 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -5,35 +5,30 @@ on:
branches: [main]
workflow_dispatch:
-# Allow only one deployment at a time
+# Allow only one production deployment at a time
concurrency:
group: "pages"
cancel-in-progress: false
permissions:
- contents: read
- pages: write
- id-token: write
+ contents: write
jobs:
deploy:
+ runs-on: ubuntu-latest
environment:
name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- runs-on: ubuntu-latest
+ url: https://nitrocode.github.io/token-deathclock/
steps:
- name: Checkout
uses: actions/checkout@v4
- - name: Configure Pages
- uses: actions/configure-pages@v4
-
- - name: Upload Pages artifact
- uses: actions/upload-pages-artifact@v3
+ - name: Deploy to GitHub Pages (gh-pages branch)
+ uses: peaceiris/actions-gh-pages@v4
with:
- # Serve entire repository root (index.html, styles.css, script.js, death-clock-core.js)
- path: '.'
-
- - name: Deploy to GitHub Pages
- id: deployment
- uses: actions/deploy-pages@v4
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: .
+ # Preserve any existing PR preview directories across production deploys
+ keep_files: true
+ # Exclude non-site files from the deployment
+ exclude_assets: '.github,node_modules,tests,package-lock.json,package.json'
diff --git a/.github/workflows/preview-cleanup.yml b/.github/workflows/preview-cleanup.yml
new file mode 100644
index 0000000..80a3854
--- /dev/null
+++ b/.github/workflows/preview-cleanup.yml
@@ -0,0 +1,31 @@
+name: PR Preview Cleanup
+
+on:
+ pull_request:
+ types: [closed]
+
+permissions:
+ contents: write
+
+jobs:
+ cleanup:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout gh-pages branch
+ uses: actions/checkout@v4
+ with:
+ ref: gh-pages
+
+ - name: Remove PR preview directory
+ run: |
+ PR_DIR="previews/pr-${{ github.event.number }}"
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ if [ -d "$PR_DIR" ]; then
+ git rm -rf "$PR_DIR"
+ git commit -m "chore: remove preview for PR #${{ github.event.number }}"
+ git push
+ echo "Removed $PR_DIR"
+ else
+ echo "No preview directory found at $PR_DIR — nothing to clean up"
+ fi
diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
new file mode 100644
index 0000000..3865283
--- /dev/null
+++ b/.github/workflows/preview.yml
@@ -0,0 +1,69 @@
+name: PR Preview
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ preview:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Deploy PR preview to gh-pages branch
+ uses: peaceiris/actions-gh-pages@v4
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: .
+ destination_dir: previews/pr-${{ github.event.number }}
+ # Preserve existing previews and production files
+ keep_files: true
+ # Exclude non-site files
+ exclude_assets: '.github,node_modules,tests,package-lock.json,package.json'
+
+ - name: Post or update preview URL comment
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const prNumber = context.payload.pull_request.number;
+ const sha = context.payload.pull_request.head.sha.slice(0, 7);
+ const url = `https://nitrocode.github.io/token-deathclock/previews/pr-${prNumber}/`;
+ const body = [
+ '## 👁️ PR Preview',
+ '',
+ `🚀 **[Open Preview](${url})**`,
+ '',
+ `> Deployed from commit \`${sha}\` · Updates on every push to this PR`,
+ '> _(Preview is removed automatically when the PR is closed.)_',
+ ].join('\n');
+
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ });
+
+ const existing = comments.find(c =>
+ c.user.type === 'Bot' && c.body.includes('## 👁️ PR Preview')
+ );
+
+ if (existing) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: existing.id,
+ body,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ body,
+ });
+ }
diff --git a/AGENTS.md b/AGENTS.md
index b198f99..7e32e22 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -28,8 +28,10 @@ a Chart.js growth chart with projections, and a prompt/PR quality scoring sectio
│ └── death-clock.test.js ← 75 Jest unit tests for death-clock-core.js
└── .github/
└── workflows/
- ├── deploy.yml ← Deploys index.html etc. to GitHub Pages on push to main
- └── test.yml ← Runs `npm run test:ci` on every push / PR
+ ├── deploy.yml ← Deploys site to gh-pages branch (production) on push to main
+ ├── preview.yml ← Deploys PR preview to previews/pr-N/ and posts URL comment
+ ├── preview-cleanup.yml ← Removes preview directory when a PR is closed
+ └── test.yml ← Runs `npm run test:ci` on every push / PR
```
---
@@ -95,7 +97,17 @@ Keep the array sorted in ascending `tokens` order — the constants test enforce
Edit `styles.css`. CSS custom properties for colours live in `:root[data-theme="dark"]` and `:root[data-theme="light"]`. The theme toggle is managed by `applyTheme()` in `script.js`.
### Deployment
-Merging to `main` triggers the `deploy.yml` workflow automatically. No manual steps are required after the one-time GitHub Pages source has been set to **GitHub Actions** in repository settings.
+Merging to `main` triggers the `deploy.yml` workflow automatically. It pushes the static site to the `gh-pages` branch (root).
+
+**One-time repo setup required** (only needs to be done once by a maintainer):
+> Settings → Pages → Source → **Deploy from a branch** → Branch: `gh-pages` / `(root)` → Save.
+
+### PR Preview URLs
+Every pull request automatically gets a live preview URL:
+- Triggered by `preview.yml` on `pull_request` (opened / synchronize / reopened)
+- Deployed to: `https://nitrocode.github.io/token-deathclock/previews/pr-{number}/`
+- A bot comment is posted (and updated) on the PR with the link
+- Preview directory is removed automatically by `preview-cleanup.yml` when the PR is closed
---