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 = ['
' + + '' + esc(m.unit) + '' + + '
' + BADGE_LABEL[state] + '' + + '' + esc(svc.name) + '
' + + '
' + esc(m.role) + '
', + voiceBtn(m.tagline, m.sfx) + ]; + if (m.equiv) parts.push('
' + esc(m.equiv) + '
'); + if (m.spec) parts.push('
' + esc(m.spec) + '
'); + if (m.inputs && m.inputs.length) parts.push('
Inputs: ' + esc(m.inputs.join(' · ')) + '
'); + if (m.use) parts.push('
Use: ' + esc(m.use) + '
'); + if (m.voice) parts.push(voiceBtn(m.voice.text, m.voice.sfx)); + if (m.supervision) parts.push('
' + esc(m.supervision) + '
'); + if (m.warn) parts.push('
' + + '⚠ ' + esc(m.warn.split(':')[0]) + ' \u2014 hover to read
'); + 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.

Open WebUI chat screen with a question and the AI's answer

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.

- The Open WebUI welcome screen with frank-secondary selected in the model box + The Open WebUI welcome screen with the family model selected in the model box

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)

-

frank-primary — the primary model

+

frank-archon — the big one (api id: frank-primary)

-

frank-secondary — the fast utility model

+

frank-zealot — the fast utility model (api id: frank-secondary)

-

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

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" }