From e01232e5f4e2b542427800dd1f2f5ff7d55bc03e Mon Sep 17 00:00:00 2001 From: root Date: Mon, 8 Jun 2026 14:38:05 +1000 Subject: [PATCH] docs(fold-in): implementation plan for Apps fold-in + timelapse restyle 13 tasks across 3 phases: CF/Traefik provisioning for aiusage.hynesy.com, Void rail+embed views (TDD), and Phase-1 timelapse restyle. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-08-fold-in-timelapse-aiusage.md | 643 ++++++++++++++++++ 1 file changed, 643 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-fold-in-timelapse-aiusage.md diff --git a/docs/superpowers/plans/2026-06-08-fold-in-timelapse-aiusage.md b/docs/superpowers/plans/2026-06-08-fold-in-timelapse-aiusage.md new file mode 100644 index 0000000..8347f36 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-fold-in-timelapse-aiusage.md @@ -0,0 +1,643 @@ +# 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).