openvibers/openvibe.link Commits
Link's window: the design and the JSON it reads (not served yet) (#125)
Changes
nnew file mode 100644@@@ -0,0 +1,50 @@++# Link's window (the quick panel and the full window)++++Designed 2026-11-07 (domains plan §13.1). `index.html`, `panel.css` and `panel.js` are the whole UI: no build, no++framework, no network fetches beyond the daemon itself. Narrow (the tray's popover, a phone) it stacks; from 720 px it++is two columns and adds your devices and the activity log beside the controls.++++## What the daemon serves++++The daemon serves this folder at `http://127.0.0.1:<port>/panel/` and opens it with a per-session token in the++fragment: `/panel/#t=<token>`. The page sends that token as `X-Link-Panel` on every request. The daemon refuses:++- anything without the token;++- any request whose `Origin` is not its own loopback origin;++- any bind other than loopback.++++| Call | Body | What it does |++|---|---|---|++| `GET /panel/api/state` | — | Everything the window shows (below). Polled every 3 s while the window is visible. |++| `POST /panel/api/pause` | `{ "paused": true }` | The local kill switch: it stops everything at once and always wins over any remote request. |++| `POST /panel/api/running/:id/stop` | — | Stops one running thing. |++| `POST /panel/api/capabilities/:id` | `{ "on": false }` | Turns a capability on or off. Turning one on is the consent step. |++| `POST /panel/api/shares` | `{ "port": 3000 }` | Shares a local port through Connect, behind the person's sign-in. |++| `DELETE /panel/api/shares/:port` | — | Ends that share. |++| `POST /panel/api/files` | multipart `file`… | Sends the files to the person's other devices. |++++## `GET /panel/api/state`++++Every key is optional. A section with nothing in it is left out; the window never shows sample data.++++```json++{++ "machine": { "name": "Workshop desktop", "os": "linux", "online": true, "paused": false, "needs_you": 1 },++ "running": [{ "id": "r_1", "kind": "agent", "title": "Vibe is comparing three hosting plans", "detail": "Browser", "started_at": "2026-11-07T10:02:00Z" }],++ "capabilities": [{ "id": "browser", "name": "A browser for your agents", "who": "Your agents, after you approve each task", "on": true }],++ "shares": [{ "port": 3000, "url": "https://dev--alex.openvibe.website", "expires_at": "2026-11-07T22:00:00Z" }],++ "devices": [{ "name": "Workshop desktop", "kind": "desktop", "online": true, "here": true }],++ "gpu": { "name": "RTX 3090", "vram_gb": 24, "models": [{ "name": "Qwen2.5 14B", "tps": 41, "loaded": true }] },++ "earnings_today": { "coins": 120 },++ "activity": [{ "at": "2026-11-07T10:02:00Z", "text": "Vibe opened a browser to compare hosting plans" }]++}++```++++- `running[].kind` is `agent`, `desk`, `share` or `model`.++- `machine.needs_you` counts what waits for the person: it puts a dot on the ring.++- The ring shows:++ - green when online;++ - blue and filling while things run;++ - a slash when paused;++ - grey while not connected.++++`testdata/state.json` is a full example. The preview and the tests serve it as the answer.nnew file mode 100644@@@ -0,0 +1,82 @@++<!doctype html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">++<meta name="color-scheme" content="dark light">++<title>OpenVibe Link</title>++<link rel="stylesheet" href="panel.css">++</head>++<!-- OpenVibe Link's own window (domains plan §13.1, designed 2026-11-07). The daemon serves this folder on 127.0.0.1++ and answers /panel/api/* (see README.md here for the JSON). Narrow, it is the tray's quick panel; wide, the full++ window adds your devices and the activity log. Everything shown comes from the daemon: no sample data ships. -->++<body>++<main class="lk" data-state="loading" aria-busy="true">++ <header class="lk-head">++ <span class="lk-ring" aria-hidden="true"><svg viewBox="0 0 40 40"><circle class="lk-ring-bg" cx="20" cy="20" r="16"/><circle class="lk-ring-fill" cx="20" cy="20" r="16"/></svg><i class="lk-ring-dot"></i></span>++ <div class="lk-who"><h1 data-lk-name>This computer</h1><p data-lk-status role="status">Connecting to Link…</p></div>++ <button class="lk-pause" type="button" data-lk-pause aria-pressed="false"><span class="lk-pause-ic" aria-hidden="true"></span><span data-lk-pause-label>Pause everything</span></button>++ </header>++++ <p class="lk-down" data-lk-down hidden>Link is not running on this computer. Start it from your apps, or run <code>openvibe-node</code>.</p>++++ <div class="lk-cols">++ <div class="lk-col">++ <section class="lk-sec" data-lk-sec="running" aria-labelledby="lk-run-h">++ <h2 id="lk-run-h">Running now <span data-lk-run-n></span></h2>++ <ul class="lk-run" data-lk-running></ul>++ <p class="lk-none" data-lk-running-none>Nothing is running here. Your agents ask before they start.</p>++ </section>++++ <section class="lk-sec" data-lk-sec="caps" aria-labelledby="lk-cap-h">++ <h2 id="lk-cap-h">What this computer allows</h2>++ <ul class="lk-caps" data-lk-caps></ul>++ </section>++++ <section class="lk-sec" data-lk-sec="share" aria-labelledby="lk-share-h">++ <h2 id="lk-share-h">Share a port</h2>++ <form class="lk-share" data-lk-share-form>++ <label for="lk-port" class="lk-sr">Port on this computer</label>++ <input id="lk-port" name="port" inputmode="numeric" pattern="[0-9]{2,5}" placeholder="3000" required autocomplete="off">++ <button type="submit" class="lk-btn is-primary">Share</button>++ </form>++ <ul class="lk-shares" data-lk-shares></ul>++ <p class="lk-fine">A link behind your OpenVibe sign-in; only you, unless you add people.</p>++ </section>++++ <section class="lk-sec" data-lk-sec="drop" aria-labelledby="lk-drop-h">++ <h2 id="lk-drop-h" class="lk-sr">Send files</h2>++ <label class="lk-drop" data-lk-drop for="lk-files"><span class="lk-drop-ic" aria-hidden="true"></span><b>Send files to your other devices</b><small>Drop them here, or choose</small></label>++ <input id="lk-files" class="lk-sr" type="file" multiple data-lk-files>++ <p class="lk-fine" data-lk-drop-say role="status"></p>++ </section>++ </div>++++ <div class="lk-col lk-col-wide">++ <section class="lk-sec" data-lk-sec="gpu" aria-labelledby="lk-gpu-h" hidden>++ <h2 id="lk-gpu-h">Models on this computer</h2>++ <p class="lk-gpu-name" data-lk-gpu-name></p>++ <ul class="lk-models" data-lk-models></ul>++ </section>++++ <section class="lk-sec" data-lk-sec="devices" aria-labelledby="lk-dev-h">++ <h2 id="lk-dev-h">Your devices</h2>++ <ul class="lk-devices" data-lk-devices></ul>++ </section>++++ <section class="lk-sec" data-lk-sec="activity" aria-labelledby="lk-act-h">++ <h2 id="lk-act-h">What happened here</h2>++ <ol class="lk-activity" data-lk-activity></ol>++ <p class="lk-none" data-lk-activity-none>Nothing yet today.</p>++ </section>++ </div>++ </div>++++ <footer class="lk-foot">++ <span data-lk-earn hidden></span>++ <a href="https://openvibe.network/my#machines" target="_blank" rel="noopener">All your machines</a>++ </footer>++</main>++<script src="panel.js" defer></script>++</body>++</html>nnew file mode 100644@@@ -0,0 +1,119 @@++/* OpenVibe Link's window. OpenVibe Design's tokens, written out (this page has no network to fetch them from):++ dark by default, light when the system is light. Narrow it is the tray's quick panel; from 720 px, two columns. */++:root {++ --bg: #0b0f1a; --surface: #121a2b; --surface-2: #0f1626; --line: #22304a; --text: #e8edf6; --text-2: #a9b6cb; --muted: #7d8ba3;++ --accent: #4f7dff; --accent-2: #8fb0ff; --on-accent: #fff; --ok: #22c55e; --warn: #f59e0b; --bad: #ef4444;++ --r: 14px; --shadow: 0 14px 36px rgba(0, 0, 0, .35);++ color-scheme: dark;++}++@media (prefers-color-scheme: light) {++ :root { --bg: #f5f7fb; --surface: #ffffff; --surface-2: #eef2f8; --line: #d9e0ec; --text: #0f172a; --text-2: #3c4a63; --muted: #66748c;++ --accent: #2f5bea; --accent-2: #2f5bea; --shadow: 0 10px 28px rgba(15, 23, 42, .12); color-scheme: light; }++}++* { box-sizing: border-box; }++html, body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }++a { color: var(--accent-2); }++button, input { font: inherit; color: inherit; }++:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }++.lk-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++[hidden] { display: none !important; }++++.lk { max-width: 1040px; margin: 0 auto; padding: max(14px, env(safe-area-inset-top)) max(14px, env(safe-area-inset-right)) max(14px, env(safe-area-inset-bottom)) max(14px, env(safe-area-inset-left)); display: grid; gap: 14px; }++++/* The head: the ring says the state at a glance; Pause everything always wins. */++.lk-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 14px; border-radius: 18px; background: var(--surface); border: 1px solid var(--line); }++.lk-ring { position: relative; width: 44px; height: 44px; }++.lk-ring svg { width: 44px; height: 44px; transform: rotate(-90deg); }++.lk-ring circle { fill: none; stroke-width: 4; }++.lk-ring-bg { stroke: var(--line); }++.lk-ring-fill { stroke: var(--ok); stroke-linecap: round; stroke-dasharray: 100.5; stroke-dashoffset: 0; transition: stroke-dashoffset .4s ease, stroke .2s; }++.lk-ring-dot { position: absolute; right: 0; top: 0; width: 12px; height: 12px; border-radius: 50%; background: var(--warn); border: 2px solid var(--surface); display: none; }++.lk[data-state="loading"] .lk-ring-fill, .lk[data-state="down"] .lk-ring-fill { stroke: var(--muted); stroke-dashoffset: 75; }++.lk[data-state="running"] .lk-ring-fill { stroke: var(--accent); stroke-dashoffset: var(--fill, 50); }++.lk[data-state="paused"] .lk-ring-fill { stroke: var(--muted); }++.lk[data-state="paused"] .lk-ring::after { content: ""; position: absolute; left: 50%; top: 8px; bottom: 8px; width: 3px; margin-left: -1.5px; border-radius: 2px; background: var(--muted); transform: rotate(45deg); }++.lk[data-needs="1"] .lk-ring-dot { display: block; }++.lk-who { min-width: 0; }++.lk-who h1 { margin: 0; font-size: 16px; font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.lk-who p { margin: 1px 0 0; color: var(--text-2); font-size: 13px; }++.lk-pause { display: inline-flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--line); background: var(--surface-2); cursor: pointer; font-weight: 700; }++.lk-pause:hover { border-color: color-mix(in srgb, var(--bad) 55%, var(--line)); }++.lk-pause-ic { width: 12px; height: 12px; border-left: 3px solid currentColor; border-right: 3px solid currentColor; }++.lk-pause[aria-pressed="true"] { background: color-mix(in srgb, var(--bad) 16%, var(--surface-2)); border-color: color-mix(in srgb, var(--bad) 55%, var(--line)); }++.lk-pause[aria-pressed="true"] .lk-pause-ic { width: 0; height: 0; border-left: 10px solid currentColor; border-right: 0; border-top: 6px solid transparent; border-bottom: 6px solid transparent; }++.lk-down { margin: 0; padding: 12px 14px; border-radius: var(--r); background: color-mix(in srgb, var(--warn) 12%, var(--surface)); border: 1px solid color-mix(in srgb, var(--warn) 40%, var(--line)); }++++.lk-cols { display: grid; gap: 14px; }++.lk-col { display: grid; gap: 14px; align-content: start; min-width: 0; }++.lk-sec { padding: 14px; border-radius: 18px; background: var(--surface); border: 1px solid var(--line); min-width: 0; }++.lk-sec h2 { margin: 0 0 10px; font-size: 12px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }++.lk-sec h2 span { margin-left: 4px; color: var(--text-2); }++.lk-none, .lk-fine { margin: 6px 0 0; color: var(--muted); font-size: 12.5px; }++.lk ul, .lk ol { list-style: none; margin: 0; padding: 0; }++++.lk-run { display: grid; gap: 6px; }++.lk-run li { display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 8px 8px 8px 10px; border-radius: 12px; background: var(--surface-2); }++.lk-kind { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; font-size: 15px; background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent-2); }++.lk-kind[data-kind="desk"] { background: color-mix(in srgb, #a855f7 16%, transparent); color: #c084fc; }++.lk-kind[data-kind="share"] { background: color-mix(in srgb, var(--ok) 16%, transparent); color: var(--ok); }++.lk-kind[data-kind="model"] { background: color-mix(in srgb, var(--warn) 16%, transparent); color: var(--warn); }++.lk-shares b, .lk-devices b, .lk-models b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.lk-run b { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; }++.lk-run small, .lk-shares small, .lk-devices small, .lk-models small { color: var(--muted); font-size: 12px; }++.lk-btn { min-height: 36px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--line); background: transparent; cursor: pointer; font-weight: 700; font-size: 13px; }++.lk-btn:hover { background: color-mix(in srgb, var(--text) 7%, transparent); }++.lk-btn.is-primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }++.lk-btn.is-stop { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 45%, var(--line)); }++++/* Capabilities: a switch each, and who may use it in one line. */++.lk-caps { display: grid; gap: 2px; }++.lk-cap { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 9px 4px; }++.lk-cap + .lk-cap { border-top: 1px solid var(--line); }++.lk-cap b { display: block; font-size: 14px; }++.lk-cap small { color: var(--muted); font-size: 12px; }++.lk-switch { position: relative; width: 44px; height: 26px; flex: none; }++.lk-switch input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }++.lk-switch i { position: absolute; inset: 0; border-radius: 999px; background: var(--line); transition: background .2s; pointer-events: none; }++.lk-switch i::after { content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .3); transition: transform .2s; }++.lk-switch input:checked + i { background: var(--ok); }++.lk-switch input:checked + i::after { transform: translateX(18px); }++.lk-switch input:focus-visible + i { outline: 2px solid var(--accent); outline-offset: 2px; }++++.lk-share { display: flex; gap: 8px; }++.lk-share input { flex: 1; min-width: 0; height: 40px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }++.lk-shares { display: grid; gap: 6px; margin-top: 8px; }++.lk-shares li { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 12px; background: var(--surface-2); }++++.lk-drop { display: grid; justify-items: center; gap: 4px; padding: 18px 12px; border-radius: 16px; border: 2px dashed var(--line); text-align: center; cursor: pointer; transition: border-color .2s, background .2s; }++.lk-drop:hover, .lk-drop.is-over { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }++.lk-drop-ic { width: 28px; height: 28px; border-radius: 9px; background: color-mix(in srgb, var(--accent) 18%, transparent); position: relative; }++.lk-drop-ic::after { content: "↑"; position: absolute; inset: 0; display: grid; place-items: center; color: var(--accent-2); font-weight: 900; }++.lk-drop small { color: var(--muted); }++++.lk-gpu-name { margin: 0 0 8px; font-weight: 700; }++.lk-models { display: grid; gap: 6px; }++.lk-models li { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 12px; background: var(--surface-2); }++.lk-tps { font-variant-numeric: tabular-nums; font-weight: 800; }++++.lk-devices { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }++.lk-devices li { position: relative; display: grid; gap: 2px; padding: 10px 28px 10px 12px; border-radius: 12px; background: var(--surface-2); border: 1px solid transparent; }++.lk-devices li.is-here { border-color: color-mix(in srgb, var(--accent) 50%, var(--line)); }++.lk-devices li::before { content: ""; position: absolute; top: 12px; right: 12px; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }++.lk-devices li.is-on::before { background: var(--ok); }++++.lk-activity { display: grid; gap: 0; }++.lk-activity li { display: grid; grid-template-columns: 74px minmax(0, 1fr); gap: 10px; padding: 7px 0; border-top: 1px solid var(--line); font-size: 13px; }++.lk-activity li:first-child { border-top: 0; }++.lk-activity time { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }++++.lk-foot { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 13px; }++++/* The tray's narrow popover: Pause takes its own row under the name. */++@media (max-width: 460px) {++ .lk-head { grid-template-columns: auto minmax(0, 1fr); }++ .lk-pause { grid-column: 1 / -1; justify-content: center; }++}++@media (min-width: 720px) {++ .lk-cols { grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); align-items: start; }++}++@media (prefers-reduced-motion: reduce) { .lk-ring-fill, .lk-switch i, .lk-switch i::after { transition: none; } }nnew file mode 100644@@@ -0,0 +1,130 @@++/* OpenVibe Link's window: draws what the daemon reports (GET /panel/api/state every 3 s while visible) and sends the++ person's choices back. Every write carries the panel's session header (the daemon gives it in the page's++ address fragment, #t=…, and refuses writes without it). Text only: nothing from the daemon is ever parsed as HTML. */++(function () {++ 'use strict';++ var root = document.querySelector('.lk');++ if (!root) return;++ var token = (/[#&]t=([A-Za-z0-9_-]{16,128})/.exec(location.hash) || [])[1] || '';++ var KIND = { agent: '✦', desk: '▣', share: '⇄', model: '◆' };++ var $ = function (sel) { return root.querySelector(sel); };++ function el(tag, cls, text) { var n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }++ function ago(iso) {++ var t = Date.parse(iso); if (!isFinite(t)) return '';++ var s = Math.max(0, (Date.now() - t) / 1000);++ return s < 60 ? 'just now' : s < 3600 ? Math.floor(s / 60) + ' min' : s < 86400 ? Math.floor(s / 3600) + ' h' : Math.floor(s / 86400) + ' d';++ }++ function hhmm(iso) { var d = new Date(iso); return isNaN(d) ? '' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }++ function send(path, method, body) {++ return fetch('/panel/api/' + path, { method: method || 'POST', headers: { 'Content-Type': 'application/json', 'X-Link-Panel': token }, body: body ? JSON.stringify(body) : undefined, credentials: 'same-origin' })++ .then(function (r) { if (!r.ok) throw new Error('Link said ' + r.status); return r.status === 204 ? null : r.json().catch(function () { return null; }); })++ .then(function () { load(); });++ }++++ function draw(s) {++ var m = s.machine || {};++ var running = Array.isArray(s.running) ? s.running : [];++ root.setAttribute('aria-busy', 'false');++ root.setAttribute('data-state', m.paused ? 'paused' : running.length ? 'running' : 'online');++ root.setAttribute('data-needs', m.needs_you ? '1' : '0');++ root.style.setProperty('--fill', String(Math.max(10, 100 - Math.min(running.length, 4) * 22)));++ $('[data-lk-down]').hidden = true;++ $('[data-lk-name]').textContent = m.name || 'This computer';++ $('[data-lk-status]').textContent = m.paused ? 'Paused: nothing runs until you resume'++ : m.needs_you ? (m.needs_you === 1 ? 'Online · something needs you' : 'Online · ' + m.needs_you + ' things need you')++ : running.length ? 'Online · ' + running.length + ' running' : 'Online · ready';++ var pause = $('[data-lk-pause]');++ pause.setAttribute('aria-pressed', m.paused ? 'true' : 'false');++ $('[data-lk-pause-label]').textContent = m.paused ? 'Resume' : 'Pause everything';++++ $('[data-lk-run-n]').textContent = running.length ? '(' + running.length + ')' : '';++ $('[data-lk-running]').replaceChildren.apply($('[data-lk-running]'), running.map(function (r) {++ var li = el('li'); var k = el('span', 'lk-kind', KIND[r.kind] || '•'); k.setAttribute('data-kind', r.kind || ''); k.setAttribute('aria-hidden', 'true');++ var t = el('span'); t.append(el('b', '', r.title || 'Working'), el('small', '', [r.detail, r.started_at ? ago(r.started_at) : ''].filter(Boolean).join(' · ')));++ var stop = el('button', 'lk-btn is-stop', 'Stop'); stop.type = 'button'; stop.setAttribute('aria-label', 'Stop ' + (r.title || 'this'));++ stop.addEventListener('click', function () { stop.disabled = true; send('running/' + encodeURIComponent(r.id) + '/stop').catch(function () { stop.disabled = false; }); });++ li.append(k, t, stop); return li;++ }));++ $('[data-lk-running-none]').hidden = running.length > 0;++++ var caps = Array.isArray(s.capabilities) ? s.capabilities : [];++ $('[data-lk-caps]').replaceChildren.apply($('[data-lk-caps]'), caps.map(function (c) {++ var li = el('li', 'lk-cap'); var t = el('span'); t.append(el('b', '', c.name || c.id), el('small', '', c.who || ''));++ var sw = el('label', 'lk-switch'); var box = el('input'); box.type = 'checkbox'; box.checked = !!c.on; box.setAttribute('aria-label', c.name || c.id);++ box.addEventListener('change', function () { send('capabilities/' + encodeURIComponent(c.id), 'POST', { on: box.checked }).catch(function () { box.checked = !box.checked; }); });++ sw.append(box, el('i')); li.append(t, sw); return li;++ }));++ root.querySelector('[data-lk-sec="caps"]').hidden = caps.length === 0;++++ var shares = Array.isArray(s.shares) ? s.shares : [];++ $('[data-lk-shares]').replaceChildren.apply($('[data-lk-shares]'), shares.map(function (sh) {++ var li = el('li'); var t = el('span'); t.append(el('b', '', 'Port ' + sh.port), el('small', '', sh.url || ''));++ var copy = el('button', 'lk-btn', 'Copy'); copy.type = 'button';++ copy.addEventListener('click', function () { if (navigator.clipboard) navigator.clipboard.writeText(sh.url || '').then(function () { copy.textContent = 'Copied'; setTimeout(function () { copy.textContent = 'Copy'; }, 1500); }, function () {}); });++ var end = el('button', 'lk-btn is-stop', 'End'); end.type = 'button';++ end.addEventListener('click', function () { send('shares/' + encodeURIComponent(sh.port), 'DELETE'); });++ li.append(t, copy, end); return li;++ }));++++ var gpu = s.gpu;++ root.querySelector('[data-lk-sec="gpu"]').hidden = !gpu;++ if (gpu) {++ $('[data-lk-gpu-name]').textContent = [gpu.name, gpu.vram_gb ? gpu.vram_gb + ' GB' : ''].filter(Boolean).join(' · ');++ $('[data-lk-models]').replaceChildren.apply($('[data-lk-models]'), (gpu.models || []).map(function (mo) {++ var li = el('li'); var t = el('span'); t.append(el('b', '', mo.name), el('small', '', mo.loaded ? 'Loaded' : 'Installed'));++ li.append(t, el('span', 'lk-tps', mo.tps ? mo.tps + ' tok/s' : '')); return li;++ }));++ }++++ var devices = Array.isArray(s.devices) ? s.devices : [];++ $('[data-lk-devices]').replaceChildren.apply($('[data-lk-devices]'), devices.map(function (d) {++ var li = el('li', (d.online ? 'is-on' : '') + (d.here ? ' is-here' : ''));++ li.append(el('b', '', d.name || 'A device'), el('small', '', (d.here ? 'This one · ' : '') + (d.online ? 'Online' : 'Offline') + (d.kind ? ' · ' + d.kind : '')));++ return li;++ }));++ root.querySelector('[data-lk-sec="devices"]').hidden = devices.length === 0;++++ var act = Array.isArray(s.activity) ? s.activity.slice(0, 30) : [];++ $('[data-lk-activity]').replaceChildren.apply($('[data-lk-activity]'), act.map(function (a) {++ var li = el('li'); var t = el('time', '', hhmm(a.at)); t.setAttribute('datetime', a.at || ''); li.append(t, el('span', '', a.text || '')); return li;++ }));++ $('[data-lk-activity-none]').hidden = act.length > 0;++++ var earn = $('[data-lk-earn]');++ earn.hidden = !(s.earnings_today && s.earnings_today.coins);++ if (!earn.hidden) earn.textContent = 'Earned today: ' + Number(s.earnings_today.coins).toLocaleString() + ' OpenCoins';++ }++ function down() {++ root.setAttribute('aria-busy', 'false');++ root.setAttribute('data-state', 'down');++ $('[data-lk-status]').textContent = 'Not connected';++ $('[data-lk-down]').hidden = false;++ }++ var timer = 0;++ function load() {++ return fetch('/panel/api/state', { credentials: 'same-origin', headers: { 'X-Link-Panel': token } })++ .then(function (r) { if (!r.ok) throw new Error(String(r.status)); return r.json(); }).then(draw).catch(down);++ }++ $('[data-lk-pause]').addEventListener('click', function () { var on = this.getAttribute('aria-pressed') !== 'true'; send('pause', 'POST', { paused: on }); });++ $('[data-lk-share-form]').addEventListener('submit', function (e) {++ e.preventDefault(); var port = Number(this.port.value);++ if (!(port >= 1 && port <= 65535)) return;++ var f = this; send('shares', 'POST', { port: port }).then(function () { f.reset(); });++ });++ var drop = $('[data-lk-drop]'), say = $('[data-lk-drop-say]');++ function sendFiles(files) {++ if (!files || !files.length) return;++ var fd = new FormData(); for (var i = 0; i < files.length; i++) fd.append('file', files[i]);++ say.textContent = 'Sending ' + files.length + ' file' + (files.length === 1 ? '' : 's') + '…';++ fetch('/panel/api/files', { method: 'POST', body: fd, headers: { 'X-Link-Panel': token }, credentials: 'same-origin' })++ .then(function (r) { say.textContent = r.ok ? 'Sent to your other devices.' : 'Link could not send them (' + r.status + ').'; }, function () { say.textContent = 'Link is not answering.'; });++ }++ ['dragenter', 'dragover'].forEach(function (t) { drop.addEventListener(t, function (e) { e.preventDefault(); drop.classList.add('is-over'); }); });++ ['dragleave', 'drop'].forEach(function (t) { drop.addEventListener(t, function () { drop.classList.remove('is-over'); }); });++ drop.addEventListener('drop', function (e) { e.preventDefault(); sendFiles(e.dataTransfer.files); });++ $('[data-lk-files]').addEventListener('change', function () { sendFiles(this.files); this.value = ''; });++ function tick() { if (document.visibilityState === 'visible') load(); }++ load();++ timer = setInterval(tick, 3000);++ document.addEventListener('visibilitychange', tick);++})();nnew file mode 100644@@@ -0,0 +1,27 @@++{++ "machine": { "name": "Workshop desktop", "os": "linux", "online": true, "paused": false, "needs_you": 1 },++ "running": [++ { "id": "r_1", "kind": "agent", "title": "Vibe is comparing three hosting plans", "detail": "Browser", "started_at": "2026-11-07T10:02:00Z" },++ { "id": "r_2", "kind": "desk", "title": "A desk for Codes: drive redesign", "detail": "Codes", "started_at": "2026-11-07T09:40:00Z" },++ { "id": "r_3", "kind": "share", "title": "Port 3000 shared", "detail": "Only you", "started_at": "2026-11-07T09:10:00Z" }++ ],++ "capabilities": [++ { "id": "browser", "name": "A browser for your agents", "who": "Your agents, after you approve each task", "on": true },++ { "id": "files", "name": "Files between your devices", "who": "Only you", "on": true },++ { "id": "models", "name": "Run AI models here", "who": "Only you, from your other devices", "on": false },++ { "id": "earn", "name": "Earn with spare power", "who": "OpenVibe Exchange, when this computer is idle", "on": false }++ ],++ "shares": [ { "port": 3000, "url": "https://dev--alex.openvibe.website", "expires_at": "2026-11-07T22:00:00Z" } ],++ "devices": [++ { "name": "Workshop desktop", "kind": "desktop", "online": true, "here": true },++ { "name": "Laptop", "kind": "laptop", "online": true, "here": false },++ { "name": "Pixel 8", "kind": "phone", "online": false, "here": false }++ ],++ "gpu": null,++ "earnings_today": null,++ "activity": [++ { "at": "2026-11-07T10:02:00Z", "text": "Vibe opened a browser to compare hosting plans" },++ { "at": "2026-11-07T09:40:00Z", "text": "Codes started a desk for the drive redesign" },++ { "at": "2026-11-07T09:10:00Z", "text": "You shared port 3000" }++ ]++}
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+# Link's window (the quick panel and the full window)
+
+Designed 2026-11-07 (domains plan §13.1). `index.html`, `panel.css` and `panel.js` are the whole UI: no build, no
+framework, no network fetches beyond the daemon itself. Narrow (the tray's popover, a phone) it stacks; from 720 px it
+is two columns and adds your devices and the activity log beside the controls.
+
+## What the daemon serves
+
+The daemon serves this folder at `http://127.0.0.1:<port>/panel/` and opens it with a per-session token in the
+fragment: `/panel/#t=<token>`. The page sends that token as `X-Link-Panel` on every request. The daemon refuses:
+- anything without the token;
+- any request whose `Origin` is not its own loopback origin;
+- any bind other than loopback.
+
+| Call | Body | What it does |
+|---|---|---|
+| `GET /panel/api/state` | — | Everything the window shows (below). Polled every 3 s while the window is visible. |
+| `POST /panel/api/pause` | `{ "paused": true }` | The local kill switch: it stops everything at once and always wins over any remote request. |
+| `POST /panel/api/running/:id/stop` | — | Stops one running thing. |
+| `POST /panel/api/capabilities/:id` | `{ "on": false }` | Turns a capability on or off. Turning one on is the consent step. |
+| `POST /panel/api/shares` | `{ "port": 3000 }` | Shares a local port through Connect, behind the person's sign-in. |
+| `DELETE /panel/api/shares/:port` | — | Ends that share. |
+| `POST /panel/api/files` | multipart `file`… | Sends the files to the person's other devices. |
+
+## `GET /panel/api/state`
+
+Every key is optional. A section with nothing in it is left out; the window never shows sample data.
+
+```json
+{
+ "machine": { "name": "Workshop desktop", "os": "linux", "online": true, "paused": false, "needs_you": 1 },
+ "running": [{ "id": "r_1", "kind": "agent", "title": "Vibe is comparing three hosting plans", "detail": "Browser", "started_at": "2026-11-07T10:02:00Z" }],
+ "capabilities": [{ "id": "browser", "name": "A browser for your agents", "who": "Your agents, after you approve each task", "on": true }],
+ "shares": [{ "port": 3000, "url": "https://dev--alex.openvibe.website", "expires_at": "2026-11-07T22:00:00Z" }],
+ "devices": [{ "name": "Workshop desktop", "kind": "desktop", "online": true, "here": true }],
+ "gpu": { "name": "RTX 3090", "vram_gb": 24, "models": [{ "name": "Qwen2.5 14B", "tps": 41, "loaded": true }] },
+ "earnings_today": { "coins": 120 },
+ "activity": [{ "at": "2026-11-07T10:02:00Z", "text": "Vibe opened a browser to compare hosting plans" }]
+}
+```
+
+- `running[].kind` is `agent`, `desk`, `share` or `model`.
+- `machine.needs_you` counts what waits for the person: it puts a dot on the ring.
+- The ring shows:
+ - green when online;
+ - blue and filling while things run;
+ - a slash when paused;
+ - grey while not connected.
+
+`testdata/state.json` is a full example. The preview and the tests serve it as the answer.
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
+<meta name="color-scheme" content="dark light">
+<title>OpenVibe Link</title>
+<link rel="stylesheet" href="panel.css">
+</head>
+<!-- OpenVibe Link's own window (domains plan §13.1, designed 2026-11-07). The daemon serves this folder on 127.0.0.1
+ and answers /panel/api/* (see README.md here for the JSON). Narrow, it is the tray's quick panel; wide, the full
+ window adds your devices and the activity log. Everything shown comes from the daemon: no sample data ships. -->
+<body>
+<main class="lk" data-state="loading" aria-busy="true">
+ <header class="lk-head">
+ <span class="lk-ring" aria-hidden="true"><svg viewBox="0 0 40 40"><circle class="lk-ring-bg" cx="20" cy="20" r="16"/><circle class="lk-ring-fill" cx="20" cy="20" r="16"/></svg><i class="lk-ring-dot"></i></span>
+ <div class="lk-who"><h1 data-lk-name>This computer</h1><p data-lk-status role="status">Connecting to Link…</p></div>
+ <button class="lk-pause" type="button" data-lk-pause aria-pressed="false"><span class="lk-pause-ic" aria-hidden="true"></span><span data-lk-pause-label>Pause everything</span></button>
+ </header>
+
+ <p class="lk-down" data-lk-down hidden>Link is not running on this computer. Start it from your apps, or run <code>openvibe-node</code>.</p>
+
+ <div class="lk-cols">
+ <div class="lk-col">
+ <section class="lk-sec" data-lk-sec="running" aria-labelledby="lk-run-h">
+ <h2 id="lk-run-h">Running now <span data-lk-run-n></span></h2>
+ <ul class="lk-run" data-lk-running></ul>
+ <p class="lk-none" data-lk-running-none>Nothing is running here. Your agents ask before they start.</p>
+ </section>
+
+ <section class="lk-sec" data-lk-sec="caps" aria-labelledby="lk-cap-h">
+ <h2 id="lk-cap-h">What this computer allows</h2>
+ <ul class="lk-caps" data-lk-caps></ul>
+ </section>
+
+ <section class="lk-sec" data-lk-sec="share" aria-labelledby="lk-share-h">
+ <h2 id="lk-share-h">Share a port</h2>
+ <form class="lk-share" data-lk-share-form>
+ <label for="lk-port" class="lk-sr">Port on this computer</label>
+ <input id="lk-port" name="port" inputmode="numeric" pattern="[0-9]{2,5}" placeholder="3000" required autocomplete="off">
+ <button type="submit" class="lk-btn is-primary">Share</button>
+ </form>
+ <ul class="lk-shares" data-lk-shares></ul>
+ <p class="lk-fine">A link behind your OpenVibe sign-in; only you, unless you add people.</p>
+ </section>
+
+ <section class="lk-sec" data-lk-sec="drop" aria-labelledby="lk-drop-h">
+ <h2 id="lk-drop-h" class="lk-sr">Send files</h2>
+ <label class="lk-drop" data-lk-drop for="lk-files"><span class="lk-drop-ic" aria-hidden="true"></span><b>Send files to your other devices</b><small>Drop them here, or choose</small></label>
+ <input id="lk-files" class="lk-sr" type="file" multiple data-lk-files>
+ <p class="lk-fine" data-lk-drop-say role="status"></p>
+ </section>
+ </div>
+
+ <div class="lk-col lk-col-wide">
+ <section class="lk-sec" data-lk-sec="gpu" aria-labelledby="lk-gpu-h" hidden>
+ <h2 id="lk-gpu-h">Models on this computer</h2>
+ <p class="lk-gpu-name" data-lk-gpu-name></p>
+ <ul class="lk-models" data-lk-models></ul>
+ </section>
+
+ <section class="lk-sec" data-lk-sec="devices" aria-labelledby="lk-dev-h">
+ <h2 id="lk-dev-h">Your devices</h2>
+ <ul class="lk-devices" data-lk-devices></ul>
+ </section>
+
+ <section class="lk-sec" data-lk-sec="activity" aria-labelledby="lk-act-h">
+ <h2 id="lk-act-h">What happened here</h2>
+ <ol class="lk-activity" data-lk-activity></ol>
+ <p class="lk-none" data-lk-activity-none>Nothing yet today.</p>
+ </section>
+ </div>
+ </div>
+
+ <footer class="lk-foot">
+ <span data-lk-earn hidden></span>
+ <a href="https://openvibe.network/my#machines" target="_blank" rel="noopener">All your machines</a>
+ </footer>
+</main>
+<script src="panel.js" defer></script>
+</body>
+</html>
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
+/* OpenVibe Link's window. OpenVibe Design's tokens, written out (this page has no network to fetch them from):
+ dark by default, light when the system is light. Narrow it is the tray's quick panel; from 720 px, two columns. */
+:root {
+ --bg: #0b0f1a; --surface: #121a2b; --surface-2: #0f1626; --line: #22304a; --text: #e8edf6; --text-2: #a9b6cb; --muted: #7d8ba3;
+ --accent: #4f7dff; --accent-2: #8fb0ff; --on-accent: #fff; --ok: #22c55e; --warn: #f59e0b; --bad: #ef4444;
+ --r: 14px; --shadow: 0 14px 36px rgba(0, 0, 0, .35);
+ color-scheme: dark;
+}
+@media (prefers-color-scheme: light) {
+ :root { --bg: #f5f7fb; --surface: #ffffff; --surface-2: #eef2f8; --line: #d9e0ec; --text: #0f172a; --text-2: #3c4a63; --muted: #66748c;
+ --accent: #2f5bea; --accent-2: #2f5bea; --shadow: 0 10px 28px rgba(15, 23, 42, .12); color-scheme: light; }
+}
+* { box-sizing: border-box; }
+html, body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
+a { color: var(--accent-2); }
+button, input { font: inherit; color: inherit; }
+:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.lk-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+[hidden] { display: none !important; }
+
+.lk { max-width: 1040px; margin: 0 auto; padding: max(14px, env(safe-area-inset-top)) max(14px, env(safe-area-inset-right)) max(14px, env(safe-area-inset-bottom)) max(14px, env(safe-area-inset-left)); display: grid; gap: 14px; }
+
+/* The head: the ring says the state at a glance; Pause everything always wins. */
+.lk-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 14px; border-radius: 18px; background: var(--surface); border: 1px solid var(--line); }
+.lk-ring { position: relative; width: 44px; height: 44px; }
+.lk-ring svg { width: 44px; height: 44px; transform: rotate(-90deg); }
+.lk-ring circle { fill: none; stroke-width: 4; }
+.lk-ring-bg { stroke: var(--line); }
+.lk-ring-fill { stroke: var(--ok); stroke-linecap: round; stroke-dasharray: 100.5; stroke-dashoffset: 0; transition: stroke-dashoffset .4s ease, stroke .2s; }
+.lk-ring-dot { position: absolute; right: 0; top: 0; width: 12px; height: 12px; border-radius: 50%; background: var(--warn); border: 2px solid var(--surface); display: none; }
+.lk[data-state="loading"] .lk-ring-fill, .lk[data-state="down"] .lk-ring-fill { stroke: var(--muted); stroke-dashoffset: 75; }
+.lk[data-state="running"] .lk-ring-fill { stroke: var(--accent); stroke-dashoffset: var(--fill, 50); }
+.lk[data-state="paused"] .lk-ring-fill { stroke: var(--muted); }
+.lk[data-state="paused"] .lk-ring::after { content: ""; position: absolute; left: 50%; top: 8px; bottom: 8px; width: 3px; margin-left: -1.5px; border-radius: 2px; background: var(--muted); transform: rotate(45deg); }
+.lk[data-needs="1"] .lk-ring-dot { display: block; }
+.lk-who { min-width: 0; }
+.lk-who h1 { margin: 0; font-size: 16px; font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.lk-who p { margin: 1px 0 0; color: var(--text-2); font-size: 13px; }
+.lk-pause { display: inline-flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--line); background: var(--surface-2); cursor: pointer; font-weight: 700; }
+.lk-pause:hover { border-color: color-mix(in srgb, var(--bad) 55%, var(--line)); }
+.lk-pause-ic { width: 12px; height: 12px; border-left: 3px solid currentColor; border-right: 3px solid currentColor; }
+.lk-pause[aria-pressed="true"] { background: color-mix(in srgb, var(--bad) 16%, var(--surface-2)); border-color: color-mix(in srgb, var(--bad) 55%, var(--line)); }
+.lk-pause[aria-pressed="true"] .lk-pause-ic { width: 0; height: 0; border-left: 10px solid currentColor; border-right: 0; border-top: 6px solid transparent; border-bottom: 6px solid transparent; }
+.lk-down { margin: 0; padding: 12px 14px; border-radius: var(--r); background: color-mix(in srgb, var(--warn) 12%, var(--surface)); border: 1px solid color-mix(in srgb, var(--warn) 40%, var(--line)); }
+
+.lk-cols { display: grid; gap: 14px; }
+.lk-col { display: grid; gap: 14px; align-content: start; min-width: 0; }
+.lk-sec { padding: 14px; border-radius: 18px; background: var(--surface); border: 1px solid var(--line); min-width: 0; }
+.lk-sec h2 { margin: 0 0 10px; font-size: 12px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
+.lk-sec h2 span { margin-left: 4px; color: var(--text-2); }
+.lk-none, .lk-fine { margin: 6px 0 0; color: var(--muted); font-size: 12.5px; }
+.lk ul, .lk ol { list-style: none; margin: 0; padding: 0; }
+
+.lk-run { display: grid; gap: 6px; }
+.lk-run li { display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 8px 8px 8px 10px; border-radius: 12px; background: var(--surface-2); }
+.lk-kind { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; font-size: 15px; background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent-2); }
+.lk-kind[data-kind="desk"] { background: color-mix(in srgb, #a855f7 16%, transparent); color: #c084fc; }
+.lk-kind[data-kind="share"] { background: color-mix(in srgb, var(--ok) 16%, transparent); color: var(--ok); }
+.lk-kind[data-kind="model"] { background: color-mix(in srgb, var(--warn) 16%, transparent); color: var(--warn); }
+.lk-shares b, .lk-devices b, .lk-models b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.lk-run b { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; }
+.lk-run small, .lk-shares small, .lk-devices small, .lk-models small { color: var(--muted); font-size: 12px; }
+.lk-btn { min-height: 36px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--line); background: transparent; cursor: pointer; font-weight: 700; font-size: 13px; }
+.lk-btn:hover { background: color-mix(in srgb, var(--text) 7%, transparent); }
+.lk-btn.is-primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
+.lk-btn.is-stop { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 45%, var(--line)); }
+
+/* Capabilities: a switch each, and who may use it in one line. */
+.lk-caps { display: grid; gap: 2px; }
+.lk-cap { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 9px 4px; }
+.lk-cap + .lk-cap { border-top: 1px solid var(--line); }
+.lk-cap b { display: block; font-size: 14px; }
+.lk-cap small { color: var(--muted); font-size: 12px; }
+.lk-switch { position: relative; width: 44px; height: 26px; flex: none; }
+.lk-switch input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
+.lk-switch i { position: absolute; inset: 0; border-radius: 999px; background: var(--line); transition: background .2s; pointer-events: none; }
+.lk-switch i::after { content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .3); transition: transform .2s; }
+.lk-switch input:checked + i { background: var(--ok); }
+.lk-switch input:checked + i::after { transform: translateX(18px); }
+.lk-switch input:focus-visible + i { outline: 2px solid var(--accent); outline-offset: 2px; }
+
+.lk-share { display: flex; gap: 8px; }
+.lk-share input { flex: 1; min-width: 0; height: 40px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
+.lk-shares { display: grid; gap: 6px; margin-top: 8px; }
+.lk-shares li { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 12px; background: var(--surface-2); }
+
+.lk-drop { display: grid; justify-items: center; gap: 4px; padding: 18px 12px; border-radius: 16px; border: 2px dashed var(--line); text-align: center; cursor: pointer; transition: border-color .2s, background .2s; }
+.lk-drop:hover, .lk-drop.is-over { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
+.lk-drop-ic { width: 28px; height: 28px; border-radius: 9px; background: color-mix(in srgb, var(--accent) 18%, transparent); position: relative; }
+.lk-drop-ic::after { content: "↑"; position: absolute; inset: 0; display: grid; place-items: center; color: var(--accent-2); font-weight: 900; }
+.lk-drop small { color: var(--muted); }
+
+.lk-gpu-name { margin: 0 0 8px; font-weight: 700; }
+.lk-models { display: grid; gap: 6px; }
+.lk-models li { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 12px; background: var(--surface-2); }
+.lk-tps { font-variant-numeric: tabular-nums; font-weight: 800; }
+
+.lk-devices { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
+.lk-devices li { position: relative; display: grid; gap: 2px; padding: 10px 28px 10px 12px; border-radius: 12px; background: var(--surface-2); border: 1px solid transparent; }
+.lk-devices li.is-here { border-color: color-mix(in srgb, var(--accent) 50%, var(--line)); }
+.lk-devices li::before { content: ""; position: absolute; top: 12px; right: 12px; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
+.lk-devices li.is-on::before { background: var(--ok); }
+
+.lk-activity { display: grid; gap: 0; }
+.lk-activity li { display: grid; grid-template-columns: 74px minmax(0, 1fr); gap: 10px; padding: 7px 0; border-top: 1px solid var(--line); font-size: 13px; }
+.lk-activity li:first-child { border-top: 0; }
+.lk-activity time { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
+
+.lk-foot { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 13px; }
+
+/* The tray's narrow popover: Pause takes its own row under the name. */
+@media (max-width: 460px) {
+ .lk-head { grid-template-columns: auto minmax(0, 1fr); }
+ .lk-pause { grid-column: 1 / -1; justify-content: center; }
+}
+@media (min-width: 720px) {
+ .lk-cols { grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); align-items: start; }
+}
+@media (prefers-reduced-motion: reduce) { .lk-ring-fill, .lk-switch i, .lk-switch i::after { transition: none; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+/* OpenVibe Link's window: draws what the daemon reports (GET /panel/api/state every 3 s while visible) and sends the
+ person's choices back. Every write carries the panel's session header (the daemon gives it in the page's
+ address fragment, #t=…, and refuses writes without it). Text only: nothing from the daemon is ever parsed as HTML. */
+(function () {
+ 'use strict';
+ var root = document.querySelector('.lk');
+ if (!root) return;
+ var token = (/[#&]t=([A-Za-z0-9_-]{16,128})/.exec(location.hash) || [])[1] || '';
+ var KIND = { agent: '✦', desk: '▣', share: '⇄', model: '◆' };
+ var $ = function (sel) { return root.querySelector(sel); };
+ function el(tag, cls, text) { var n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }
+ function ago(iso) {
+ var t = Date.parse(iso); if (!isFinite(t)) return '';
+ var s = Math.max(0, (Date.now() - t) / 1000);
+ return s < 60 ? 'just now' : s < 3600 ? Math.floor(s / 60) + ' min' : s < 86400 ? Math.floor(s / 3600) + ' h' : Math.floor(s / 86400) + ' d';
+ }
+ function hhmm(iso) { var d = new Date(iso); return isNaN(d) ? '' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }
+ function send(path, method, body) {
+ return fetch('/panel/api/' + path, { method: method || 'POST', headers: { 'Content-Type': 'application/json', 'X-Link-Panel': token }, body: body ? JSON.stringify(body) : undefined, credentials: 'same-origin' })
+ .then(function (r) { if (!r.ok) throw new Error('Link said ' + r.status); return r.status === 204 ? null : r.json().catch(function () { return null; }); })
+ .then(function () { load(); });
+ }
+
+ function draw(s) {
+ var m = s.machine || {};
+ var running = Array.isArray(s.running) ? s.running : [];
+ root.setAttribute('aria-busy', 'false');
+ root.setAttribute('data-state', m.paused ? 'paused' : running.length ? 'running' : 'online');
+ root.setAttribute('data-needs', m.needs_you ? '1' : '0');
+ root.style.setProperty('--fill', String(Math.max(10, 100 - Math.min(running.length, 4) * 22)));
+ $('[data-lk-down]').hidden = true;
+ $('[data-lk-name]').textContent = m.name || 'This computer';
+ $('[data-lk-status]').textContent = m.paused ? 'Paused: nothing runs until you resume'
+ : m.needs_you ? (m.needs_you === 1 ? 'Online · something needs you' : 'Online · ' + m.needs_you + ' things need you')
+ : running.length ? 'Online · ' + running.length + ' running' : 'Online · ready';
+ var pause = $('[data-lk-pause]');
+ pause.setAttribute('aria-pressed', m.paused ? 'true' : 'false');
+ $('[data-lk-pause-label]').textContent = m.paused ? 'Resume' : 'Pause everything';
+
+ $('[data-lk-run-n]').textContent = running.length ? '(' + running.length + ')' : '';
+ $('[data-lk-running]').replaceChildren.apply($('[data-lk-running]'), running.map(function (r) {
+ var li = el('li'); var k = el('span', 'lk-kind', KIND[r.kind] || '•'); k.setAttribute('data-kind', r.kind || ''); k.setAttribute('aria-hidden', 'true');
+ var t = el('span'); t.append(el('b', '', r.title || 'Working'), el('small', '', [r.detail, r.started_at ? ago(r.started_at) : ''].filter(Boolean).join(' · ')));
+ var stop = el('button', 'lk-btn is-stop', 'Stop'); stop.type = 'button'; stop.setAttribute('aria-label', 'Stop ' + (r.title || 'this'));
+ stop.addEventListener('click', function () { stop.disabled = true; send('running/' + encodeURIComponent(r.id) + '/stop').catch(function () { stop.disabled = false; }); });
+ li.append(k, t, stop); return li;
+ }));
+ $('[data-lk-running-none]').hidden = running.length > 0;
+
+ var caps = Array.isArray(s.capabilities) ? s.capabilities : [];
+ $('[data-lk-caps]').replaceChildren.apply($('[data-lk-caps]'), caps.map(function (c) {
+ var li = el('li', 'lk-cap'); var t = el('span'); t.append(el('b', '', c.name || c.id), el('small', '', c.who || ''));
+ var sw = el('label', 'lk-switch'); var box = el('input'); box.type = 'checkbox'; box.checked = !!c.on; box.setAttribute('aria-label', c.name || c.id);
+ box.addEventListener('change', function () { send('capabilities/' + encodeURIComponent(c.id), 'POST', { on: box.checked }).catch(function () { box.checked = !box.checked; }); });
+ sw.append(box, el('i')); li.append(t, sw); return li;
+ }));
+ root.querySelector('[data-lk-sec="caps"]').hidden = caps.length === 0;
+
+ var shares = Array.isArray(s.shares) ? s.shares : [];
+ $('[data-lk-shares]').replaceChildren.apply($('[data-lk-shares]'), shares.map(function (sh) {
+ var li = el('li'); var t = el('span'); t.append(el('b', '', 'Port ' + sh.port), el('small', '', sh.url || ''));
+ var copy = el('button', 'lk-btn', 'Copy'); copy.type = 'button';
+ copy.addEventListener('click', function () { if (navigator.clipboard) navigator.clipboard.writeText(sh.url || '').then(function () { copy.textContent = 'Copied'; setTimeout(function () { copy.textContent = 'Copy'; }, 1500); }, function () {}); });
+ var end = el('button', 'lk-btn is-stop', 'End'); end.type = 'button';
+ end.addEventListener('click', function () { send('shares/' + encodeURIComponent(sh.port), 'DELETE'); });
+ li.append(t, copy, end); return li;
+ }));
+
+ var gpu = s.gpu;
+ root.querySelector('[data-lk-sec="gpu"]').hidden = !gpu;
+ if (gpu) {
+ $('[data-lk-gpu-name]').textContent = [gpu.name, gpu.vram_gb ? gpu.vram_gb + ' GB' : ''].filter(Boolean).join(' · ');
+ $('[data-lk-models]').replaceChildren.apply($('[data-lk-models]'), (gpu.models || []).map(function (mo) {
+ var li = el('li'); var t = el('span'); t.append(el('b', '', mo.name), el('small', '', mo.loaded ? 'Loaded' : 'Installed'));
+ li.append(t, el('span', 'lk-tps', mo.tps ? mo.tps + ' tok/s' : '')); return li;
+ }));
+ }
+
+ var devices = Array.isArray(s.devices) ? s.devices : [];
+ $('[data-lk-devices]').replaceChildren.apply($('[data-lk-devices]'), devices.map(function (d) {
+ var li = el('li', (d.online ? 'is-on' : '') + (d.here ? ' is-here' : ''));
+ li.append(el('b', '', d.name || 'A device'), el('small', '', (d.here ? 'This one · ' : '') + (d.online ? 'Online' : 'Offline') + (d.kind ? ' · ' + d.kind : '')));
+ return li;
+ }));
+ root.querySelector('[data-lk-sec="devices"]').hidden = devices.length === 0;
+
+ var act = Array.isArray(s.activity) ? s.activity.slice(0, 30) : [];
+ $('[data-lk-activity]').replaceChildren.apply($('[data-lk-activity]'), act.map(function (a) {
+ var li = el('li'); var t = el('time', '', hhmm(a.at)); t.setAttribute('datetime', a.at || ''); li.append(t, el('span', '', a.text || '')); return li;
+ }));
+ $('[data-lk-activity-none]').hidden = act.length > 0;
+
+ var earn = $('[data-lk-earn]');
+ earn.hidden = !(s.earnings_today && s.earnings_today.coins);
+ if (!earn.hidden) earn.textContent = 'Earned today: ' + Number(s.earnings_today.coins).toLocaleString() + ' OpenCoins';
+ }
+ function down() {
+ root.setAttribute('aria-busy', 'false');
+ root.setAttribute('data-state', 'down');
+ $('[data-lk-status]').textContent = 'Not connected';
+ $('[data-lk-down]').hidden = false;
+ }
+ var timer = 0;
+ function load() {
+ return fetch('/panel/api/state', { credentials: 'same-origin', headers: { 'X-Link-Panel': token } })
+ .then(function (r) { if (!r.ok) throw new Error(String(r.status)); return r.json(); }).then(draw).catch(down);
+ }
+ $('[data-lk-pause]').addEventListener('click', function () { var on = this.getAttribute('aria-pressed') !== 'true'; send('pause', 'POST', { paused: on }); });
+ $('[data-lk-share-form]').addEventListener('submit', function (e) {
+ e.preventDefault(); var port = Number(this.port.value);
+ if (!(port >= 1 && port <= 65535)) return;
+ var f = this; send('shares', 'POST', { port: port }).then(function () { f.reset(); });
+ });
+ var drop = $('[data-lk-drop]'), say = $('[data-lk-drop-say]');
+ function sendFiles(files) {
+ if (!files || !files.length) return;
+ var fd = new FormData(); for (var i = 0; i < files.length; i++) fd.append('file', files[i]);
+ say.textContent = 'Sending ' + files.length + ' file' + (files.length === 1 ? '' : 's') + '…';
+ fetch('/panel/api/files', { method: 'POST', body: fd, headers: { 'X-Link-Panel': token }, credentials: 'same-origin' })
+ .then(function (r) { say.textContent = r.ok ? 'Sent to your other devices.' : 'Link could not send them (' + r.status + ').'; }, function () { say.textContent = 'Link is not answering.'; });
+ }
+ ['dragenter', 'dragover'].forEach(function (t) { drop.addEventListener(t, function (e) { e.preventDefault(); drop.classList.add('is-over'); }); });
+ ['dragleave', 'drop'].forEach(function (t) { drop.addEventListener(t, function () { drop.classList.remove('is-over'); }); });
+ drop.addEventListener('drop', function (e) { e.preventDefault(); sendFiles(e.dataTransfer.files); });
+ $('[data-lk-files]').addEventListener('change', function () { sendFiles(this.files); this.value = ''; });
+ function tick() { if (document.visibilityState === 'visible') load(); }
+ load();
+ timer = setInterval(tick, 3000);
+ document.addEventListener('visibilitychange', tick);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+{
+ "machine": { "name": "Workshop desktop", "os": "linux", "online": true, "paused": false, "needs_you": 1 },
+ "running": [
+ { "id": "r_1", "kind": "agent", "title": "Vibe is comparing three hosting plans", "detail": "Browser", "started_at": "2026-11-07T10:02:00Z" },
+ { "id": "r_2", "kind": "desk", "title": "A desk for Codes: drive redesign", "detail": "Codes", "started_at": "2026-11-07T09:40:00Z" },
+ { "id": "r_3", "kind": "share", "title": "Port 3000 shared", "detail": "Only you", "started_at": "2026-11-07T09:10:00Z" }
+ ],
+ "capabilities": [
+ { "id": "browser", "name": "A browser for your agents", "who": "Your agents, after you approve each task", "on": true },
+ { "id": "files", "name": "Files between your devices", "who": "Only you", "on": true },
+ { "id": "models", "name": "Run AI models here", "who": "Only you, from your other devices", "on": false },
+ { "id": "earn", "name": "Earn with spare power", "who": "OpenVibe Exchange, when this computer is idle", "on": false }
+ ],
+ "shares": [ { "port": 3000, "url": "https://dev--alex.openvibe.website", "expires_at": "2026-11-07T22:00:00Z" } ],
+ "devices": [
+ { "name": "Workshop desktop", "kind": "desktop", "online": true, "here": true },
+ { "name": "Laptop", "kind": "laptop", "online": true, "here": false },
+ { "name": "Pixel 8", "kind": "phone", "online": false, "here": false }
+ ],
+ "gpu": null,
+ "earnings_today": null,
+ "activity": [
+ { "at": "2026-11-07T10:02:00Z", "text": "Vibe opened a browser to compare hosting plans" },
+ { "at": "2026-11-07T09:40:00Z", "text": "Codes started a desk for the drive redesign" },
+ { "at": "2026-11-07T09:10:00Z", "text": "You shared port 3000" }
+ ]
+}