diff --git a/dankclaw/Caddyfile b/dankclaw/Caddyfile
index 1569963..5a32c37 100644
--- a/dankclaw/Caddyfile
+++ b/dankclaw/Caddyfile
@@ -337,7 +337,7 @@ http://:8017 {
try_files /docs.html
file_server
}
- @data path /services.json /status.json /app.js /style.css /deployed.js /img/*
+ @data path /services.json /status.json /app.js /style.css /deployed.js /img/* /sfx/*
handle @data {
file_server
}
diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh
index 51e23b5..cbeae99 100755
--- a/scripts/deploy-remote.sh
+++ b/scripts/deploy-remote.sh
@@ -325,6 +325,9 @@ deploy_homepage() {
if [[ -d shared/homepage/img ]]; then
run_privileged cp -r shared/homepage/img /srv/homepage/
fi
+ if [[ -d shared/homepage/sfx ]]; then
+ run_privileged cp -r shared/homepage/sfx /srv/homepage/
+ fi
local deployed_at
deployed_at="$(git log -1 --format=%cI)"
printf 'window.DEPLOYED_AT = "%s";\n' "$deployed_at" > /tmp/deployed.js
diff --git a/serverAppConfigs-docs/Infrastructure/Homepage.md b/serverAppConfigs-docs/Infrastructure/Homepage.md
index d489685..0ee4888 100644
--- a/serverAppConfigs-docs/Infrastructure/Homepage.md
+++ b/serverAppConfigs-docs/Infrastructure/Homepage.md
@@ -62,6 +62,31 @@ intelligence)", frank-primary "equivalent to Claude Opus 4.8 / OpenAI-Terra
(2025)". Rendered by `descHTML()` as `.desc-equiv` (head color, italic);
pinned in `homepage_config.bats`.
+**Protoss rename + Model Information component (2026-10-08):** the model
+cards became the StarCraft trio — `frank-large` → **frank-dragoon**
+(iGPU), `frank-primary` → **frank-archon** (Dankserver), `frank-secondary`
+→ **frank-zealot** (3090). The endpoint/API ids did NOT change; only the
+family-facing names. The three cards fused into one **Model Information**
+component (`app.js` `renderModelInfo()`, `.model-info`/`.mi-*` styles):
+Protoss unit art (`/img/unit-.jpg`, hex-clipped), per-unit accent
+colours (Dragoon gold, Archon cyan, Zealot blue), smaller type (15px vs
+19px body), and clickable unit-voice lines that play the original
+StarCraft `.ogg` quotations from `/sfx/` (fetched from
+starcraft.fandom.com via its MediaWiki API; the quote→file mapping lives
+in the `model.sfx`/`model.voice.sfx` fields). New `services.json`
+per-model fields: `unit`, `tagline`, `sfx[]`, `voice`, `supervision`
+(dragoon/archon autonomous-safe, zealot tight-supervision), and `warn`
+(uncensored legal note, rendered as a hover tooltip, not wall text).
+A compaction-model recommendation for frank-zealot rides in its `use`
+text. The Caddy `@data` allowlist grew `/sfx/*`; `deploy_homepage` copies
+`sfx/`. A `wip: "down"` badge state ("WIP: DOWN", red) was added; Seerr,
+Calibre-Web, RomM, and Jellyfin are WIP: DOWN with their Open links
+removed. Card notes added: Copyparty holds every other media app's
+content (including the WIP ones); Gitea and Plex need logins separate
+from the dankcloud SSO; SearXNG is obfuscated, not anonymous (upstream
+API keys tie to Frank). The user docs `/docs#models` section carries the
+same renames, api-id mapping, supervision and compaction notes.
+
## Design decision — 2026-08-22
The live up/down check runs server-side. A checker on Dankclaw probes each backend and writes a same-origin `status.json`. The page fetches only that file and renders the badges. The browser never calls a backend directly. The checker runs as a systemd timer every 3 minutes.
diff --git a/shared/homepage/app.js b/shared/homepage/app.js
index 95680fb..471eb85 100644
--- a/shared/homepage/app.js
+++ b/shared/homepage/app.js
@@ -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('
' + esc(d.role) + '
');
- if (d.equiv) lines.push('
' + esc(d.equiv) + '
');
if (d.spec) lines.push('
' + esc(d.spec) + '
');
if (d.inputs && d.inputs.length) lines.push('
Inputs: ' + esc(d.inputs.join(' · ')) + '
');
if (d.use) lines.push('
Use: ' + esc(d.use) + '
');
@@ -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 '';
+}
+
+function miCard(svc, state) {
+ const m = svc.model;
+ const parts = ['
');
+ 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;
diff --git a/shared/homepage/docs.html b/shared/homepage/docs.html
index e3d093f..b0efe55 100644
--- a/shared/homepage/docs.html
+++ b/shared/homepage/docs.html
@@ -40,50 +40,60 @@
conversations are saved to your account, so history follows you between devices.
Below the text box, the model box on the right chooses which model answers you.
- Family accounts get frank-secondary — the fleet's fast all-rounder,
+ Family accounts get frank-zealot — the fleet's fast all-rounder
+ (Open WebUI lists models by their API id; this one is frank-secondary),
which also sees images: drag a photo or screenshot straight into the chat box.
Frank's account can additionally reach the bigger models.
-
+
Open WebUI keeps your chat history; the raw model endpoints (used by developer
tools, see API access) do not.
The running models
-
Three models serve the family. The homepage status cards show whether
- each one is up right now.
+
Three models serve the family, named for the Protoss units of StarCraft.
+ The homepage "Model Information" panel shows whether each one is up right now —
+ and plays the unit's voice if you click its quoted line.
-
frank-large — deep reasoning
+
frank-dragoon — deep reasoning (api id: frank-large)
-
Model: Qwen3.8-Flash-Next v2 Swift1.5 abliterated (halogen flash-server, ~62 GiB) — an uncensored edition that thinks before answering.
+
Model: Qwen3.8-Flash-Next v2 Swift1.5 abliterated (halogen flash-server, ~62 GiB) on the Strix Halo iGPU — an abliterated (censorship-removed) edition that thinks before answering.
Equivalent: Claude Sonnet 5 / OpenAI-Luna (Summer 2026 frontier intelligence).
-
Capacity: 200k-token context, two conversations at a time; sees images.
+
Capacity: 200k-token context, two submissions at a time; sees images.
Inputs: text, image.
-
Best for: cybersecurity and debugging. Use requires Frank's permission.
+
Best for: complex agentic tasks, programming, system administration, and "claw"-style personal agents.
+
Supervision: safe for autonomous, low-supervision use.
+
Warning:⚠ legal note — hover to read
-
frank-primary — the primary model
+
frank-archon — the big one (api id: frank-primary)
-
Model: Qwen3.8-Flash-Next at native FP8 precision (radiance, 173 GiB across both R9700s) — the biggest brain on the network.
+
Model: Qwen3.8-Flash-Next at native FP8 precision (radiance, 173 GiB) on twin AMD R9700s with more than 200 GB of system RAM — the biggest brain on the network and the fastest runtime.
Equivalent: Claude Opus 4.8 / OpenAI-Terra (Summer 2026 frontier intelligence).
-
Capacity: 200k-token context, two conversations at a time; sees images.
+
Capacity: 200k-token context, two submissions at a time; sees images.
Inputs: text, image.
-
Best for: software engineering, system administration, and instruction tasks.
+
Best for: software engineering, system administration, and instruction tasks. Please ask Frank before use — he uses this one primarily.
+
Supervision: safe for autonomous, low-supervision use.
-
frank-secondary — the fast utility model
+
frank-zealot — the fast utility model (api id: frank-secondary)
-
Model: Qwen3.5-9B-Uncensored (Q6_K, 6.9 GiB) on the RTX 3090 — roughly 95 tokens per second.
+
Model: Qwen3.5-9B-Uncensored (Q6_K) on the RTX 3090 — roughly 95 tokens per second.
Equivalent: last year's frontier level (2025).
-
Capacity: two simultaneous conversations, 200k tokens each; sees images.
+
Capacity: 200k-token context, two submissions at a time; sees images.
Inputs: text, image.
-
Best for: agentic search, RAG, research subagents, translation, roleplay, and summarization — any small task that doesn't need much intelligence, and anything with a picture (screenshots, photos, diagrams). This is the model Open WebUI offers to family accounts.
+
Best for: code editor autocomplete, agentic search, RAG, research subagents, translation, roleplay, and summarization — any small task that doesn't need much intelligence, and anything with a picture. This is the model Open WebUI offers to family accounts.
+
Compaction: if your agent harness supports setting a compaction model, set this one. A compaction job severely slows the bigger models for far longer than it slows this one.
+
Supervision: requires tight human supervision.
+
Warning:⚠ legal note — hover to read
-
Which one to pick? Small utility work, RAG, search, and
- screenshots: frank-secondary. Software engineering, sysadmin, and
- instruction tasks: frank-primary. Cybersecurity and debugging:
- frank-large (with Frank's permission).
+
Which one to pick? Small utility work, autocomplete, RAG,
+ search, and screenshots: frank-zealot. Complex agentic work,
+ programming, and personal agents: frank-dragoon. Software
+ engineering, sysadmin, and instruction tasks: frank-archon (ask
+ Frank first). The names in brackets are the ids the API and Open WebUI
+ use; the family names are the Protoss ones.
@@ -110,7 +120,7 @@
Step 2 — the three details every tool asks for
Base URL:https://ai.franksdank.cloud/api/v1
-
Model:frank-secondary
+
Model:frank-secondary — this is frank-zealot, the family utility model (the API uses the id, not the family name)
API key: the sk-… from step 1 — sent as the header Authorization: Bearer sk-…
Proof that it works, from any terminal (replace the key):
@@ -159,7 +169,7 @@
The key works only for the family AI — it is not your
Authentik password, and losing it costs nothing beyond asking Frank to rotate it.
- Want a bigger model (frank-primary, frank-large)? Ask Frank to raise your access.
+ Want a bigger model (frank-archon, frank-dragoon)? Ask Frank to raise your access.
diff --git a/shared/homepage/img/unit-archon.jpg b/shared/homepage/img/unit-archon.jpg
new file mode 100644
index 0000000..da1932b
Binary files /dev/null and b/shared/homepage/img/unit-archon.jpg differ
diff --git a/shared/homepage/img/unit-dragoon.jpg b/shared/homepage/img/unit-dragoon.jpg
new file mode 100644
index 0000000..6ef8bf7
Binary files /dev/null and b/shared/homepage/img/unit-dragoon.jpg differ
diff --git a/shared/homepage/img/unit-zealot.jpg b/shared/homepage/img/unit-zealot.jpg
new file mode 100644
index 0000000..45e1a1b
Binary files /dev/null and b/shared/homepage/img/unit-zealot.jpg differ
diff --git a/shared/homepage/services.json b/shared/homepage/services.json
index 8b42520..0d4a730 100644
--- a/shared/homepage/services.json
+++ b/shared/homepage/services.json
@@ -3,7 +3,7 @@
"name": "SearXNG",
"category": "user",
"section": "Web Apps",
- "description": "AI-free, VPN-routed search engine, fully hosted on Frank's server.",
+ "description": "AI-free, VPN-routed search engine, fully hosted on Frank's server. Your searches are obfuscated, not anonymous: upstream API keys are tied to Frank's identity.",
"url": "http://search.franksdank.cloud",
"docs": "",
"probe": "127.0.0.1:8081",
@@ -27,7 +27,7 @@
"name": "Gitea",
"category": "user",
"section": "Web Apps",
- "description": "Git code hosting and review platform.",
+ "description": "Git code hosting and review platform. Needs its own login, separate from the dankcloud single sign-on (ask Frank).",
"url": "http://gitea.franksdank.cloud",
"docs": "",
"probe": "127.0.0.1:3000",
@@ -39,31 +39,31 @@
"name": "Calibre-Web",
"category": "user",
"section": "Media Library Apps",
- "description": "E-book library.",
- "url": "http://books.franksdank.cloud",
+ "description": "E-book library. Work in progress: not open to family users yet.",
+ "url": "",
"docs": "",
"probe": "192.168.0.235:8083",
"method": "http",
- "wip": false,
- "showLink": true
+ "wip": "down",
+ "showLink": false
},
{
"name": "RomM",
"category": "user",
"section": "Media Library Apps",
- "description": "ROM and emulator library.",
- "url": "http://romm.franksdank.cloud",
+ "description": "ROM and emulator library. Work in progress: not open to family users yet.",
+ "url": "",
"docs": "",
"probe": "192.168.0.235:8085",
"method": "http",
- "wip": false,
- "showLink": true
+ "wip": "down",
+ "showLink": false
},
{
"name": "Copyparty",
"category": "user",
"section": "Web Apps",
- "description": "Direct downloads from Frank's server.",
+ "description": "Direct downloads from Frank's server. Everything the other media apps hold — including the work-in-progress ones (Calibre-Web, RomM, Seerr) — is available here.",
"url": "http://files.franksdank.cloud",
"docs": "",
"probe": "192.168.0.235:3923",
@@ -75,7 +75,7 @@
"name": "Plex",
"category": "user",
"section": "Media Library Apps",
- "description": "Closed source media server with apps for everything.",
+ "description": "Closed source media server with apps for everything. Needs its own Plex login, separate from the dankcloud single sign-on (ask Frank).",
"url": "http://plex.franksdank.cloud/web/index.html",
"docs": "",
"probe": "192.168.0.235:32400",
@@ -87,25 +87,25 @@
"name": "Jellyfin",
"category": "user",
"section": "Media Library Apps",
- "description": "Open source media server.",
- "url": "http://jelly.franksdank.cloud",
+ "description": "Open source media server. Work in progress: not open to family users yet.",
+ "url": "",
"docs": "",
"probe": "192.168.0.235:8096",
"method": "http",
- "wip": false,
- "showLink": true
+ "wip": "down",
+ "showLink": false
},
{
"name": "Seerr",
"category": "user",
"section": "Media Library Apps",
- "description": "Download Requests",
- "url": "http://seerr.franksdank.cloud",
+ "description": "Download requests. Work in progress: not open to family users yet.",
+ "url": "",
"docs": "",
"probe": "127.0.0.1:5055",
"method": "http",
- "wip": false,
- "showLink": true
+ "wip": "down",
+ "showLink": false
},
{
"name": "Marreta",
@@ -264,15 +264,21 @@
"showLink": false
},
{
- "name": "frank-large",
+ "name": "frank-dragoon",
"category": "user",
"section": "AI",
- "details": {
- "role": "Deep reasoning model",
+ "model": {
+ "unit": "dragoon",
+ "role": "Frank's deep reasoning model",
+ "tagline": "I have returned — make use of me.",
+ "sfx": ["dragoon-returned.ogg", "dragoon-makeuse.ogg"],
"equiv": "Equivalent to Claude Sonnet 5 / OpenAI-Luna (Summer 2026 frontier intelligence).",
- "spec": "Qwen3.8-Flash-Next v2 Swift1.5 abliterated (halogen flash-server, ~62 GiB) — 200k-token context, two conversations at a time; sees images. Speculative decoding always on.",
+ "spec": "Qwen3.8-Flash-Next v2 Swift1.5 abliterated (halogen flash-server, ~62 GiB) on the Strix Halo iGPU — 200k-token context, two submissions at a time; sees images.",
"inputs": ["text", "image"],
- "use": "Cybersecurity and debugging. Requires Frank's permission."
+ "use": "Complex agentic tasks, programming, system administration, and \"claw\"-style personal agents.",
+ "voice": { "text": "Awaiting instructions.", "sfx": ["dragoon-awaiting.ogg"] },
+ "supervision": "Safe for autonomous, low-supervision use.",
+ "warn": "Abliterated (censorship-removed) model: output can include adult themes. You are responsible for the legal consequences of how you use it."
},
"url": "",
"docs": "/docs#models",
@@ -282,15 +288,20 @@
"showLink": false
},
{
- "name": "frank-primary",
+ "name": "frank-archon",
"category": "user",
"section": "AI",
- "details": {
- "role": "Primary model",
+ "model": {
+ "unit": "archon",
+ "role": "Frank's strongest and fastest model runtime",
+ "tagline": "...Power Overwhelming...",
+ "sfx": ["archon-power.ogg"],
"equiv": "Equivalent to Claude Opus 4.8 / OpenAI-Terra (Summer 2026 frontier intelligence).",
- "spec": "Qwen3.8-Flash-Next at native FP8 precision (radiance, 173 GiB) — 200k-token context, two conversations; sees images. Runs on both R9700s with tiered expert memory.",
+ "spec": "Qwen3.8-Flash-Next at native FP8 precision (radiance, 173 GiB) on twin AMD R9700s with more than 200 GB of system RAM — 200k-token context, two submissions at a time; sees images.",
"inputs": ["text", "image"],
- "use": "Software engineering, system administration, and instruction tasks."
+ "use": "Software engineering, system administration, and instruction tasks. Please ask Frank before use, as he uses this one primarily.",
+ "voice": { "text": "...we BURN.", "sfx": ["archon-burn.ogg"] },
+ "supervision": "Safe for autonomous, low-supervision use."
},
"url": "",
"docs": "/docs#models",
@@ -300,15 +311,21 @@
"showLink": false
},
{
- "name": "frank-secondary",
+ "name": "frank-zealot",
"category": "user",
"section": "AI",
- "details": {
- "role": "Fast utility model",
+ "model": {
+ "unit": "zealot",
+ "role": "Frank's utility model",
+ "tagline": "HONOR GUIDE ME!",
+ "sfx": ["zealot-honor.ogg"],
"equiv": "Equivalent to last year's frontier level (2025).",
- "spec": "Qwen3.5-9B-Uncensored (Q6_K, 6.9 GiB) on llama.cpp CUDA — two 200k-token conversations at a time; sees images; ~95 tok/s decode on the RTX 3090.",
+ "spec": "Qwen3.5-9B-Uncensored (Q6_K) on an NVIDIA RTX 3090 — ~95 tokens/s; 200k-token context, two submissions at a time; sees images.",
"inputs": ["text", "image"],
- "use": "Agentic search, RAG, research subagents, translation, roleplay, and summarization — any small task that doesn't need much intelligence."
+ "use": "Code editor autocomplete, agentic search, RAG, research subagents, translation, roleplay, and summarization. If your agent harness lets you set a compaction model, set this one: a compaction job on the bigger models blocks them for far longer.",
+ "voice": { "text": "I LONG FOR COMBAT!", "sfx": ["zealot-combat.ogg"] },
+ "supervision": "Requires tight human supervision.",
+ "warn": "Uncensored model: output can include adult content. You are responsible for the legal consequences of how you use it."
},
"url": "",
"docs": "/docs#models",
diff --git a/shared/homepage/sfx/archon-burn.ogg b/shared/homepage/sfx/archon-burn.ogg
new file mode 100644
index 0000000..58addd3
Binary files /dev/null and b/shared/homepage/sfx/archon-burn.ogg differ
diff --git a/shared/homepage/sfx/archon-power.ogg b/shared/homepage/sfx/archon-power.ogg
new file mode 100644
index 0000000..cf87ae9
Binary files /dev/null and b/shared/homepage/sfx/archon-power.ogg differ
diff --git a/shared/homepage/sfx/dragoon-awaiting.ogg b/shared/homepage/sfx/dragoon-awaiting.ogg
new file mode 100644
index 0000000..910dd4e
Binary files /dev/null and b/shared/homepage/sfx/dragoon-awaiting.ogg differ
diff --git a/shared/homepage/sfx/dragoon-makeuse.ogg b/shared/homepage/sfx/dragoon-makeuse.ogg
new file mode 100644
index 0000000..f08b185
Binary files /dev/null and b/shared/homepage/sfx/dragoon-makeuse.ogg differ
diff --git a/shared/homepage/sfx/dragoon-returned.ogg b/shared/homepage/sfx/dragoon-returned.ogg
new file mode 100644
index 0000000..962e091
Binary files /dev/null and b/shared/homepage/sfx/dragoon-returned.ogg differ
diff --git a/shared/homepage/sfx/zealot-combat.ogg b/shared/homepage/sfx/zealot-combat.ogg
new file mode 100644
index 0000000..06eebbb
Binary files /dev/null and b/shared/homepage/sfx/zealot-combat.ogg differ
diff --git a/shared/homepage/sfx/zealot-honor.ogg b/shared/homepage/sfx/zealot-honor.ogg
new file mode 100644
index 0000000..4e8a032
Binary files /dev/null and b/shared/homepage/sfx/zealot-honor.ogg differ
diff --git a/shared/homepage/style.css b/shared/homepage/style.css
index bc5c10d..d23536d 100644
--- a/shared/homepage/style.css
+++ b/shared/homepage/style.css
@@ -78,6 +78,7 @@ body::before {
.badge.down { color: var(--down); }
.badge.wip-open { color: var(--wip); }
.badge.wip-closed { color: var(--dim); }
+.badge.wip-down { color: var(--down); }
.desc { color: var(--fg); margin: 8px 0; }
.desc-role { color: var(--dim); font-weight: bold; }
.desc-spec { color: var(--fg); }
@@ -122,3 +123,80 @@ html[data-bare="1"] .badge, html[data-bare="1"] .name, html[data-bare="1"] .desc
html[data-bare="1"] .desc-role, html[data-bare="1"] .desc-spec, html[data-bare="1"] .desc-inputs, html[data-bare="1"] .desc-use, html[data-bare="1"] .desc-equiv { color: #000; font-weight: normal; }
html[data-bare="1"] .section-title { border-color: #000; }
html[data-bare="1"] .links a, html[data-bare="1"] .toggle, html[data-bare="1"] .foot a { color: #000; }
+
+/* Model Information: one fused panel for the fleet models. Informational
+ panels, so the type is smaller than the app panels; each model wears its
+ StarCraft Protoss unit's colours and art. */
+.model-info { grid-column: 1 / -1; }
+.mi-title {
+ color: var(--head);
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 10px;
+ padding-bottom: 4px;
+}
+.mi-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 12px;
+}
+.mi-card {
+ font-size: 15px;
+ line-height: 1.35;
+ border: 1px solid var(--mi);
+ border-radius: 2px;
+ padding: 10px 12px;
+ background:
+ linear-gradient(160deg, color-mix(in srgb, var(--mi) 7%, var(--panel)), var(--panel) 60%);
+ box-shadow: 0 0 14px color-mix(in srgb, var(--mi) 18%, transparent),
+ inset 0 0 22px color-mix(in srgb, var(--mi) 6%, transparent);
+}
+.mi-zealot { --mi: #4fb4ff; } /* Zealot: Khai's blue blades */
+.mi-archon { --mi: #a8ecff; } /* Archon: pure psychic light */
+.mi-dragoon { --mi: #ffc94d; } /* Dragoon: gold phase disruptor */
+.mi-top { display: flex; align-items: center; gap: 10px; }
+.mi-art {
+ width: 56px;
+ height: 56px;
+ object-fit: cover;
+ flex: 0 0 auto;
+ clip-path: polygon(25% 3%, 75% 3%, 100% 50%, 75% 97%, 25% 97%, 0% 50%);
+ border: 1px solid var(--mi);
+ filter: saturate(0.9) brightness(0.95);
+}
+.mi-id { display: flex; align-items: baseline; gap: 8px; }
+.mi-name { color: var(--mi); font-size: 19px; text-shadow: 0 0 6px color-mix(in srgb, var(--mi) 55%, transparent); }
+.mi-role { color: var(--dim); margin: 6px 0 2px; }
+.mi-voice {
+ display: block;
+ appearance: none;
+ background: none;
+ border: none;
+ padding: 2px 0;
+ margin: 4px 0;
+ text-align: left;
+ font: inherit;
+ color: var(--mi);
+ cursor: pointer;
+ text-decoration: underline dotted color-mix(in srgb, var(--mi) 60%, transparent);
+}
+.mi-voice:hover { text-shadow: 0 0 8px var(--mi); }
+.mi-voice.mi-speaking { animation: mi-pulse 0.7s ease-in-out infinite; }
+@keyframes mi-pulse { 50% { text-shadow: 0 0 12px var(--mi); } }
+.mi-equiv { color: var(--head); font-style: italic; margin: 4px 0; }
+.mi-spec { color: var(--fg); margin: 4px 0; }
+.mi-inputs { color: var(--dim); }
+.mi-use { color: var(--fg); margin-top: 4px; }
+.mi-super { color: var(--link); margin-top: 6px; }
+.mi-warn { color: var(--down); margin-top: 4px; cursor: help;
+ text-decoration: underline dotted color-mix(in srgb, var(--down) 60%, transparent); }
+
+/* Bare mode strips the model component to plain prose too. */
+html[data-bare="1"] .mi-card { border-color: #000; box-shadow: none; background: none; }
+html[data-bare="1"] .mi-art { display: none; }
+html[data-bare="1"] .mi-name { color: #000; text-shadow: none; }
+html[data-bare="1"] .mi-voice { color: #000; text-decoration: underline; }
+html[data-bare="1"] .mi-role, html[data-bare="1"] .mi-spec, html[data-bare="1"] .mi-use,
+html[data-bare="1"] .mi-inputs, html[data-bare="1"] .mi-equiv, html[data-bare="1"] .mi-super,
+html[data-bare="1"] .mi-warn { color: #000; }
diff --git a/tests/homepage_caddy.bats b/tests/homepage_caddy.bats
index 90ad2d5..ea4f7d8 100644
--- a/tests/homepage_caddy.bats
+++ b/tests/homepage_caddy.bats
@@ -34,6 +34,8 @@ setup() {
# docs screenshots (/img/*) must be served from disk, not answered by the
# index.html catch-all (2026-10-08: PNGs came back as 200 text/html).
grep -qE "@data[[:space:]]+path.*(/img/\*|/img/\*\")" "$CADDY"
+ # the model panel's unit voices ride the same allowlist
+ grep -qE "@data[[:space:]]+path.*/sfx/\*" "$CADDY"
}
@test "the homepage revalidates its assets (no stale cache after deploys)" {
diff --git a/tests/homepage_config.bats b/tests/homepage_config.bats
index d38d156..52a3411 100644
--- a/tests/homepage_config.bats
+++ b/tests/homepage_config.bats
@@ -3,7 +3,7 @@
# and the pages render. It must be a JSON list; every entry carries name,
# category, section, and a host:port probe. category is user or admin;
# section must be one the page's SECTION_ORDER renders. method, when
-# present, is tcp or http. wip, when set, is "open" or "closed".
+# present, is tcp or http. wip, when set, is "open", "closed" or "down".
load helpers
@@ -24,7 +24,7 @@ for svc in data:
if "method" in svc:
assert svc["method"] in ("tcp", "http"), svc["method"]
wip = svc.get("wip")
- assert wip is False or wip in ("open", "closed"), f"{svc['name']} wip must be false, 'open' or 'closed'"
+ assert wip is False or wip in ("open", "closed", "down"), f"{svc['name']} wip must be false, 'open', 'closed' or 'down'"
host, _, port = svc["probe"].rpartition(":")
assert host and port.isdigit(), f"bad probe {svc['probe']}"
PY
@@ -61,36 +61,47 @@ PY
@test "the LLM status cards track the fleet, not retired service names" {
# This drifted twice: the card probing Dankserver's :7770 was still named
- # frank-small long after frank-medium took that endpoint, and the endpoint
- # itself has since been frank-medium (radiance), frank-primary (lucebox)
- # and frank-primary (llama.cpp). The probe target is what matters; the
- # name has to follow it.
+ # frank-small long after frank-medium took that endpoint; the endpoints
+ # were renamed to the Protoss set on 2026-10-08. The probe target is what
+ # matters; the name has to follow it.
python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY'
import json, sys
services = json.load(open(sys.argv[1]))
by_probe = {s["probe"]: s["name"] for s in services}
-primary = by_probe.get("192.168.0.235:7770")
-assert primary == "frank-primary", f"Dankserver :7770 card is named {primary!r}"
-# Dankclaw serves two endpoints on two ports: frank-large (unsloth llama.cpp
-# on the iGPU, 7770) and frank-secondary (llama.cpp on the RTX 3090, 7771).
-assert by_probe.get("127.0.0.1:7770") == "frank-large", by_probe.get("127.0.0.1:7770")
-assert by_probe.get("127.0.0.1:7771") == "frank-secondary", by_probe.get("127.0.0.1:7771")
+assert by_probe.get("192.168.0.235:7770") == "frank-archon", by_probe.get("192.168.0.235:7770")
+assert by_probe.get("127.0.0.1:7770") == "frank-dragoon", by_probe.get("127.0.0.1:7770")
+assert by_probe.get("127.0.0.1:7771") == "frank-zealot", by_probe.get("127.0.0.1:7771")
PY
}
-@test "every fleet LLM card carries a user-facing equivalence note" {
- # Frank's capability framing (2026-10-08): each card must state, in plain
- # terms, what the model is equivalent to — the cards' whole purpose for
- # family users is "how smart is this and what is it for".
- python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY'
-import json, sys
+@test "the Model Information component is complete: text, art, voices, warnings" {
+ # The fused model panel is the family's only description of the fleet.
+ # Every model card must carry: equivalence note, supervision level, the
+ # unit art, at least one playable voice line, and (for uncensored models)
+ # the legal warning. The compaction recommendation must name the zealot.
+ python3 - "$REPO_ROOT/shared/homepage/services.json" "$REPO_ROOT/shared/homepage" <<'PY'
+import json, os, sys
services = json.load(open(sys.argv[1]))
-fleet = {s["name"]: s for s in services if s.get("name") in ("frank-large", "frank-primary", "frank-secondary")}
-assert len(fleet) == 3, sorted(fleet)
-for name, s in fleet.items():
- eq = s.get("details", {}).get("equiv", "")
- assert eq.strip(), f"{name} card has no equivalence note"
+root = sys.argv[2]
+fleet = [s for s in services if "model" in s]
+assert {s["name"] for s in fleet} == {"frank-dragoon", "frank-archon", "frank-zealot"}, [s["name"] for s in fleet]
+for s in fleet:
+ m = s["model"]
+ assert m["equiv"].strip(), f"{s['name']} has no equivalence note"
+ assert m["supervision"].strip(), f"{s['name']} has no supervision note"
+ assert m["sfx"], f"{s['name']} has no voice line"
+ assert os.path.isfile(os.path.join(root, "img", "unit-" + m["unit"] + ".jpg")), f"{s['name']} art missing"
+ for f in m["sfx"] + m.get("voice", {}).get("sfx", []):
+ assert os.path.isfile(os.path.join(root, "sfx", f)), f"{s['name']} voice {f} missing"
+zealot = next(s for s in fleet if s["name"] == "frank-zealot")
+assert "compaction" in zealot["model"]["use"].lower(), "zealot must be recommended as compaction model"
+assert "supervision" in zealot["model"]["supervision"].lower()
+for s in fleet:
+ m = s["model"]
+ uncensored = "ncensored" in m["spec"] or "bliterated" in m["spec"]
+ if uncensored:
+ assert "legal" in m.get("warn", "").lower(), f"{s['name']} is uncensored but has no legal warning"
PY
- grep -q "desc-equiv" "$REPO_ROOT/shared/homepage/app.js"
- grep -q "desc-equiv" "$REPO_ROOT/shared/homepage/style.css"
+ grep -q "renderModelInfo" "$REPO_ROOT/shared/homepage/app.js"
+ grep -q ".mi-card" "$REPO_ROOT/shared/homepage/style.css"
}