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>
This commit is contained in:
root
2026-06-08 14:38:05 +10:00
parent 4f97add050
commit e01232e5f4

View File

@@ -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 `<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):
```bash
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):
```yaml
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:
```bash
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:
```bash
# 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).
```bash
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**
```bash
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`:
```bash
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**
```bash
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:
```html
<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**
```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('<!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:
```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('<!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`**
```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('<!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 `)`):
```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('<!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:
```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@<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):
```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 `<link rel="stylesheet" href="/static/style.css">` line, add:
```html
<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:
```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@<pve-host-of-ct108> "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).