chore: bootstrap lean sysadmin-chronicles repo

Import the runnable game code, content, docs, scripts, and repo guidance while leaving local agent state, dependency installs, build output, and backup copies out of the published tree.
This commit is contained in:
2026-05-02 11:49:07 -04:00
commit 0265afa054
252 changed files with 37574 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sysadmin Chronicles</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1176
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "sysadmin-chronicles-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"svelte": "^5.55.5",
"vite": "^8.0.10"
}
}
+367
View File
@@ -0,0 +1,367 @@
<script>
import { onMount } from 'svelte';
import HeaderBar from './components/HeaderBar.svelte';
import SidebarTabs from './components/SidebarTabs.svelte';
import TicketsPanel from './components/TicketsPanel.svelte';
import MailPanel from './components/MailPanel.svelte';
import DocsPanel from './components/DocsPanel.svelte';
import VmsPanel from './components/VmsPanel.svelte';
import ProfilePanel from './components/ProfilePanel.svelte';
import { api } from './lib/api.js';
import DebugPanel from './components/DebugPanel.svelte';
const showDebug = new URLSearchParams(window.location.search).has('debug');
let activeTab = 'tickets';
let state = null;
let tickets = [];
let selectedTicket = null;
let mail = [];
let selectedMail = null;
let docs = [];
let selectedDoc = null;
let vms = [];
let connection = 'offline';
let loading = true;
let busy = false;
let error = '';
let alerts = [];
let playerPortrait = 'player-silhouette';
$: unreadMailCount = mail.filter((item) => !item.read).length;
function syncSelectedRecord(collection, selected, setter) {
if (!selected) {
return;
}
const match = collection.find((item) => item.id === selected.id);
if (!match) {
setter(null);
}
}
function attachmentToDocId(attachment) {
return String(attachment ?? '')
.replace(/^docs\//, '')
.replace(/\.json$/, '');
}
async function refreshCore() {
const [nextState, nextTickets, nextMail, nextDocs, nextVms] = await Promise.all([
api.getState(),
api.getTickets(),
api.getMail(),
api.getDocs(),
api.getVms()
]);
state = nextState;
tickets = nextTickets;
mail = nextMail;
docs = nextDocs;
vms = nextVms;
if (!selectedTicket && tickets[0]) {
await selectTicket(tickets[0].id);
} else {
syncSelectedRecord(tickets, selectedTicket, (value) => selectedTicket = value);
}
if (!selectedDoc) {
const firstUnlocked = docs.find((doc) => !doc.locked);
if (firstUnlocked) {
await selectDoc(firstUnlocked.id);
}
} else {
syncSelectedRecord(docs, selectedDoc, (value) => selectedDoc = value);
}
if (selectedMail) {
const stillExists = mail.find((item) => item.id === selectedMail.id);
if (stillExists) {
selectedMail = await api.getMailById(selectedMail.id);
} else {
selectedMail = null;
}
}
}
async function selectTicket(id) {
selectedTicket = await api.getTicket(id);
}
async function selectMail(id) {
const detail = await api.getMailById(id);
if (!detail.read) {
await api.markMailRead(id);
mail = mail.map((item) => item.id === id ? { ...item, read: true } : item);
selectedMail = { ...detail, read: true };
return;
}
selectedMail = detail;
}
async function selectDoc(id) {
selectedDoc = await api.getDoc(id);
}
async function completeTicket(event) {
try {
busy = true;
error = '';
const result = await api.completeTicket(event.detail);
if (!result?.passed) {
const failureText = Array.isArray(result?.failures) && result.failures.length > 0
? result.failures.join('\n')
: result?.reason ?? 'Validation failed';
error = `Ticket could not be completed.\n${failureText}`;
return;
}
await refreshCore();
if (tickets[0]) {
await selectTicket(tickets[0].id);
}
} catch (nextError) {
error = nextError.message;
} finally {
busy = false;
}
}
async function replyToMail(event) {
const { id, choice } = event.detail;
try {
busy = true;
error = '';
await api.replyMail(id, choice);
await refreshCore();
await selectMail(id);
} catch (nextError) {
error = nextError.message;
} finally {
busy = false;
}
}
async function openAttachment(event) {
const docId = attachmentToDocId(event.detail);
activeTab = 'docs';
try {
await selectDoc(docId);
} catch (nextError) {
error = nextError.message;
}
}
function connectSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(`${protocol}//${window.location.host}`);
socket.addEventListener('open', () => {
connection = 'connected';
});
socket.addEventListener('close', () => {
connection = 'offline';
window.setTimeout(connectSocket, 1500);
});
socket.addEventListener('message', async (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'shift:tick') {
state = {
...(state ?? {}),
shift: message.payload
};
return;
}
if (message.type === 'incident:alert') {
alerts = [message.payload, ...alerts].slice(0, 3);
}
if (['trust:changed', 'progression:changed', 'mail:new', 'quest:completed', 'quest:activated', 'incident:alert', 'shift:ended', 'certification:awarded'].includes(message.type)) {
await refreshCore();
}
} catch {
// Ignore malformed socket events in the HUD.
}
});
}
async function savePortrait(portrait) {
playerPortrait = portrait;
try {
await api.setProfile(portrait);
} catch {
// portrait is already set locally; persist failure is non-fatal
}
}
onMount(async () => {
try {
await api.ensureSession();
const profile = await api.getProfile();
playerPortrait = profile.portrait ?? 'player-silhouette';
await refreshCore();
connectSocket();
} catch (nextError) {
error = nextError.message;
} finally {
loading = false;
}
});
</script>
<svelte:head>
<title>Sysadmin Chronicles HUD</title>
</svelte:head>
{#if loading}
<main class="shell centered">
<p>Bringing the desk online…</p>
</main>
{:else}
<main class="shell">
<HeaderBar {state} {connection} {playerPortrait} />
{#if error}
<div class="error">{error}</div>
{/if}
{#if alerts.length > 0}
<section class="alerts">
{#each alerts as alert}
<article class={`alert ${alert.severity ?? 'warning'}`}>
<strong>{alert.subject}</strong>
<p>{alert.message}</p>
</article>
{/each}
</section>
{/if}
<section class="workspace">
<SidebarTabs
active={activeTab}
unreadMailCount={unreadMailCount}
on:change={(event) => activeTab = event.detail}
/>
<div class="content">
{#if activeTab === 'tickets'}
<TicketsPanel
{tickets}
{selectedTicket}
{busy}
on:select={(event) => selectTicket(event.detail)}
on:complete={completeTicket}
/>
{:else if activeTab === 'mail'}
<MailPanel
{mail}
{selectedMail}
{busy}
on:select={(event) => selectMail(event.detail)}
on:reply={replyToMail}
on:attachment={openAttachment}
/>
{:else if activeTab === 'docs'}
<DocsPanel
{docs}
{selectedDoc}
on:select={(event) => selectDoc(event.detail)}
/>
{:else if activeTab === 'vms'}
<VmsPanel {vms} />
{:else if activeTab === 'profile'}
<ProfilePanel
certifications={state?.certifications ?? []}
shiftHistory={state?.shiftHistory ?? []}
currentShiftStats={state?.currentShiftStats ?? null}
{playerPortrait}
on:portrait={(event) => savePortrait(event.detail)}
/>
{/if}
</div>
</section>
{#if import.meta.env.DEV && showDebug}<DebugPanel />{/if}
</main>
{/if}
<style>
.shell {
max-width: 1440px;
margin: 0 auto;
padding: 2rem;
}
.centered {
display: grid;
place-items: center;
min-height: 100vh;
}
.workspace {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 1rem;
}
.alerts {
display: grid;
gap: 0.75rem;
margin-bottom: 1rem;
}
.alert {
border-radius: 16px;
border: 1px solid var(--border);
background: var(--panel);
box-shadow: var(--shadow);
padding: 0.9rem 1rem;
}
.alert.warning {
border-color: rgba(213, 166, 75, 0.35);
}
.alert.error,
.alert.critical {
border-color: rgba(200, 101, 101, 0.4);
}
.alert strong {
display: block;
margin-bottom: 0.3rem;
}
.alert p {
margin: 0;
color: var(--text-muted);
}
.content {
min-height: 70vh;
}
.error {
margin-bottom: 1rem;
padding: 0.9rem 1rem;
border: 1px solid rgba(200, 101, 101, 0.35);
border-radius: 16px;
background: rgba(200, 101, 101, 0.12);
color: #ffddd8;
}
@media (max-width: 960px) {
.shell {
padding: 1rem;
}
.workspace {
grid-template-columns: 1fr;
}
}
</style>
+35
View File
@@ -0,0 +1,35 @@
:root {
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
color: #f2efe9;
background:
radial-gradient(circle at top left, rgba(214, 104, 56, 0.18), transparent 28%),
linear-gradient(180deg, #121315 0%, #171a1d 100%);
color-scheme: dark;
--panel: rgba(18, 20, 24, 0.82);
--panel-strong: rgba(24, 27, 31, 0.94);
--border: rgba(255, 255, 255, 0.1);
--text-muted: #bdb6ab;
--accent: #d66838;
--accent-soft: rgba(214, 104, 56, 0.18);
--good: #63b67a;
--warn: #d5a64b;
--bad: #c86565;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
}
html, body, #app {
margin: 0;
min-height: 100%;
}
body {
min-height: 100vh;
}
button, input, textarea {
font: inherit;
}
button {
cursor: pointer;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 844 KiB

+134
View File
@@ -0,0 +1,134 @@
<script>
import { onMount } from 'svelte';
import { ensureSession } from '../lib/api.js';
const PHASES = ['normal_work', 'unease', 'suspicion', 'investigation', 'conflict', 'resolution'];
let snapshot = null;
let ending = null;
let phaseInput = 'normal_work';
let behaviorInput = { curiosity: 50, obedience: 50, risk: 50, suspicion: 0 };
let status = '';
async function dbgGet(path) {
const token = await ensureSession();
const res = await fetch(path, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}
async function dbgPost(path, body) {
const token = await ensureSession();
const res = await fetch(path, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}
async function refresh() {
try {
const [raw, e] = await Promise.all([
dbgGet('/api/debug/state'),
dbgGet('/api/debug/ending')
]);
snapshot = raw;
ending = e;
if (raw.behavior) behaviorInput = { ...raw.behavior };
phaseInput = raw.narrative_phase ?? 'normal_work';
status = '';
} catch (err) {
status = err.message;
}
}
async function overrideBehavior() {
try {
await dbgPost('/api/debug/behavior', {
curiosity: Number(behaviorInput.curiosity),
obedience: Number(behaviorInput.obedience),
risk: Number(behaviorInput.risk),
suspicion: Number(behaviorInput.suspicion)
});
await refresh();
status = 'Behavior updated.';
} catch (err) {
status = err.message;
}
}
async function forcePhase() {
try {
await dbgPost('/api/debug/phase', { phase: phaseInput });
await refresh();
status = `Phase set to ${phaseInput}.`;
} catch (err) {
status = err.message;
}
}
onMount(refresh);
</script>
<aside class="dbg">
<h3>Debug</h3>
<button on:click={refresh}>Refresh</button>
{#if status}<p class="st">{status}</p>{/if}
{#if snapshot}
<section>
<h4>Phase</h4>
<p>{snapshot.narrative_phase}</p>
<select bind:value={phaseInput}>
{#each PHASES as p}<option value={p}>{p}</option>{/each}
</select>
<button on:click={forcePhase}>Force</button>
</section>
<section>
<h4>Behavior</h4>
{#each ['curiosity', 'obedience', 'risk', 'suspicion'] as field}
<label>{field}<input type="number" min="0" max="100" bind:value={behaviorInput[field]} /></label>
{/each}
<button on:click={overrideBehavior}>Apply</button>
</section>
<section>
<h4>Ending</h4>
<p>active: <strong>{ending?.active}</strong></p>
<p>candidates: {ending?.candidates?.join(', ') || 'none'}</p>
</section>
<section>
<h4>Hooks</h4>
<p>{snapshot.hidden_hooks_discovered?.join(', ') || 'none'}</p>
</section>
{/if}
</aside>
<style>
.dbg {
position: fixed;
bottom: 1rem;
right: 1rem;
width: 260px;
background: #111;
border: 1px solid #444;
border-radius: 8px;
padding: 1rem;
font-size: 0.78rem;
color: #ccc;
z-index: 9999;
max-height: 80vh;
overflow-y: auto;
}
h3, h4 { margin: 0.4rem 0 0.2rem; color: #fff; }
section { margin-bottom: 0.75rem; }
label { display: block; margin-bottom: 0.2rem; }
input { width: 55px; margin-left: 0.4rem; }
select { width: 100%; margin-bottom: 0.4rem; }
button { margin-right: 0.25rem; margin-top: 0.25rem; }
.st { color: #8f8; margin: 0.2rem 0; }
</style>
+100
View File
@@ -0,0 +1,100 @@
<script>
import { createEventDispatcher } from 'svelte';
export let docs = [];
export let selectedDoc = null;
const dispatch = createEventDispatcher();
</script>
<section class="panel-grid">
<aside class="list">
<header>
<h2>Docs</h2>
<span>{docs.length} indexed</span>
</header>
{#each docs as doc}
<button class:selected={doc.id === selectedDoc?.id} on:click={() => !doc.locked && dispatch('select', doc.id)} disabled={doc.locked}>
<div>{doc.title}</div>
<small>{doc.locked ? 'locked' : 'available'}</small>
</button>
{/each}
</aside>
<article class="detail">
{#if selectedDoc}
<h3>{selectedDoc.title}</h3>
<div class="body">{selectedDoc.content}</div>
{:else}
<p class="empty">Select an unlocked document to read it.</p>
{/if}
</article>
</section>
<style>
.panel-grid {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
gap: 1rem;
}
.list,
.detail {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 24px;
padding: 1rem;
box-shadow: var(--shadow);
}
.list {
display: grid;
gap: 0.75rem;
align-content: start;
}
header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
h2, h3 {
margin: 0;
}
.list button {
text-align: left;
border: 1px solid var(--border);
border-radius: 16px;
padding: 0.9rem;
background: rgba(255, 255, 255, 0.03);
color: inherit;
}
.list button:disabled {
opacity: 0.4;
cursor: default;
}
.list button.selected {
border-color: rgba(214, 104, 56, 0.45);
background: rgba(214, 104, 56, 0.1);
}
.body,
.empty,
small {
white-space: pre-wrap;
line-height: 1.6;
color: var(--text-muted);
}
@media (max-width: 960px) {
.panel-grid {
grid-template-columns: 1fr;
}
}
</style>
+182
View File
@@ -0,0 +1,182 @@
<script>
import logoUrl from '../assets/logo.png';
export let state = null;
export let connection = 'offline';
export let playerPortrait = 'player-silhouette';
function trustLabel(score) {
if (score >= 75) return 'Entrusted';
if (score >= 65) return 'Reliable';
if (score >= 55) return 'Settling In';
return 'Probationary';
}
function formatRemaining(seconds) {
const safe = Math.max(0, Number(seconds ?? 0));
const minutes = Math.floor(safe / 60);
const remainder = safe % 60;
return `${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`;
}
</script>
<header class="header">
<div class="brand">
<img class="brand-logo" src={logoUrl} alt="Axiom Works">
<div>
<p class="eyebrow">Axiom Works Internal</p>
<h1>Sysadmin Chronicles</h1>
</div>
</div>
<div class="status-cluster">
<div class="trust-card">
<span class="label">Ops Standing</span>
<strong>{trustLabel(state?.trust ?? 50)}</strong>
<div class="meter">
<span style={`width:${Math.max(8, state?.trust ?? 50)}%`}></span>
</div>
</div>
<div class="meta-card">
<span class="label">Shift #{state?.shiftNumber ?? 1}</span>
<strong>{formatRemaining(state?.shift?.remainingSeconds ?? 0)}</strong>
<span class:online={connection === 'connected'} class="socket">{connection}</span>
</div>
<div class="meta-card">
<span class="label">Certifications</span>
<strong>{state?.certifications?.length ?? 0}</strong>
<span class="socket">{state?.shiftHistory?.length ?? 0} reviews archived</span>
</div>
<div class="portrait-card">
<img
class="portrait-avatar"
src={`/public/portraits/${playerPortrait}.png`}
alt="Your portrait"
onerror={(e) => { e.currentTarget.src = '/public/portraits/player-silhouette.png'; }}
>
</div>
</div>
</header>
<style>
.header {
display: flex;
justify-content: space-between;
gap: 1.5rem;
align-items: flex-end;
margin-bottom: 1.5rem;
}
.eyebrow,
.label,
.socket {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.72rem;
color: var(--text-muted);
}
h1 {
margin: 0.2rem 0 0;
font-size: clamp(2rem, 3vw, 3rem);
line-height: 0.95;
}
.brand {
display: flex;
align-items: center;
gap: 1rem;
}
.brand-logo {
height: 48px;
width: 48px;
border-radius: 10px;
flex-shrink: 0;
}
.status-cluster {
display: grid;
grid-template-columns: repeat(3, minmax(170px, 1fr)) 64px;
gap: 0.8rem;
min-width: min(480px, 100%);
}
.portrait-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 18px;
padding: 0.5rem;
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
display: flex;
align-items: center;
justify-content: center;
}
.portrait-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(255,255,255,0.15);
}
.trust-card,
.meta-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 18px;
padding: 1rem;
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
}
strong {
display: block;
margin-top: 0.25rem;
font-size: 1.05rem;
}
.meter {
margin-top: 0.8rem;
height: 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.meter span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #b06c37, var(--accent));
}
.socket {
display: inline-flex;
margin-top: 0.8rem;
padding: 0.25rem 0.55rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
}
.socket.online {
color: #dfeedd;
background: rgba(99, 182, 122, 0.16);
}
@media (max-width: 900px) {
.header {
flex-direction: column;
align-items: stretch;
}
.status-cluster {
min-width: 0;
}
}
</style>
+192
View File
@@ -0,0 +1,192 @@
<script>
import { createEventDispatcher } from 'svelte';
export let mail = [];
export let selectedMail = null;
export let busy = false;
const dispatch = createEventDispatcher();
$: unreadCount = mail.filter((item) => !item.read).length;
</script>
<section class="panel-grid">
<aside class="list">
<header>
<h2>Mail</h2>
<div class="mail-meta">
<span>{mail.length} threads</span>
{#if unreadCount > 0}
<span class="badge unread-summary">{unreadCount} unread</span>
{/if}
</div>
</header>
{#if mail.length === 0}
<p class="empty">No mail yet.</p>
{:else}
{#each mail as item}
<button class:selected={item.id === selectedMail?.id} class:has-unread={!item.read} on:click={() => dispatch('select', item.id)}>
<div class="topline">
<strong>{item.subject}</strong>
{#if !item.read}<span class="badge unread">unread</span>{/if}
</div>
<div>{item.from}</div>
<small>{new Date(item.timestamp).toLocaleString()}</small>
</button>
{/each}
{/if}
</aside>
<article class="detail">
{#if selectedMail}
<div class="mail-header">
<div>
<h3>{selectedMail.subject}</h3>
<p>{selectedMail.from}</p>
</div>
<span class={`badge ${selectedMail.read ? 'read' : 'unread'}`}>
{selectedMail.read ? 'Read' : 'Unread'}
</span>
</div>
<div class="body">{selectedMail.body}</div>
{#if selectedMail.attachments?.length}
<section class="attachments">
<h4>Attachments</h4>
<div class="chips">
{#each selectedMail.attachments as attachment}
<button on:click={() => dispatch('attachment', attachment)}>{attachment}</button>
{/each}
</div>
</section>
{/if}
{#if selectedMail.reply_options?.length && !selectedMail.replied}
<section class="reply-block">
<h4>Reply</h4>
<div class="chips">
{#each selectedMail.reply_options as option, index}
<button disabled={busy} on:click={() => dispatch('reply', { id: selectedMail.id, choice: index })}>
{option.label}
</button>
{/each}
</div>
</section>
{/if}
{:else}
<p class="empty">Select a message to read it.</p>
{/if}
</article>
</section>
<style>
.panel-grid {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
gap: 1rem;
min-height: 0;
}
.list,
.detail {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 24px;
padding: 1rem;
box-shadow: var(--shadow);
}
.list {
display: grid;
gap: 0.75rem;
align-content: start;
}
header,
.topline {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.mail-meta,
.mail-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
h2, h3, h4 {
margin: 0;
}
.list button,
.chips button {
text-align: left;
border: 1px solid var(--border);
border-radius: 16px;
padding: 0.9rem;
background: rgba(255, 255, 255, 0.03);
color: inherit;
}
.list button.selected {
border-color: rgba(214, 104, 56, 0.45);
background: rgba(214, 104, 56, 0.1);
}
.list button.has-unread {
box-shadow: inset 3px 0 0 rgba(214, 104, 56, 0.75);
}
.badge {
font-size: 0.72rem;
text-transform: uppercase;
padding: 0.18rem 0.5rem;
border-radius: 999px;
letter-spacing: 0.08em;
}
.unread,
.unread-summary {
background: rgba(214, 104, 56, 0.18);
color: #f8e5d7;
}
.read {
background: rgba(255, 255, 255, 0.08);
color: var(--text-muted);
}
.body {
white-space: pre-wrap;
line-height: 1.6;
color: #f4eee4;
}
p,
small {
color: var(--text-muted);
}
.attachments,
.reply-block {
margin-top: 1.25rem;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
margin-top: 0.75rem;
}
@media (max-width: 960px) {
.panel-grid {
grid-template-columns: 1fr;
}
}
</style>
+344
View File
@@ -0,0 +1,344 @@
<script>
import { createEventDispatcher } from 'svelte';
export let certifications = [];
export let shiftHistory = [];
export let currentShiftStats = null;
export let playerPortrait = 'player-silhouette';
const dispatch = createEventDispatcher();
const PORTRAITS = [
'player-silhouette',
'player-01',
'player-02',
'player-03',
'player-04',
'player-05'
];
const PORTRAIT_LABELS = {
'player-silhouette': 'Default',
'player-01': 'Option 1',
'player-02': 'Option 2',
'player-03': 'Option 3',
'player-04': 'Option 4',
'player-05': 'Option 5'
};
function selectPortrait(id) {
dispatch('portrait', id);
}
function formatDuration(seconds) {
const safe = Number(seconds);
if (!Number.isFinite(safe) || safe < 0) {
return 'n/a';
}
const minutes = Math.floor(safe / 60);
const remainder = safe % 60;
return `${minutes}m ${String(remainder).padStart(2, '0')}s`;
}
function prettyTier(tier) {
if (tier === 'excellent') return 'Excellent';
if (tier === 'ok') return 'Solid';
if (tier === 'poor') return 'Needs Work';
return 'Unknown';
}
</script>
<section class="portrait-section">
<div class="portrait-current">
<img
class="portrait-large"
src={`/public/portraits/${playerPortrait}.png`}
alt="Your portrait"
onerror={(e) => { e.currentTarget.src = '/public/portraits/player-silhouette.png'; }}
>
<div>
<p class="portrait-heading">Your Portrait</p>
<p class="portrait-sub">Select a portrait to use in the HUD header.</p>
</div>
</div>
<div class="portrait-grid">
{#each PORTRAITS as id}
<button
class={`portrait-option ${playerPortrait === id ? 'selected' : ''}`}
onclick={() => selectPortrait(id)}
title={PORTRAIT_LABELS[id]}
>
<img
src={`/public/portraits/${id}.png`}
alt={PORTRAIT_LABELS[id]}
onerror={(e) => { e.currentTarget.src = '/public/portraits/player-silhouette.png'; }}
>
{#if playerPortrait === id}
<span class="portrait-check"></span>
{/if}
</button>
{/each}
</div>
</section>
<section class="panel-grid">
<article class="panel">
<header>
<h2>Certifications</h2>
<span>{certifications.length} earned</span>
</header>
{#if certifications.length === 0}
<p class="empty">No internal certifications awarded yet.</p>
{:else}
<div class="stack">
{#each certifications as certification}
<article class="card">
<strong>{certification.title}</strong>
<p>{certification.description}</p>
<small>{new Date(certification.awarded_at).toLocaleString()}</small>
</article>
{/each}
</div>
{/if}
</article>
<article class="panel">
<header>
<h2>Shift Reviews</h2>
<span>{shiftHistory.length} archived</span>
</header>
{#if currentShiftStats}
<section class="current">
<h3>Current Shift</h3>
<div class="current-stats">
<article class="stat">
<span>Assigned</span>
<strong>{(currentShiftStats.assigned_ticket_ids ?? []).length}</strong>
</article>
<article class="stat">
<span>Resolved</span>
<strong>{(currentShiftStats.resolved_tickets ?? []).length}</strong>
</article>
<article class="stat">
<span>Flagged</span>
<strong>{(currentShiftStats.flagged_issues ?? []).length}</strong>
</article>
</div>
</section>
{/if}
{#if shiftHistory.length === 0}
<p class="empty">No reviews yet. Finish a shift to see Priya's assessment.</p>
{:else}
<div class="stack">
{#each [...shiftHistory].reverse() as review}
<article class="card">
<div class="topline">
<strong>Shift {review.shift_number}</strong>
<span class={`tier ${review.performance_tier ?? 'ok'}`}>{prettyTier(review.performance_tier)}</span>
</div>
<p>Resolved {review.tickets_resolved}/{review.tickets_assigned}</p>
<p>Average resolution: {formatDuration(review.average_resolution_seconds)}</p>
<p>Flagged issues: {(review.flagged_issues ?? []).length}</p>
<small>{new Date(review.ended_at).toLocaleString()}</small>
</article>
{/each}
</div>
{/if}
</article>
</section>
<style>
.portrait-section {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 24px;
padding: 1.25rem;
box-shadow: var(--shadow);
margin-bottom: 1rem;
}
.portrait-current {
display: flex;
align-items: center;
gap: 1.25rem;
margin-bottom: 1.25rem;
}
.portrait-large {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: 3px solid var(--border);
flex-shrink: 0;
}
.portrait-heading {
font-weight: 600;
font-size: 1rem;
color: #f4eee4;
margin-bottom: 0.25rem;
}
.portrait-sub {
font-size: 0.83rem;
color: var(--text-muted);
}
.portrait-grid {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.portrait-option {
position: relative;
background: none;
border: 2px solid var(--border);
border-radius: 50%;
padding: 0;
cursor: pointer;
width: 58px;
height: 58px;
overflow: visible;
transition: border-color 0.15s;
}
.portrait-option img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
display: block;
}
.portrait-option.selected {
border-color: var(--accent);
}
.portrait-option:hover:not(.selected) {
border-color: rgba(255,255,255,0.35);
}
.portrait-check {
position: absolute;
bottom: -2px;
right: -2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent);
color: #fff;
font-size: 0.65rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.panel-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 24px;
padding: 1rem;
box-shadow: var(--shadow);
}
.stack {
display: grid;
gap: 0.8rem;
margin-top: 0.9rem;
}
.card,
.current {
border: 1px solid var(--border);
border-radius: 16px;
padding: 0.9rem;
background: rgba(255, 255, 255, 0.03);
}
.topline,
header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.current-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
margin-top: 0.9rem;
}
.stat {
border: 1px solid var(--border);
border-radius: 14px;
padding: 0.85rem;
background: rgba(255, 255, 255, 0.02);
}
.stat strong {
display: block;
margin-top: 0.35rem;
color: #f4eee4;
font-size: 1.4rem;
}
h2,
h3 {
margin: 0;
}
p,
small,
span {
color: var(--text-muted);
}
.tier {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.72rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
}
.tier.excellent {
background: rgba(99, 182, 122, 0.16);
color: #dfeedd;
}
.tier.poor {
background: rgba(200, 101, 101, 0.16);
color: #ffd9d4;
}
.tier.ok {
background: rgba(214, 104, 56, 0.14);
color: #f5ddcf;
}
@media (max-width: 960px) {
.panel-grid {
grid-template-columns: 1fr;
}
.current-stats {
grid-template-columns: 1fr;
}
}
</style>
+133
View File
@@ -0,0 +1,133 @@
<script>
import { createEventDispatcher } from 'svelte';
export let conversation = [];
export let busy = false;
let draft = '';
const dispatch = createEventDispatcher();
function submit(message = draft) {
const next = String(message ?? '').trim();
if (!next || busy) {
return;
}
dispatch('send', next);
draft = '';
}
</script>
<section class="sage-shell">
<div class="conversation">
{#if conversation.length === 0}
<article class="message sage">
<strong>Sage</strong>
<p>Ask for a hint, a task summary, the target VM, or relevant docs.</p>
</article>
{:else}
{#each conversation as entry}
<article class={`message ${entry.role}`}>
<strong>{entry.role === 'user' ? 'You' : 'Sage'}</strong>
<p>{entry.body}</p>
{#if entry.followUps?.length}
<div class="chips">
{#each entry.followUps as followUp}
<button disabled={busy} on:click={() => submit(followUp)}>{followUp}</button>
{/each}
</div>
{/if}
</article>
{/each}
{/if}
</div>
<form class="composer" on:submit|preventDefault={() => submit()}>
<textarea bind:value={draft} rows="3" placeholder="Ask Sage for a hint or summary…"></textarea>
<button class="primary" disabled={busy}>{busy ? 'Thinking…' : 'Send'}</button>
</form>
</section>
<style>
.sage-shell {
display: grid;
gap: 1rem;
min-height: 70vh;
grid-template-rows: minmax(0, 1fr) auto;
}
.conversation,
.composer {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 24px;
padding: 1rem;
box-shadow: var(--shadow);
}
.conversation {
display: grid;
gap: 0.9rem;
align-content: start;
overflow: auto;
}
.message {
max-width: min(860px, 100%);
border-radius: 18px;
padding: 0.9rem 1rem;
background: rgba(255, 255, 255, 0.03);
}
.message.user {
justify-self: end;
background: rgba(214, 104, 56, 0.14);
}
.message p {
margin: 0.4rem 0 0;
white-space: pre-wrap;
line-height: 1.6;
color: #f1ede5;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 0.8rem;
}
.chips button,
.primary {
border: 1px solid var(--border);
border-radius: 14px;
padding: 0.7rem 0.9rem;
background: rgba(255, 255, 255, 0.04);
color: inherit;
}
.composer {
display: grid;
gap: 0.8rem;
}
textarea {
width: 100%;
border-radius: 16px;
border: 1px solid var(--border);
background: rgba(9, 10, 13, 0.5);
color: inherit;
padding: 0.9rem 1rem;
resize: vertical;
min-height: 88px;
box-sizing: border-box;
}
.primary {
justify-self: start;
background: var(--accent);
color: #fff7f1;
border: 0;
}
</style>
@@ -0,0 +1,73 @@
<script>
import { createEventDispatcher } from 'svelte';
export let active = 'tickets';
export let unreadMailCount = 0;
const dispatch = createEventDispatcher();
const tabs = [
{ id: 'tickets', label: 'Tickets' },
{ id: 'mail', label: 'Mail' },
{ id: 'docs', label: 'Docs' },
{ id: 'vms', label: 'VMs' },
{ id: 'profile', label: 'Profile' }
];
</script>
<nav class="sidebar">
{#each tabs as tab}
<button
class:active={tab.id === active}
on:click={() => dispatch('change', tab.id)}
>
<span>{tab.label}</span>
{#if tab.id === 'mail' && unreadMailCount > 0}
<span class="badge">{unreadMailCount}</span>
{/if}
</button>
{/each}
</nav>
<style>
.sidebar {
display: grid;
gap: 0.6rem;
align-content: start;
}
button {
display: flex;
justify-content: space-between;
align-items: center;
text-align: left;
padding: 0.9rem 1rem;
border-radius: 16px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.03);
color: inherit;
transition: border-color 120ms ease, transform 120ms ease, background 120ms ease;
}
button:hover {
transform: translateX(2px);
border-color: rgba(214, 104, 56, 0.35);
}
button.active {
background: linear-gradient(135deg, rgba(214, 104, 56, 0.22), rgba(255, 255, 255, 0.04));
border-color: rgba(214, 104, 56, 0.45);
}
.badge {
min-width: 1.5rem;
height: 1.5rem;
display: inline-grid;
place-items: center;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
color: #fff4ed;
background: rgba(214, 104, 56, 0.22);
border: 1px solid rgba(214, 104, 56, 0.35);
}
</style>
+201
View File
@@ -0,0 +1,201 @@
<script>
import { createEventDispatcher } from 'svelte';
export let tickets = [];
export let selectedTicket = null;
export let busy = false;
const dispatch = createEventDispatcher();
function badgeTone(priority) {
if (priority === 'high') return 'bad';
if (priority === 'medium') return 'warn';
return 'good';
}
</script>
<section class="panel-grid">
<aside class="list">
<header>
<h2>Tickets</h2>
<span>{tickets.length} active</span>
</header>
{#if tickets.length === 0}
<p class="empty">No tickets assigned.</p>
{:else}
{#each tickets as ticket}
<button class:selected={ticket.id === selectedTicket?.id} on:click={() => dispatch('select', ticket.id)}>
<div class="topline">
<strong>{ticket.id}</strong>
<span class={`badge ${badgeTone(ticket.priority)}`}>{ticket.priority}</span>
</div>
<div>{ticket.subject}</div>
<small>{ticket.status}</small>
</button>
{/each}
{/if}
</aside>
<article class="detail">
{#if selectedTicket}
<div class="detail-header">
<div>
<p class="eyebrow">{selectedTicket.id}</p>
<h3>{selectedTicket.subject}</h3>
</div>
<button
class="primary"
disabled={busy || selectedTicket.status === 'resolved'}
on:click={() => dispatch('complete', selectedTicket.id)}
>
{busy ? 'Working…' : selectedTicket.status === 'resolved' ? 'Resolved' : 'Mark Complete'}
</button>
</div>
<dl>
<div>
<dt>Status</dt>
<dd>{selectedTicket.status}</dd>
</div>
<div>
<dt>Priority</dt>
<dd>{selectedTicket.current_priority ?? selectedTicket.priority}</dd>
</div>
<div>
<dt>Linked Quest</dt>
<dd>{selectedTicket.linked_quest}</dd>
</div>
</dl>
{#if selectedTicket.body}
<div class="body">{selectedTicket.body}</div>
{:else}
<p class="muted">Quest-linked ticket. Use the workstation and resolve the underlying system state.</p>
{/if}
{:else}
<p class="empty">Select a ticket to inspect its details.</p>
{/if}
</article>
</section>
<style>
.panel-grid {
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
gap: 1rem;
min-height: 0;
}
.list,
.detail {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 24px;
padding: 1rem;
box-shadow: var(--shadow);
}
.list {
display: grid;
align-content: start;
gap: 0.75rem;
}
header,
.detail-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
h2,
h3 {
margin: 0;
}
.eyebrow,
dt,
small {
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.list button {
text-align: left;
border: 1px solid var(--border);
border-radius: 16px;
padding: 0.9rem;
background: rgba(255, 255, 255, 0.03);
color: inherit;
}
.list button.selected {
border-color: rgba(214, 104, 56, 0.45);
background: rgba(214, 104, 56, 0.1);
}
.topline {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.35rem;
}
.badge {
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
}
.badge.good { background: rgba(99, 182, 122, 0.15); }
.badge.warn { background: rgba(213, 166, 75, 0.18); }
.badge.bad { background: rgba(200, 101, 101, 0.18); }
.primary {
border: 0;
border-radius: 14px;
padding: 0.8rem 1rem;
background: var(--accent);
color: #fff7f1;
}
.primary:disabled {
opacity: 0.55;
cursor: default;
}
dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
dd {
margin: 0.3rem 0 0;
}
.body,
.empty,
.muted {
color: var(--text-muted);
white-space: pre-wrap;
line-height: 1.5;
}
@media (max-width: 960px) {
.panel-grid {
grid-template-columns: 1fr;
}
dl {
grid-template-columns: 1fr;
}
}
</style>
+92
View File
@@ -0,0 +1,92 @@
<script>
export let vms = [];
function tone(state) {
if (state === 'running') return 'good';
if (state === 'shut off') return 'warn';
if (state === 'missing') return 'bad';
return 'neutral';
}
</script>
<section class="grid">
{#each vms as vm}
<article class="card">
<div class="heading">
<div>
<p class="eyebrow">{vm.id}</p>
<h3>{vm.hostname}</h3>
</div>
<span class={`badge ${tone(vm.state)}`}>{vm.state}</span>
</div>
<dl>
<div>
<dt>Domain</dt>
<dd>{vm.domain}</dd>
</div>
<div>
<dt>Access</dt>
<dd>{vm.unlocked ? 'available' : 'locked'}</dd>
</div>
</dl>
</article>
{/each}
</section>
<style>
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 24px;
padding: 1rem;
box-shadow: var(--shadow);
}
.heading {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.eyebrow,
dt {
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
h3 {
margin: 0.15rem 0 0;
}
.badge {
padding: 0.25rem 0.6rem;
border-radius: 999px;
text-transform: uppercase;
font-size: 0.72rem;
}
.badge.good { background: rgba(99, 182, 122, 0.15); }
.badge.warn { background: rgba(213, 166, 75, 0.18); }
.badge.bad { background: rgba(200, 101, 101, 0.18); }
.badge.neutral { background: rgba(255, 255, 255, 0.08); }
dl {
margin: 1rem 0 0;
display: grid;
gap: 0.75rem;
}
dd {
margin: 0.25rem 0 0;
}
</style>
+101
View File
@@ -0,0 +1,101 @@
const STORAGE_KEY = 'sc-session-token';
function getStoredToken() {
return window.localStorage.getItem(STORAGE_KEY);
}
function storeToken(token) {
window.localStorage.setItem(STORAGE_KEY, token);
}
async function requestSessionToken() {
const response = await fetch('/api/session');
if (!response.ok) {
throw new Error(`Failed to create session: ${response.status}`);
}
const payload = await response.json();
storeToken(payload.token);
return payload.token;
}
export async function ensureSession() {
return getStoredToken() ?? await requestSessionToken();
}
async function authenticatedFetch(path, options = {}, retry = true) {
const token = await ensureSession();
const headers = new Headers(options.headers ?? {});
headers.set('Authorization', `Bearer ${token}`);
if (options.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const response = await fetch(path, {
...options,
headers
});
if (response.status === 401 && retry) {
const nextToken = await requestSessionToken();
return await authenticatedFetch(path, {
...options,
headers: {
...(options.headers ?? {}),
Authorization: `Bearer ${nextToken}`
}
}, false);
}
if (!response.ok) {
let detail = '';
try {
const payload = await response.json();
detail = payload.error ?? JSON.stringify(payload);
} catch {
detail = await response.text();
}
throw new Error(detail || `Request failed: ${response.status}`);
}
if (response.status === 204) {
return null;
}
return await response.json();
}
export const api = {
ensureSession,
getState: () => authenticatedFetch('/api/state'),
getTickets: () => authenticatedFetch('/api/tickets'),
getTicket: (id) => authenticatedFetch(`/api/tickets/${id}`),
completeTicket: (id, branchId = null) =>
authenticatedFetch(`/api/tickets/${id}/complete`, {
method: 'POST',
body: JSON.stringify(branchId ? { branchId } : {})
}),
getMail: () => authenticatedFetch('/api/mail'),
getMailById: (id) => authenticatedFetch(`/api/mail/${id}`),
markMailRead: (id) => authenticatedFetch(`/api/mail/${id}/read`, { method: 'POST' }),
replyMail: (id, choice) =>
authenticatedFetch(`/api/mail/${id}/reply`, {
method: 'POST',
body: JSON.stringify({ choice })
}),
getDocs: () => authenticatedFetch('/api/docs'),
getDoc: (id) => authenticatedFetch(`/api/docs/${id}`),
getVms: () => authenticatedFetch('/api/vms'),
askSage: (message) =>
authenticatedFetch('/api/sage/message', {
method: 'POST',
body: JSON.stringify({ message })
}),
getProfile: () => authenticatedFetch('/api/profile'),
setProfile: (portrait) =>
authenticatedFetch('/api/profile', {
method: 'PUT',
body: JSON.stringify({ portrait })
})
};
+8
View File
@@ -0,0 +1,8 @@
import './app.css';
import App from './App.svelte';
const app = new App({
target: document.getElementById('app')
});
export default app;
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte({
compilerOptions: {
compatibility: {
componentApi: 4
}
}
})],
build: {
outDir: 'dist'
}
});