Add isolated read-only spectator monitor for public sharing
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install the isolated spectator service; public Funnel setup is a separate step."""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
repo = Path(__file__).resolve().parents[2]
|
||||
python = shutil.which('python3')
|
||||
assert python
|
||||
units = Path.home() / '.config/systemd/user'
|
||||
units.mkdir(parents=True, exist_ok=True)
|
||||
(units / 'azerothai-spectator.service').write_text(f'''[Unit]
|
||||
Description=AzerothAI read-only spectator monitor
|
||||
After=network-online.target azerothai-runtime.service
|
||||
[Service]
|
||||
WorkingDirectory={repo}
|
||||
ExecStart={python} {repo}/scripts/spectator-server.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
UMask=0077
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
''')
|
||||
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
|
||||
subprocess.run(['systemctl', '--user', 'enable', '--now', 'azerothai-spectator.service'], check=True)
|
||||
print('Spectator ready on loopback port 8790. Public sharing:')
|
||||
print('sudo tailscale funnel --bg --https=8443 http://127.0.0.1:8790')
|
||||
print('Keep the existing private dashboard on port 443.')
|
||||
@@ -0,0 +1,60 @@
|
||||
# Spectator monitor
|
||||
|
||||
A separate loopback service serves a public, read-only projection of the realm.
|
||||
It never forwards viewer paths, methods, cookies, authorization headers or query
|
||||
parameters to the administrative application. It uses a fixed local GET to
|
||||
`http://127.0.0.1:8787/api/state`, caches the sanitized result for ten seconds, and
|
||||
marks delayed observations. Visitors refresh every fifteen seconds.
|
||||
|
||||
Published fields: online bot names, class, race, level, faction, zone, guild;
|
||||
observed bot parties; guild goals and progress; confirmed social activity.
|
||||
Only autonomous bot-to-bot reply transcripts are public. Human conversations,
|
||||
whispers, memories, account/character IDs, tokens, configuration and PC telemetry
|
||||
are excluded. All text is rendered with textContent, never HTML interpolation.
|
||||
The HTTP service allows only explicit static paths and `/api/realm`, `/health`,
|
||||
`/robots.txt`. Mutation methods return 405; admin paths return 404.
|
||||
|
||||
## Install and publish
|
||||
|
||||
Run as the realm owner from the repository:
|
||||
|
||||
```sh
|
||||
python3 scripts/test-spectator.py
|
||||
python3 deploy/playable/install-spectator.py
|
||||
```
|
||||
|
||||
The user service restarts automatically and listens only on `127.0.0.1:8790`.
|
||||
User lingering, already used by runtime recovery, is required for boot startup.
|
||||
Keep the existing private Tailscale Serve dashboard on HTTPS 443. Publish only
|
||||
the spectator service on a separate HTTPS port:
|
||||
|
||||
```sh
|
||||
sudo tailscale funnel --bg --https=8443 http://127.0.0.1:8790
|
||||
```
|
||||
|
||||
If Tailscale presents a Funnel enablement link, the tailnet owner must complete
|
||||
that authorization. Verify `tailscale funnel status` lists **only port 8443** as
|
||||
public and port 443 remains tailnet-only. Share the printed HTTPS URL including
|
||||
`:8443`. Visitors do not need Tailscale. This is a public link, without a password.
|
||||
The host PC, Ubuntu VM and Tailscale must remain running.
|
||||
|
||||
Do not publish port 8787 or 8788, or change the administrative listener into a
|
||||
Funnel. Hiding controls in the browser would not protect their APIs.
|
||||
|
||||
To turn public access off without disturbing the private dashboard:
|
||||
|
||||
```sh
|
||||
sudo tailscale funnel --https=8443 off
|
||||
```
|
||||
|
||||
To stop the local spectator service:
|
||||
|
||||
```sh
|
||||
systemctl --user disable --now azerothai-spectator
|
||||
```
|
||||
|
||||
After changing spectator source or assets, restart its service:
|
||||
|
||||
```sh
|
||||
systemctl --user restart azerothai-spectator
|
||||
```
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only public realm projection. Never proxy arbitrary routes or headers."""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1] / 'spectator'
|
||||
ALLIANCE = {'Human', 'Dwarf', 'Night Elf', 'Gnome', 'Draenei'}
|
||||
HORDE = {'Orc', 'Undead', 'Tauren', 'Troll', 'Blood Elf'}
|
||||
|
||||
|
||||
def text(value, limit=200):
|
||||
return str(value or '')[:limit]
|
||||
|
||||
|
||||
def number(value, default=0):
|
||||
return value if type(value) in (int, float) and math.isfinite(value) else default
|
||||
|
||||
|
||||
def project(state):
|
||||
"""Explicit field allowlist: no settings, IDs, memories, whispers or host data."""
|
||||
managed = [b for b in state.get('bots', []) if b.get('autonomy', {}).get('managed')]
|
||||
names = {b['name'] for b in managed}
|
||||
bots = []
|
||||
parties = {}
|
||||
for b in managed:
|
||||
if b.get('status') != 'online':
|
||||
continue
|
||||
world = b.get('world') or {}
|
||||
race = text(b.get('race'), 40)
|
||||
team = world.get('team')
|
||||
faction = ('Alliance' if team == 0 else 'Horde' if team == 1 else
|
||||
'Alliance' if race in ALLIANCE else 'Horde' if race in HORDE else 'Unknown')
|
||||
bots.append({'name': text(b.get('name'), 40), 'class': text(b.get('role'), 40),
|
||||
'race': race, 'level': number(b.get('level')), 'zone': text(b.get('zone'), 100),
|
||||
'faction': faction, 'guild': text(world.get('guildName'), 80)})
|
||||
members = sorted({text(n, 40) for n in world.get('groupNames', []) if n in names})
|
||||
if world.get('groupId') and len(members) > 1:
|
||||
parties[tuple(members)] = {'members': members, 'faction': faction}
|
||||
community = state.get('community') or {}
|
||||
feed = []
|
||||
for e in community.get('feed', []):
|
||||
if e.get('status') != 'confirmed' or e.get('kind') not in {'conversation', 'relationship', 'party', 'leave', 'recruit', 'guild'}:
|
||||
continue
|
||||
# Only completed autonomous bot-to-bot exchanges. Human conversations,
|
||||
# whispers and synthetic opener prompts never enter this public feed.
|
||||
if e['kind'] == 'conversation' and not (
|
||||
str(e.get('id', '')).startswith('social-') and str(e.get('id', '')).endswith(':reply')
|
||||
and len(e.get('names', [])) == 2 and all(n in names for n in e['names'])):
|
||||
continue
|
||||
feed.append({'at': text(e.get('at'), 40), 'kind': e['kind'],
|
||||
'names': [text(n, 80) for n in e.get('names', [])[:5]],
|
||||
'text': text(e.get('text'), 1200)})
|
||||
guilds = [{'name': text(g.get('name'), 80), 'goal': text(g.get('goal'), 30),
|
||||
'progress': number(g.get('progress')), 'target': number(g.get('target'), 20)}
|
||||
for g in community.get('guilds', [])[:100]]
|
||||
return {'rosterAt': number(state.get('aleRosterAt')), 'fetchedAt': int(time.time()),
|
||||
'bots': sorted(bots, key=lambda b: (b['faction'], b['name'])),
|
||||
'parties': list(parties.values()), 'guilds': guilds, 'feed': feed[-60:][::-1]}
|
||||
|
||||
|
||||
class Snapshot:
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self.data = None
|
||||
self.checked = 0
|
||||
self.failed = False
|
||||
|
||||
def get(self):
|
||||
with self.lock:
|
||||
if time.monotonic() - self.checked > 10:
|
||||
try:
|
||||
# Fixed local endpoint. Viewer input cannot affect this request.
|
||||
opener = urllib.request.build_opener(NoRedirect(), urllib.request.ProxyHandler({}))
|
||||
with opener.open('http://127.0.0.1:8787/api/state', timeout=5) as response:
|
||||
raw = response.read(8 * 1024 * 1024 + 1)
|
||||
if len(raw) > 8 * 1024 * 1024:
|
||||
raise ValueError('State too large')
|
||||
self.data = project(json.loads(raw))
|
||||
self.failed = False
|
||||
except Exception:
|
||||
self.failed = True
|
||||
self.checked = time.monotonic()
|
||||
if self.data is None:
|
||||
return None
|
||||
return {**self.data, 'stale': self.failed or time.time() - self.data['rosterAt'] > 120}
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
SNAPSHOT = Snapshot()
|
||||
ASSETS = {'/': ('index.html', 'text/html; charset=utf-8'),
|
||||
'/app.js': ('app.js', 'text/javascript; charset=utf-8'),
|
||||
'/style.css': ('style.css', 'text/css; charset=utf-8')}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = 'AzerothAI-Spectator'
|
||||
sys_version = ''
|
||||
|
||||
def setup(self):
|
||||
super().setup()
|
||||
self.connection.settimeout(10)
|
||||
|
||||
def reply(self, status, body, content_type='application/json; charset=utf-8'):
|
||||
self.send_response(status)
|
||||
self.send_header('Content-Type', content_type)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.send_header('X-Content-Type-Options', 'nosniff')
|
||||
self.send_header('X-Frame-Options', 'DENY')
|
||||
self.send_header('Referrer-Policy', 'no-referrer')
|
||||
self.send_header('X-Robots-Tag', 'noindex, nofollow')
|
||||
self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
|
||||
self.end_headers()
|
||||
if self.command != 'HEAD':
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split('?', 1)[0]
|
||||
if path in ASSETS:
|
||||
filename, content_type = ASSETS[path]
|
||||
self.reply(200, (ROOT / filename).read_bytes(), content_type)
|
||||
elif path == '/api/realm':
|
||||
data = SNAPSHOT.get()
|
||||
self.reply(200 if data else 503, json.dumps(data or {'error': 'Realm temporarily unavailable'}).encode())
|
||||
elif path == '/health':
|
||||
self.reply(200, b'{"service":"spectator","readOnly":true}')
|
||||
elif path == '/robots.txt':
|
||||
self.reply(200, b'User-agent: *\nDisallow: /\n', 'text/plain; charset=utf-8')
|
||||
else:
|
||||
self.reply(404, b'{"error":"Not found"}')
|
||||
|
||||
do_HEAD = do_GET
|
||||
|
||||
def reject(self):
|
||||
self.close_connection = True
|
||||
self.reply(405, b'{"error":"This monitor is read-only"}')
|
||||
|
||||
do_POST = do_PUT = do_PATCH = do_DELETE = do_OPTIONS = do_TRACE = do_CONNECT = reject
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
|
||||
class Server(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
slots = threading.BoundedSemaphore(24)
|
||||
|
||||
def process_request(self, request, client_address):
|
||||
if not self.slots.acquire(blocking=False):
|
||||
self.shutdown_request(request)
|
||||
return
|
||||
try:
|
||||
super().process_request(request, client_address)
|
||||
except Exception:
|
||||
self.slots.release()
|
||||
raise
|
||||
|
||||
def process_request_thread(self, request, client_address):
|
||||
try:
|
||||
super().process_request_thread(request, client_address)
|
||||
finally:
|
||||
self.slots.release()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
Server(('127.0.0.1', int(os.environ.get('AZEROTHAI_SPECTATOR_PORT', '8790'))), Handler).serve_forever()
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
import importlib.util
|
||||
import json
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
spec = importlib.util.spec_from_file_location('spectator', Path(__file__).with_name('spectator-server.py'))
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
|
||||
|
||||
class SpectatorTests(unittest.TestCase):
|
||||
def test_public_projection_excludes_secrets_and_whispers(self):
|
||||
bots = [{'name': n, 'status': 'online', 'race': r, 'role': 'Death Knight', 'level': 55,
|
||||
'autonomy': {'managed': True, 'accountId': 123}, 'memories': ['PRIVATE_MEMORY'],
|
||||
'world': {'team': team, 'groupId': 'internal-guid', 'groupNames': ['A', 'B', 'HumanPlayer']}}
|
||||
for n, r, team in [('A', 'Human', 0), ('B', 'Orc', 1)]]
|
||||
feed = [{'id': i, 'kind': 'conversation', 'status': 'confirmed', 'names': names, 'text': t}
|
||||
for i, names, t in [('whisper-1', ['HumanPlayer', 'A'], 'PRIVATE_WHISPER'),
|
||||
('social-1', ['A', 'B'], 'SYNTHETIC_PROMPT'),
|
||||
('social-1:reply', ['A', 'B'], 'Hello friend'),
|
||||
('social-2:reply', ['HumanPlayer', 'A'], 'NOT_BOT_CHAT')]]
|
||||
projected = m.project({'bots': bots, 'settings': {'token': 'SECRET'}, 'community': {'feed': feed}})
|
||||
serialized = json.dumps(projected)
|
||||
for private in ['PRIVATE_MEMORY', 'PRIVATE_WHISPER', 'SYNTHETIC_PROMPT', 'NOT_BOT_CHAT', 'HumanPlayer', 'SECRET', 'accountId', 'internal-guid']:
|
||||
self.assertNotIn(private, serialized)
|
||||
self.assertEqual([b['faction'] for b in projected['bots']], ['Alliance', 'Horde'])
|
||||
self.assertEqual([e['text'] for e in projected['feed']], ['Hello friend'])
|
||||
|
||||
def test_http_allowlist_and_mutations(self):
|
||||
server = m.Server(('127.0.0.1', 0), m.Handler)
|
||||
worker = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
worker.start()
|
||||
base = 'http://127.0.0.1:' + str(server.server_port)
|
||||
try:
|
||||
for route in ['/', '/app.js', '/style.css', '/health']:
|
||||
with urllib.request.urlopen(base + route) as response:
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIn("default-src 'none'", response.headers['Content-Security-Policy'])
|
||||
for path in ['/api/state', '/api/actions', '/api/ale/operator', '/api/operations', '/.dev.vars', '/../.dev.vars', '/%2e%2e/.dev.vars', '//api/state']:
|
||||
with self.assertRaises(urllib.error.HTTPError) as error:
|
||||
urllib.request.urlopen(base + path)
|
||||
self.assertEqual(error.exception.code, 404)
|
||||
for method in ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE']:
|
||||
with self.assertRaises(urllib.error.HTTPError) as error:
|
||||
urllib.request.urlopen(urllib.request.Request(base + '/api/actions', method=method, data=b'{}'))
|
||||
self.assertEqual(error.exception.code, 405)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
worker.join()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,46 @@
|
||||
'use strict';
|
||||
const $ = id => document.getElementById(id);
|
||||
let realm;
|
||||
function el(tag, text, cls) { const node = document.createElement(tag); if (text !== undefined) node.textContent = String(text); if (cls) node.className = cls; return node; }
|
||||
function empty(node, text) { node.replaceChildren(el('p', text, 'empty')); }
|
||||
function renderRoster() {
|
||||
if (!realm) return;
|
||||
const term = $('search').value.trim().toLowerCase(), faction = $('faction').value;
|
||||
const bots = realm.bots.filter(b => (faction === 'all' || b.faction === faction) && [b.name,b.class,b.race,b.zone,b.guild].join(' ').toLowerCase().includes(term));
|
||||
$('roster').replaceChildren(...bots.map(b => {
|
||||
const row = el('tr'); row.append(el('td', b.name));
|
||||
const team = el('td'); team.append(el('span', b.faction, 'tag ' + b.faction.toLowerCase())); row.append(team);
|
||||
const role = el('td', b.class); role.append(el('small', b.race)); row.append(role, el('td', b.level), el('td', b.zone || 'Unknown'), el('td', b.guild || '—')); return row;
|
||||
}));
|
||||
$('roster-empty').hidden = bots.length > 0;
|
||||
$('roster-empty').textContent = realm.bots.length ? 'No residents match this search.' : 'No residents are currently reported online.';
|
||||
}
|
||||
function renderFeed() {
|
||||
if (!realm) return;
|
||||
const entries = realm.feed.filter(e => $('activity-filter').value === 'all' || e.kind === $('activity-filter').value);
|
||||
if (!entries.length) return empty($('feed'), 'No confirmed activity of this type yet. New encounters will appear here.');
|
||||
$('feed').replaceChildren(...entries.map(e => {
|
||||
const node = el('article', undefined, 'entry');
|
||||
node.append(el('span', e.kind + ' · ' + new Date(e.at).toLocaleString(), 'meta'), el('h3', e.names.join(' ↔ ')), el('p', e.text)); return node;
|
||||
}));
|
||||
}
|
||||
function render() {
|
||||
$('total').textContent = realm.bots.length;
|
||||
$('alliance').textContent = realm.bots.filter(b => b.faction === 'Alliance').length;
|
||||
$('horde').textContent = realm.bots.filter(b => b.faction === 'Horde').length;
|
||||
$('parties-count').textContent = realm.parties.length;
|
||||
$('status').className = 'status' + (realm.stale ? ' stale' : '');
|
||||
$('status').textContent = (realm.stale ? 'Updates delayed · Showing last known roster' : 'Live realm observations') + (realm.rosterAt ? ' · Observed ' + new Date(realm.rosterAt * 1000).toLocaleTimeString() : '');
|
||||
renderRoster(); renderFeed();
|
||||
$('parties').replaceChildren(...realm.parties.map(p => { const n=el('article',undefined,'group');n.append(el('strong',p.members.join(' · ')),el('p',p.faction));return n; }));
|
||||
if (!realm.parties.length) empty($('parties'), 'No bot parties observed yet.');
|
||||
const goals={quest:'Complete quests together',support:'Help guildmates in combat',explore:'Explore other maps'};
|
||||
$('guilds').replaceChildren(...realm.guilds.map(g => { const n=el('article',undefined,'group');n.append(el('strong',g.name),el('p',goals[g.goal]||'Shared adventures'),el('p',g.progress+' contributions · milestone '+g.target));const p=el('progress');p.max=Math.max(1,g.target);p.value=g.progress;p.setAttribute('aria-label',g.name+' goal progress');n.append(p);return n; }));
|
||||
if (!realm.guilds.length) empty($('guilds'), 'Waiting for guild observations.');
|
||||
}
|
||||
async function refresh() {
|
||||
try { const response=await fetch('/api/realm',{cache:'no-store',signal:AbortSignal.timeout(10000)});if(!response.ok)throw new Error();realm=await response.json();render(); }
|
||||
catch { $('status').className='status stale';$('status').textContent=realm?'Connection interrupted · Showing the last observation. Retrying automatically.':'Realm temporarily unavailable. Retrying automatically.'; }
|
||||
finally { setTimeout(refresh,15000); }
|
||||
}
|
||||
$('search').addEventListener('input',renderRoster);$('faction').addEventListener('change',renderRoster);$('activity-filter').addEventListener('change',renderFeed);refresh();
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>AzerothAI · Living Realm</title><link rel="stylesheet" href="/style.css"><script src="/app.js" defer></script></head>
|
||||
<body><main>
|
||||
<header><div><p class="eyebrow">AZEROTHAI / SPECTATOR</p><h1>A world with a life of its own.</h1><p class="intro">Follow the residents of Azeroth as they adventure, meet, and build a shared history.</p></div><span class="badge">View only</span></header>
|
||||
<div class="status" role="status" id="status">Connecting to the realm…</div>
|
||||
<section class="stats" aria-label="Realm population"><article><span>Residents online</span><strong id="total">—</strong><small>50 resident limit</small></article><article class="alliance"><span>Alliance</span><strong id="alliance">—</strong><small>25 resident target</small></article><article class="horde"><span>Horde</span><strong id="horde">—</strong><small>25 resident target</small></article><article><span>Adventuring parties</span><strong id="parties-count">—</strong><small>Observed bot groups</small></article></section>
|
||||
<nav aria-label="Realm sections"><a href="#residents">Residents</a><a href="#activity">Social life</a><a href="#groups">Parties & guilds</a></nav>
|
||||
<section id="residents" class="panel"><div class="heading"><div><p class="eyebrow">IN THE WORLD</p><h2>Meet the residents</h2></div><div class="filters"><label><span class="sr-only">Search residents</span><input id="search" type="search" placeholder="Find a name, class, or zone…"></label><label><span class="sr-only">Faction</span><select id="faction"><option value="all">Both factions</option><option>Alliance</option><option>Horde</option></select></label></div></div><div class="table-wrap"><table><thead><tr><th>Name</th><th>Faction</th><th>Class / race</th><th>Level</th><th>Location</th><th>Guild</th></tr></thead><tbody id="roster"></tbody></table></div><p id="roster-empty" class="empty">Waiting for the first observation.</p></section>
|
||||
<div class="columns"><section id="activity" class="panel"><div class="heading"><div><p class="eyebrow">SHARED STORIES</p><h2>Social life</h2></div><label><span class="sr-only">Activity type</span><select id="activity-filter"><option value="all">All activity</option><option value="conversation">Conversations</option><option value="relationship">Relationships</option></select></label></div><div id="feed" class="feed"></div></section><section id="groups" class="panel"><div class="heading"><div><p class="eyebrow">BETTER TOGETHER</p><h2>Parties & guilds</h2></div></div><div class="group-body"><h3>Current parties</h3><div id="parties"></div><h3 class="guild-title">Guild ambitions</h3><div id="guilds"></div></div></section></div>
|
||||
<footer>Automatic updates every 15 seconds · Population can briefly dip while a replacement joins.<br>Bot-to-bot conversations only. Player whispers stay private.</footer>
|
||||
</main></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
:root{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#e6e8ed;background:#0b1017;font-synthesis:none}*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 15% 0,#23314455,transparent 55%)}main{max-width:1280px;margin:auto;padding:48px 28px}header{display:flex;justify-content:space-between;gap:24px;align-items:flex-start}h1{font-family:Georgia,serif;font-size:clamp(30px,4vw,48px);font-weight:400;letter-spacing:-1px;margin:12px 0}h2{font-size:22px;margin:5px 0}h3{font-size:15px;margin:0 0 14px}.eyebrow{font-size:11px;letter-spacing:2px;color:#c3a56f;margin:0}.intro{max-width:640px;color:#a9b2c1;line-height:1.7}.badge{border:1px solid #415245;border-radius:30px;color:#a8c8a5;white-space:nowrap;padding:8px 14px;font-size:12px}.status{color:#a9b2c1;margin:20px 0;font-size:13px}.status.stale{color:#f2bd79}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}.stats article{background:#121a25;border:1px solid #293241;border-radius:14px;padding:22px;display:flex;flex-direction:column;gap:6px}.stats span{font-size:13px;color:#bec7d3}.stats strong{font-size:40px;font-weight:500;color:#dcc18e}.stats small{font-size:11px;color:#8e9aaa}.stats .alliance strong{color:#8dbbff}.stats .horde strong{color:#ef9995}nav{display:flex;gap:25px;padding:26px 0}a{color:#bcc7d5;text-decoration:none;font-size:14px}a:hover{color:#efd69f}.panel{background:#111923;border:1px solid #293241;border-radius:14px;overflow:hidden;scroll-margin-top:20px}.heading{padding:24px;display:flex;align-items:center;justify-content:space-between;gap:18px;border-bottom:1px solid #27303c}.filters{display:flex;gap:8px}input,select{font:inherit;font-size:13px;background:#0c131c;border:1px solid #354153;color:#dfe5ec;padding:11px;border-radius:7px}input{width:250px}input:focus,select:focus,a:focus-visible{outline:2px solid #c3a56f;outline-offset:3px}.table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse;text-align:left;white-space:nowrap}th{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#919eaf;padding:15px 22px}td{font-size:13px;padding:15px 22px;border-top:1px solid #202b38;color:#b5c0ce}td:first-child{color:#eee6d9;font-weight:600}td small{display:block;color:#8b98aa;font-size:11px;margin-top:4px}.tag{font-size:11px;border-radius:4px;padding:4px 7px;background:#263347;color:#a6c8fb}.tag.horde{background:#38282e;color:#eab0a7}.columns{display:grid;grid-template-columns:1.25fr 1fr;gap:22px;margin-top:22px;align-items:start}.feed{max-height:660px;overflow-y:auto;padding:0 24px}.entry{padding:22px 0;border-bottom:1px solid #293241}.entry:last-child{border:0}.entry .meta{font-size:10px;color:#a6b0bd;text-transform:uppercase;letter-spacing:.8px}.entry h3{margin:10px 0;color:#d8c19a}.entry p{font-size:14px;line-height:1.7;white-space:pre-wrap;margin:0;color:#b6c1d0;overflow-wrap:anywhere}.group-body{padding:24px}.group{background:#17212e;border-radius:9px;padding:16px;margin-bottom:12px;font-size:14px;line-height:1.6}.group strong{display:block;color:#dfcfb2}.group p{font-size:12px;color:#aab7c8;margin:5px 0}.guild-title{margin-top:30px}progress{width:100%;height:6px;accent-color:#bea573}.empty{color:#95a2b4;font-size:13px;line-height:1.7;padding:20px;margin:0}footer{margin-top:30px;color:#8593a6;font-size:12px;line-height:1.9}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:850px){.columns{grid-template-columns:1fr}.heading{align-items:flex-start;flex-direction:column}.filters{width:100%}input{width:100%}.filters label:first-child{flex:1}}@media(max-width:540px){main{padding:28px 16px}.stats{grid-template-columns:1fr 1fr;gap:10px}.stats article{padding:17px}.stats strong{font-size:34px}header{display:block}.badge{display:inline-block;margin-top:8px}.heading{padding:20px}.filters{flex-direction:column}select{max-width:100%}nav{gap:20px}nav a{font-size:13px}}
|
||||
Reference in New Issue
Block a user