homepage: Protoss rename + fused Model Information component

The model cards become the StarCraft trio: frank-large -> frank-dragoon,
frank-primary -> frank-archon, frank-secondary -> frank-zealot (family
names only; endpoint/API ids unchanged). The three cards fuse into one
Model Information component: per-unit Protoss accent colours and hex-
clipped unit art, 15px type, clickable StarCraft unit-voice quotations
(original .ogg files from the fandom wiki, served from /sfx/), supervision
notes (dragoon/archon safe for autonomous use, zealot tight supervision),
compaction-model recommendation for frank-zealot, and uncensored-model
legal notes rendered as hover tooltips instead of wall text. docs.html
carries the same renames plus api-id mapping.

App-card updates: Seerr, Calibre-Web, RomM, Jellyfin set to WIP: DOWN
with Open links removed (new wip "down" badge state); Copyparty notes it
serves all other media apps content including WIP apps; Gitea and Plex
noted as needing logins separate from SSO; SearXNG noted as obfuscated,
not anonymous (upstream API keys tie to Frank).

Plumbing: Caddy @data allowlist grew /sfx/*; deploy_homepage copies sfx/;
homepage_config.bats pins the new probe names, the component fields,
art/voice-file presence, and the wip-down schema.
This commit is contained in:
Frank committed 2026-10-08 02:40:45 -07:00
1 parent 74da746c51
commit 203b0be145
19 files changed
+312 -88

No files matched your search

+83 -5
View File
@@ -19,6 +19,7 @@ const BADGE_LABEL = {
down: 'DOWN',
'wip-open': 'WIP: OPEN',
'wip-closed': 'WIP: CLOSED',
'wip-down': 'WIP: DOWN',
};
function load(url) {
@@ -31,14 +32,13 @@ function esc(s) {
});
}
// The description: structured role/spec/use lines when the service defines
// them (the LLM cards), else the plain description string.
// The plain description for ordinary panels. (Model cards render through
// renderModelInfo below, not through descHTML.)
function descHTML(svc) {
const d = svc.details;
if (d && (d.role || d.spec || d.inputs || d.use || d.equiv)) {
if (d && (d.role || d.spec || d.inputs || d.use)) {
const lines = [];
if (d.role) lines.push('<div class="desc-role">' + esc(d.role) + '</div>');
if (d.equiv) lines.push('<div class="desc-equiv">' + esc(d.equiv) + '</div>');
if (d.spec) lines.push('<div class="desc-spec">' + esc(d.spec) + '</div>');
if (d.inputs && d.inputs.length) lines.push('<div class="desc-inputs">Inputs: ' + esc(d.inputs.join(' · ')) + '</div>');
if (d.use) lines.push('<div class="desc-use">Use: ' + esc(d.use) + '</div>');
@@ -47,6 +47,77 @@ function descHTML(svc) {
return esc(svc.description || '');
}
// The Model Information component: one panel fusing every fleet model card.
// Each model is an informational sub-card (smaller type, Protoss unit art,
// unit-coloured flair); the quoted lines are buttons that play the matching
// StarCraft unit voice from /sfx/. Audio files are sequential: a click plays
// the whole queue (the Dragoon tagline is two lines spliced).
let sfxQueue = [];
let sfxPlaying = false;
function playSfx(files, el) {
sfxQueue = sfxQueue.concat(files);
if (sfxPlaying) return;
sfxPlaying = true;
el.classList.add('mi-speaking');
const next = function () {
const f = sfxQueue.shift();
if (!f) { sfxPlaying = false; el.classList.remove('mi-speaking'); return; }
const a = new Audio('/sfx/' + f);
a.volume = 0.5;
a.onended = next;
a.onerror = next;
a.play().then(function () {}, next);
};
next();
}
function voiceBtn(text, files) {
return '<button type="button" class="mi-voice" title="play voice" ' +
'data-sfx="' + esc(files.join(',')) + '">&#9654; ' + esc(text) + '</button>';
}
function miCard(svc, state) {
const m = svc.model;
const parts = ['<div class="mi-top">' +
'<img class="mi-art" src="/img/unit-' + esc(m.unit) + '.jpg" alt="' + esc(m.unit) + '">' +
'<div class="mi-head"><div class="mi-id"><span class="badge ' + state + '">' + BADGE_LABEL[state] + '</span>' +
'<span class="mi-name">' + esc(svc.name) + '</span></div>' +
'<div class="mi-role">' + esc(m.role) + '</div></div></div>',
voiceBtn(m.tagline, m.sfx)
];
if (m.equiv) parts.push('<div class="mi-equiv">' + esc(m.equiv) + '</div>');
if (m.spec) parts.push('<div class="mi-spec">' + esc(m.spec) + '</div>');
if (m.inputs && m.inputs.length) parts.push('<div class="mi-inputs">Inputs: ' + esc(m.inputs.join(' · ')) + '</div>');
if (m.use) parts.push('<div class="mi-use">Use: ' + esc(m.use) + '</div>');
if (m.voice) parts.push(voiceBtn(m.voice.text, m.voice.sfx));
if (m.supervision) parts.push('<div class="mi-super">' + esc(m.supervision) + '</div>');
if (m.warn) parts.push('<div class="mi-warn" title="' + esc(m.warn) + '">' +
'&#9888; ' + esc(m.warn.split(':')[0]) + ' \u2014 hover to read</div>');
const card = document.createElement('article');
card.className = 'mi-card mi-' + esc(m.unit);
card.innerHTML = parts.join('');
return card;
}
function renderModelInfo(models) {
const panel = document.createElement('div');
panel.className = 'panel model-info';
const head = document.createElement('div');
head.className = 'mi-title';
head.textContent = 'Model Information';
panel.appendChild(head);
const grid = document.createElement('div');
grid.className = 'mi-grid';
for (const m of models) grid.appendChild(m.el);
panel.appendChild(grid);
panel.addEventListener('click', function (ev) {
const btn = ev.target.closest('.mi-voice');
if (btn) playSfx(btn.getAttribute('data-sfx').split(','), btn);
});
return panel;
}
// Shared panel component: one TUI panel for a service in a given state.
function renderPanel(svc, state) {
const panel = document.createElement('div');
@@ -88,15 +159,22 @@ function renderPanels(services, status) {
let up = 0, down = 0, wip = 0;
const bySection = {};
const modelsBySection = {};
for (const svc of services) {
if (svc.category !== CATEGORY) continue;
const state = svc.wip ? 'wip-' + svc.wip : (stateByName[svc.name] || 'down');
if (state === 'up') up++; else if (state === 'down') down++; else wip++;
if (svc.model) {
(modelsBySection[svc.section] = modelsBySection[svc.section] || []).push({ svc: svc, el: miCard(svc, state) });
continue;
}
(bySection[svc.section] = bySection[svc.section] || []).push(renderPanel(svc, state));
}
for (const title of SECTION_ORDER[CATEGORY]) {
if (bySection[title]) root.appendChild(renderSection(title, bySection[title]));
const panels = bySection[title] || [];
if (modelsBySection[title]) panels.push(renderModelInfo(modelsBySection[title]));
if (panels.length) root.appendChild(renderSection(title, panels));
}
document.getElementById('summary').textContent = 'UP ' + up + ' DOWN ' + down + ' WIP ' + wip;