Files
Void-Homelab/docs/superpowers/plans/2026-06-08-fold-in-timelapse-aiusage.md
root e01232e5f4 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 <noreply@anthropic.com>
2026-06-08 14:38:05 +10:00

24 KiB

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 <iframe> pointing at the app's own HTTPS origin. Timelapse already has timelapse.hynesy.com; phuryn gets a new aiusage.hynesy.com (CF tunnel → CT 300:8080) behind CF Access. A shared embedView() factory keeps the two views DRY.

Tech Stack: Node/Express + vanilla-ESM SPA (Void), vitest + jsdom (tests), Cloudflare API + Traefik (infra), FastAPI/HTMX + plain CSS (Timelapse restyle).

Spec: docs/superpowers/specs/2026-06-08-fold-in-timelapse-aiusage-design.md

Branch: feat/fold-in-apps (already created; spec already committed there).


Phase A — Infra: provision aiusage.hynesy.com

Credentials live in the reference_cloudflare_api memory and the farm-timelapse README (/project/farm-timelapse/README.md → CF resources table). Export them into the shell before the CF API steps: export CF_API_KEY=... CF_EMAIL=mrhynesy@gmail.com CF_ACCOUNT_ID=...

Task 1: Traefik route for aiusage.hynesy.com → CT 300:8080

Files: Traefik dynamic config on CT 100 (path discovered in Step 1).

  • Step 1: Find the existing timelapse router to mirror

Run (from CT 300):

ssh root@192.168.1.100 "grep -rl 'timelapse.hynesy.com' /etc/traefik /opt 2>/dev/null"

Expected: a dynamic-config file path (e.g. /etc/traefik/dynamic/homelab.yml) containing a timelapse router + service. Open it and note the exact router/service block shape.

  • Step 2: Add a sibling router + service for aiusage

In that same dynamic file, add (mirroring the timelapse block's structure — adjust keys to match what you found):

  routers:
    aiusage:
      rule: "Host(`aiusage.hynesy.com`)"
      entryPoints: ["websecure"]
      service: aiusage
      tls: {}
  services:
    aiusage:
      loadBalancer:
        servers:
          - url: "http://192.168.1.212:8080"

Traefik file-provider hot-reloads; no restart needed.

  • Step 3: Verify the route resolves (pre-Access)

Run:

curl -sI https://aiusage.hynesy.com | head -5

Expected: a 200 or a CF Access 302/403 (NOT a Traefik 404). A 404 means the host rule didn't match — fix before continuing.

  • Step 4: Commit (infra notes only)

Record the change in the homelab docs so it's reproducible:

# on CT 300, in /project
$EDITOR homelab-docs/07-claude-usage-dashboard.md   # add an "External access" note: aiusage.hynesy.com via CT100 Traefik

(No code commit in the void-v2 repo for this task.)

Task 2: Cloudflare Access app for aiusage.hynesy.com

Files: none (CF API).

  • Step 1: Fetch the timelapse Access app as a template

The timelapse Access app id is ea3b80eb-f613-4761-b23e-a4f6becf3481 (farm-timelapse README).

curl -s -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_API_KEY" \
  "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/access/apps/ea3b80eb-f613-4761-b23e-a4f6becf3481" \
  | python3 -m json.tool | tee /tmp/tl-access.json

Expected: JSON with domain, session_duration, and a Google-IdP / email-allowlist policy. Note the policies and allowed_idps.

  • Step 2: Create the aiusage Access app with the same policy
curl -s -X POST -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_API_KEY" \
  -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/access/apps" \
  -d '{
    "name": "aiusage",
    "domain": "aiusage.hynesy.com",
    "type": "self_hosted",
    "session_duration": "24h",
    "allowed_idps": [ /* paste the Google IdP id(s) from /tmp/tl-access.json */ ],
    "auto_redirect_to_identity": true
  }' | python3 -m json.tool

Expected: "success": true with a new app id. Save the id into the homelab doc.

  • Step 3: Attach the email-allowlist policy

Mirror the policy from /tmp/tl-access.json (same allowed emails). Using the new app id $AIUSAGE_APP_ID:

curl -s -X POST -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_API_KEY" \
  -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/access/apps/$AIUSAGE_APP_ID/policies" \
  -d '{ "name": "owner", "decision": "allow", "include": [ { "email": { "email": "mrhynesy@gmail.com" } } ] }' \
  | python3 -m json.tool

Expected: "success": true.

  • Step 4: Verify auth gating
curl -sI https://aiusage.hynesy.com | grep -iE "^location|^HTTP"

Expected: a 302 to a *.cloudflareaccess.com login URL when unauthenticated. (Authenticated browser session → 200.)

Task 3: Gate — validate iframe SSO renders without a login wall

Files: none (manual / Playwright check). This is a go/no-go gate for Phase B's embed approach.

  • Step 1: Confirm in a logged-in browser

In a browser already authenticated to void.hynesy.com, open a scratch page:

<iframe src="https://aiusage.hynesy.com/"  style="width:48%;height:90vh"></iframe>
<iframe src="https://timelapse.hynesy.com/" style="width:48%;height:90vh"></iframe>

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 -- <file>. 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

// 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('<!doctype html>', { 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:

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

// tests/frontend/embed.test.js
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { JSDOM } from 'jsdom';

beforeAll(() => {
  const dom = new JSDOM('<!doctype html><html><body><div id="main"></div></body></html>', { 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
// 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
// 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'
});
// 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
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:

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

// 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('<!doctype html><html><body><div id="sidebar"></div></body></html>', { 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 )):

    ,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
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"

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

// 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('<!doctype html><html><body></body></html>', { 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:

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

## 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
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)
ssh root@<pve-host-of-ct311> "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):

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
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 <link rel="stylesheet" href="/static/style.css"> line, add:

  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono:wght@400;500&display=swap">
  • Step 2: Replace the :root block in style.css

Replace the existing :root { … } (top of tlcapture/static/style.css) with:

: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):
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
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)
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
ssh root@<pve-host-of-ct108> "pct snapshot 108 pre_void_restyle --description 'before timelapse Phase-1 restyle'"
  • Step 2: Deploy
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).