diff --git a/shared/homepage/admin.html b/shared/homepage/admin.html new file mode 100644 index 0000000..7ff7f00 --- /dev/null +++ b/shared/homepage/admin.html @@ -0,0 +1,23 @@ + + + + + +frank — admin + + + +
+ frank@home:~$ admin + bare +
+
+
+ + + + + diff --git a/shared/homepage/app.js b/shared/homepage/app.js index c2167f4..80805b5 100644 --- a/shared/homepage/app.js +++ b/shared/homepage/app.js @@ -1,79 +1,76 @@ -// Dankclaw homepage status checker. -// Loads services.json, renders cards, probes each service URL for up/down. -(function () { - "use strict"; +// The homepage front end. It reads status.json and services.json once on load +// (no auto-refresh, Q41), renders one TUI panel per service for this page's +// category, offers the styling toggle (Q27), and shows the last master +// deployment datetime (Q43). It never alters the user's cursor (Q28). - function probe(url, timeoutMs) { - // Same-origin fetch only; cross-origin will throw -> counted as checking - // via proxy. For LAN hostnames Caddy proxies everything, so URLs here are - // Caddy hostnames and fetch works same-origin. - var controller = new AbortController(); - var timer = setTimeout(function () { controller.abort(); }, timeoutMs); - return fetch(url, { signal: controller.signal, cache: "no-store" }) - .then(function (r) { return r.ok; }) - .catch(function () { return false; }) - .finally(function () { clearTimeout(timer); }); - } +const CATEGORY = window.location.pathname.indexOf('/admin') !== -1 ? 'admin' : 'user'; - function card(svc) { - var el = document.createElement("article"); - el.className = "card"; +function load(url) { + return fetch(url, { cache: 'no-store' }).then(function (r) { return r.json(); }); +} - var top = document.createElement("div"); - top.className = "top"; - var badge = document.createElement("span"); - badge.className = "badge checking"; - badge.title = "checking"; - var h2 = document.createElement("h2"); - h2.textContent = svc.name; - top.appendChild(badge); - top.appendChild(h2); - - var desc = document.createElement("p"); - desc.textContent = svc.description; - - var links = document.createElement("div"); - links.className = "links"; - var aOpen = document.createElement("a"); - aOpen.href = svc.url; - aOpen.target = "_blank"; - aOpen.rel = "noopener"; - aOpen.textContent = "Open"; - var aDocs = document.createElement("a"); - aDocs.href = svc.docs; - aDocs.textContent = "Docs"; - links.appendChild(aOpen); - links.appendChild(aDocs); - - el.appendChild(top); - el.appendChild(desc); - el.appendChild(links); - return { el: el, badge: badge }; - } - - fetch("services.json") - .then(function (r) { return r.json(); }) - .then(function (data) { - var grid = document.getElementById("services"); - data.services.forEach(function (svc) { - var c = card(svc); - grid.appendChild(c.el); - // For the homepage itself, mark up immediately. - if (svc.url === "/") { - c.badge.className = "badge up"; - c.badge.title = "up"; - return; - } - probe(svc.url, 6000).then(function (ok) { - c.badge.className = "badge " + (ok ? "up" : "down"); - c.badge.title = ok ? "up" : "down"; - }); - }); - }) - .catch(function (err) { - var grid = document.getElementById("services"); - var p = document.createElement("p"); - p.textContent = "Failed to load services.json: " + err.message; - grid.appendChild(p); +function esc(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); -})(); +} + +function renderPanels(services, status) { + const stateByName = {}; + for (const s of (status.services || [])) stateByName[s.name] = s.state; + + const grid = document.getElementById('grid'); + grid.innerHTML = ''; + let up = 0, down = 0, wip = 0; + + for (const svc of services) { + if (svc.category !== CATEGORY) continue; + const state = svc.wip ? 'wip' : (stateByName[svc.name] || 'down'); + if (state === 'up') up++; else if (state === 'down') down++; else wip++; + + const panel = document.createElement('div'); + panel.className = 'panel ' + state; + const links = []; + if (svc.showLink && svc.url) links.push('open'); + if (svc.docs) links.push('docs'); + panel.innerHTML = + '
' + + ' ' + state.toUpperCase() + '' + + ' ' + esc(svc.name) + '' + + '
' + + '
' + esc(svc.description || '') + '
' + + ''; + grid.appendChild(panel); + } + + document.getElementById('summary').textContent = 'UP ' + up + ' DOWN ' + down + ' WIP ' + wip; +} + +// The styling toggle (Q27): ?bare=1 strips the page to bare, unstyled HTML. +function applyBare() { + const bare = new URLSearchParams(window.location.search).get('bare') === '1'; + document.documentElement.setAttribute('data-bare', bare ? '1' : '0'); + const t = document.getElementById('bare-toggle'); + if (t) { + t.textContent = bare ? 'styled' : 'bare'; + t.href = bare ? '?bare=0' : '?bare=1'; + } +} + +// The last master deployment datetime (Q43), baked in at deploy time. +function fillDeployed() { + const el = document.getElementById('deployed-at'); + if (el && window.DEPLOYED_AT) el.textContent = window.DEPLOYED_AT; +} + +function main() { + applyBare(); + fillDeployed(); + Promise.all([load('services.json'), load('status.json')]) + .then(function (pair) { renderPanels(pair[0], pair[1]); }) + .catch(function (e) { + document.getElementById('summary').textContent = 'status unavailable: ' + e; + }); +} + +main(); diff --git a/shared/homepage/index.html b/shared/homepage/index.html index 6755733..1cf55a6 100644 --- a/shared/homepage/index.html +++ b/shared/homepage/index.html @@ -3,20 +3,21 @@ -Dankclaw Services +frank — services -
-
-

Dankclaw Services

-

Local server services — click a name to open it.

-
-
- -
+
+ frank@home:~$ services + bare +
+
+
+ + diff --git a/shared/homepage/style.css b/shared/homepage/style.css index 2239cda..505364d 100644 --- a/shared/homepage/style.css +++ b/shared/homepage/style.css @@ -1,63 +1,85 @@ +/* The homepage theme: terminal TUI, green-on-black, 1995 phosphor palette. + A ?bare=1 toggle strips the page to bare, unstyled HTML (Q27). The page + never sets a custom or hidden cursor, so the user's cursor is untouched (Q28). */ + :root { - --bg: #0f1115; - --card: #171a21; - --border: #2a2f3a; - --text: #e6e8eb; - --muted: #8b93a1; - --up: #3fb950; - --down: #f85149; - --accent: #58a6ff; + --bg: #050805; + --fg: #33ff66; + --dim: #2f8f52; + --down: #ff5555; + --wip: #ffcc33; + --link: #33ccff; + --head: #aaffcc; + --border: #1a4d2e; + --panel: #0a120a; } -* { box-sizing: border-box; margin: 0; padding: 0; } +* { box-sizing: border-box; } + +html { background: var(--bg); } body { background: var(--bg); - color: var(--text); - font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; - line-height: 1.5; - min-height: 100vh; + color: var(--fg); + font-family: "Terminus", "Fira Code", "DejaVu Sans Mono", "Consolas", monospace; + font-size: 15px; + line-height: 1.55; + margin: 0; + padding: 24px; + text-shadow: 0 0 4px rgba(51, 255, 102, 0.30); } -main { max-width: 960px; margin: 0 auto; padding: 2rem 1.5rem; } +/* Subtle scanlines. */ +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9999; + background: repeating-linear-gradient(0deg, rgba(0, 0, 0, 0) 0 2px, rgba(0, 0, 0, 0.14) 2px 4px); +} -header { margin-bottom: 2rem; } -h1 { font-size: 2rem; font-weight: 650; letter-spacing: -0.02em; } -.subtitle { color: var(--muted); margin-top: 0.25rem; } +.top { + display: flex; + justify-content: space-between; + align-items: baseline; + border-bottom: 1px solid var(--border); + padding-bottom: 8px; +} +.title { color: var(--head); } +.toggle { color: var(--link); text-decoration: none; border: 1px solid var(--border); padding: 0 8px; } + +.summary { color: var(--dim); margin: 12px 0; letter-spacing: 0.05em; } .grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; } -.card { - background: var(--card); - border: 1px solid var(--border); - border-radius: 10px; - padding: 1rem 1.1rem; +.panel { border: 1px solid var(--border); background: var(--panel); padding: 12px 14px; border-radius: 2px; } +.panel-head { display: flex; justify-content: space-between; align-items: center; } +.name { color: var(--head); } +.badge { border: 1px solid currentColor; padding: 0 6px; border-radius: 2px; font-size: 12px; } +.badge.up { color: var(--fg); } +.badge.down { color: var(--down); } +.badge.wip { color: var(--wip); } +.desc { color: var(--fg); margin: 8px 0; } +.links a { color: var(--link); text-decoration: underline; margin-right: 12px; } + +.foot { + margin-top: 24px; + border-top: 1px solid var(--border); + padding-top: 12px; + color: var(--dim); display: flex; - flex-direction: column; - gap: 0.4rem; - transition: border-color 0.15s ease; + justify-content: space-between; } +.foot a { color: var(--link); text-decoration: none; } -.card:hover { border-color: var(--accent); } - -.card .top { display: flex; align-items: center; gap: 0.6rem; } -.card h2 { font-size: 1.05rem; font-weight: 600; } - -.badge { - width: 10px; height: 10px; border-radius: 50%; - background: var(--muted); flex-shrink: 0; -} -.badge.up { background: var(--up); box-shadow: 0 0 6px var(--up); } -.badge.down { background: var(--down); box-shadow: 0 0 6px var(--down); } -.badge.checking { background: var(--accent); } - -.card p { color: var(--muted); font-size: 0.9rem; } -.card .links { margin-top: auto; display: flex; gap: 1rem; font-size: 0.85rem; } -.card a { color: var(--accent); text-decoration: none; } -.card a:hover { text-decoration: underline; } - -footer { margin-top: 2.5rem; color: var(--muted); font-size: 0.8rem; } +/* Bare mode (Q27): strip to default browser rendering. */ +html[data-bare="1"] body { background: #fff; color: #000; font-family: serif; text-shadow: none; font-size: 16px; } +html[data-bare="1"] body::before { display: none; } +html[data-bare="1"] .panel, html[data-bare="1"] .top, html[data-bare="1"] .foot { border-color: #000; background: none; } +html[data-bare="1"] .badge, html[data-bare="1"] .name, html[data-bare="1"] .desc, html[data-bare="1"] .summary { color: #000; } +html[data-bare="1"] .links a, html[data-bare="1"] .toggle, html[data-bare="1"] .foot a { color: #000; } diff --git a/tests/homepage_pages.bats b/tests/homepage_pages.bats new file mode 100644 index 0000000..d34ca21 --- /dev/null +++ b/tests/homepage_pages.bats @@ -0,0 +1,45 @@ +#!/usr/bin/env bats +# Seam: the homepage pages (index.html, admin.html, app.js, style.css). +# A browser cannot run under bats, so the tests assert the public contract +# statically: the pages reference the data and the theme, the page does not +# auto-refresh (Q41), does not alter the user's cursor (Q28), offers the +# styling toggle (Q27), and shows the last master deployment datetime (Q43). + +load helpers + +setup() { + BASE="$REPO_ROOT/shared/homepage" +} + +@test "pages exist and reference the data and the theme" { + [ -f "$BASE/index.html" ] + [ -f "$BASE/admin.html" ] + [ -f "$BASE/app.js" ] + [ -f "$BASE/style.css" ] + for f in app.js style.css; do + grep -q "$f" "$BASE/index.html" + grep -q "$f" "$BASE/admin.html" + done + grep -q "status.json" "$BASE/app.js" + grep -q "services.json" "$BASE/app.js" +} + +@test "the page does not auto-refresh (Q41)" { + ! grep -q "setInterval" "$BASE/app.js" + ! grep -qi "poll" "$BASE/app.js" +} + +@test "the page does not alter the user's cursor (Q28)" { + ! grep -qE "cursor:[[:space:]]*(none|url\()" "$BASE/style.css" +} + +@test "the styling toggle strips to bare HTML (Q27)" { + grep -q "data-bare" "$BASE/style.css" + grep -q "bare" "$BASE/app.js" +} + +@test "the footer shows the last master deployment datetime (Q43)" { + grep -q "deployed-at" "$BASE/index.html" + grep -q "deployed-at" "$BASE/admin.html" + grep -q "deployed-at" "$BASE/app.js" +}