diff --git a/dev/web/public/app.mjs b/dev/web/public/app.mjs index 93e73d5..8e0b1e6 100644 --- a/dev/web/public/app.mjs +++ b/dev/web/public/app.mjs @@ -1,3 +1,5 @@ +import { by_id, qs, clone, show, hide } from '/lib/dom.mjs'; + /* V4L2 control type codes (must match CTRL_TYPE_* in v4l2_ctrl.h) */ const CTRL_INTEGER = 1; const CTRL_BOOLEAN = 2; @@ -9,332 +11,11 @@ const CTRL_INTEGER_MENU = 9; const FLAG_DISABLED = 0x0001; const FLAG_GRABBED = 0x0002; const FLAG_READ_ONLY = 0x0004; -const FLAG_INACTIVE = 0x0010; /* V4L2_CAP bits (from linux/videodev2.h) */ const CAP_VIDEO_CAPTURE = 0x00000001; const CAP_META_CAPTURE = 0x00800000; -/* ------------------------------------------------------------------------- - * State - * ------------------------------------------------------------------------- */ - -let selected_device_idx = null; -let device_data = null; /* last ENUM_DEVICES result */ - -/* ------------------------------------------------------------------------- - * Utilities - * ------------------------------------------------------------------------- */ - -const $ = id => document.getElementById(id); - -function toast(msg, type = '') { - const el = document.createElement('div'); - el.className = 'toast' + (type ? ` ${type}` : ''); - el.textContent = msg; - $('toast-container').appendChild(el); - setTimeout(() => { - el.classList.add('fading'); - setTimeout(() => el.remove(), 450); - }, 2500); -} - -async function api(method, path, body) { - const opts = { method, headers: { 'Content-Type': 'application/json' } }; - if (body !== undefined) { opts.body = JSON.stringify(body); } - const res = await fetch(path, opts); - const data = await res.json(); - if (!res.ok) { throw new Error(data.error || `HTTP ${res.status}`); } - return data; -} - -function caps_tags(device_caps) { - const tags = []; - if (device_caps & CAP_VIDEO_CAPTURE) { tags.push('video'); } - if (device_caps & CAP_META_CAPTURE) { tags.push('meta'); } - return tags.join(' '); -} - -/* ------------------------------------------------------------------------- - * Connection UI - * ------------------------------------------------------------------------- */ - -async function refresh_status() { - try { - const s = await api('GET', '/api/status'); - const dot = $('status-dot'); - const text = $('status-text'); - if (s.connected) { - dot.className = 'ok'; - text.textContent = `${s.host}:${s.port}`; - $('btn-disconnect').style.display = ''; - $('btn-connect').style.display = 'none'; - await refresh_devices(); - } else { - dot.className = ''; - text.textContent = 'disconnected'; - $('btn-disconnect').style.display = 'none'; - $('btn-connect').style.display = ''; - show_empty_devices(); - } - } catch (err) { - console.error(err); - } -} - -$('btn-connect').addEventListener('click', async () => { - const host = $('inp-host').value.trim(); - const port = $('inp-port').value.trim(); - if (!host || !port) { toast('enter host and port', 'err'); return; } - try { - await api('POST', '/api/connect', { host, port: parseInt(port) }); - toast('connected', 'ok'); - await refresh_status(); - } catch (err) { - toast(err.message, 'err'); - } -}); - -let discovery_es = null; - -$('btn-discover').addEventListener('click', () => { - if (discovery_es) { return; } /* already open */ - show_peer_picker(); -}); - -async function connect_to_peer(peer) { - await api('POST', '/api/connect', { host: peer.addr, port: peer.tcp_port }); - $('inp-host').value = peer.addr; - $('inp-port').value = peer.tcp_port; - toast(`connected to ${peer.name}`, 'ok'); -} - -function show_peer_picker() { - /* -- overlay ----------------------------------------------------------- */ - const overlay = document.createElement('div'); - overlay.style.cssText = ` - position:fixed; inset:0; background:rgba(0,0,0,0.6); - display:flex; align-items:center; justify-content:center; z-index:200; - `; - - const box = document.createElement('div'); - box.style.cssText = ` - background:var(--surface); border:1px solid var(--border); - border-radius:8px; padding:20px; min-width:320px; max-width:480px; - `; - - const header = document.createElement('div'); - header.style.cssText = 'display:flex; align-items:center; gap:10px; margin-bottom:14px;'; - - const title = document.createElement('div'); - title.style.cssText = 'font-weight:600; flex:1;'; - title.textContent = 'Discovering nodes…'; - - const spinner = document.createElement('span'); - spinner.textContent = '⟳'; - spinner.style.cssText = 'animation:spin 1s linear infinite; display:inline-block; font-size:16px;'; - - const cancel_btn = document.createElement('button'); - cancel_btn.textContent = 'Close'; - - header.appendChild(title); - header.appendChild(spinner); - header.appendChild(cancel_btn); - box.appendChild(header); - - const peer_list = document.createElement('div'); - peer_list.style.cssText = 'display:flex; flex-direction:column; gap:6px; min-height:40px;'; - - const hint = document.createElement('div'); - hint.style.cssText = 'color:var(--text-dim); font-size:12px; padding:8px 0;'; - hint.textContent = 'Waiting for announcements…'; - peer_list.appendChild(hint); - - box.appendChild(peer_list); - overlay.appendChild(box); - document.body.appendChild(overlay); - - /* add spin keyframe once */ - if (!document.getElementById('spin-style')) { - const s = document.createElement('style'); - s.id = 'spin-style'; - s.textContent = '@keyframes spin { to { transform:rotate(360deg); } }'; - document.head.appendChild(s); - } - - /* -- SSE --------------------------------------------------------------- */ - const es = new EventSource('/api/discover'); - discovery_es = es; - - es.onmessage = e => { - const peer = JSON.parse(e.data); - hint.remove(); - title.textContent = 'Select a node:'; - - const btn = document.createElement('button'); - btn.style.cssText = 'display:flex; width:100%; text-align:left; padding:8px 12px; gap:12px; align-items:baseline;'; - btn.innerHTML = ` - ${peer.name} - - ${peer.addr}:${peer.tcp_port} - `; - btn.addEventListener('click', async () => { - close_picker(); - try { - await connect_to_peer(peer); - await refresh_status(); - } catch (err) { - toast(err.message, 'err'); - } - }); - peer_list.appendChild(btn); - }; - - es.onerror = () => { - title.textContent = 'Discovery error'; - spinner.style.display = 'none'; - }; - - /* -- close ------------------------------------------------------------- */ - function close_picker() { - es.close(); - discovery_es = null; - overlay.remove(); - } - - cancel_btn.addEventListener('click', close_picker); - overlay.addEventListener('click', e => { if (e.target === overlay) { close_picker(); } }); -} - -$('btn-disconnect').addEventListener('click', async () => { - await api('POST', '/api/disconnect'); - selected_device_idx = null; - device_data = null; - show_empty_devices(); - show_empty_controls(); - await refresh_status(); -}); - -$('btn-refresh-devices').addEventListener('click', refresh_devices); - -/* ------------------------------------------------------------------------- - * Device list - * ------------------------------------------------------------------------- */ - -function show_empty_devices() { - $('device-list').innerHTML = '
Not connected
'; -} - -function show_empty_controls(msg = 'Select a device') { - $('controls-title').textContent = 'Controls'; - $('controls-scroll').innerHTML = `
${msg}
`; -} - -async function refresh_devices() { - try { - device_data = await api('GET', '/api/devices'); - render_device_list(); - } catch (err) { - toast(err.message, 'err'); - } -} - -function render_device_list() { - const list = $('device-list'); - list.innerHTML = ''; - - if (!device_data) { return; } - - let flat_idx = 0; - - const { media, standalone } = device_data; - - if (media.length > 0) { - for (const md of media) { - const group = document.createElement('div'); - group.className = 'device-group'; - - const gh = document.createElement('div'); - gh.className = 'device-group-header'; - gh.textContent = `${md.model} (${md.driver})`; - group.appendChild(gh); - - for (const vn of md.video_nodes) { - const idx = flat_idx++; - group.appendChild(make_device_item(idx, vn.path, - vn.entity_name, vn.device_caps, vn.is_capture)); - } - list.appendChild(group); - } - } - - if (standalone.length > 0) { - const group = document.createElement('div'); - group.className = 'device-group'; - const gh = document.createElement('div'); - gh.className = 'device-group-header'; - gh.textContent = 'Standalone'; - group.appendChild(gh); - for (const sd of standalone) { - const idx = flat_idx++; - group.appendChild(make_device_item(idx, sd.path, sd.name, 0, false)); - } - list.appendChild(group); - } - - if (flat_idx === 0) { - list.innerHTML = '
No devices found
'; - } - - /* re-select previously selected device */ - if (selected_device_idx !== null) { - const item = list.querySelector(`[data-idx="${selected_device_idx}"]`); - if (item) { item.classList.add('selected'); } - } -} - -function make_device_item(idx, path, label, device_caps, is_capture) { - const el = document.createElement('div'); - el.className = 'device-item'; - el.dataset.idx = idx; - - const caps_text = caps_tags(device_caps); - const badge = is_capture ? 'capture' : ''; - - el.innerHTML = ` -
${path}${badge}
-
${label}
- ${caps_text ? `
${caps_text}
` : ''} - `; - - el.addEventListener('click', () => select_device(idx, path)); - return el; -} - -async function select_device(idx, path) { - selected_device_idx = idx; - document.querySelectorAll('.device-item').forEach(el => { - el.classList.toggle('selected', parseInt(el.dataset.idx) === idx); - }); - await load_controls(idx, path); -} - -/* ------------------------------------------------------------------------- - * Controls - * ------------------------------------------------------------------------- */ - -async function load_controls(device_idx, device_path) { - $('controls-title').textContent = `Controls — ${device_path}`; - $('controls-scroll').innerHTML = '
Loading…
'; - try { - const result = await api('GET', `/api/devices/${device_idx}/controls`); - render_controls(device_idx, result.controls ?? []); - } catch (err) { - toast(err.message, 'err'); - show_empty_controls('Failed to load controls'); - } -} - /* * V4L2 control IDs with their class prefix (from linux/v4l2-controls.h). * Used for grouping controls into sections. @@ -349,6 +30,46 @@ const CTRL_CLASSES = [ { base: 0x00a20000, name: 'Codec' }, ]; +/* ------------------------------------------------------------------------- + * State + * ------------------------------------------------------------------------- */ + +const known_peers = new Map(); /* key: addr:name -> peer */ +let selected_peer = null; +let selected_device_idx = null; +let device_data = null; + +/* ------------------------------------------------------------------------- + * Utilities + * ------------------------------------------------------------------------- */ + +function toast(msg, type = '') { + const el = document.createElement('div'); + el.className = 'toast' + (type ? ` ${type}` : ''); + el.textContent = msg; + by_id('toast-container').appendChild(el); + setTimeout(() => { + el.classList.add('fading'); + setTimeout(() => el.remove(), 450); + }, 2500); +} + +async function api(method, path, body) { + const opts = { method, headers: { 'Content-Type': 'application/json' } }; + if (body !== undefined) { opts.body = JSON.stringify(body); } + const res = await fetch(path, opts); + const data = await res.json(); + if (!res.ok) { throw new Error(data.error || `HTTP ${res.status}`); } + return data; +} + +function caps_tags(device_caps) { + const tags = []; + if (device_caps & CAP_VIDEO_CAPTURE) { tags.push('video'); } + if (device_caps & CAP_META_CAPTURE) { tags.push('meta'); } + return tags.join(' '); +} + function ctrl_class_name(id) { for (const c of CTRL_CLASSES) { if ((id & 0xFFFF0000) === c.base) { return c.name; } @@ -356,12 +77,206 @@ function ctrl_class_name(id) { return 'Other'; } +function empty_el(msg) { + const el = document.createElement('div'); + el.className = 'empty'; + el.textContent = msg; + return el; +} + +/* ------------------------------------------------------------------------- + * Node panel — live SSE-fed list + * ------------------------------------------------------------------------- */ + +const discovery_es = new EventSource('/api/discover'); + +discovery_es.onmessage = e => { + const peer = JSON.parse(e.data); + const key = `${peer.addr}:${peer.name}`; + known_peers.set(key, peer); + render_node_list(); +}; + +discovery_es.addEventListener('discovery_error', e => { + try { + const { error } = JSON.parse(e.data); + toast(`discovery: ${error}`, 'err'); + } catch {} +}); + +function render_node_list() { + const list = by_id('node-list'); + if (known_peers.size === 0) { + list.replaceChildren(empty_el('Discovering…')); + return; + } + list.replaceChildren(...[...known_peers.values()].map(make_node_item)); +} + +function make_node_item(peer) { + const key = `${peer.addr}:${peer.name}`; + const el = clone('t-node-item'); + if (selected_peer && `${selected_peer.addr}:${selected_peer.name}` === key) { + el.classList.add('selected'); + } + qs('.n-name', el).textContent = peer.name; + qs('.n-addr', el).textContent = `${peer.addr}:${peer.tcp_port}`; + el.addEventListener('click', () => select_node(peer)); + return el; +} + +async function select_node(peer) { + try { + await api('POST', '/api/connect', { host: peer.addr, port: peer.tcp_port }); + selected_peer = peer; + selected_device_idx = null; + device_data = null; + render_node_list(); + await refresh_status(); + toast(`connected to ${peer.name}`, 'ok'); + } catch (err) { + toast(err.message, 'err'); + } +} + +/* ------------------------------------------------------------------------- + * Status + * ------------------------------------------------------------------------- */ + +async function refresh_status() { + try { + const s = await api('GET', '/api/status'); + by_id('status-dot').className = s.connected ? 'ok' : ''; + by_id('status-text').textContent = s.connected ? `${s.host}:${s.port}` : 'not connected'; + if (s.connected) { + show(by_id('btn-disconnect')); + await refresh_devices(); + } else { + hide(by_id('btn-disconnect')); + show_empty_devices(); + } + } catch (err) { + console.error(err); + } +} + +/* ------------------------------------------------------------------------- + * Device panel + * ------------------------------------------------------------------------- */ + +function show_empty_devices(msg = 'Select a node') { + by_id('device-list').replaceChildren(empty_el(msg)); +} + +async function refresh_devices() { + try { + device_data = await api('GET', '/api/devices'); + render_device_list(); + } catch (err) { + toast(err.message, 'err'); + } +} + +function render_device_list() { + const list = by_id('device-list'); + + if (!device_data) { + list.replaceChildren(empty_el('Select a node')); + return; + } + + let flat_idx = 0; + const children = []; + const { media, standalone } = device_data; + + for (const md of media) { + const group = clone('t-device-group'); + qs('.device-group-header', group).textContent = `${md.model} (${md.driver})`; + for (const vn of md.video_nodes) { + group.appendChild(make_device_item(flat_idx++, vn.path, + vn.entity_name, vn.device_caps, vn.is_capture)); + } + children.push(group); + } + + if (standalone.length > 0) { + const group = clone('t-device-group'); + qs('.device-group-header', group).textContent = 'Standalone'; + for (const sd of standalone) { + group.appendChild(make_device_item(flat_idx++, sd.path, sd.name, 0, false)); + } + children.push(group); + } + + if (flat_idx === 0) { + list.replaceChildren(empty_el('No devices found')); + return; + } + + list.replaceChildren(...children); + + if (selected_device_idx !== null) { + const item = list.querySelector(`[data-idx="${selected_device_idx}"]`); + if (item) { item.classList.add('selected'); } + } +} + +function make_device_item(idx, path, label, device_caps, is_capture) { + const el = clone('t-device-item'); + el.dataset.idx = idx; + + const path_el = qs('.d-path', el); + path_el.textContent = path; + if (is_capture) { path_el.appendChild(clone('t-capture-badge')); } + + qs('.d-meta', el).textContent = label; + + const caps_text = caps_tags(device_caps); + if (caps_text) { + const caps_el = document.createElement('div'); + caps_el.className = 'd-caps'; + caps_el.textContent = caps_text; + el.appendChild(caps_el); + } + + el.addEventListener('click', () => select_device(idx, path)); + return el; +} + +async function select_device(idx, path) { + selected_device_idx = idx; + document.querySelectorAll('.device-item').forEach(el => { + el.classList.toggle('selected', parseInt(el.dataset.idx) === idx); + }); + await load_controls(idx, path); +} + +/* ------------------------------------------------------------------------- + * Controls panel + * ------------------------------------------------------------------------- */ + +function show_empty_controls(msg = 'Select a device') { + by_id('controls-title').textContent = 'Controls'; + by_id('controls-scroll').replaceChildren(empty_el(msg)); +} + +async function load_controls(device_idx, device_path) { + by_id('controls-title').textContent = `Controls — ${device_path}`; + by_id('controls-scroll').replaceChildren(empty_el('Loading…')); + try { + const result = await api('GET', `/api/devices/${device_idx}/controls`); + render_controls(device_idx, result.controls ?? []); + } catch (err) { + toast(err.message, 'err'); + show_empty_controls('Failed to load controls'); + } +} + function render_controls(device_idx, controls) { - const scroll = $('controls-scroll'); - scroll.innerHTML = ''; + const scroll = by_id('controls-scroll'); if (controls.length === 0) { - scroll.innerHTML = '
No controls
'; + scroll.replaceChildren(empty_el('No controls')); return; } @@ -372,42 +287,32 @@ function render_controls(device_idx, controls) { groups.get(cls).push(ctrl); } + const children = []; for (const [cls_name, ctrls] of groups) { - const group_el = document.createElement('div'); - group_el.className = 'ctrl-group'; - - const title = document.createElement('div'); - title.className = 'ctrl-group-title'; - title.textContent = cls_name; - group_el.appendChild(title); - + const group_el = clone('t-ctrl-group'); + qs('.ctrl-group-title', group_el).textContent = cls_name; for (const ctrl of ctrls) { const row = make_ctrl_row(device_idx, ctrl); if (row) { group_el.appendChild(row); } } - scroll.appendChild(group_el); + children.push(group_el); } + scroll.replaceChildren(...children); } function make_ctrl_row(device_idx, ctrl) { const disabled = !!(ctrl.flags & FLAG_DISABLED); const read_only = !!(ctrl.flags & (FLAG_READ_ONLY | FLAG_GRABBED)); - const row = document.createElement('div'); - row.className = 'ctrl-row' - + (disabled ? ' disabled' : '') - + (read_only ? ' readonly' : ''); + const row = clone('t-ctrl-row'); + if (disabled) { row.classList.add('disabled'); } + if (read_only) { row.classList.add('readonly'); } + + const label_el = qs('.ctrl-label', row); + const input_wrap = qs('.ctrl-input', row); + const value_el = qs('.ctrl-value-display', row); - const label_el = document.createElement('div'); - label_el.className = 'ctrl-label'; label_el.textContent = ctrl.name; - row.appendChild(label_el); - - const input_wrap = document.createElement('div'); - input_wrap.className = 'ctrl-input'; - - const value_el = document.createElement('div'); - value_el.className = 'ctrl-value-display'; let input_el = null; let get_value = null; @@ -415,18 +320,17 @@ function make_ctrl_row(device_idx, ctrl) { switch (ctrl.type) { case CTRL_BOOLEAN: { input_el = document.createElement('input'); - input_el.type = 'checkbox'; + input_el.type = 'checkbox'; input_el.checked = !!ctrl.current_val; get_value = () => input_el.checked ? 1 : 0; - value_el.textContent = ''; break; } case CTRL_MENU: case CTRL_INTEGER_MENU: { input_el = document.createElement('select'); for (const item of ctrl.menu_items) { - const opt = document.createElement('option'); - opt.value = item.index; + const opt = document.createElement('option'); + opt.value = item.index; opt.textContent = ctrl.type === CTRL_INTEGER_MENU ? item.int_value.toString() : item.name; @@ -434,31 +338,25 @@ function make_ctrl_row(device_idx, ctrl) { input_el.appendChild(opt); } get_value = () => parseInt(input_el.value); - value_el.textContent = ''; break; } case CTRL_BUTTON: { - input_el = document.createElement('button'); + input_el = document.createElement('button'); input_el.textContent = ctrl.name; - input_el.style.width = '100%'; - get_value = () => 1; - value_el.textContent = ''; + get_value = () => 1; break; } case CTRL_INTEGER: default: { - const range = document.createElement('input'); - range.type = 'range'; - range.min = ctrl.min; - range.max = ctrl.max; - range.step = ctrl.step || 1; - range.value = ctrl.current_val; + input_el = document.createElement('input'); + input_el.type = 'range'; + input_el.min = ctrl.min; + input_el.max = ctrl.max; + input_el.step = ctrl.step || 1; + input_el.value = ctrl.current_val; value_el.textContent = ctrl.current_val; - range.addEventListener('input', () => { - value_el.textContent = range.value; - }); - input_el = range; - get_value = () => parseInt(range.value); + input_el.addEventListener('input', () => { value_el.textContent = input_el.value; }); + get_value = () => parseInt(input_el.value); break; } } @@ -466,10 +364,7 @@ function make_ctrl_row(device_idx, ctrl) { if (!input_el) { return null; } input_wrap.appendChild(input_el); - row.appendChild(input_wrap); - row.appendChild(value_el); - /* send SET_CONTROL on change/click */ if (!read_only && !disabled) { const send = async () => { try { @@ -487,15 +382,51 @@ function make_ctrl_row(device_idx, ctrl) { || ctrl.type === CTRL_INTEGER_MENU) { input_el.addEventListener('change', send); } else { - /* slider: send on pointerup so we don't flood the node */ input_el.addEventListener('pointerup', send); input_el.addEventListener('keyup', send); + input_el.addEventListener('input', () => { + if (by_id('chk-continuous').checked) { send(); } + }); } } return row; } +/* ------------------------------------------------------------------------- + * Event wiring + * ------------------------------------------------------------------------- */ + +by_id('btn-connect').addEventListener('click', async () => { + const host = by_id('inp-host').value.trim(); + const port = by_id('inp-port').value.trim(); + if (!host || !port) { toast('enter host and port', 'err'); return; } + try { + await api('POST', '/api/connect', { host, port: parseInt(port) }); + selected_peer = null; + selected_device_idx = null; + device_data = null; + render_node_list(); + await refresh_status(); + toast('connected', 'ok'); + } catch (err) { + toast(err.message, 'err'); + } +}); + +by_id('btn-disconnect').addEventListener('click', async () => { + await api('POST', '/api/disconnect'); + selected_peer = null; + selected_device_idx = null; + device_data = null; + render_node_list(); + show_empty_devices(); + show_empty_controls(); + await refresh_status(); +}); + +by_id('btn-refresh-devices').addEventListener('click', refresh_devices); + /* ------------------------------------------------------------------------- * Init * ------------------------------------------------------------------------- */ diff --git a/dev/web/public/index.html b/dev/web/public/index.html index f1e0065..b88ec63 100644 --- a/dev/web/public/index.html +++ b/dev/web/public/index.html @@ -4,317 +4,45 @@ Video Node Inspector - +

Video Node Inspector

- disconnected - - -
- -
- - - - - + not connected +
-
-

- Devices - -

-
-
Not connected
+
+

Nodes

+
+
Discovering…
+
+
+ + +
-
-

Controls

+
+

+ Devices + +

+
+
Select a node
+
+
+
+

+ Controls + +

Select a device
@@ -323,6 +51,44 @@
- + + + + + + + + + + + + + diff --git a/dev/web/public/lib/dom.mjs b/dev/web/public/lib/dom.mjs new file mode 100644 index 0000000..63cfe49 --- /dev/null +++ b/dev/web/public/lib/dom.mjs @@ -0,0 +1,5 @@ +export const by_id = id => document.getElementById(id); +export const qs = (sel, root = document) => root.querySelector(sel); +export const clone = id => document.getElementById(id).content.cloneNode(true).firstElementChild; +export const show = el => el.removeAttribute('hidden'); +export const hide = el => el.setAttribute('hidden', ''); diff --git a/dev/web/public/style.css b/dev/web/public/style.css new file mode 100644 index 0000000..cc6c443 --- /dev/null +++ b/dev/web/public/style.css @@ -0,0 +1,294 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +[hidden] { display: none; } + +:root { + --bg: #1a1a1e; + --surface: #25252b; + --surface2: #2e2e36; + --border: #3a3a44; + --text: #e0e0e8; + --text-dim: #888898; + --accent: #5b8af0; + --accent2: #3a5cc0; + --ok: #4caf80; + --warn: #e0a030; + --err: #e05050; + --radius: 6px; + --font-mono: 'Cascadia Code', 'Fira Mono', monospace; +} + +body { + background: var(--bg); + color: var(--text); + font-family: system-ui, sans-serif; + font-size: 14px; + display: flex; + flex-direction: column; + height: 100dvh; + overflow: hidden; +} + +/* -- Top bar --------------------------------------------------------------- */ + +#topbar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + background: var(--surface); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +#topbar h1 { font-size: 15px; font-weight: 600; } + +#status-dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--err); + transition: background 0.3s; +} +#status-dot.ok { background: var(--ok); } + +#status-text { font-size: 12px; color: var(--text-dim); margin-right: auto; } + +/* -- Buttons + inputs ------------------------------------------------------ */ + +button { + padding: 5px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface2); + color: var(--text); + font-size: 13px; + cursor: pointer; +} +button:hover { background: var(--border); } +button.primary { background: var(--accent2); border-color: var(--accent); } +button.primary:hover { background: var(--accent); } +button.compact { font-size: 11px; padding: 2px 8px; } + +input[type=text], input[type=number] { + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface2); + color: var(--text); + font-size: 13px; +} + +/* -- Main layout ----------------------------------------------------------- */ + +#main { + display: grid; + grid-template-columns: 200px 260px 1fr; + flex: 1; + overflow: hidden; +} + +/* -- Shared panel ---------------------------------------------------------- */ + +.panel { + background: var(--surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; +} +.panel:last-child { border-right: none; background: var(--bg); } + +.panel > h2 { + padding: 10px 14px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-dim); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +/* -- Node panel ------------------------------------------------------------ */ + +#node-list { overflow-y: auto; flex: 1; } + +.node-item { + padding: 9px 14px; + cursor: pointer; + border-left: 3px solid transparent; + transition: background 0.1s; +} +.node-item:hover { background: var(--surface2); } +.node-item.selected { + background: var(--surface2); + border-left-color: var(--accent); +} +.node-item .n-name { font-size: 13px; font-weight: 500; } +.node-item .n-addr { + font-size: 11px; + color: var(--text-dim); + font-family: var(--font-mono); + margin-top: 2px; +} + +#manual-connect { + display: flex; + gap: 4px; + padding: 8px 10px; + border-top: 1px solid var(--border); + flex-shrink: 0; +} +#manual-connect input[type=text] { flex: 1; min-width: 0; } +#manual-connect input[type=number] { width: 60px; } +#manual-connect button { padding: 5px 8px; } + +/* -- Device panel ---------------------------------------------------------- */ + +#device-list { overflow-y: auto; flex: 1; } + +.device-group { padding: 8px 0 4px; } + +.device-group-header { + padding: 4px 14px; + font-size: 11px; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.device-item { + padding: 8px 14px; + cursor: pointer; + border-left: 3px solid transparent; + transition: background 0.1s; +} +.device-item:hover { background: var(--surface2); } +.device-item.selected { + background: var(--surface2); + border-left-color: var(--accent); +} + +.device-item .d-path { + font-family: var(--font-mono); + font-size: 12px; + color: var(--accent); +} +.device-item .d-meta { font-size: 11px; color: var(--text-dim); margin-top: 2px; } +.device-item .d-caps { + font-size: 10px; + color: var(--text-dim); + margin-top: 2px; + font-family: var(--font-mono); +} + +.capture-badge { + display: inline-block; + font-size: 9px; + padding: 1px 5px; + border-radius: 3px; + background: var(--accent2); + color: #fff; + margin-left: 6px; + vertical-align: middle; +} + +/* -- Controls panel -------------------------------------------------------- */ + +#controls-scroll { overflow-y: auto; flex: 1; padding: 12px 16px; } + +.continuous-label { + font-weight: 400; + font-size: 11px; + display: flex; + align-items: center; + gap: 5px; + margin-left: auto; + text-transform: none; + letter-spacing: 0; + cursor: pointer; +} + +.ctrl-group { margin-bottom: 16px; } + +.ctrl-group-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-dim); + padding: 4px 0 8px; + border-bottom: 1px solid var(--border); + margin-bottom: 8px; +} + +.ctrl-row { + display: grid; + grid-template-columns: 200px 1fr auto; + align-items: center; + gap: 12px; + padding: 6px 0; + border-bottom: 1px solid var(--border); +} +.ctrl-row:last-child { border-bottom: none; } +.ctrl-row.disabled { opacity: 0.45; pointer-events: none; } +.ctrl-row.readonly { opacity: 0.65; } +.ctrl-row.readonly .ctrl-input { pointer-events: none; } + +.ctrl-label { font-size: 13px; } +.ctrl-value-display { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-dim); + text-align: right; + min-width: 60px; +} + +.ctrl-input { width: 100%; } +.ctrl-input button { width: 100%; } + +input[type=range] { width: 100%; accent-color: var(--accent); } + +select { + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface2); + color: var(--text); + font-size: 13px; + width: 100%; +} + +input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--accent); } + +/* -- Toasts ---------------------------------------------------------------- */ + +#toast-container { + position: fixed; bottom: 16px; right: 16px; + display: flex; flex-direction: column; gap: 6px; + pointer-events: none; z-index: 100; +} +.toast { + padding: 8px 14px; + border-radius: var(--radius); + font-size: 13px; + background: var(--surface2); + border: 1px solid var(--border); + opacity: 1; + transition: opacity 0.4s; +} +.toast.err { border-color: var(--err); color: var(--err); } +.toast.ok { border-color: var(--ok); color: var(--ok); } +.toast.fading { opacity: 0; } + +/* -- Empty states ---------------------------------------------------------- */ + +.empty { + padding: 32px 16px; + text-align: center; + color: var(--text-dim); + font-size: 13px; +}