feat(ui): blackflame theming pass — edit toggle, md tables, back button, Little Blue action cards

- markdown_editor Edit toggle uses themed ghost button
- .md-preview gets full blackflame styling incl. tables (migrated BookStack tables now render as tables)
- reusable back button on page/reference/project/resource reading views
- Little Blue actions regrouped into themed cards, pairing Start/Stop per guest

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-04 23:02:32 +10:00
parent f5c7b24d81
commit 16f2083253
8 changed files with 115 additions and 29 deletions

View File

@@ -0,0 +1,11 @@
// Blackflame-themed back button for detail/reading views. Goes back in history
// (hash navigation is in history); falls back to home if there's nowhere to go.
import { el } from '../dom.js';
export function backButton() {
return el('button', {
class: 'ghost back-btn',
title: 'Back',
onclick: () => { if (history.length > 1) history.back(); else location.hash = '#/'; }
}, '← Back');
}

View File

@@ -51,6 +51,7 @@ export function markdownEditor({ initial = '', save }) {
}, 'Save'); }, 'Save');
const toggle = el('button', { const toggle = el('button', {
class: 'ghost',
onclick: () => { onclick: () => {
editing = !editing; editing = !editing;
if (editing) { if (editing) {

View File

@@ -113,6 +113,47 @@ button.ghost {
border-radius: 3px; cursor: pointer; font-size: 13px; border-radius: 3px; cursor: pointer; font-size: 13px;
} }
button.ghost:hover { color: var(--text); border-color: var(--accent-dim); } button.ghost:hover { color: var(--text); border-color: var(--accent-dim); }
.back-btn { margin-bottom: 12px; }
.back-btn:hover { color: var(--accent); border-color: var(--accent-dim); }
/* Rendered markdown (page preview, chat) — blackflame styling. */
.md-preview { color: var(--text); line-height: 1.6; }
.md-preview h1, .md-preview h2, .md-preview h3, .md-preview h4 {
font-family: var(--font-display); color: var(--accent); letter-spacing: 0.03em; margin: 16px 0 8px;
}
.md-preview h1 { font-size: 20px; } .md-preview h2 { font-size: 16px; }
.md-preview h3 { font-size: 14px; text-transform: uppercase; }
.md-preview a { color: var(--accent); }
.md-preview code { font-family: var(--font-mono); background: var(--panel-2); padding: 1px 5px; border: 1px solid var(--border); border-radius: 3px; font-size: 12px; }
.md-preview pre { background: var(--panel-2); border: 1px solid var(--border); border-radius: 5px; padding: 12px; overflow-x: auto; }
.md-preview pre code { border: none; background: none; padding: 0; }
.md-preview blockquote { border-left: 2px solid var(--accent-dim); margin: 8px 0; padding: 2px 12px; color: var(--muted); }
.md-preview table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 13px; }
.md-preview th, .md-preview td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; vertical-align: top; }
.md-preview th { background: var(--panel-2); color: var(--accent); font-weight: 600; letter-spacing: 0.02em; }
.md-preview tbody tr:nth-child(even) td { background: rgba(255, 255, 255, 0.02); }
/* Little Blue caretaker view */
.lb-grid { display: grid; grid-template-columns: 1fr 360px; gap: 14px; align-items: start; }
.lb-chat { display: flex; flex-direction: column; height: 70vh; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); overflow: hidden; }
.lb-chat .lb-log { flex: 1; overflow-y: auto; padding: 14px; }
.lb-actions { display: flex; flex-direction: column; gap: 8px; }
.lb-sec { font-family: var(--font-display); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); margin: 6px 0 2px; }
.lb-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
.lb-card { padding: 12px; margin: 0; }
.lb-pending-card { padding: 12px; margin: 0; border-color: var(--accent-dim); }
.lb-card-title { font-size: 12.5px; color: var(--text); margin-bottom: 10px; word-break: break-word; }
.lb-btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
.lb-act { padding: 5px 12px; border-radius: 3px; cursor: pointer; font-size: 12px; font-family: var(--font-ui); border: 1px solid var(--border); background: transparent; color: var(--muted); }
.lb-act.safe { color: var(--ok); }
.lb-act.safe:hover { border-color: var(--ok); color: var(--ok); }
.lb-act.risky { color: var(--accent); background: var(--accent-soft); border-color: var(--accent-dim); }
.lb-act.risky:hover { background: var(--accent-dim); color: var(--text); }
.lb-act:disabled { opacity: 0.5; cursor: default; }
.lb-toasts { position: fixed; bottom: 16px; right: 16px; display: flex; flex-direction: column; gap: 6px; z-index: 60; }
.lb-toast { background: var(--panel-2); border: 1px solid var(--accent-dim); color: var(--text); padding: 8px 12px; border-radius: 4px; font-size: 12px; box-shadow: 0 4px 16px rgba(0,0,0,0.45); }
.lb-toast.err { border-color: var(--bad); color: var(--bad); }
@media (max-width: 900px) { .lb-grid { grid-template-columns: 1fr; } }
/* modal */ /* modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; } .modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; }

View File

@@ -1,5 +1,6 @@
// #/little-blue — the caretaker page: Little Blue's chat (reuses agent_chat) + // #/little-blue — the caretaker page: Little Blue's chat (reuses agent_chat) +
// a manual Actions panel (run whitelisted actions; approve/reject risky ones). // a manual Actions panel. Actions are grouped into cards (Start/Stop of the same
// guest pair together); risky ones queue for owner approval.
import { el, mount, clear } from '../dom.js'; import { el, mount, clear } from '../dom.js';
import { api } from '../api.js'; import { api } from '../api.js';
import { wireAgentChat } from '../components/agent_chat.js'; import { wireAgentChat } from '../components/agent_chat.js';
@@ -7,6 +8,8 @@ import { wireAgentChat } from '../components/agent_chat.js';
const BLUE_LABELS = { const BLUE_LABELS = {
search: '🔍 looking', list_actions: '📋 checking what she can do', propose_action: '🔧 taking action' search: '🔍 looking', list_actions: '📋 checking what she can do', propose_action: '🔧 taking action'
}; };
const VERB = { start: 'Start', stop: 'Stop', shutdown: 'Shutdown', reboot: 'Reboot' };
const stripVerb = (l) => ((l || '').replace(/^(Start|Stop|Restart|Reboot|Shutdown)\s+/i, '').trim() || l);
function toast(host, msg, kind) { function toast(host, msg, kind) {
const t = el('div', { class: 'lb-toast' + (kind === 'err' ? ' err' : '') }, msg); const t = el('div', { class: 'lb-toast' + (kind === 'err' ? ' err' : '') }, msg);
@@ -14,48 +17,70 @@ function toast(host, msg, kind) {
setTimeout(() => t.remove(), 4000); setTimeout(() => t.remove(), 4000);
} }
function actionButton(a, panel, toastHost) {
const verb = a.kind === 'guest_power' ? (VERB[a.op] || a.op) : 'Restart';
const btn = el('button', {
class: 'lb-act ' + (a.tier === 'risky' ? 'risky' : 'safe'),
title: a.tier === 'risky' ? 'Needs your approval' : 'Runs immediately',
onclick: async () => {
btn.disabled = true;
try {
const r = await api.post(`/api/actions/${a.id}/run`);
toast(toastHost, r.executed ? `Ran: ${a.label}` : `Queued for approval: ${a.label}`);
renderActions(panel, toastHost);
} catch (e) { toast(toastHost, 'Failed: ' + e.message, 'err'); btn.disabled = false; }
}
}, verb);
return btn;
}
async function renderActions(panel, toastHost) { async function renderActions(panel, toastHost) {
clear(panel); clear(panel);
let actions = [], pending = []; let actions = [], pending = [];
try { ({ actions } = await api.get('/api/actions')); } catch (e) { actions = []; } try { ({ actions } = await api.get('/api/actions')); } catch { /* */ }
try { ({ pending } = await api.get('/api/actions/pending')); } catch (e) { pending = []; } try { ({ pending } = await api.get('/api/actions/pending')); } catch { /* */ }
panel.appendChild(el('div', { class: 'lb-sec-t' }, 'Actions')); panel.appendChild(el('div', { class: 'lb-sec' }, 'Actions'));
if (!actions.length) { if (!actions.length) panel.appendChild(el('p', { class: 'muted' }, 'No actions configured yet.'));
panel.appendChild(el('div', { class: 'muted' }, 'No actions configured yet (populate config/actions.json).'));
} // Group guest power by guest (pairs Start/Stop); service restarts stand alone.
const groups = new Map();
for (const a of actions) { for (const a of actions) {
panel.appendChild(el('div', { class: 'lb-action' }, const key = a.kind === 'guest_power' ? `g:${a.node}:${a.vmid}` : `s:${a.id}`;
el('span', { class: 'lb-a-label' }, a.label || a.id), if (!groups.has(key)) groups.set(key, { name: stripVerb(a.label), acts: [] });
el('span', { class: 'lb-a-tier ' + a.tier }, a.tier), groups.get(key).acts.push(a);
el('button', { class: 'lb-run', onclick: async (ev) => { }
ev.target.disabled = true; if (actions.length) {
try { const cards = el('div', { class: 'lb-cards' });
const r = await api.post(`/api/actions/${a.id}/run`); for (const { name, acts } of groups.values()) {
toast(toastHost, r.executed ? `Ran "${a.label || a.id}"` : `Queued "${a.label || a.id}" for approval`); acts.sort((x, y) => (x.op === 'start' ? -1 : y.op === 'start' ? 1 : 0));
renderActions(panel, toastHost); cards.appendChild(el('div', { class: 'card lb-card' },
} catch (e) { toast(toastHost, 'Failed: ' + e.message, 'err'); ev.target.disabled = false; } el('div', { class: 'lb-card-title' }, name),
} }, a.tier === 'risky' ? 'Request' : 'Run'))); el('div', { class: 'lb-btn-row' }, acts.map(a => actionButton(a, panel, toastHost)))));
}
panel.appendChild(cards);
} }
if (pending.length) { if (pending.length) {
panel.appendChild(el('div', { class: 'lb-sec-t' }, 'Awaiting approval')); panel.appendChild(el('div', { class: 'lb-sec' }, 'Awaiting approval'));
for (const p of pending) { const resolve = async (id, verb) => {
const row = el('div', { class: 'lb-pending' },
el('span', { class: 'lb-a-label' }, p.action_id),
el('button', { class: 'ok', onclick: () => resolve(p.id, 'approve') }, 'Approve'),
el('button', { class: 'no', onclick: () => resolve(p.id, 'reject') }, 'Reject'));
panel.appendChild(row);
async function resolve(id, verb) {
try { await api.post(`/api/actions/pending/${id}/${verb}`); toast(toastHost, verb === 'approve' ? 'Approved' : 'Rejected'); renderActions(panel, toastHost); } try { await api.post(`/api/actions/pending/${id}/${verb}`); toast(toastHost, verb === 'approve' ? 'Approved' : 'Rejected'); renderActions(panel, toastHost); }
catch (e) { toast(toastHost, 'Failed: ' + e.message, 'err'); } catch (e) { toast(toastHost, 'Failed: ' + e.message, 'err'); }
};
const pcards = el('div', { class: 'lb-cards' });
for (const p of pending) {
pcards.appendChild(el('div', { class: 'card lb-pending-card' },
el('div', { class: 'lb-card-title' }, p.action_id),
el('div', { class: 'lb-btn-row' },
el('button', { class: 'lb-act safe', onclick: () => resolve(p.id, 'approve') }, 'Approve'),
el('button', { class: 'lb-act risky', onclick: () => resolve(p.id, 'reject') }, 'Reject'))));
} }
} panel.appendChild(pcards);
} }
} }
export async function render(main) { export async function render(main) {
const log = el('div', { class: 'rail-log sentinel-log' }); const log = el('div', { class: 'rail-log lb-log' });
const input = el('textarea', { class: 'rail-input', rows: 1, placeholder: 'Tell Little Blue whats wrong…' }); const input = el('textarea', { class: 'rail-input', rows: 1, placeholder: 'Tell Little Blue whats wrong…' });
const actionsPanel = el('div', { class: 'lb-actions' }); const actionsPanel = el('div', { class: 'lb-actions' });
const toastHost = el('div', { class: 'lb-toasts' }); const toastHost = el('div', { class: 'lb-toasts' });

View File

@@ -2,6 +2,7 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { el, mount } from '../dom.js'; import { el, mount } from '../dom.js';
import { markdownEditor } from '../components/markdown_editor.js'; import { markdownEditor } from '../components/markdown_editor.js';
import { backButton } from '../components/backbtn.js';
export async function render(main, ctx) { export async function render(main, ctx) {
const id = ctx.params.id; const id = ctx.params.id;
@@ -28,6 +29,7 @@ export async function render(main, ctx) {
); );
mount(main, mount(main,
backButton(),
el('h1', { class: 'view-h1' }, page.title), el('h1', { class: 'view-h1' }, page.title),
el('p', { class: 'view-sub muted' }, '/' + page.slug), el('p', { class: 'view-sub muted' }, '/' + page.slug),
editor, editor,

View File

@@ -1,6 +1,7 @@
// Project view — header + tasks (with inline status toggle) + pages + refs + add-task form. // Project view — header + tasks (with inline status toggle) + pages + refs + add-task form.
import { api } from '../api.js'; import { api } from '../api.js';
import { el, mount, clear } from '../dom.js'; import { el, mount, clear } from '../dom.js';
import { backButton } from '../components/backbtn.js';
const STATUSES = ['todo', 'doing', 'blocked', 'done']; const STATUSES = ['todo', 'doing', 'blocked', 'done'];
const STATUS_CLASS = { todo: 'idle', doing: 'warn', blocked: 'bad', done: 'ok' }; const STATUS_CLASS = { todo: 'idle', doing: 'warn', blocked: 'bad', done: 'ok' };
@@ -75,6 +76,7 @@ export async function render(main, ctx) {
newTitle.addEventListener('keydown', (e) => { if (e.key === 'Enter') addTask(); }); newTitle.addEventListener('keydown', (e) => { if (e.key === 'Enter') addTask(); });
mount(main, mount(main,
backButton(),
el('h1', { class: 'view-h1' }, proj.name), el('h1', { class: 'view-h1' }, proj.name),
el('p', { class: 'view-sub' }, el('p', { class: 'view-sub' },
el('span', { class: 'status ' + (proj.status === 'done' ? 'ok' : proj.status === 'paused' ? 'warn' : '') }, proj.status), el('span', { class: 'status ' + (proj.status === 'done' ? 'ok' : proj.status === 'paused' ? 'warn' : '') }, proj.status),

View File

@@ -1,6 +1,7 @@
// Reference detail: media block + summary + metadata + tag attach/detach + linked-from. // Reference detail: media block + summary + metadata + tag attach/detach + linked-from.
import { api } from '../api.js'; import { api } from '../api.js';
import { el, mount, clear, safeHref } from '../dom.js'; import { el, mount, clear, safeHref } from '../dom.js';
import { backButton } from '../components/backbtn.js';
function mediaBlock(ref) { function mediaBlock(ref) {
if (ref.kind === 'image' && (ref.source_url || ref.blob_path)) { if (ref.kind === 'image' && (ref.source_url || ref.blob_path)) {
@@ -108,6 +109,7 @@ export async function render(main, ctx) {
); );
mount(main, mount(main,
backButton(),
el('h1', { class: 'view-h1' }, ref.title || '(untitled reference)'), el('h1', { class: 'view-h1' }, ref.title || '(untitled reference)'),
el('p', { class: 'view-sub' }, el('p', { class: 'view-sub' },
el('span', { class: 'status idle' }, ref.kind), el('span', { class: 'status idle' }, ref.kind),

View File

@@ -1,6 +1,7 @@
// Resource detail: status header + dependencies + source docs + runbook pages + change history. // Resource detail: status header + dependencies + source docs + runbook pages + change history.
import { api } from '../api.js'; import { api } from '../api.js';
import { el, mount, clear, safeHref } from '../dom.js'; import { el, mount, clear, safeHref } from '../dom.js';
import { backButton } from '../components/backbtn.js';
function statusClass(s) { function statusClass(s) {
return s === 'running' ? 'ok' : s === 'stopped' ? 'warn' : s === 'down' ? 'bad' : 'idle'; return s === 'running' ? 'ok' : s === 'stopped' ? 'warn' : s === 'down' ? 'bad' : 'idle';
@@ -108,6 +109,7 @@ export async function render(main, ctx) {
} }
mount(main, mount(main,
backButton(),
el('h1', { class: 'view-h1' }, res.name), el('h1', { class: 'view-h1' }, res.name),
el('p', { class: 'view-sub' }, el('p', { class: 'view-sub' },
el('span', { class: 'status ' + statusClass(res.status) }, res.status), el('span', { class: 'status ' + statusClass(res.status) }, res.status),