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:
643
docs/superpowers/plans/2026-06-08-fold-in-timelapse-aiusage.md
Normal file
643
docs/superpowers/plans/2026-06-08-fold-in-timelapse-aiusage.md
Normal 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).
|
||||
Reference in New Issue
Block a user