# Fold Timelapse + AI Usage into the Void — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add Timelapse and AI Usage as left-rail "Apps" items in the Void, embedded as cross-origin HTTPS iframes, each still reachable chromeless at its own URL; plus a Phase-1 palette/typography restyle of the Timelapse app. **Architecture:** Each app is rendered by a tiny Void view = a Void-styled header bar + a full-height ` ``` Expected: BOTH apps render inside the frames (no CF login wall, no `X-Frame-Options` block). - [ ] **Step 2: If a frame is blocked** If CF Access shows a login wall inside the frame or framing is denied: do NOT change the approach silently — STOP and report. The fallback is to keep the rail item but have the view show only the `↗ Open` button (open at top level). Note the outcome in the plan before proceeding. - [ ] **Step 3: Record result** Add a one-line note to the spec's "Error handling" section recording that SSO-in-iframe was validated (date), then continue. --- ## Phase B — Void: rail items + embed views (branch `feat/fold-in-apps`) > All Void work is in `/project/src/void-v2`. Run tests with > `npm test -- `. Frontend tests use jsdom set up per-file (see > `tests/frontend/service_tile.test.js` for the canonical pattern). ### Task 4: Router routes for `#/timelapse` and `#/ai-usage` **Files:** - Modify: `public/router.js` (route table, near the `terminal` entry ~line 26) - Test: `tests/frontend/router.test.js` (create) - [ ] **Step 1: Write the failing test** ```js // tests/frontend/router.test.js import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { JSDOM } from 'jsdom'; let current; beforeAll(async () => { const dom = new JSDOM('', { url: 'http://localhost/#/' }); global.window = dom.window; global.document = dom.window.document; global.location = dom.window.location; ({ current } = await import('../../public/router.js')); }); afterAll(() => { delete global.window; delete global.document; delete global.location; }); describe('router app routes', () => { it('resolves #/timelapse', () => { location.hash = '#/timelapse'; expect(current().name).toBe('timelapse'); }); it('resolves #/ai-usage', () => { location.hash = '#/ai-usage'; expect(current().name).toBe('ai-usage'); }); }); ``` - [ ] **Step 2: Run it; verify it fails** Run: `npm test -- tests/frontend/router.test.js` Expected: FAIL — both resolve to `home` (routes not defined yet). - [ ] **Step 3: Add the routes** In `public/router.js`, in the `ROUTES` array immediately after the `terminal` entry, add: ```js { name: 'timelapse', re: /^\/timelapse$/, keys: [] }, { name: 'ai-usage', re: /^\/ai-usage$/, keys: [] }, ``` - [ ] **Step 4: Run it; verify it passes** Run: `npm test -- tests/frontend/router.test.js` Expected: PASS (2 passed). - [ ] **Step 5: Commit** ```bash git add public/router.js tests/frontend/router.test.js git commit -m "feat(router): add #/timelapse and #/ai-usage routes" ``` ### Task 5: Shared `embedView()` factory + the two view wrappers **Files:** - Create: `public/views/embed.js` - Create: `public/views/timelapse.js` - Create: `public/views/aiusage.js` - Test: `tests/frontend/embed.test.js` (create) - [ ] **Step 1: Write the failing test** ```js // tests/frontend/embed.test.js import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { JSDOM } from 'jsdom'; beforeAll(() => { const dom = new JSDOM('
', { url: 'http://localhost/' }); global.window = dom.window; global.document = dom.window.document; global.Node = dom.window.Node; global.location = dom.window.location; }); afterAll(() => { delete global.window; delete global.document; delete global.Node; delete global.location; }); describe('embedView + wrappers', () => { it('mounts an iframe with the given src + matching Open link', async () => { const { embedView } = await import('../../public/views/embed.js'); const main = document.getElementById('main'); await embedView({ title: 'Timelapse', sub: 'x', src: 'https://timelapse.hynesy.com/', allow: 'fullscreen' })(main); const frame = main.querySelector('iframe.term-frame'); expect(frame.getAttribute('src')).toBe('https://timelapse.hynesy.com/'); expect(frame.getAttribute('allow')).toBe('fullscreen'); expect(main.querySelector('a.ghost').getAttribute('href')).toBe('https://timelapse.hynesy.com/'); expect(main.querySelector('.term-title').textContent).toContain('Timelapse'); }); it('timelapse wrapper points at timelapse.hynesy.com', async () => { const main = document.getElementById('main'); await (await import('../../public/views/timelapse.js')).render(main); expect(main.querySelector('iframe.term-frame').getAttribute('src')).toBe('https://timelapse.hynesy.com/'); }); it('aiusage wrapper points at aiusage.hynesy.com', async () => { const main = document.getElementById('main'); await (await import('../../public/views/aiusage.js')).render(main); expect(main.querySelector('iframe.term-frame').getAttribute('src')).toBe('https://aiusage.hynesy.com/'); }); }); ``` - [ ] **Step 2: Run it; verify it fails** Run: `npm test -- tests/frontend/embed.test.js` Expected: FAIL — modules don't exist. - [ ] **Step 3: Create `public/views/embed.js`** ```js // Shared cross-origin app embed: a Void header bar + a full-height iframe. // Reuses the Terminal tab's themed classes (.term-bar/.term-frame/.ghost). // The embedded app lives at its own HTTPS origin, so visiting that origin // directly shows no Void chrome — there is no "back to Void" affordance here. import { el, mount } from '../dom.js'; export function embedView({ title, sub, src, allow }) { return async function render(main) { mount(main, el('div', { class: 'term-bar' }, el('span', { class: 'term-title' }, '◆ ' + title), sub ? el('span', { class: 'muted', style: { fontSize: '11px' } }, sub) : null, el('a', { class: 'ghost', style: { marginLeft: 'auto' }, href: src, target: '_blank', rel: 'noopener' }, '↗ Open'), el('button', { class: 'ghost', onclick: () => { const f = document.getElementById('embed-frame'); if (f) f.src = f.src; } }, '⟳ Reload') ), el('iframe', { id: 'embed-frame', src, class: 'term-frame', ...(allow ? { allow } : {}) }) ); }; } ``` - [ ] **Step 4: Create the two wrappers** ```js // public/views/timelapse.js — #/timelapse import { embedView } from './embed.js'; export const render = embedView({ title: 'Timelapse', sub: 'farm · 4K timelapse', src: 'https://timelapse.hynesy.com/', allow: 'fullscreen' }); ``` ```js // public/views/aiusage.js — #/ai-usage (phuryn claude-usage) import { embedView } from './embed.js'; export const render = embedView({ title: 'AI Usage', sub: 'claude code · token usage', src: 'https://aiusage.hynesy.com/' }); ``` - [ ] **Step 5: Run it; verify it passes** Run: `npm test -- tests/frontend/embed.test.js` Expected: PASS (3 passed). - [ ] **Step 6: Commit** ```bash git add public/views/embed.js public/views/timelapse.js public/views/aiusage.js tests/frontend/embed.test.js git commit -m "feat(views): cross-origin embed factory + Timelapse/AI Usage views" ``` ### Task 6: Register the views in `app.js` **Files:** Modify `public/app.js` (the `VIEWS` map ~lines 15-30) - [ ] **Step 1: Add the two entries** In `public/app.js`, inside the `VIEWS` object, after the `terminal` entry add: ```js timelapse: () => import('./views/timelapse.js'), 'ai-usage': () => import('./views/aiusage.js'), ``` - [ ] **Step 2: Sanity-check the bundle still parses** Run: `node --check public/app.js` Expected: no output (exit 0). (Dispatch is verified end-to-end by Playwright in Task 11.) - [ ] **Step 3: Commit** ```bash git add public/app.js git commit -m "feat(app): dispatch #/timelapse and #/ai-usage to their views" ``` ### Task 7: Sidebar "Apps" section **Files:** - Modify: `public/components/sidebar.js` (the `mount(root, …)` call in `renderSidebar`, ~lines 112-131) - Test: `tests/frontend/sidebar_apps.test.js` (create) - [ ] **Step 1: Write the failing test** ```js // tests/frontend/sidebar_apps.test.js import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; import { JSDOM } from 'jsdom'; vi.mock('../../public/api.js', () => ({ api: { get: vi.fn().mockResolvedValue([]) } })); let renderSidebar; beforeAll(async () => { const dom = new JSDOM('', { url: 'http://localhost/#/' }); global.window = dom.window; global.document = dom.window.document; global.Node = dom.window.Node; global.location = dom.window.location; ({ renderSidebar } = await import('../../public/components/sidebar.js')); }); afterAll(() => { delete global.window; delete global.document; delete global.Node; delete global.location; }); describe('sidebar Apps section', () => { it('renders Timelapse and AI Usage nav items', () => { const root = document.getElementById('sidebar'); renderSidebar(root); const hrefs = [...root.querySelectorAll('a.sb-item')].map(a => a.getAttribute('href')); expect(hrefs).toContain('#/timelapse'); expect(hrefs).toContain('#/ai-usage'); }); }); ``` - [ ] **Step 2: Run it; verify it fails** Run: `npm test -- tests/frontend/sidebar_apps.test.js` Expected: FAIL — neither href present. - [ ] **Step 3: Add the Apps section** In `public/components/sidebar.js`, inside the `mount(root, …)` call, add a new section after the existing `Navigate` section block (after its closing `)`): ```js ,el('div', { class: 'sb-section' }, el('div', { class: 'sb-title' }, 'Apps'), navItem('Timelapse', '/timelapse'), navItem('AI Usage', '/ai-usage') ) ``` (Note the leading comma — it's a new argument to `mount`. If the preceding section already ends with a comma, drop the leading one.) - [ ] **Step 4: Run it; verify it passes** Run: `npm test -- tests/frontend/sidebar_apps.test.js` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add public/components/sidebar.js tests/frontend/sidebar_apps.test.js git commit -m "feat(sidebar): add Apps section with Timelapse and AI Usage" ``` ### Task 8: Retarget the Sacred Valley AI Usage card link **Files:** - Modify: `public/views/cards/ai_usage.js` (~line 33) - Test: `tests/frontend/ai_usage_card.test.js` (create) - [ ] **Step 1: Write the failing test** ```js // tests/frontend/ai_usage_card.test.js import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; import { JSDOM } from 'jsdom'; vi.mock('../../public/api.js', () => ({ api: { get: vi.fn().mockResolvedValue({ ok: true, claude: { today: { input: 1, output: 2, cache: 3, turns: 4 }, week: { input: 5, output: 6 }, top_model: 'claude-opus-4-8' }, local: { top: { model: 'llama', p50_ms: 100, p95_ms: 200, error_rate: 0 }, runs: 3 } }) } })); let card; beforeAll(async () => { const dom = new JSDOM('', { url: 'http://localhost/' }); global.window = dom.window; global.document = dom.window.document; global.Node = dom.window.Node; global.location = dom.window.location; card = (await import('../../public/views/cards/ai_usage.js')).default; }); afterAll(() => { delete global.window; delete global.document; delete global.Node; delete global.location; }); describe('AI Usage SV card link', () => { it('points the Full dashboard link at the in-Void #/ai-usage route', async () => { const e = document.createElement('div'); card.mount(e); await new Promise(r => setTimeout(r, 0)); // let load() resolve const link = e.querySelector('a.aiu-link'); expect(link.getAttribute('href')).toBe('#/ai-usage'); expect(link.hasAttribute('target')).toBe(false); }); }); ``` - [ ] **Step 2: Run it; verify it fails** Run: `npm test -- tests/frontend/ai_usage_card.test.js` Expected: FAIL — href is still `http://192.168.1.212:8080/`. - [ ] **Step 3: Retarget the link** In `public/views/cards/ai_usage.js`, replace the `Full dashboard ↗` line: ```js el('a', { class: 'aiu-link', href: '#/ai-usage' }, 'Full dashboard ↗') ``` (Removes the `http://192.168.1.212:8080/` href and the `target`/`rel` attrs.) - [ ] **Step 4: Run it; verify it passes** Run: `npm test -- tests/frontend/ai_usage_card.test.js` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add public/views/cards/ai_usage.js tests/frontend/ai_usage_card.test.js git commit -m "feat(sv-card): open AI Usage dashboard via in-Void #/ai-usage route" ``` ### Task 9: Version bump + CHANGELOG **Files:** Modify `package.json`, `server.js` (line 16), `CHANGELOG.md` - [ ] **Step 1: Bump version** - `package.json`: `"version": "2.0.0-alpha.27"` - `server.js` line 16: `const VERSION = '2.0.0-alpha.27';` - [ ] **Step 2: Add CHANGELOG entry** Prepend to `CHANGELOG.md`: ```markdown ## 2.0.0-alpha.27 - feat: Timelapse + AI Usage folded into the left rail as an "Apps" section, embedded as cross-origin HTTPS iframes; each stays chromeless at its own URL. - feat: phuryn usage dashboard now reachable at aiusage.hynesy.com behind CF Access. - feat: Sacred Valley AI Usage card opens the in-Void #/ai-usage route. ``` - [ ] **Step 3: Run the full suite** Run: `npm test` Expected: all green (existing + the 4 new frontend test files). - [ ] **Step 4: Commit** ```bash git add package.json server.js CHANGELOG.md git commit -m "chore(release): 2.0.0-alpha.27 — fold-in Apps section" ``` ### Task 10: Deploy Void to CT 311 **Files:** none (deploy). Uses existing `deploy/`. - [ ] **Step 1: Snapshot first (backup-before-changes rule)** ```bash ssh root@ "pct snapshot 311 pre_alpha27 --description 'before fold-in Apps'" ``` Expected: snapshot created, no error. - [ ] **Step 2: Deploy** Run the repo's deploy path (inspect `deploy/` for the exact script): ```bash ls deploy/ && cat deploy/*.sh | head -40 # confirm the deploy command # then run it, e.g.: ./deploy/deploy.sh ``` - [ ] **Step 3: Smoke-test the running service** ```bash curl -s https://void.hynesy.com/health | python3 -m json.tool ``` Expected: `"version": "2.0.0-alpha.27"`, `"db_ok": true`. ### Task 11: Browser verification (Playwright / webapp-testing) **Files:** none (manual via webapp-testing skill). - [ ] **Step 1: In-Void embeds load** Open `void.hynesy.com`, click **Apps → Timelapse**: the timelapse UI renders in the iframe. Click **Apps → AI Usage**: the phuryn dashboard renders. The `↗ Open` and `⟳ Reload` header buttons are present. - [ ] **Step 2: SV card routes correctly** Open **Sacred Valley**, click the AI Usage card's **Full dashboard ↗**: URL becomes `#/ai-usage` and the embed loads (no new tab, no separate login). - [ ] **Step 3: Standalone = no Void chrome** Visit `https://timelapse.hynesy.com/` and `https://aiusage.hynesy.com/` directly: each shows only its own app — no Void sidebar, no path back into the Void. --- ## Phase C — Timelapse Phase-1 restyle (repo: `/project/farm-timelapse`) > Separate repo and host (CT 108). The app's CSS already uses `var()` tokens, so > the restyle is mostly a `:root` remap + font wiring. Commit in that repo, not void-v2. ### Task 12: Palette + typography restyle **Files:** - Modify: `tlcapture/templates/base.html` (head) - Modify: `tlcapture/static/style.css` (`:root`, base typography rules) - [ ] **Step 1: Add web fonts in `base.html` head** In `tlcapture/templates/base.html`, immediately before the `` line, add: ```html ``` - [ ] **Step 2: Replace the `:root` block in `style.css`** Replace the existing `:root { … }` (top of `tlcapture/static/style.css`) with: ```css :root { --bg: #0a0a0e; --bg-2: #14141c; --surface: #14141c; --surface-2: #1c1c26; --border: #2a2a36; --text: #e8e6ed; --muted: #888094; --accent: #ff4f2e; /* blackflame */ --accent-2: #ff7a5e; /* lighter blackflame for links/headings legibility */ --ok: #6fa86a; --warn: #d4a04a; --caution: #e07a3f; --danger: #c45a4a; --info: #4f8fcc; --shadow: 0 1px 0 rgba(255,255,255,0.03) inset, 0 8px 24px rgba(0,0,0,0.45); --font-display: 'Cinzel', 'Cormorant Garamond', serif; --font-body: 'Cormorant Garamond', Georgia, serif; --font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; --font-mono: 'JetBrains Mono', 'SF Mono', Consolas, monospace; font-size: 14px; } ``` - [ ] **Step 3: Wire the font roles** In `tlcapture/static/style.css`: - In `html, body { … }` change the `font-family:` to `var(--font-body)`. - In `h1, h2, h3, h4 { … }` add `font-family: var(--font-display);` and change `letter-spacing` to `0.02em`. - In `.brand { … }` add `font-family: var(--font-display);` and set `letter-spacing: 0.12em;`. - Append a role-override block near the top (after the `a { … }` rules): ```css nav a, button, input, select, textarea, label.row { font-family: var(--font-ui); } .usage-pill, .mono, code { font-family: var(--font-mono); } ``` - [ ] **Step 4: Verify it still serves and looks right** ```bash curl -sI http://192.168.1.212:8000/ 2>/dev/null | head -1 # or the CT108 LAN IP:8000 ``` Expected: `200 OK`. Then use the webapp-testing skill to screenshot `timelapse.hynesy.com` and eyeball the blackflame palette + serif headings. (No restart needed — static CSS + template are read per request by Jinja/StaticFiles.) - [ ] **Step 5: Commit (farm-timelapse repo)** ```bash cd /project/farm-timelapse git add tlcapture/templates/base.html tlcapture/static/style.css git commit -m "style: Phase-1 Void palette + typography for timelapse UI" ``` ### Task 13: Deploy the restyle to CT 108 **Files:** none (deploy via `scripts/install.sh`). - [ ] **Step 1: Snapshot CT 108** ```bash ssh root@ "pct snapshot 108 pre_void_restyle --description 'before timelapse Phase-1 restyle'" ``` - [ ] **Step 2: Deploy** ```bash cd /project/farm-timelapse && ./scripts/install.sh ``` Expected: rsync + reload completes without error. - [ ] **Step 3: Verify live** Open `https://timelapse.hynesy.com/` (standalone) and `void.hynesy.com → Apps → Timelapse` (embedded): both show the restyled UI; the app's tabs/controls still work. --- ## Self-review notes - **Spec coverage:** rail items (T4,T6,T7) · cross-origin embeds (T5) · aiusage host + CF Access (T1,T2) · SSO-in-iframe gate (T3) · SV card retarget keeping the card (T8) · standalone-chromeless verification (T11.3) · Phase-1 restyle palette+typography (T12) · version/deploy/safety snapshots (T9,T10,T13). All spec sections map to a task. - **Out of scope (not planned, per spec):** phuryn functional fix, Phase-2 restyle, Void 1 teardown, moving the Terminal item. - **Type consistency:** `embedView({title,sub,src,allow})` defined in T5 and consumed identically by both wrappers; iframe id `embed-frame` and classes `.term-bar/.term-frame/.term-title/.ghost` reused consistently; route names `timelapse`/`ai-usage` match across router (T4), app.js dispatch (T6), and sidebar hrefs (T7).