12 KiB
AGENTS.md — Thornhill TV
Guidance for AI agents and contributors working in this repository.
What this project is
Thornhill TV is a desktop Electron app with a Vue 3 frontend. It presents a scrollable “space” of feed tiles and a year/month timeline overlay, controlled primarily by the keyboard. The UI is dark-themed, full-viewport, and framed as a TV-like surface rather than a conventional document app.
At a high level:
- Electron owns the window, native theme, optional CORS bypass, and packaging.
- Vue 3 owns the interactive UI (space grid + timeline).
- Vite serves the UI in development and bundles it for production.
- electron-builder produces platform installers (AppImage / NSIS / DMG).
Package metadata: thornhill-tv 0.1.0, MIT, entry start.js.
Architecture overview
┌─────────────────────────────────────────────────────────────┐
│ Node process (start.js dual role) │
│ │
│ yarn start → start.js as Node │
│ ├─ Vite dev server (src-vue, random free port) │
│ └─ spawn(electron, start.js) with PORT env │
│ │
│ start.js as Electron main │
│ ├─ BrowserWindow + preload (src-electron/preload.js) │
│ ├─ config.js → window chrome / CORS / title │
│ └─ loadURL(localhost:PORT) | loadFile(dist/index.html) │
└────────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Renderer (Vue 3) │
│ App.vue │
│ ├─ Space.vue → grid of Feed.vue tiles (scrollable) │
│ └─ Timeline.vue → year / month navigator (Tab toggle) │
└─────────────────────────────────────────────────────────────┘
Dual-entry bootstrap (start.js)
start.js is both the npm main and the Electron entry. Behavior depends on what require('electron') returns:
| Context | require('electron') |
Behavior |
|---|---|---|
Plain Node (yarn start) |
Path string to the Electron binary | Start Vite on an ephemeral port, spawn Electron with PORT set, exit when the child exits |
| Electron main process | Electron API object | Create the window, apply config, load the UI |
This avoids a separate “main” vs “launcher” file.
Dev vs packaged load path
- Development: renderer loads
http://localhost:${PORT}/from Vite. Vue DevTools (nhdogjmejiglipccpnnnanhbledajbpd) is installed when unpackaged. Detached DevTools open and stay active. - Packaged: renderer loads
./dist/index.html. DevTools are closed after open.
Native theme is forced to dark (electron.nativeTheme.themeSource = 'dark').
Build pipeline (build.mjs)
- Delete
./distand./buildif present. - Vite build of
src-vue→../distwithbase: './'(file://-safe relative assets). electron-builderfor the current host platform only:- Linux → AppImage (x64)
- Windows → NSIS (x64)
- macOS → DMG
- Output under
./build; build resources fromsrc-electron/assets. - Packaged file set is derived from the repo root listing, excluding
.git, tooling files, and most.gitignoreentries (but keepingdist,node_modules, andconfig.jsin the packaged set when present).
Configuration (config.js)
Runtime config is required as ./config.js (gitignored). Copy from config.example.js:
| Key | Type | Role |
|---|---|---|
isSpellcheckEnabled |
boolean | BrowserWindow spellcheck |
isTitleBarEnabled |
boolean | Native frame / title bar (frame) |
isMenuBarEnabled |
boolean | Menu bar visibility |
isCorsEnabled |
boolean | When false, strips real CORS headers and injects * allow-origin/methods/headers on all responses (useful for embedding remote media in feeds) |
title |
string | Window / HTML title (falls back to package name) |
Agents changing window behavior should update config.example.js and document new keys here; never commit secrets in config.js.
Preload (src-electron/preload.js)
Currently a stub (console.log). The preload path is wired in BrowserWindow webPreferences.preload. Prefer exposing a narrow contextBridge API here if the renderer needs Node/Electron capabilities; do not enable nodeIntegration unless explicitly required.
Directory map
thornhill-tv/
├── AGENTS.md # This file
├── package.json # Scripts, deps, Electron main = start.js
├── yarn.lock
├── start.js # Dev launcher + Electron main
├── build.mjs # Vite production build + electron-builder
├── config.example.js # Template for config.js
├── config.js # Local overrides (gitignored)
├── src-electron/
│ ├── preload.js # Preload script for the renderer
│ └── assets/
│ └── icon.png # App / builder icon
├── src-vue/
│ ├── index.html # Shell: destyle.css, dark color-scheme, Apple font
│ ├── main.js # createApp(App) → .App
│ ├── static/fonts/
│ │ └── Apple.woff2
│ └── components/
│ ├── App.vue # Layout + Tab focus switch
│ ├── Space.vue # Feed grid + arrow-key pan
│ ├── Timeline.vue # Year/month bar
│ └── Space/
│ └── Feed.vue # Single feed tile (placeholder color)
├── dist/ # Vite output (gitignored)
└── build/ # electron-builder output (gitignored)
UI features and interaction model
Layout (App.vue)
- Full viewport CSS grid:
1frmain area +125pxbottom strip for the timeline. - Two siblings:
SpaceandTimeline. - Tab (on keyup): toggles
isTimelineActive, focuses either Timeline or Space. Tab keydown is always prevented (no browser focus cycle). - Timeline receives
:is-activeto animate open/closed.
Space — feed mosaic (Space.vue)
- Renders
feedCount(currently 100)Feedchildren in a CSS grid. - Grid dimensions:
columnCount = floor(sqrt(feedCount)),rowCount = ceil(feedCount / columnCount)so the mosaic stays roughly square. - Tile size via CSS variables:
--feed-width: 400px,--feed-height: 300px,--feed-gap: 50px. OuterSpaceoverflows hidden; the innerSpace__Feedsis larger and is panned by scrolling the host. - Arrow keys while Space is focused: continuous pan via
requestAnimationFrameloops (SCROLL_STEP = 25px per frame). Separate flags for X/Y so diagonal motion works. Keyup clears the corresponding axis flag. - Host is
tabindex="1"so it can receive keyboard focus.
Feed tile (Space/Feed.vue)
- Placeholder: random hex background color per instance.
- Intended extension point for real video / stream / channel content.
Timeline (Timeline.vue)
- Collapsed by default (
max-height: 0); whenTimeline--active, expands with a 0.25s transition over a semi-transparent black bar. - Years: current year − 5 through current year + 1 (seven slots). Default
activeYearindex is5(current year). - Months: single-letter labels
J F M A M J J A S O N D. Month row appears only whenactiveMonthis a number. - Keyboard (on keyup, when Timeline focused):
- Up: leave month mode (
activeMonth = undefined). - Down: enter month mode (current calendar month if active year is current; otherwise month
0). - Left / Right: move month (0–11) or year index within bounds.
- Up: leave month mode (
- Active year/month use opacity (0.5 inactive, 1 active). Uses the custom
Applefont face.
Visual / CSS conventions
- Global reset:
destyle.css. color-scheme: dark;:focus-visible { outline: none }(keyboard focus is app-managed).- Prefer local
<style>in SFCs; layout uses CSS grid and custom properties rather than a CSS framework. - Do not introduce Inter/Roboto/system defaults for branded chrome; the project already ships
Apple.woff2for timeline type.
Scripts and workflow
| Command | Purpose |
|---|---|
yarn / yarn install |
Install dependencies |
yarn start |
Vite + Electron (development) |
yarn build |
Production Vite build + electron-builder for this OS |
Prerequisites for agents:
- Ensure
config.jsexists (cp config.example.js config.js) beforestartorbuild. - Use Yarn (lockfile is
yarn.lock); do not switch package managers without an explicit request. - Do not commit
config.js,dist/,build/, ornode_modules/.
Dependency roles
| Package | Role |
|---|---|
electron |
Desktop shell |
electron-builder |
Installers |
electron-devtools-installer |
Vue DevTools in unpackaged runs |
vite, @vitejs/plugin-vue, vite-plugin-html |
Dev server + HTML title injection + production bundle |
vue |
UI framework (Options API via defineComponent) |
destyle.css |
CSS reset |
dayjs |
Declared dependency; not yet used in source — prefer it for any new date logic to match the package intent |
Conventions for code changes
Prefer existing patterns
- Vue components use Options API (
defineComponent,data,methods,mounted/beforeUnmount). Match that style unless migrating the whole tree. - Keyboard handling is done with DOM listeners on the component root (or
windowinApp), not Vue@keydowntemplates — keep that consistent for focus-scoped controls. - Event listener cleanup belongs in
beforeUnmount. - Keep Electron concerns in
start.js/src-electron; keep UI insrc-vue.
Safe extension points
- Real feeds: flesh out
Feed.vue(and possibly props fromSpace.vue) without changing the grid math unless product requirements demand it. - Timeline ↔ Space coupling: today Timeline selection does not filter feeds; wiring year/month to feed data is a natural next feature.
- IPC / APIs: add through
preload.js+contextBridge, then consume from Vue. - Config toggles: extend
config.example.jsand destructure instart.js.
Avoid unless asked
- Enabling
nodeIntegrationor disablingcontextIsolation. - Committing generated
dist//build/artifacts. - Cross-compiling all platforms in one build (current builder only targets
process.platform). - Rewriting the dual-role
start.jswithout a clear migration plan — Vite port discovery and Electron spawn are tightly coupled.
Testing / verification
There is no automated test suite. After UI or Electron changes:
yarn startand verify Tab focus switching, Space arrow panning, Timeline year/month navigation.- For packaging changes,
yarn buildon the relevant OS and smoke-test the artifact underbuild/.
Mental model for agents
Treat Thornhill TV as a keyboard-first media wall: a large pannable grid of feeds plus a temporal navigator. Changes should preserve full-viewport immersion, dark theme, and arrow/Tab navigation. Electron is a thin host; almost all product behavior lives under src-vue/components.