Files
llmgw/internal/web/static/base.css
T
Ilker ManapandClaude Opus 5.5 3104f4b706 Manage people and the tenancy tree, from the web and the CLI
Nothing in the tree could be renamed, moved or removed, and an approved
person stayed in the project they were first put in — from the web or
the CLI. Now:

- /admin/users is the tree, each project opening onto its people and
  keys, with approve, move, suspend, reinstate and remove for people,
  revoke for keys, and add, rename, move and remove at every level,
  plus a search over every account. Forms post and get the page back,
  so it works with no script; htmx 2.0.11 (0BSD, embedded, checked
  against npm's integrity hash) swaps only the changed part, keeping
  open projects open.
- llmgw org tree/add/rename/move/remove, users move/delete, and
  keys list --all.
- Projects, teams and organizations are named by id, path
  (Acme/platform/gateway) or unique name; users by id or email. With a
  single project --project can be left out, instead of defaulting to
  "default", which a tree built from the web does not have.

Moving a person moves the keys they made; operator keys stay with the
project. Only empty things are removed, and never a project that had a
key, since usage history refers to it.

Also: keys init no longer reparents an existing team or project;
reinstating an account with no project is refused (a rejected signup
used to come back active and fail at its first key); rejecting a
signup removes it; keys limits --budget takes whole units like the
quotas page.

Checked in headless Chrome: a move from an open project updates the
page in place, keeps the project open, and the search still filters.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:26:39 +02:00

360 lines
19 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); }
/* KaTeX sits inside a bubble that sets white-space: pre-wrap for plain text;
display maths needs to escape that or every fraction gets the newlines in
its own source. */
.bubble .katex-display { white-space: normal; margin: .6rem 0; overflow-x: auto; }
.bubble .katex { white-space: normal; }
.bubble.you .katex { color: inherit; }
/* A reply that stopped because it ran out of room, not because it was done. */
.note.truncated { color: var(--warn, #a15c00); margin: .2rem 0 .8rem; }
/* Offered under a reply that contains files worth keeping. */
.link.download { display: inline-block; margin: .3rem 0 .8rem; }
/* A usage bar inside a table cell. Drawn with two elements rather than a
gradient so the number stays selectable and readable by a screen reader. */
.meter { position: relative; min-width: 9rem; }
.meter > span {
display: block; height: .75rem; border-radius: 2px;
background: var(--accent); opacity: .55;
}
.meter > em { font-style: normal; font-size: .8rem; color: var(--muted); }
.bad { color: var(--bad); }
/* Chart palette.
*
* Roles rather than raw colours, so light and dark swap in one place and the
* charts are written against meaning. The values are a validated categorical
* set: every adjacent pair clears the colour-vision-deficiency separation
* threshold, and the dark column is the same hues re-stepped for the dark
* surface rather than an automatic flip of the light one.
*
* Checked with a validator rather than by eye, which is the only way anyone
* can honestly claim a palette is colourblind-safe. */
:root {
--viz-1: #2a78d6; /* blue */
--viz-2: #eb6834; /* orange */
--viz-3: #1baf7a; /* aqua */
--viz-4: #eda100; /* yellow */
--viz-grid: #e6e5e1;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--viz-1: #3987e5;
--viz-2: #d95926;
--viz-3: #199e70;
--viz-4: #c98500;
--viz-grid: #2e2e2c;
}
}
:root[data-theme="dark"] {
--viz-1: #3987e5;
--viz-2: #d95926;
--viz-3: #199e70;
--viz-4: #c98500;
--viz-grid: #2e2e2c;
}
/* Stat tiles: the headline figures, which are numbers rather than charts. */
.tiles { display: flex; flex-wrap: wrap; gap: .75rem; margin: .5rem 0 1.25rem; }
.tile {
flex: 1 1 8.5rem; min-width: 8.5rem; padding: .6rem .75rem;
border: 1px solid var(--viz-grid); border-radius: 6px; background: var(--card);
}
.tile .v { font-size: 1.5rem; line-height: 1.1; font-variant-numeric: tabular-nums; }
.tile .k { font-size: .8rem; color: var(--muted); }
.tile svg { display: block; margin-top: .35rem; }
/* Ranked horizontal bars. A wrapper, not the SVG itself: .chart above is the
* fixed-height time-series box, and sharing the name gave these a 120px cage
* their taller content overflowed, over the table beneath them. Capped at the
* design width so scaling up never inflates the 12px labels. */
.ranked { margin: .25rem 0 1rem; }
.ranked svg { display: block; width: 100%; max-width: 720px; height: auto; }
/* The landing page.
*
* It is the only page here a stranger sees, and for most readers it is the whole
* product until they have a key. Everything below is layout and surface: the
* page still says exactly what it said before, and still names no model, no node
* and no count. */
.banner { border-radius: 6px; padding: .7rem .9rem; margin: 0 0 1.5rem;
border: 1px solid var(--line); background: var(--card); font-size: .875rem; }
.warn-banner { border-color: var(--warn); color: var(--warn); }
.hero { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 420px);
gap: 2rem; align-items: center; border-bottom: 1px solid var(--line);
margin-bottom: 2.25rem; padding-bottom: 2rem; }
.hero h1 { font-size: 2.6rem; line-height: 1; margin: 0 0 .5rem; letter-spacing: -.03em; }
.hero .sub { font-size: 1.15rem; color: var(--fg); font-weight: 500; }
.hero .lede { color: var(--muted); max-width: 34rem; margin: .75rem 0 0; }
.cta { display: flex; flex-wrap: wrap; gap: .6rem; margin: 1.35rem 0 0; }
.btn { display: inline-block; font-size: .9rem; font-weight: 600; text-decoration: none;
background: var(--accent); color: var(--bg); border: 1px solid var(--accent);
border-radius: 6px; padding: .5rem 1.1rem; }
.btn:hover { filter: brightness(1.1); }
.btn.ghost { background: none; color: var(--accent); border-color: var(--line); }
.btn.ghost:hover { border-color: var(--accent); filter: none; }
.schematic { margin: 0; }
.schematic svg { display: block; width: 100%; height: auto; }
.schematic figcaption { color: var(--muted); font-size: .8rem; margin-top: .6rem; }
@media (max-width: 760px) {
.hero { grid-template-columns: 1fr; gap: 1.5rem; }
.hero h1 { font-size: 2rem; }
}
/* The base URL is what a reader came for, so it is a headline rather than a
* line of prose with a colon in it. */
.url-card { display: flex; flex-wrap: wrap; align-items: baseline; gap: .35rem 1rem;
background: var(--card); border: 1px solid var(--line);
border-left: 3px solid var(--accent); border-radius: 6px;
padding: .85rem 1.1rem; }
.url-card .k { font-size: .72rem; font-weight: 600; text-transform: uppercase;
letter-spacing: .06em; color: var(--muted); }
.url-card code { font-size: 1.05rem; word-break: break-all; }
.steps { list-style: none; padding: 0; margin: .5rem 0 0;
display: grid; gap: 1rem; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.steps > li { margin: 0; background: var(--card); border: 1px solid var(--line);
border-radius: 8px; padding: 1.1rem 1.1rem 1.25rem; }
.steps .n { display: inline-grid; place-items: center; width: 1.6rem; height: 1.6rem;
border-radius: 50%; background: var(--accent); color: var(--bg);
font-size: .8rem; font-weight: 700; }
.steps h3 { font-size: .98rem; margin: .6rem 0 .4rem; }
.steps p { margin: .4rem 0 0; font-size: .9rem; }
/* A card is narrower than a line of shell, and a block that scrolls sideways
* inside a card is one nobody discovers they can scroll. */
.steps pre { margin: .7rem 0 0; font-size: .78em;
white-space: pre-wrap; overflow-wrap: anywhere; }
@media (max-width: 860px) { .steps { grid-template-columns: 1fr; } }
/* Tabs with no JavaScript: the radios carry the state, so the panel a reader
* chose survives everything including the script never loading. */
.tabs { display: flex; flex-wrap: wrap; gap: .35rem; margin: .5rem 0 .75rem; }
.tabs > input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.tabs > label { display: inline-block; margin: 0; cursor: pointer; font-size: .85rem;
font-weight: 600; color: var(--muted); padding: .35rem .85rem;
border: 1px solid transparent; border-radius: 999px; }
.tabs > label:hover { color: var(--fg); }
.tabs > input:checked + label { color: var(--fg); background: var(--card);
border-color: var(--line); }
.tabs > input:focus-visible + label { outline: 2px solid var(--accent); outline-offset: 2px; }
.tabs > .panels { flex: 1 0 100%; }
.tabs .panel { display: none; margin: 0; }
.tabs > input:nth-of-type(1):checked ~ .panels > .panel:nth-child(1),
.tabs > input:nth-of-type(2):checked ~ .panels > .panel:nth-child(2),
.tabs > input:nth-of-type(3):checked ~ .panels > .panel:nth-child(3) { display: block; }
/* Copy buttons are injected by script, so they are positioned against the block
* they copy rather than reserving space that stays empty without one. */
pre { position: relative; }
pre .copy { position: absolute; top: .4rem; right: .4rem; padding: .15rem .5rem;
font-size: .72rem; font-weight: 600; background: var(--bg);
color: var(--muted); border: 1px solid var(--line); border-radius: 4px;
opacity: 0; transition: opacity .12s; }
pre:hover .copy, pre .copy:focus-visible { opacity: 1; }
pre .copy:hover { color: var(--fg); filter: none; }
.endpoints { list-style: none; padding: 0; margin: .5rem 0 0; }
.endpoints > li { display: flex; flex-wrap: wrap; align-items: baseline; gap: .3rem .8rem;
padding: .55rem 0; border-bottom: 1px solid var(--line); margin: 0; }
.endpoints > li:last-child { border-bottom: none; }
.endpoints > li span { color: var(--muted); font-size: .85rem; }
/* A button revealed by hover is a button a phone never has. */
@media (hover: none) { pre .copy { opacity: 1; } }
/* The mirror page. A model version spills onto a second line under its row:
the catalog URL is long, and a column wide enough for it would squeeze every
other column on the table into uselessness. */
.url-row code, .error-row { font-size: .78rem; padding-top: 0; }
.url-row { color: var(--muted); }
.url-row code { color: var(--muted); }
.error-row { color: var(--bad); }
tr:has(+ .url-row) td, tr:has(+ .error-row) td { border-bottom: none; }
/* The fetch form's three short fields sit on one line on a wide screen and
stack on a narrow one, because two of them are a word long and a full-width
input for "2026-09" reads as though more is wanted. */
.field-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.field-row label { flex: 1 1 12rem; }
.opt { font-weight: 400; color: var(--muted); font-size: .8rem; }
/* The row whose quota form is open. Tinted rather than outlined: a border
inside a table cell breaks the ruling of every column beside it. */
tr.editing > td { background: color-mix(in srgb, var(--accent) 8%, var(--card)); }
tr.editing form { margin: .4rem 0; }
/* Multi-line time series. Thin marks, recessive grid: the data is the darkest
thing on the chart, which is the only rule that makes a chart readable. */
.chart-lines { height: 150px; }
.chart-lines .line { stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.chart-lines .axis { stroke: var(--viz-grid); stroke-width: 1; }
.chart-lines .tick { fill: var(--muted); font-size: 9px; text-anchor: end; }
/* A legend is always present for two lines or more: colour alone is not an
encoding a reader can rely on. */
.legend { display: flex; flex-wrap: wrap; gap: .1rem 1rem; margin: .2rem 0 1rem; font-size: .8rem; }
.legend span { display: inline-flex; align-items: center; gap: .35rem; color: var(--muted); }
.legend i { width: 10px; height: 2px; border-radius: 1px; display: inline-block; }
.legend b { font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; }
.chart-title { font-size: .9rem; font-weight: 600; margin: 0 0 .3rem; }
/* The filter row above a report's charts.
One row, above the charts rather than beside them: the reader chooses what
they are looking at before they look, and a control column on the right is
found after the question has already been answered wrongly. Links rather
than a form, so the view is in the URL and can be shared. */
.filters { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap;
margin: 0 0 1rem; }
.filter-label { color: var(--muted); font-size: .78rem; text-transform: uppercase;
letter-spacing: .04em; margin-right: .2rem; }
.filter-label + .chip { margin-right: 0; }
.filters .filter-label:not(:first-child) { margin-left: 1rem; }
.chip { font-size: .85rem; text-decoration: none; color: var(--fg);
background: var(--card); border: 1px solid var(--line);
border-radius: 999px; padding: .25rem .7rem; }
.chip:hover { border-color: var(--accent); }
/* The selected one is filled, not merely outlined: an outline reads as
available rather than as chosen, and with four of them side by side the
difference has to survive a glance. */
.chip.on { background: var(--accent); border-color: var(--accent); color: var(--bg);
font-weight: 600; }
/* People: the tenancy tree with its people and keys. Indented by level and
ruled on the left, so which team a project is in reads at a glance without
a table per level. */
.org { background: var(--card); border: 1px solid var(--line); border-radius: 8px;
padding: .9rem 1rem; margin: 0 0 1rem; }
.team { margin: .8rem 0 0 .4rem; padding-left: .9rem; border-left: 2px solid var(--line); }
.node-head { display: flex; align-items: center; gap: .4rem .9rem; flex-wrap: wrap; }
.node-name { font-weight: 600; }
.org > .node-head .node-name { font-size: 1.05rem; }
.node-meta { color: var(--muted); font-size: .82rem; font-weight: 400; }
details.edit { display: inline-block; font-size: .85rem; }
details.edit > summary { cursor: pointer; color: var(--accent); list-style: none;
text-decoration: underline; }
details.edit > summary::-webkit-details-marker { display: none; }
details.edit[open] > summary { color: var(--muted); }
details.edit form { display: flex; gap: .4rem; align-items: center; margin: .35rem 0; }
details.edit input[type=text] { width: 14rem; }
details.project { margin: .45rem 0; border: 1px solid var(--line); border-radius: 6px;
background: var(--bg); }
details.project > summary { cursor: pointer; padding: .45rem .7rem; display: flex;
gap: .8rem; align-items: baseline; }
details.project[open] > summary { border-bottom: 1px solid var(--line); }
.project-body { padding: .6rem .7rem .8rem; display: grid; gap: .7rem; }
table.compact { font-size: .83rem; }
table.compact th, table.compact td { padding: .35rem .55rem; }
form.add { display: flex; gap: .4rem; margin: .5rem 0 0; }
form.add input[type=text], details.edit input[type=text] { margin: 0; padding: .35rem .55rem; }
form.add input[type=text] { max-width: 18rem; }
form.add button, button.small { padding: .3rem .7rem; font-size: .82rem; }
form.org-add { margin: 0 0 1rem; }
td.actions { white-space: nowrap; }
td.actions form + form { margin-left: .6rem; }
.tag { font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em;
color: var(--accent); border: 1px solid var(--accent); border-radius: 4px;
padding: 0 .3rem; margin-left: .3rem; }
input.search { width: 100%; max-width: 26rem; margin: 0 0 .7rem; padding: .45rem .6rem;
font: inherit; border: 1px solid var(--line); border-radius: 6px;
background: var(--card); color: var(--fg); }
tr[hidden] { display: none; }