Files
llmgw/internal/web/templates/base.css
T
Ilker ManapandClaude Opus 5 de21649ad6 Add an in-browser chat, without putting a key in the browser
So someone can try a model without setting up a client, and — more often —
tell whether a problem is the gateway or their code.

The page carries a session, and the server turns that into the identity an API
key would have produced, resolved from the person's project. The request then
goes down the ordinary path by being handed to the same handler /v1 uses:
admission, model allowlist, rate limits, budget, accounting. There is exactly
one request path, so there is no second place for its rules to drift — and
without that, a playground is a hole in every limit the rest of the gateway
enforces. The allowlist test posts a forbidden model and expects 403 for that
reason.

The body is rebuilt rather than forwarded, so the page cannot set fields it has
no business setting: a stream:false that would hang the reader, a max_tokens
large enough to occupy a replica for an hour. One turn is capped at 512 tokens.

The picker offers what is actually served and allowed, not what is catalogued,
because offering a model with no replica produces a 503 the reader cannot
interpret.

Inline vanilla JavaScript, for the same reason the charts are hand-drawn SVG.
It streams, because on CPU inference ten seconds is normal and a chat that
hangs and then dumps a paragraph feels broken. Replies are written with
textContent throughout: model output and user input have no business being
parsed as markup. Its POSTs carry the CSRF token in a header, which a
cross-site form cannot set at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:05:30 +02:00

102 lines
5.5 KiB
CSS

/* Deliberately one small stylesheet with no build step. The single-binary goal
rules out a JS toolchain, and nothing here needs one. */
:root {
color-scheme: light dark;
--bg: #fbfbfa;
--fg: #1c1c1a;
--muted: #6b6b66;
--line: #e3e3df;
--card: #ffffff;
--accent: #2f5d50;
--ok: #2f7a4f;
--warn: #9a6b1f;
--bad: #a33a32;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #17181a; --fg: #e8e8e4; --muted: #9a9a93; --line: #2c2e31;
--card: #1e2022; --accent: #7fb3a2; --ok: #6fbd8c; --warn: #d6a44e; --bad: #e07b72;
}
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 15px/1.55 ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.wrap { max-width: 1100px; margin: 0 auto; padding: 2.5rem 1.25rem 4rem; }
header { border-bottom: 1px solid var(--line); margin-bottom: 2rem; padding-bottom: 1.25rem; }
h1 { font-size: 1.5rem; margin: 0 0 .25rem; letter-spacing: -.01em; }
h2 { font-size: 1.05rem; margin: 2.25rem 0 .75rem; letter-spacing: -.005em; }
.sub { color: var(--muted); margin: 0; }
nav { margin-top: .75rem; display: flex; gap: 1rem; flex-wrap: wrap; }
nav a { color: var(--accent); text-decoration: none; font-size: .9rem; }
nav a:hover { text-decoration: underline; }
table { width: 100%; border-collapse: collapse; font-size: .875rem; background: var(--card);
border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
th, td { text-align: left; padding: .5rem .7rem; border-bottom: 1px solid var(--line); }
th { font-weight: 600; color: var(--muted); font-size: .78rem; text-transform: uppercase;
letter-spacing: .04em; }
tr:last-child td { border-bottom: none; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em; }
pre { background: var(--card); border: 1px solid var(--line); border-radius: 6px;
padding: .9rem 1rem; overflow-x: auto; }
.state { font-weight: 600; font-size: .8rem; }
.state-ready, .state-active { color: var(--ok); }
.state-pending, .state-starting, .state-draining, .state-cordoned { color: var(--warn); }
.state-failed, .state-lost, .state-unreachable, .state-revoked { color: var(--bad); }
.empty { color: var(--muted); font-style: italic; padding: .9rem 0; }
.note { color: var(--muted); font-size: .85rem; margin: .4rem 0 0; }
footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line);
color: var(--muted); font-size: .8rem; display: flex; justify-content: space-between; gap: 1rem; }
button {
font: inherit; font-weight: 600; cursor: pointer;
background: var(--accent); color: var(--bg); border: none;
border-radius: 6px; padding: .55rem 1.1rem;
}
button:hover { filter: brightness(1.1); }
ul { padding-left: 1.2rem; }
li { margin: .25rem 0; }
a { color: var(--accent); }
.wrap.narrow { max-width: 30rem; }
label { display: block; margin: .9rem 0; font-size: .9rem; font-weight: 600; }
input[type=text], input[type=email], input[type=password] {
display: block; width: 100%; margin-top: .3rem; font: inherit; font-weight: 400;
padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 6px;
background: var(--card); color: var(--fg);
}
input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.error { color: var(--bad); border: 1px solid var(--bad); border-radius: 6px;
padding: .6rem .8rem; background: var(--card); }
form.inline { display: inline; }
button.link { background: none; color: var(--accent); padding: 0;
text-decoration: underline; font-weight: 400; font-size: .9rem; }
button.link.danger { color: var(--bad); }
.ok { color: var(--ok); border: 1px solid var(--ok); border-radius: 6px;
padding: .6rem .8rem; background: var(--card); }
select { font: inherit; padding: .35rem .4rem; border: 1px solid var(--line);
border-radius: 6px; background: var(--card); color: var(--fg); }
.chart-wrap { position: relative; margin: .5rem 0 1.5rem; }
.chart { width: 100%; height: 120px; display: block;
background: var(--card); border: 1px solid var(--line); border-radius: 6px; }
.chart rect { transition: opacity .1s; }
.chart rect:hover { opacity: .65; }
.chart .axis { stroke: var(--line); stroke-width: 1; }
.chart-peak { position: absolute; top: .4rem; right: .6rem;
font-size: .72rem; color: var(--muted); }
.chart-row { display: grid; gap: 1rem; grid-template-columns: 1fr 1fr; }
@media (max-width: 720px) { .chart-row { grid-template-columns: 1fr; } }
.chat-bar { display: flex; align-items: center; gap: 1rem; margin-bottom: .75rem; }
.inline-label { font-size: .85rem; font-weight: 600; margin: 0; }
.inline-label select { margin-left: .4rem; font-weight: 400; }
.chat-log { min-height: 16rem; max-height: 55vh; overflow-y: auto; padding: .5rem;
background: var(--card); border: 1px solid var(--line); border-radius: 6px; }
.bubble { white-space: pre-wrap; word-wrap: break-word; padding: .55rem .75rem;
margin: .4rem 0; border-radius: 8px; max-width: 46rem; }
.bubble.you { background: var(--accent); color: var(--bg); margin-left: auto; }
.bubble.assistant { background: var(--bg); border: 1px solid var(--line); }
.bubble.failed { color: var(--bad); border-color: var(--bad); }
.chat-composer { display: flex; gap: .6rem; margin-top: .75rem; align-items: flex-end; }
.chat-composer textarea { flex: 1; font: inherit; padding: .5rem .6rem; resize: vertical;
border: 1px solid var(--line); border-radius: 6px; background: var(--card); color: var(--fg); }