training/dashboard: references scene with PDF viewer + tab strip

New scene 13 (after perf, the last in the deck) renders a tabbed
PDF viewer. Each tab is one .pdf in /opt/cis490/references/; the
active tab swaps the iframe's src to /refs/<encoded-filename>.

Backend
- /api/references — lists pdfs in REFS_DIR, returning
  {"name": stem (newlines stripped), "path": "/refs/<urlencoded>"}.
- /refs static mount — serves the PDFs directly. check_dir=False
  so the dashboard still boots if the directory is missing.
- REFS_DIR resolves relative to the install root so it works on
  /opt/cis490 in production and any dev tree.

Frontend
- Stage view uses metric-stack-wide for the broader card; the
  references scene also overrides .stage-view padding-right down
  to a small gutter so the iframe takes most of the screen
  horizontally — the prose card still sits on the right but the
  PDF area is roughly 70% wide on standard viewports.
- Tabs are styled like .db-tab (palette-aware pills) and stop
  propagation so they don't trigger the click-to-advance gesture.
- Iframe is lazy-loaded: src isn't set until the user actually
  scrolls into the references scene OR clicks a tab, so the
  browser doesn't fetch a big PDF the user may never view.
This commit is contained in:
Max Gorog 2026-05-08 12:34:42 -05:00
parent 308140c6ce
commit bee40a6ae9
4 changed files with 190 additions and 2 deletions

View file

@ -9,6 +9,7 @@ import tarfile
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from urllib.parse import quote
from starlette.applications import Starlette
from starlette.requests import Request
@ -21,6 +22,11 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
log = logging.getLogger("cis490.dashboard")
STATIC_DIR = Path(__file__).parent / "static"
# References (PDFs the deck links to). Resolved relative to the
# install root so it works at /opt/cis490 (production) and the
# dev tree alike. The /refs URL serves files directly; /api/references
# returns the listing the frontend's tab UI iterates.
REFS_DIR = Path(__file__).parent.parent.parent / "references"
# Used to validate URL-supplied host_id / episode_id before they
# reach the filesystem. Allows the alphanumeric ULID episode IDs
@ -224,6 +230,34 @@ def make_app(
finally:
await broadcaster.unregister(q)
async def references(request: Request) -> JSONResponse:
"""List available PDFs in REFS_DIR. Returns
``{"references": [{"name": str, "path": str}, ...]}`` sorted
alphabetically by name. Empty if the directory doesn't exist
or has no PDFs."""
if not REFS_DIR.is_dir():
return JSONResponse({"references": []})
items = []
try:
for p in REFS_DIR.iterdir():
if not p.is_file():
continue
if p.suffix.lower() != ".pdf":
continue
# Some downloaded PDFs ship with newlines in the
# filename (badly-wrapped DOI titles). Strip them
# for display and URL-encode for the path so the
# iframe can fetch /refs/<encoded-name>.
display_name = " ".join(p.stem.split())
items.append({
"name": display_name,
"path": "/refs/" + quote(p.name, safe=""),
})
except OSError:
log.exception("could not list references in %s", REFS_DIR)
items.sort(key=lambda r: r["name"].lower())
return JSONResponse({"references": items})
async def episode(request: Request) -> JSONResponse:
host_id = request.path_params["host_id"]
episode_id = request.path_params["episode_id"]
@ -243,7 +277,10 @@ def make_app(
Route("/healthz", healthz, methods=["GET"]),
Route("/publish", publish, methods=["POST"]),
Route("/api/episode/{host_id}/{episode_id}", episode, methods=["GET"]),
Route("/api/references", references, methods=["GET"]),
WebSocketRoute("/ws", ws_endpoint),
Mount("/static", app=StaticFiles(directory=str(STATIC_DIR)), name="static"),
Mount("/refs", app=StaticFiles(directory=str(REFS_DIR), check_dir=False),
name="refs"),
]
return Starlette(routes=routes, lifespan=lifespan)

View file

@ -286,6 +286,56 @@ body[data-theme="laser"] .bg-laser { display: block; }
to { transform: rotate(360deg); }
}
/* ─── References scene (PDF viewer + tab strip) ─────────────────────── */
.ref-stack { /* metric-stack-wide variant; let viewer take the height */
height: 100%;
justify-content: flex-start;
}
.ref-tabs {
display: flex; flex-wrap: wrap; gap: 6px;
max-height: clamp(60px, 9vh, 110px);
overflow-y: auto;
}
.ref-tabs .awaiting {
color: var(--fg-mute); font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
padding: 4px 0;
}
.ref-tab {
background: transparent; color: var(--fg-dim);
border: 1px solid var(--line); border-radius: 16px;
padding: 4px 12px; font: inherit; font-size: 12px; cursor: pointer;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
transition: color 120ms, border-color 120ms, background 120ms;
max-width: 28em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ref-tab:hover { color: var(--fg); border-color: var(--fg-mute); }
.ref-tab.active {
color: var(--accent); border-color: var(--accent);
background: var(--accent-soft);
}
.ref-viewer-wrap {
flex: 1 1 auto; min-height: 0;
background: var(--bg-elev);
border: 1px solid var(--line); border-radius: 4px;
overflow: hidden;
}
.ref-viewer {
width: 100%; height: 100%;
min-height: clamp(360px, 70vh, 900px);
border: 0; display: block; background: var(--bg-elev);
}
/* References scene wants more horizontal room than the default
metric scenes the PDF is the point. Drop the right padding
that reserves space for the prose column down to a small gutter,
so the iframe can stretch most of the way across. The prose card
still overlays the right edge with its feathered backdrop. */
.stage-view[data-view="references"] {
padding-right: clamp(8px, 4vw, 96px);
}
/* ─── Per-theme settings section ───────────────────────────────────── */
.theme-bg-section { display: none; }
.theme-bg-section.is-active { display: block; }

View file

@ -1632,6 +1632,83 @@ for epoch in range(20):
emptyState();
})();
// ── References viewer (scene: references) ────────────────────
// Lists PDFs from /api/references; clicking a tab swaps the
// iframe's src to /refs/<filename>. List is fetched once at
// init; iframe is lazy — src isn't set until the user enters
// the references scene OR clicks a tab, so the browser doesn't
// download a PDF that never gets viewed.
(function () {
const tabsEl = document.getElementById('ref-tabs');
const viewerEl = document.getElementById('ref-viewer');
if (!tabsEl || !viewerEl) return;
let refs = [];
let activeIdx = -1;
let pendingFirst = true; // load first PDF only when scene becomes active
function loadFirstIfReady() {
if (!pendingFirst) return;
if (refs.length === 0) return;
pendingFirst = false;
selectRef(0);
}
function rebuildTabs() {
tabsEl.innerHTML = '';
if (refs.length === 0) {
const empty = document.createElement('div');
empty.className = 'awaiting';
empty.textContent = 'no PDFs found in /opt/cis490/references/';
tabsEl.appendChild(empty);
return;
}
refs.forEach((r, i) => {
const btn = document.createElement('button');
btn.className = 'ref-tab' + (i === activeIdx ? ' active' : '');
btn.textContent = r.name;
btn.title = r.name;
btn.addEventListener('click', e => {
e.stopPropagation(); // don't bubble to stage-col click-nav
selectRef(i);
});
tabsEl.appendChild(btn);
});
}
function selectRef(i) {
if (i < 0 || i >= refs.length) return;
activeIdx = i;
rebuildTabs();
// Append a hash so that hitting the same PDF twice in a row
// still triggers a reload (helps if the file was updated on
// disk; iframes cache aggressively otherwise).
viewerEl.src = refs[i].path;
}
fetch('/api/references')
.then(r => r.json())
.then(data => {
refs = (data && data.references) || [];
rebuildTabs();
// If user is already on the references scene at boot, load now.
if (document.querySelector('.scene[data-stage="references"][data-active]')) {
loadFirstIfReady();
}
})
.catch(err => {
tabsEl.innerHTML = '';
const e = document.createElement('div');
e.className = 'awaiting';
e.textContent = `failed to load references list: ${err.message}`;
tabsEl.appendChild(e);
});
// Defer the first iframe load until the references scene actually
// becomes active (no point fetching a PDF the user may never see).
window.dashboard.scene('references', { onEnter: loadFirstIfReady });
})();
// ── Performance scatter — DEMO ONLY ───────────────────────────
(function () {
const svg = document.getElementById('perf-scatter');

View file

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CIS490 — live</title>
<link rel="stylesheet" href="/static/dashboard.css?v=fbd0584a">
<link rel="stylesheet" href="/static/dashboard.css?v=a591789b">
</head>
<body>
<!-- SVG filter defs for the lava-lamp goo effect. Width/height 0
@ -301,6 +301,19 @@
</div>
</div>
<!-- 13. references — PDF viewer with tabs -->
<div class="stage-view" data-view="references">
<div class="metric-stack metric-stack-wide ref-stack">
<div class="metric-eyebrow">references · papers, notes, prior work</div>
<div class="ref-tabs" id="ref-tabs"></div>
<div class="ref-viewer-wrap">
<iframe class="ref-viewer" id="ref-viewer"
title="reference viewer"
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"></iframe>
</div>
</div>
</div>
<!-- 12. perf -->
<div class="stage-view" data-view="perf">
<div class="metric-stack">
@ -487,10 +500,21 @@
</div>
</section>
<section class="scene" data-stage="references">
<div class="prose">
<h2>References</h2>
<p>The papers, notes, and prior work this project leans on.
Pick a tab on the left to load the document; the viewer
takes the bulk of the stage so you can scroll through
without leaving the deck.</p>
<p class="hint">end of deck · ← to flip back</p>
</div>
</section>
<div class="scene-end-spacer"></div>
</article>
</div>
<script src="/static/dashboard.js?v=a00dc514"></script>
<script src="/static/dashboard.js?v=b1cb9f39"></script>
</body>
</html>