diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 3c8f470..881c02e 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -38,6 +38,36 @@ return f; }; + // ─── side panel ───────────────────────────────────────────────────────── + // Singleton drawer that swipes in from the right. Long content + // (file previews, approval diffs, journald logs, applied config) + // opens here via `Panel.open(title, node)` instead of expanding + // inline. Body is swapped on each open; closing just slides out so + // the content stays visible through the transition. + const Panel = (() => { + const root = $('side-panel'); + const titleEl = $('side-panel-title'); + const bodyEl = $('side-panel-body'); + function open(title, content) { + titleEl.textContent = title; + bodyEl.replaceChildren(...(content ? [content] : [])); + root.classList.add('open'); + root.setAttribute('aria-hidden', 'false'); + } + function close() { + root.classList.remove('open'); + root.setAttribute('aria-hidden', 'true'); + } + function bind() { + $('side-panel-close').addEventListener('click', close); + $('side-panel-backdrop').addEventListener('click', close); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && root.classList.contains('open')) close(); + }); + } + return { open, close, bind }; + })(); + // ─── path linkification ───────────────────────────────────────────────── // Agents constantly drop pointer strings into messages + question // bodies (it's the 1 KiB-cap escape hatch). Anything matching the @@ -54,53 +84,40 @@ if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status)); return text; } - function makePathPreview(path) { - // Inline anchor + a sibling
that lazy-loads the file - // on first open. Caller appends both: the anchor inline with the - // surrounding text, the details as a block sibling after the - // line so the layout doesn't get awkward. - const anchor = el('a', { - href: '#', class: 'path-link', title: 'click to preview ' + path, - }, path); - const details = el('details', { class: 'path-preview' }); - const summary = el('summary', {}, '↳ ' + path); + // Lazy-load `path` from /api/state-file into the side panel. + async function openFilePanel(path) { const pre = el('pre', { class: 'path-preview-body' }, '(fetching…)'); - details.append(summary, pre); - let fetched = false; - async function doFetch() { - if (fetched) return; - fetched = true; - try { - pre.textContent = await fetchStateFile(path); - } catch (e) { - pre.textContent = 'error: ' + (e.message || e); - fetched = false; // allow retry on next open - } + Panel.open('↳ ' + path, pre); + try { + pre.textContent = await fetchStateFile(path); + } catch (e) { + pre.textContent = 'error: ' + (e.message || e); } - details.addEventListener('toggle', () => { if (details.open) doFetch(); }); + } + function makePathLink(path) { + const anchor = el('a', { + href: '#', class: 'path-link', title: 'open ' + path + ' in panel', + }, path); anchor.addEventListener('click', (e) => { e.preventDefault(); - details.open = !details.open; + openFilePanel(path); }); - return { anchor, details }; + return anchor; } - // Append `text` to `parent` as a mix of text nodes + path - // anchors. `refs` is the server-attached `file_refs` array - // (verified-file tokens that appear in `text`); each occurrence - // of a ref in `text` becomes a clickable anchor + a sibling - //
preview that lazy-fetches from /api/state-file. - // Anything not in `refs` stays plain text. No client-side - // regex, no probe endpoint — the server saw the body first - // and made the call. When `refs` is empty/missing we just - // emit plain text. + // Append `text` to `parent` as a mix of text nodes + path anchors. + // `refs` is the server-attached `file_refs` array (verified-file + // tokens that appear in `text`); each occurrence of a ref becomes a + // clickable anchor that opens the file in the side panel. Anything + // not in `refs` stays plain text. No client-side regex, no probe + // endpoint — the server saw the body first and made the call. When + // `refs` is empty/missing we just emit plain text. function appendLinkified(parent, text, refs) { - const previews = []; - if (text == null) return previews; + if (text == null) return; const str = String(text); const tokens = (refs || []).slice(); if (!tokens.length) { if (str) parent.appendChild(document.createTextNode(str)); - return previews; + return; } // Walk the string left-to-right, at each step looking for the // next occurrence of any token. Longest-first tie-break so a @@ -128,12 +145,9 @@ if (bestStart > i) { parent.appendChild(document.createTextNode(str.slice(i, bestStart))); } - const { anchor, details } = makePathPreview(bestToken); - parent.appendChild(anchor); - previews.push(details); + parent.appendChild(makePathLink(bestToken)); i = bestStart + bestToken.length; } - return previews; } // ─── browser notifications ────────────────────────────────────────────── @@ -567,112 +581,111 @@ } li.append(actions); - // Per-container journald viewer. Expand to fetch + render the - // last N lines; refresh button re-fetches; unit selector + // Per-container journald viewer. Opens the side panel and + // fetches the last N lines; refresh re-fetches; unit selector // narrows to the harness service (or empty = full machine). const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service'; - li.append(buildJournalDetails(c.container, journalUnit)); + li.append(buildJournalTrigger(c.container, journalUnit)); // Per-container applied config viewer. Shows the agent.nix // the container is actually built against. - li.append(buildConfigDetails(c.name)); + li.append(buildConfigTrigger(c.name)); ul.append(li); } root.append(ul); } - // Build the per-container journald
. Lazy-fetches when the - // operator expands; refresh re-fetches; unit toggle switches - // between the harness service and the full machine journal. - function buildJournalDetails(containerName, defaultUnit) { - const details = el('details', { - class: 'journal', - 'data-restore-key': 'journal:' + containerName, - }); - const summary = el('summary', {}, '↳ logs · ' + containerName); - const body = el('div', { class: 'journal-body' }); - const controls = el('div', { class: 'journal-controls' }); - const unitSelect = el('select', { class: 'journal-unit' }); - unitSelect.append( - el('option', { value: defaultUnit }, defaultUnit), - el('option', { value: '' }, '(full machine journal)'), - ); - const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' }, - '↻ refresh'); - const pre = el('pre', { class: 'journal-output' }, 'fetching…'); - let fetching = false; - async function fetchLogs() { - if (fetching) return; - fetching = true; - pre.textContent = 'fetching…'; - const unit = unitSelect.value; - const params = new URLSearchParams({ lines: '500' }); - if (unit) params.set('unit', unit); - try { - const resp = await fetch('/api/journal/' + containerName + '?' + params); - const text = await resp.text(); - if (!resp.ok) { - pre.textContent = 'error: ' + resp.status + '\n' + text; - } else { - pre.textContent = text || '(empty)'; - // Auto-scroll to bottom on fresh fetch. - pre.scrollTop = pre.scrollHeight; + // Per-container journald viewer. Returns an inline trigger; the + // click opens the side panel and fetches the last N lines. Refresh + // re-fetches; the unit toggle switches between the harness service + // and the full machine journal. + function buildJournalTrigger(containerName, defaultUnit) { + const trigger = el('button', { type: 'button', class: 'panel-trigger' }, + '↳ logs · ' + containerName); + trigger.addEventListener('click', () => { + const body = el('div', { class: 'journal-body' }); + const controls = el('div', { class: 'journal-controls' }); + const unitSelect = el('select', { class: 'journal-unit' }); + unitSelect.append( + el('option', { value: defaultUnit }, defaultUnit), + el('option', { value: '' }, '(full machine journal)'), + ); + const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' }, + '↻ refresh'); + const pre = el('pre', { class: 'journal-output' }, 'fetching…'); + let fetching = false; + async function fetchLogs() { + if (fetching) return; + fetching = true; + pre.textContent = 'fetching…'; + const unit = unitSelect.value; + const params = new URLSearchParams({ lines: '500' }); + if (unit) params.set('unit', unit); + try { + const resp = await fetch('/api/journal/' + containerName + '?' + params); + const text = await resp.text(); + if (!resp.ok) { + pre.textContent = 'error: ' + resp.status + '\n' + text; + } else { + pre.textContent = text || '(empty)'; + // Auto-scroll the panel to the newest lines on fresh fetch. + const sb = $('side-panel-body'); + if (sb) sb.scrollTop = sb.scrollHeight; + } + } catch (err) { + pre.textContent = 'fetch failed: ' + err; + } finally { + fetching = false; } - } catch (err) { - pre.textContent = 'fetch failed: ' + err; - } finally { - fetching = false; } - } - details.addEventListener('toggle', () => { if (details.open) fetchLogs(); }); - refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); }); - unitSelect.addEventListener('change', fetchLogs); - controls.append(unitSelect, refresh); - body.append(controls, pre); - details.append(summary, body); - return details; + refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); }); + unitSelect.addEventListener('change', fetchLogs); + controls.append(unitSelect, refresh); + body.append(controls, pre); + Panel.open('logs · ' + containerName, body); + fetchLogs(); + }); + return trigger; } - // Per-container applied-config viewer. Lazy-fetches on expand; - // refresh button re-fetches. Read-only — the file is hive-c0re's - // applied repo, mutated only via the approval flow. - function buildConfigDetails(agentName) { - const details = el('details', { - class: 'journal', - 'data-restore-key': 'agent-config:' + agentName, - }); - const summary = el('summary', {}, '↳ agent.nix · ' + agentName); - const body = el('div', { class: 'journal-body' }); - const controls = el('div', { class: 'journal-controls' }); - const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' }, - '↻ refresh'); - const pre = el('pre', { class: 'journal-output' }, 'fetching…'); - let fetching = false; - async function fetchConfig() { - if (fetching) return; - fetching = true; - pre.textContent = 'fetching…'; - try { - const resp = await fetch('/api/agent-config/' + agentName); - const text = await resp.text(); - if (!resp.ok) { - pre.textContent = 'error: ' + resp.status + '\n' + text; - } else { - pre.textContent = text || '(empty)'; - pre.scrollTop = 0; + // Per-container applied-config viewer. Returns an inline trigger; + // the click opens the side panel and fetches agent.nix. Read-only — + // the file is hive-c0re's applied repo, mutated only via approvals. + function buildConfigTrigger(agentName) { + const trigger = el('button', { type: 'button', class: 'panel-trigger' }, + '↳ agent.nix · ' + agentName); + trigger.addEventListener('click', () => { + const body = el('div', { class: 'journal-body' }); + const controls = el('div', { class: 'journal-controls' }); + const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' }, + '↻ refresh'); + const pre = el('pre', { class: 'journal-output' }, 'fetching…'); + let fetching = false; + async function fetchConfig() { + if (fetching) return; + fetching = true; + pre.textContent = 'fetching…'; + try { + const resp = await fetch('/api/agent-config/' + agentName); + const text = await resp.text(); + if (!resp.ok) { + pre.textContent = 'error: ' + resp.status + '\n' + text; + } else { + pre.textContent = text || '(empty)'; + } + } catch (err) { + pre.textContent = 'fetch failed: ' + err; + } finally { + fetching = false; } - } catch (err) { - pre.textContent = 'fetch failed: ' + err; - } finally { - fetching = false; } - } - details.addEventListener('toggle', () => { if (details.open) fetchConfig(); }); - refresh.addEventListener('click', (e) => { e.preventDefault(); fetchConfig(); }); - controls.append(refresh); - body.append(controls, pre); - details.append(summary, body); - return details; + refresh.addEventListener('click', (e) => { e.preventDefault(); fetchConfig(); }); + controls.append(refresh); + body.append(controls, pre); + Panel.open('agent.nix · ' + agentName, body); + fetchConfig(); + }); + return trigger; } function renderTombstones(s) { @@ -867,19 +880,25 @@ head.append(' ', el('span', { class: 'q-ttl' }, txt)); } const qBody = el('div', { class: 'q-body' }); - const qPreviews = appendLinkified(qBody, q.question, q.question_refs); + appendLinkified(qBody, q.question, q.question_refs); li.append(head, qBody); - for (const d of qPreviews) li.appendChild(d); const f = el('form', { method: 'POST', action: '/answer-question/' + q.id, class: 'qform', 'data-async': '', 'data-no-refresh': '', }); const hasOptions = q.options && q.options.length; const isMulti = !!q.multi && hasOptions; - const freeText = el('input', { - type: 'text', name: 'answer-free', - placeholder: hasOptions ? 'or type your own…' : 'your answer', - autocomplete: 'off', + const freeText = el('textarea', { + name: 'answer-free', rows: '2', autocomplete: 'off', + placeholder: (hasOptions ? 'or type your own…' : 'your answer') + + ' (shift+enter for newline)', + }); + // Enter submits; shift+enter inserts a newline (textarea default). + freeText.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + f.requestSubmit(); + } }); const optionGroup = el('div', { class: 'q-options' }); if (hasOptions) { @@ -963,17 +982,14 @@ el('span', { class: 'msg-sep' }, 'asked:'), ); const histBody = el('div', { class: 'q-body' }); - const histBodyPreviews = appendLinkified(histBody, q.question, q.question_refs); + appendLinkified(histBody, q.question, q.question_refs); const ansText = el('span', { class: 'q-answer-text' }); - const histAnsPreviews = appendLinkified(ansText, q.answer || '(none)', q.answer_refs); + appendLinkified(ansText, q.answer || '(none)', q.answer_refs); const ansLine = el('div', { class: 'q-answer' }, el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `), ansText, ); - li.append(head, histBody); - for (const d of histBodyPreviews) li.appendChild(d); - li.append(ansLine); - for (const d of histAnsPreviews) li.appendChild(d); + li.append(head, histBody, ansLine); hul.append(li); } details.append(hul); @@ -1012,14 +1028,13 @@ for (const m of operatorInbox) { const li = el('li'); const body = el('span', { class: 'msg-body' }); - const previews = appendLinkified(body, m.body, m.file_refs); + appendLinkified(body, m.body, m.file_refs); li.append( el('span', { class: 'msg-ts' }, fmt(m.at)), ' ', el('span', { class: 'msg-from' }, m.from), ' ', el('span', { class: 'msg-sep' }, '→ '), body, ); - for (const d of previews) li.appendChild(d); ul.append(li); } root.append(ul); @@ -1174,30 +1189,30 @@ ); li.append(row); if (a.diff) { - const details = el('details', { - 'data-restore-key': 'approval-diff:' + a.id, + const trigger = el('button', { type: 'button', class: 'panel-trigger' }, + 'diff vs applied'); + trigger.addEventListener('click', () => { + // Server ships the raw unified diff; classify each line by + // its leading char so `.diff-add` / `.diff-del` / + // `.diff-hunk` / `.diff-file` / `.diff-ctx` colour the + // output. Building spans here (instead of innerHTML-ing + // pre-rendered markup) keeps the snapshot wire format + // text-only and one less HTML-escape surface server-side. + const pre = el('pre', { class: 'diff' }); + for (const raw of a.diff.split('\n')) { + let cls = 'diff-ctx'; + if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file'; + else if (raw.startsWith('@')) cls = 'diff-hunk'; + else if (raw.startsWith('+')) cls = 'diff-add'; + else if (raw.startsWith('-')) cls = 'diff-del'; + const span = document.createElement('span'); + span.className = cls; + span.textContent = raw + '\n'; + pre.appendChild(span); + } + Panel.open('diff · ' + a.agent + ' #' + a.id, pre); }); - details.append(el('summary', {}, 'diff vs applied')); - // Server ships the raw unified diff; classify each line by its - // leading char so `.diff-add` / `.diff-del` / `.diff-hunk` / - // `.diff-file` / `.diff-ctx` colour the output. Building spans - // here (instead of innerHTML-ing pre-rendered markup) keeps - // the snapshot wire format text-only and one less HTML-escape - // surface server-side. - const pre = el('pre', { class: 'diff' }); - for (const raw of a.diff.split('\n')) { - let cls = 'diff-ctx'; - if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file'; - else if (raw.startsWith('@')) cls = 'diff-hunk'; - else if (raw.startsWith('+')) cls = 'diff-add'; - else if (raw.startsWith('-')) cls = 'diff-del'; - const span = document.createElement('span'); - span.className = cls; - span.textContent = raw + '\n'; - pre.appendChild(span); - } - details.append(pre); - li.append(details); + li.append(trigger); } ul.append(li); } @@ -1381,9 +1396,8 @@ `⚠ ${r.attempt_count} failed`)); } const body = el('div', { class: 'reminder-body' }); - const previews = appendLinkified(body, r.message); + appendLinkified(body, r.message); li.append(head, body); - for (const d of previews) li.appendChild(d); if (r.last_error) { li.append(el('div', { class: 'reminder-error' }, el('span', { class: 'msg-sep' }, 'error: '), @@ -1439,9 +1453,10 @@ //
sections that should survive a refresh need a stable // `data-restore-key` attribute. snapshotOpenDetails walks managed // sections and records which keys are currently open; restoreOpenDetails - // re-applies after the render. The `toggle` event fires on - // programmatic open changes too, so any onload-fetch wired up via - // a toggle listener (e.g. journald) re-fires cleanly. + // re-applies after the render. (Long-content drill-ins — file + // previews, diffs, logs, config — open in the side panel instead, + // which lives outside the managed sections and survives re-render + // on its own.) function snapshotOpenDetails() { const open = new Set(); for (const id of MANAGED_SECTION_IDS) { @@ -1533,6 +1548,7 @@ } refreshState(); NOTIF.bind(); + Panel.bind(); // ─── message flow: shared terminal pane ──────────────────────────────── // Scroll, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS @@ -1571,9 +1587,8 @@ to.className = 'msg-to'; to.textContent = ev.to; const body = document.createElement('span'); body.className = 'msg-body'; - const previews = appendLinkified(body, ev.body, ev.file_refs); + appendLinkified(body, ev.body, ev.file_refs); row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body); - for (const d of previews) row.appendChild(d); } HiveTerminal.create({ logEl: flow, diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index f7ec83e..282aeba 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -129,24 +129,9 @@ a:hover { opacity: 0.85; } .container-row.tombstone .name { color: var(--muted); } -/* Per-container journald viewer: collapsed by default, fetches - lazily on expand. The output is in monospace inside a bordered -
; controls (unit select + refresh) sit above. */
-.journal {
-  margin-top: 0.5em;
-  font-size: 0.85em;
-}
-.journal > summary {
-  cursor: pointer;
-  color: var(--muted);
-  letter-spacing: 0.05em;
-}
-.journal > summary:hover { color: var(--cyan); }
-.journal .journal-body {
-  margin-top: 0.4em;
-  padding-top: 0.4em;
-  border-top: 1px dashed var(--border);
-}
+/* Per-container journald viewer + applied-config viewer. Both open
+   in the side panel and lazy-fetch on open; output is monospace
+   inside a bordered 
, controls (unit select + refresh) above. */
 .journal-controls {
   display: flex;
   gap: 0.5em;
@@ -168,8 +153,7 @@ a:hover {
   color: var(--fg);
   border: 1px solid var(--purple-dim);
   padding: 0.5em 0.7em;
-  max-height: 24em;
-  overflow: auto;
+  overflow-x: auto;
   font-size: 0.85em;
   line-height: 1.4;
   white-space: pre;
@@ -495,34 +479,20 @@ summary:hover { color: var(--purple); }
 }
 
 /* Path linkification — agents drop pointer strings into messages
-   constantly; clicking the anchor expands a sibling 
that - lazy-loads from /api/state-file. */ + constantly; clicking the anchor opens the file in the side panel, + lazy-loaded from /api/state-file. */ .path-link { color: var(--blue, #89b4fa); text-decoration: underline dotted; cursor: pointer; } .path-link:hover { color: var(--amber); } -.path-preview { - margin: 0.2em 0 0.4em 1.5em; - border-left: 2px solid var(--border); - padding-left: 0.6em; -} -.path-preview > summary { - cursor: pointer; - color: var(--muted); - font-size: 0.85em; - list-style: none; - user-select: none; -} -.path-preview > summary::marker { content: ''; } +/* File-preview body — rendered inside the side panel. */ .path-preview-body { background: var(--bg); border: 1px solid var(--border); padding: 0.5em 0.7em; - margin: 0.3em 0 0; - max-height: 30em; - overflow: auto; + margin: 0; white-space: pre-wrap; word-break: break-word; font-size: 0.85em; @@ -599,7 +569,7 @@ summary:hover { color: var(--purple); } .qform .q-option label { cursor: pointer; user-select: none; } .qform .q-option input { margin-right: 0.4em; accent-color: var(--amber); } .qform .q-free { display: flex; } -.qform .q-free input { +.qform .q-free textarea { flex: 1; font-family: inherit; font-size: 1em; @@ -607,9 +577,11 @@ summary:hover { color: var(--purple); } color: var(--fg); border: 1px solid var(--border); padding: 0.4em 0.6em; + resize: vertical; + line-height: 1.4; } -.qform .q-free input::placeholder { color: var(--muted); } -.qform .q-free input:focus { outline: 1px solid var(--amber); } +.qform .q-free textarea::placeholder { color: var(--muted); } +.qform .q-free textarea:focus { outline: 1px solid var(--amber); } .qform button { align-self: flex-start; } .qform-cancel { margin-top: 0.3em; } .q-history { @@ -655,13 +627,10 @@ summary:hover { color: var(--purple); } dashboard-specific: each broker event becomes a grid of timestamp + arrow + from/sep/to + body inside the `.row` shell. */ /* Flex (not grid): the row carries the header chips (ts / arrow / - from / → / to / body) inline, and may also carry one or more - `
` path-preview siblings appended by appendLinkified. - Grid would treat each preview as an extra grid item in a fixed - column template, distorting the header column widths. Flex - collapses whitespace-only text nodes between items, gives `body` - the remaining width via `flex: 1`, and lets each preview claim a - full-width row of its own via `flex-basis: 100%`. */ + from / → / to / body) inline. Flex collapses whitespace-only text + nodes between items and gives `body` the remaining width via + `flex: 1`. Path references inside `body` are inline anchors that + open the side panel — no full-width sibling rows. */ .live .msgrow { display: flex; flex-wrap: wrap; @@ -680,12 +649,6 @@ summary:hover { color: var(--purple); } the whole flex line wider than the container. */ min-width: 0; } -.live .msgrow > .path-preview { - flex: 1 0 100%; - /* line up with the message body — no left chrome inherited from - the global .path-preview indent. */ - margin-left: 0; -} .live .msgrow.sent .msg-arrow { color: var(--cyan); } .live .msgrow.delivered .msg-arrow { color: var(--green); } .msg-ts { color: var(--muted); font-size: 0.85em; } @@ -757,3 +720,88 @@ footer { font-size: 0.9em; } footer a { color: var(--purple); } + +/* ─── side panel ───────────────────────────────────────────────── + Long content (file previews, diffs, journald, applied config) + opens in a drawer that swipes in from the right instead of + expanding inline. `.panel-trigger` is the inline affordance that + opens it. */ +.panel-trigger { + background: none; + border: none; + color: var(--muted); + font-family: inherit; + font-size: 0.85em; + letter-spacing: 0.05em; + cursor: pointer; + padding: 0; + margin-top: 0.5em; + display: inline-block; + text-align: left; +} +.panel-trigger:hover { color: var(--cyan); } + +.side-panel { + position: fixed; + inset: 0; + z-index: 50; + /* Closed: the wrapper ignores pointer events so the dashboard + underneath stays interactive; `.open` flips it back on. */ + pointer-events: none; +} +.side-panel-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.55); + opacity: 0; + transition: opacity 0.2s ease; +} +.side-panel-drawer { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: min(760px, 94vw); + display: flex; + flex-direction: column; + background: var(--bg-elev); + border-left: 2px solid var(--purple); + box-shadow: -10px 0 30px rgba(0, 0, 0, 0.45); + transform: translateX(100%); + transition: transform 0.25s ease; +} +.side-panel.open { pointer-events: auto; } +.side-panel.open .side-panel-backdrop { opacity: 1; } +.side-panel.open .side-panel-drawer { transform: translateX(0); } +.side-panel-head { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1em; + padding: 0.7em 1em; + border-bottom: 1px solid var(--border); +} +.side-panel-title { + color: var(--purple); + font-weight: bold; + letter-spacing: 0.05em; + word-break: break-all; +} +.side-panel-close { + flex: 0 0 auto; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + font-family: inherit; + font-size: 1em; + line-height: 1; + padding: 0.25em 0.55em; + cursor: pointer; +} +.side-panel-close:hover { border-color: var(--red); color: var(--red); } +.side-panel-body { + flex: 1 1 auto; + overflow: auto; + padding: 1em; +} diff --git a/hive-c0re/assets/index.html b/hive-c0re/assets/index.html index 3ce2c51..1fb304c 100644 --- a/hive-c0re/assets/index.html +++ b/hive-c0re/assets/index.html @@ -84,6 +84,23 @@

▲△▲ hyperhive ▲△▲ hive-c0re on this host ▲△▲

+ + +