From ad39f4366305c99fcdd9ae1580055a075851aad7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:29:53 +0000 Subject: [PATCH 1/2] feat: fix token counter + add Life Blocks drill-down visualization Agent-Logs-Url: https://github.com/nitrocode/token-deathclock/sessions/2e69634c-88dd-42e6-b00b-9d4e2ccd077b Co-authored-by: nitrocode <7775707+nitrocode@users.noreply.github.com> --- index.html | 17 +++ script.js | 328 ++++++++++++++++++++++++++++++++++++++++++++++++++++- styles.css | 187 +++++++++++++++++++++++++++++- 3 files changed, 530 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index b235fe1..839093a 100644 --- a/index.html +++ b/index.html @@ -82,6 +82,23 @@

Global Token Counter

+ +
+
+ +

Life Blocks — Days Until Extinction 💀

+

+ Each block is one day. Click any block to zoom into its hours → minutes → seconds. + Watch them die. +

+
+ +
+
+
+
+
+
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 ---