`.
- Dying blocks carry a live `aria-label` updated each second (e.g., `"Second 42 of 60 — active"`).
- The entire always-on panel has `aria-live="polite"` with update throttling (update aria labels at most once per second).
+- Future (clickable) blocks in the stack have `tabindex="0" role="button"` and an `aria-label` that describes the navigation action (e.g., `"Jump drill-down to minutes view"`).
---
## 5. Out of Scope
-- No click/drill-down behaviour on the new panel's blocks.
-- No changes to the existing drill-down panel's logic or data model.
+- No changes to the existing drill-down panel's logic or data model beyond the `lb` state updates needed for click-to-drill-down.
- No new npm dependencies.
- No changes to `death-clock-core.js` (pure functions are already sufficient).
@@ -100,9 +133,10 @@ All new code lives inside the existing IIFE, grouped after the existing `lb.*` h
| Function | Responsibility |
|----------|---------------|
| `lbStackInit()` | Register IntersectionObserver on `#life-blocks-section`; on intersection start RAF via `lbStackFrame()`. |
-| `lbStackRenderRow(rowEl, units, currentIdx, progress)` | Idempotently render one row of blocks into `rowEl`. Accepts unit count, which index is "dying", and the dying block's fill progress (0–100). |
+| `lbStackRenderRow(rowEl, units, currentIdx, progress)` | Idempotently render one row of blocks into `rowEl`. Accepts unit count, which index is "dying", and the dying block's fill progress (0–100). Future blocks get `tabindex="0" role="button"` and a `data-stack-level` attribute for click-to-drill-down routing. |
| `lbStackUpdateProgress(now)` | Called every RAF frame; updates `--progress` CSS custom property on the dying block in each row without full re-render. |
-| `lbStackCheckBoundaries(now)` | Detects second/minute/hour/day/month/year crossings; triggers explosions and row re-renders at the appropriate granularity. |
+| `lbStackCheckBoundaries(now)` | Detects second/minute/hour/day/month/year crossings; schedules staggered explosions via `setTimeout` (100 ms per level) and re-renders affected rows after each explosion. |
+| `lbStackHandleClick(e)` | Click handler attached to every non-dead stack block; reads `data-stack-level` and the block's positional index, sets `lb` state accordingly, calls `lbFullRender()` and `lbRenderBreadcrumb()`, then `scrollIntoView({behavior:'smooth'})` on `#lb-container`. |
| `lbStackFrame()` | RAF loop: calls `lbStackUpdateProgress`, then `lbStackCheckBoundaries`, then re-queues itself. |
State for the new panel is kept in a separate `lbStack` object (mirroring the existing `lb` object) to avoid coupling:
@@ -118,22 +152,44 @@ const lbStack = {
};
```
-### 6.3 New CSS classes (in `styles.css`)
+### 6.3 Staggered cascade implementation detail
+
+`lbStackCheckBoundaries` schedules each affected level with `setTimeout`:
+
+```js
+// Example: minute boundary crossed
+const STAGGER_MS = 100;
+scheduleExplosion('sec', 0 * STAGGER_MS);
+scheduleExplosion('min', 1 * STAGGER_MS);
+// hour / day / month / year only if those boundaries also crossed
+```
+
+`scheduleExplosion(level, delay)` sets `lbStack.exploding[level] = true`, waits `delay` ms, triggers the CSS class on the dying block, waits 560 ms for the animation, then re-renders the row and clears the flag.
+
+### 6.4 New CSS classes (in `styles.css`)
```css
/* Always-On Stack Panel */
.lb-stack-panel { … } /* flex column, gap between rows */
.lb-stack-row { … } /* flex row: label + block strip */
.lb-stack-label { … } /* fixed-width label (e.g. "SECS") */
-.lb-stack-grid { … } /* flex row of blocks, no overflow scroll */
+.lb-stack-grid { … } /* flex-wrap row of blocks — wraps on mobile */
/* Blocks in the stack reuse .lb-block, .lb-dead, .lb-dying, .lb-future, .lb-exploding */
/* Override block size to a smaller "compact" size for the stack */
.lb-stack-grid .lb-block { --lb-block-size: 9px; … }
+
+/* Mobile: wrap blocks instead of scrolling (OQ-3) */
+@media (max-width: 600px) {
+ .lb-stack-grid { flex-wrap: wrap; }
+ .lb-stack-grid .lb-block { --lb-block-size: 8px; }
+}
```
-### 6.4 Interaction with existing code
+`flex-wrap: wrap` (not `overflow-x: auto`) is the intentional choice for mobile. The seconds and minutes rows will reflow across multiple lines, keeping the content readable without horizontal scrolling.
+
+### 6.5 Interaction with existing code
- `initLifeBlocks()` gains a single extra call: `lbStackInit()`.
-- No existing functions are modified; the new panel is entirely additive.
+- `lbStackHandleClick` calls `lbFullRender()` and `lbRenderBreadcrumb()` (already exported in scope) — no signature changes needed.
- The IntersectionObserver callback starts/stops `lbStack.rafId` to avoid wasted RAF frames when the section is off-screen.
---
@@ -152,17 +208,21 @@ const lbStack = {
| AC-8 | No new runtime npm packages are added. |
| AC-9 | `death-clock-core.js` has zero new DOM references. |
| AC-10 | Each row has correct ARIA roles and labels; screen-reader announcements are throttled to ≤ 1/sec. |
+| AC-11 | The years row block count is derived from the extinction date; an overflow label appears when the window exceeds 30 blocks. |
+| AC-12 | Clicking a non-dead block in the stack navigates the drill-down panel to the correct level and scrolls it into view. |
+| AC-13 | On viewports ≤ 600 px, the seconds and minutes rows wrap across multiple lines instead of overflowing horizontally. |
+| AC-14 | At a minute boundary, explosions fire in order: seconds (t=0 ms) → minutes (t=100 ms); at an hour boundary the cascade continues through hours (t=200 ms), etc. |
---
-## 8. Open Questions
+## 8. Resolved Design Decisions
-| # | Question | Owner |
-|---|----------|-------|
-| OQ-1 | Should the year row show a fixed window (past 5 y + current + future 4 y) or dynamically compute based on extinction date? | Design |
-| OQ-2 | Should clicking a row in the always-on panel jump the drill-down panel to that time scale? (Nice-to-have, not in v1.) | Product |
-| OQ-3 | On mobile (<600 px), 60 second blocks at 9 px each = 540 px + gaps — should the seconds and minutes rows wrap or scroll horizontally? | Design |
-| OQ-4 | Should the explosion cascade (second → minute → hour …) be staggered by ~100 ms per level for dramatic effect? | Design |
+| # | Question | Decision |
+|---|----------|----------|
+| OQ-1 | Should the year row show a fixed window or dynamically compute based on extinction date? | **Dynamic.** Show `currentYear − 2` through `extinctionYear`, capped at 30 blocks with an overflow label. Keeps the row thematically honest. |
+| OQ-2 | Should clicking a row in the always-on panel jump the drill-down panel to that time scale? | **Yes.** Non-dead blocks are clickable; clicking navigates the `lb` state, calls `lbFullRender()`, and smooth-scrolls to `#lb-container`. |
+| OQ-3 | On mobile, should the seconds and minutes rows wrap or scroll horizontally? | **Wrap.** `flex-wrap: wrap` on `.lb-stack-grid` — mobile-friendly, no horizontal scrolling. |
+| OQ-4 | Should the explosion cascade be staggered by ~100 ms per level for dramatic effect? | **Yes.** `setTimeout` at 0 / 100 / 200 / 300 / 400 / 500 ms per level (seconds first, years last). |
---
@@ -171,7 +231,7 @@ const lbStack = {
| File | Change |
|------|--------|
| `index.html` | Add `#lb-stack-panel` HTML before the existing breadcrumb nav. |
-| `script.js` | Add `lbStack` state object + `lbStackInit / lbStackRenderRow / lbStackUpdateProgress / lbStackCheckBoundaries / lbStackFrame`; call `lbStackInit()` from `initLifeBlocks()`. |
+| `script.js` | Add `lbStack` state object + `lbStackInit / lbStackRenderRow / lbStackUpdateProgress / lbStackCheckBoundaries / lbStackHandleClick / lbStackFrame`; call `lbStackInit()` from `initLifeBlocks()`. |
| `styles.css` | Add `.lb-stack-panel`, `.lb-stack-row`, `.lb-stack-label`, `.lb-stack-grid` rules; compact block-size override. |
| `tests/death-clock.test.js` | No changes required (new logic is DOM-only, in `script.js`). |
| `tests/e2e/death-clock.spec.js` | Add smoke tests for AC-1 and AC-2. |
From 15dc4d4373d5c6e203299dd0501d2c3f66df21e1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 22 Apr 2026 09:28:33 +0000
Subject: [PATCH 3/3] feat: add always-on multi-scale Life Blocks stack panel
Agent-Logs-Url: https://github.com/nitrocode/token-deathclock/sessions/193c8a74-32fa-4d98-9755-402cabf81908
Co-authored-by: nitrocode <7775707+nitrocode@users.noreply.github.com>
---
index.html | 15 +-
script.js | 382 ++++++++++++++++++++++++++++++++++
styles.css | 74 +++++++
tests/e2e/death-clock.spec.js | 67 +++++-
4 files changed, 535 insertions(+), 3 deletions(-)
diff --git a/index.html b/index.html
index aa37a57..03ad12e 100644
--- a/index.html
+++ b/index.html
@@ -102,9 +102,20 @@
- Each block is one day. Click any block to zoom into its hours → minutes → seconds.
- Watch them die.
+ Every scale of time is being consumed right now. The always-on panel below shows years,
+ months, days, hours, minutes, and seconds simultaneously — no clicks required. Click any
+ block to jump the drill-down panel below to that time scale.
diff --git a/script.js b/script.js
index 725a5b5..4458cdc 100644
--- a/script.js
+++ b/script.js
@@ -667,6 +667,388 @@
function initLifeBlocks() {
lbFullRender();
lb.rafId = requestAnimationFrame(lbUpdateFrame);
+ lbStackInit();
+ }
+
+ // ---- Always-On Stack Panel ------------------------------
+
+ // Mapping from DOM level name to exploding-state key
+ const LBSTACK_LEVEL_KEY = {
+ seconds: 'sec', minutes: 'min', hours: 'hr',
+ days: 'day', months: 'month', years: 'year',
+ };
+
+ const lbStack = {
+ rafId: null,
+ active: false, // true once the section is in view
+ initialized: false, // whether lastSec/etc. have been seeded
+ lastSec: -1, lastMin: -1, lastHr: -1,
+ lastDay: -1, lastMonth: -1, lastYear: -1,
+ pendingCascade: false,
+ exploding: { sec: false, min: false, hr: false,
+ day: false, month: false, year: false },
+ };
+
+ // Render all blocks for one row into rowEl.
+ // Dying + future blocks get data-stack-level for click-to-drill-down.
+ function lbStackRenderRow(rowEl, level, now) {
+ const nowDate = new Date(now);
+ const yr = nowDate.getFullYear();
+ const mo = nowDate.getMonth(); // 0-based
+ const dayOfMonth = nowDate.getDate(); // 1-based
+ const hr = nowDate.getHours();
+ const min = nowDate.getMinutes();
+ const sec = nowDate.getSeconds();
+
+ const LABEL_TEXT = {
+ years: 'YEARS', months: 'MONTHS', days: 'DAYS',
+ hours: 'HOURS', minutes: 'MINS', seconds: 'SECS',
+ };
+ const MONTH_SHORT = ['JAN','FEB','MAR','APR','MAY','JUN',
+ 'JUL','AUG','SEP','OCT','NOV','DEC'];
+
+ let totalBlocks, currentIdx, progress;
+ let overflow = null; // overflow label text, or null
+ let ariaLabelFn; // (idx) → accessible label string
+
+ switch (level) {
+ case 'seconds':
+ totalBlocks = 60;
+ currentIdx = sec;
+ progress = (now - Math.floor(now / 1000) * 1000) / 1000 * 100;
+ ariaLabelFn = (i) => i < sec ? `Second ${i} of 60 — elapsed`
+ : i === sec ? `Second ${sec} of 60 — active`
+ : `Second ${i} of 60 — pending`;
+ break;
+
+ case 'minutes':
+ totalBlocks = 60;
+ currentIdx = min;
+ progress = (now - Math.floor(now / 60000) * 60000) / 60000 * 100;
+ ariaLabelFn = (i) => i < min ? `Minute ${i} — elapsed`
+ : i === min ? `Minute ${min} of 60 — active`
+ : `Minute ${i} — pending`;
+ break;
+
+ case 'hours':
+ totalBlocks = 24;
+ currentIdx = hr;
+ progress = (now - Math.floor(now / 3600000) * 3600000) / 3600000 * 100;
+ ariaLabelFn = (i) => {
+ const t = String(i).padStart(2, '0') + ':00';
+ if (i < hr) return t + ' — elapsed';
+ if (i === hr) return t + ' — active';
+ return t + ' — pending';
+ };
+ break;
+
+ case 'days': {
+ const daysInMonth = new Date(yr, mo + 1, 0).getDate();
+ totalBlocks = daysInMonth;
+ currentIdx = dayOfMonth - 1; // convert to 0-based
+ progress = (now - lbMidnight(now)) / 86400000 * 100;
+ ariaLabelFn = (i) => {
+ const d = i + 1;
+ if (i < currentIdx) return `Day ${d} — elapsed`;
+ if (i === currentIdx) return `Day ${d} — active`;
+ return `Day ${d} — pending`;
+ };
+ break;
+ }
+
+ case 'months': {
+ const monthStart = new Date(yr, mo, 1).getTime();
+ const monthEnd = new Date(yr, mo + 1, 1).getTime();
+ totalBlocks = 12;
+ currentIdx = mo;
+ progress = (now - monthStart) / (monthEnd - monthStart) * 100;
+ ariaLabelFn = (i) => {
+ const n = MONTH_SHORT[i];
+ if (i < mo) return `${n} — elapsed`;
+ if (i === mo) return `${n} — active`;
+ return `${n} — pending`;
+ };
+ break;
+ }
+
+ case 'years': {
+ const extinctionYear = new Date(lbExtinctionMs()).getFullYear();
+ const startYear = yr - 2;
+ const totalYears = extinctionYear - startYear + 1;
+ const displayed = Math.min(totalYears, 30);
+ totalBlocks = displayed;
+ currentIdx = yr - startYear; // always 2
+ const yearStart = new Date(yr, 0, 1).getTime();
+ const yearEnd = new Date(yr + 1, 0, 1).getTime();
+ progress = (now - yearStart) / (yearEnd - yearStart) * 100;
+ if (totalYears > 30) {
+ overflow = `+${totalYears - 30}y`;
+ }
+ ariaLabelFn = (i) => {
+ const y = startYear + i;
+ if (y < yr) return `${y} — elapsed`;
+ if (y === yr) return `${y} — active`;
+ return `${y} — pending`;
+ };
+ break;
+ }
+
+ default:
+ return;
+ }
+
+ let html = `
${LABEL_TEXT[level]} `;
+ html += `
`;
+
+ for (let i = 0; i < totalBlocks; i++) {
+ const lbl = escHtml(ariaLabelFn(i));
+ if (i < currentIdx) {
+ // dead — not interactive
+ html += `
`;
+ } else if (i === currentIdx) {
+ // dying — interactive (navigates drill-down)
+ html += `
`;
+ } else {
+ // future — interactive
+ const navLbl = escHtml(`Jump drill-down to ${level} view`);
+ html += `
`;
+ }
+ }
+
+ if (overflow) {
+ html += `
${escHtml(overflow)}
`;
+ }
+
+ html += `
`;
+ rowEl.innerHTML = html;
+
+ // Wire click + keyboard on dying and future blocks
+ rowEl.querySelectorAll('[data-stack-level]').forEach((block) => {
+ block.addEventListener('click', lbStackHandleClick);
+ block.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ lbStackHandleClick.call(block, e);
+ }
+ });
+ });
+ }
+
+ function lbStackRenderAll(now) {
+ ['years', 'months', 'days', 'hours', 'minutes', 'seconds'].forEach((level) => {
+ const rowEl = document.getElementById('lb-stack-' + level);
+ if (rowEl) lbStackRenderRow(rowEl, level, now);
+ });
+ }
+
+ // Update only the --progress CSS var on each dying block each RAF frame —
+ // no full re-render required.
+ function lbStackUpdateProgress(now) {
+ const nowDate = new Date(now);
+ const yr = nowDate.getFullYear();
+ const mo = nowDate.getMonth();
+ const monthStart = new Date(yr, mo, 1).getTime();
+ const monthEnd = new Date(yr, mo + 1, 1).getTime();
+ const yearStart = new Date(yr, 0, 1).getTime();
+ const yearEnd = new Date(yr + 1, 0, 1).getTime();
+
+ const progByLevel = {
+ seconds: (now - Math.floor(now / 1000) * 1000) / 1000 * 100,
+ minutes: (now - Math.floor(now / 60000) * 60000) / 60000 * 100,
+ hours: (now - Math.floor(now / 3600000) * 3600000) / 3600000 * 100,
+ days: (now - lbMidnight(now)) / 86400000 * 100,
+ months: (now - monthStart) / (monthEnd - monthStart) * 100,
+ years: (now - yearStart) / (yearEnd - yearStart) * 100,
+ };
+
+ Object.entries(progByLevel).forEach(([level, progress]) => {
+ if (lbStack.exploding[LBSTACK_LEVEL_KEY[level]]) return;
+ const rowEl = document.getElementById('lb-stack-' + level);
+ if (!rowEl) return;
+ const dyingEl = rowEl.querySelector('.lb-dying');
+ if (dyingEl) dyingEl.style.setProperty('--progress', progress.toFixed(2) + '%');
+ });
+ }
+
+ // Detect second/minute/hour/day/month/year crossings and schedule staggered
+ // explosions (100 ms per level, seconds first → years last).
+ function lbStackCheckBoundaries(now) {
+ const nowDate = new Date(now);
+ const sec = nowDate.getSeconds();
+ const min = nowDate.getMinutes();
+ const hr = nowDate.getHours();
+ const day = nowDate.getDate();
+ const month = nowDate.getMonth();
+ const year = nowDate.getFullYear();
+
+ // First call: seed last-seen values without triggering explosions.
+ if (!lbStack.initialized) {
+ lbStack.lastSec = sec;
+ lbStack.lastMin = min;
+ lbStack.lastHr = hr;
+ lbStack.lastDay = day;
+ lbStack.lastMonth = month;
+ lbStack.lastYear = year;
+ lbStack.initialized = true;
+ return;
+ }
+
+ if (lbStack.pendingCascade) return;
+
+ const secChanged = sec !== lbStack.lastSec;
+ if (!secChanged) return;
+
+ const minChanged = min !== lbStack.lastMin;
+ const hrChanged = hr !== lbStack.lastHr;
+ const dayChanged = day !== lbStack.lastDay;
+ const monthChanged = month !== lbStack.lastMonth;
+ const yearChanged = year !== lbStack.lastYear;
+
+ lbStack.lastSec = sec;
+ lbStack.lastMin = min;
+ lbStack.lastHr = hr;
+ lbStack.lastDay = day;
+ lbStack.lastMonth = month;
+ lbStack.lastYear = year;
+
+ // Build the cascade for all levels whose boundary was crossed.
+ // Stagger: seconds=0 ms, minutes=100 ms, hours=200 ms, …
+ const cascade = [{ key: 'sec', level: 'seconds', delay: 0 }];
+ if (minChanged) cascade.push({ key: 'min', level: 'minutes', delay: 100 });
+ if (hrChanged) cascade.push({ key: 'hr', level: 'hours', delay: 200 });
+ if (dayChanged) cascade.push({ key: 'day', level: 'days', delay: 300 });
+ if (monthChanged) cascade.push({ key: 'month', level: 'months', delay: 400 });
+ if (yearChanged) cascade.push({ key: 'year', level: 'years', delay: 500 });
+
+ lbStack.pendingCascade = true;
+ let remaining = cascade.length;
+
+ cascade.forEach(({ key, level, delay }) => {
+ setTimeout(() => {
+ const rowEl = document.getElementById('lb-stack-' + level);
+ const dyingEl = rowEl ? rowEl.querySelector('.lb-dying') : null;
+ if (!dyingEl) {
+ remaining--;
+ if (!remaining) lbStack.pendingCascade = false;
+ return;
+ }
+ lbStack.exploding[key] = true;
+ dyingEl.classList.add('lb-exploding');
+ // After the explosion animation completes, re-render the row.
+ setTimeout(() => {
+ lbStack.exploding[key] = false;
+ lbStackRenderRow(rowEl, level, Date.now());
+ remaining--;
+ if (!remaining) lbStack.pendingCascade = false;
+ }, 560);
+ }, delay);
+ });
+ }
+
+ // Click handler for dying/future blocks in the always-on stack panel.
+ // Navigates the existing drill-down panel to the corresponding time scale.
+ function lbStackHandleClick(e) {
+ const block = e.currentTarget || this;
+ const level = block.getAttribute('data-stack-level');
+ if (!level) return;
+
+ const nowDate = new Date();
+ switch (level) {
+ case 'years':
+ case 'months':
+ case 'days':
+ lb.level = 'days';
+ lb.day = null;
+ lb.hour = null;
+ lb.minute = null;
+ break;
+ case 'hours':
+ lb.level = 'hours';
+ lb.day = 0;
+ lb.hour = null;
+ lb.minute = null;
+ break;
+ case 'minutes':
+ lb.level = 'minutes';
+ lb.day = 0;
+ lb.hour = nowDate.getHours();
+ lb.minute = null;
+ break;
+ case 'seconds':
+ lb.level = 'seconds';
+ lb.day = 0;
+ lb.hour = nowDate.getHours();
+ lb.minute = nowDate.getMinutes();
+ break;
+ default:
+ return;
+ }
+
+ lbFullRender(); // internally calls lbRenderBreadcrumb()
+ const container = document.getElementById('lb-container');
+ if (container) {
+ const reducedMotion =
+ window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ container.scrollIntoView({
+ behavior: reducedMotion ? 'auto' : 'smooth',
+ block: 'start',
+ });
+ }
+ }
+
+ // RAF loop for the always-on stack panel.
+ function lbStackFrame() {
+ if (!lbStack.active) return;
+ const now = Date.now();
+ lbStackUpdateProgress(now);
+ lbStackCheckBoundaries(now);
+ lbStack.rafId = requestAnimationFrame(lbStackFrame);
+ }
+
+ // Initialise the always-on stack: render immediately, then use
+ // IntersectionObserver to start/stop the RAF loop as needed.
+ function lbStackInit() {
+ const section = document.getElementById('life-blocks-section');
+ if (!section) return;
+
+ // Render once immediately so blocks are visible before scroll.
+ lbStackRenderAll(Date.now());
+
+ if (typeof IntersectionObserver === 'undefined') {
+ // Fallback for environments without IO (e.g., jsdom in tests).
+ lbStack.active = true;
+ lbStack.rafId = requestAnimationFrame(lbStackFrame);
+ return;
+ }
+
+ const observer = new IntersectionObserver((entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ if (!lbStack.active) {
+ lbStack.active = true;
+ lbStack.rafId = requestAnimationFrame(lbStackFrame);
+ }
+ } else {
+ lbStack.active = false;
+ if (lbStack.rafId) {
+ cancelAnimationFrame(lbStack.rafId);
+ lbStack.rafId = null;
+ }
+ }
+ });
+ }, { threshold: 0.2 });
+
+ observer.observe(section);
}
// ---- Bootstrap ------------------------------------------
diff --git a/styles.css b/styles.css
index cb09e6e..f9d57b7 100644
--- a/styles.css
+++ b/styles.css
@@ -788,6 +788,80 @@ tr:hover td { background: var(--surface-2); }
.lb-grid { --lb-block-size: 8px; --lb-gap: 1px; }
.lb-grid.lb-zoom { --lb-cols: 10; --lb-block-size: 28px; --lb-gap: 3px; }
}
+
+/* ---- Always-On Stack Panel ---- */
+.lb-stack-panel {
+ display: flex;
+ flex-direction: column;
+ gap: .45rem;
+ margin-bottom: 1.5rem;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: .75rem;
+ padding: .9rem 1.1rem;
+}
+
+.lb-stack-row {
+ display: flex;
+ align-items: flex-start;
+ gap: .5rem;
+}
+
+.lb-stack-label {
+ font-family: 'Orbitron', monospace;
+ font-size: .58rem;
+ color: var(--text-muted);
+ letter-spacing: .07em;
+ width: 3.6rem;
+ flex-shrink: 0;
+ padding-top: 1px;
+ text-transform: uppercase;
+}
+
+.lb-stack-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2px;
+ align-content: flex-start;
+}
+
+/* Compact block size for the stack */
+.lb-stack-grid .lb-block {
+ --lb-block-size: 9px;
+ width: var(--lb-block-size);
+ height: var(--lb-block-size);
+ cursor: default;
+}
+
+/* Future + dying blocks in the stack are interactive */
+.lb-stack-grid .lb-block.lb-future,
+.lb-stack-grid .lb-block.lb-dying {
+ cursor: pointer;
+}
+
+/* Overflow badge is informational only */
+.lb-stack-grid .lb-block.lb-overflow {
+ cursor: default;
+ font-size: .5rem;
+ color: var(--text-dim);
+}
+
+.lb-stack-grid .lb-block.lb-future:hover {
+ transform: scale(1.6);
+ box-shadow: 0 0 6px rgba(0,200,80,.4);
+ z-index: 10;
+}
+
+/* Mobile: slightly smaller blocks (flex-wrap already set on .lb-stack-grid) */
+@media (max-width: 600px) {
+ .lb-stack-grid .lb-block {
+ --lb-block-size: 8px;
+ }
+ .lb-stack-label {
+ width: 3rem;
+ font-size: .53rem;
+ }
+}
footer {
text-align: center;
padding: 2rem 1rem;
diff --git a/tests/e2e/death-clock.spec.js b/tests/e2e/death-clock.spec.js
index 4203517..89f372b 100644
--- a/tests/e2e/death-clock.spec.js
+++ b/tests/e2e/death-clock.spec.js
@@ -189,7 +189,72 @@ test.describe('AI Death Clock — end-to-end', () => {
await expect(html).toHaveAttribute('data-theme', 'dark');
});
- // ── No JS errors ─────────────────────────────────────────────────────────
+ // ── Always-on stack panel ─────────────────────────────────────────────────
+
+ test('always-on stack panel renders all six rows (AC-1)', async ({ page }) => {
+ await page.waitForSelector('#lb-stack-panel', { timeout: 5000 });
+ const rows = page.locator('#lb-stack-panel .lb-stack-row');
+ await expect(rows).toHaveCount(6);
+ // Each row must contain at least one block
+ for (const id of ['lb-stack-years', 'lb-stack-months', 'lb-stack-days',
+ 'lb-stack-hours', 'lb-stack-minutes', 'lb-stack-seconds']) {
+ const blocks = page.locator(`#${id} .lb-block`);
+ const count = await blocks.count();
+ expect(count).toBeGreaterThan(0);
+ }
+ });
+
+ test('always-on stack panel has one dying block per row', async ({ page }) => {
+ await page.waitForSelector('#lb-stack-panel', { timeout: 5000 });
+ for (const id of ['lb-stack-years', 'lb-stack-months', 'lb-stack-days',
+ 'lb-stack-hours', 'lb-stack-minutes', 'lb-stack-seconds']) {
+ const dying = page.locator(`#${id} .lb-block.lb-dying`);
+ await expect(dying).toHaveCount(1);
+ }
+ });
+
+ test('clicking a block in the seconds row navigates drill-down to seconds view (AC-12)',
+ async ({ page }) => {
+ await page.waitForSelector('#lb-stack-seconds [data-stack-level]', { timeout: 5000 });
+ // Click the first clickable block (dying or future) in the seconds row
+ await page.locator('#lb-stack-seconds [data-stack-level]').first().click();
+ // Drill-down panel should now show 60 second blocks
+ await page.waitForSelector('#lb-container .lb-block', { timeout: 3000 });
+ const blocks = page.locator('#lb-container .lb-block');
+ await expect(blocks).toHaveCount(60);
+ });
+
+ test('clicking a block in the minutes row navigates drill-down to minutes view (AC-12)',
+ async ({ page }) => {
+ await page.waitForSelector('#lb-stack-minutes [data-stack-level]', { timeout: 5000 });
+ await page.locator('#lb-stack-minutes [data-stack-level]').first().click();
+ await page.waitForSelector('#lb-container .lb-block', { timeout: 3000 });
+ const blocks = page.locator('#lb-container .lb-block');
+ await expect(blocks).toHaveCount(60);
+ });
+
+ test('clicking a block in the hours row navigates drill-down to hours view (AC-12)',
+ async ({ page }) => {
+ await page.waitForSelector('#lb-stack-hours [data-stack-level]', { timeout: 5000 });
+ await page.locator('#lb-stack-hours [data-stack-level]').first().click();
+ await page.waitForSelector('#lb-container .lb-block', { timeout: 3000 });
+ const blocks = page.locator('#lb-container .lb-block');
+ await expect(blocks).toHaveCount(24);
+ });
+
+ test('clicking a block in the days row navigates drill-down to days view (AC-12)',
+ async ({ page }) => {
+ await page.waitForSelector('#lb-stack-days [data-stack-level]', { timeout: 5000 });
+ await page.locator('#lb-stack-days [data-stack-level]').first().click();
+ await page.waitForSelector('#lb-container .lb-block', { timeout: 3000 });
+ // days view shows 1+ blocks (variable count)
+ const blocks = page.locator('#lb-container .lb-block');
+ const count = await blocks.count();
+ expect(count).toBeGreaterThan(0);
+ // breadcrumb must be at days level
+ const info = await page.locator('#lb-info').textContent();
+ expect(info).toMatch(/day/i);
+ });
test('page loads without uncaught JS errors', async ({ page }) => {
const errors = [];