<!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="light">
<title>Holger — log in</title>
<style>
/* ── The shape ───────────────────────────────────────────────────────────────
Lifted from `holger-front/assets/front.html`, which lifted it from
gunnar-ui's own login page, which is where the measurements were taken. This
is the third instance and NOTHING here is re-derived: holger's console and
holger's public front page have to read as one product, and two pages that
were "designed to look similar" drift on the first edit.
What is a MEASUREMENT and not a taste is marked as such below. Re-measure,
do not re-taste. */
:root {
color-scheme: light;
/* MEASURED at the EDGE THAT TOUCHES THE PAGE, not at the corner.
`holger.webp` is a dark mark on a near-white field, and the column where it
meets the words is a gradient, not a colour. #f4f4f4 is the mean of that
edge, which is the shortest distance the fade below has to travel. THE
COLOUR DOES NOT CLOSE THE SEAM AND CANNOT — one value cannot match a column
that moves. The fade on `.art` closes it, at every height and every
viewport; this number only makes the fade short. Re-sample the edge, not
the corner, if the picture is replaced. */
--bg: #f4f4f4;
/* The same value as channels, so a gradient can fade to it at zero alpha.
`transparent` is NOT the same thing: several engines interpolate it as
transparent BLACK and lay a grey haze over the last stretch of the fade,
which is the very seam this is here to remove. */
--bg-rgb: 244 244 244;
--surface: oklch(1 0 0);
/* A step BELOW the ground: the controls are filled rather than outlined, and a
fill lighter than the page stops reading as a control at all. */
--raised: #e5e5e3;
--line: oklch(0.885 0.005 75);
--line-strong: oklch(0.775 0.006 75);
--ink: oklch(0.225 0.008 70);
--ink-dim: oklch(0.435 0.008 70);
--ink-mute: oklch(0.565 0.007 70);
--pass: oklch(0.52 0.15 155);
--bad: oklch(0.53 0.20 25);
--sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
--mono: ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, monospace;
/* Loaded from nowhere: an external font file would be one more thing that can
fail to arrive before the page draws, on a server whose whole point is
working with no internet at all. */
--display: ui-serif, Georgia, "Iowan Old Style", "Palatino Linotype", Palatino, serif;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
/* ── The layout ──────────────────────────────────────────────────────────────
Two halves of the viewport, nothing drawn between them: THE PICTURE OWNS THE
LEFT, everything a visitor can read or do owns the right. No card, no modal,
no panel — a card around three lines of text is a frame around a frame. */
body {
margin: 0; display: flex; min-height: 100dvh;
background: var(--bg); color: var(--ink);
font: 14.5px/1.55 var(--sans); -webkit-font-smoothing: antialiased;
}
/* **To change the picture, change this one line.** It is a custom property for
exactly that reason, so the asset is named ONCE and not scattered through the
rules that use it. The bytes are compiled into this binary
(`routes::PICTURE`) and served at this path by a route of the console's own —
not read off disk, because a login page whose picture depends on a deploy
having copied a file is a login page with a half-drawn state nobody tests. */
:root { --backdrop-image: url("/holger.webp"); }
/* Full bleed, full strength, no dimming and no blur: it has no text over it on a
desktop — that is the point of giving it a half of its own — so nothing here
is protecting anything and every filter would only cost the picture. The last
90 pixels dissolve into the page's own ground. */
.art {
flex: 0 0 50%;
background-color: var(--bg);
background-image:
linear-gradient(to right, rgb(var(--bg-rgb) / 0) calc(100% - 90px), rgb(var(--bg-rgb) / 1)),
var(--backdrop-image);
background-size: auto, contain;
background-position: center, center;
background-repeat: no-repeat, no-repeat;
}
.side {
flex: 1 1 50%; min-width: 0; min-height: 100dvh;
display: flex; flex-direction: column; gap: 1.6rem;
padding: clamp(1.25rem, 3.2vw, 2.75rem);
}
/* ── The entry block ─────────────────────────────────────────────────────────
No border, no background, no shadow: it sits on the page. */
.entry { flex: 0 0 auto; max-width: 30rem; }
h1 {
margin: 0 0 .35rem;
font-family: var(--display);
font-size: 2.1rem; font-weight: 500; letter-spacing: -.012em;
}
.tagline {
margin: 0 0 1.25rem; font-family: var(--display); font-size: 1.05rem;
color: var(--ink-dim);
}
/* The controls stay in the interface face. A serif button is a costume: it reads
as an ornament where the eye is looking for something to press. */
button {
width: 100%; padding: .62rem .8rem; border-radius: 6px;
border: 1px solid transparent; background: var(--raised); color: var(--ink);
font: inherit; font-size: .92rem; font-weight: 550; cursor: pointer;
transition: background .15s cubic-bezier(.16,1,.3,1), border-color .15s cubic-bezier(.16,1,.3,1);
}
button:hover:not(:disabled) { background: var(--raised); border-color: var(--ink-mute); }
button:disabled { opacity: .45; cursor: not-allowed; }
/* The primary action inverts rather than taking a hue: dark-on-light turned
over, a near-black button with the page's own colour as its ink. */
button.primary { background: var(--ink); border-color: var(--ink); color: var(--surface); }
button.primary:hover:not(:disabled) { background: oklch(0 0 0); border-color: oklch(0 0 0); }
button.primary.working { background: var(--surface); color: var(--ink); border-color: var(--ink); }
button.working:disabled { opacity: 1; cursor: progress; }
:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; border-radius: 5px; }
.msg { margin-top: .7rem; min-height: 1.2rem; font-size: .82rem; color: var(--ink-dim); }
/* A refusal is monospace and carries the server's own words, because those are
greppable and "something went wrong" is not. */
.msg.err { color: var(--bad); font-family: var(--mono); overflow-wrap: anywhere; }
.msg.ok { color: var(--pass); }
.offer { margin: .9rem 0 0; color: var(--ink-dim); font-size: .86rem; }
.offer a { color: var(--ink); }
.offer code { font-family: var(--mono); font-size: .92em; }
/* ── The registration panel ──────────────────────────────────────────────────
A details block, shut by default: the ordinary visit is a login, and a form
for the rarer act must not be the first thing the eye lands on. It is taken
away entirely once the first admin exists — see `bootstrapDoor()`. */
details.reg { flex: 0 0 auto; margin-top: 1.4rem; max-width: 30rem; border-top: 1px solid var(--line); padding-top: 1.1rem; }
details.reg summary { cursor: pointer; font-size: .82rem; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--ink-mute); }
details.reg summary::-webkit-details-marker { color: var(--ink-mute); }
.field { margin-top: .75rem; display: flex; flex-direction: column; gap: .3rem; }
.field label { font-size: .78rem; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-mute); }
.field input, .field select {
padding: .5rem .6rem; border-radius: 6px; border: 1px solid var(--line-strong);
background: var(--surface); color: var(--ink); font: inherit; font-size: .9rem;
}
.field input { font-family: var(--mono); }
.reg button { margin-top: 1rem; }
/* `margin-top: auto` is what puts the credit on the bottom edge, and it is the
only thing that does: the column is a flex column, so the auto margin eats
every pixel the entry block did not claim. */
.by {
flex: 0 0 auto;
display: flex; align-items: center; flex-wrap: wrap; gap: .38rem;
margin: auto 0 0; font-family: var(--display); font-size: .95rem; color: var(--ink-dim);
}
.by strong { font-weight: 600; color: var(--ink); }
.by a { color: inherit; border-radius: 3px; }
/* ── One column, ≤ 720px ─────────────────────────────────────────────────────
The left half does not survive a phone: half a viewport of picture beside a
half-width column is two unusable columns. So the picture stops being a column
and becomes the ground the single column stands on — fixed behind everything,
dimmed far enough that text still reads over it, and out of the way of every
tap. */
@media (max-width: 720px) {
body { display: block; }
.art {
position: fixed; inset: 0; z-index: 0; pointer-events: none;
opacity: .16; filter: saturate(.9);
}
.side {
position: relative; z-index: 1; width: 100%;
height: auto; min-height: 100dvh;
padding: 1.25rem 1.1rem; gap: 1.2rem;
}
/* **The controls get their edge back here, and only here.** A fill one step
below the ground reads as a control because the ground is FLAT — true of the
desktop, where the picture keeps to its own half. On a phone the picture IS
the ground, dimmed but not uniform, and a 2 % difference in lightness
disappears into the parts of it already darker than the page. So the phone
spends the line the desktop does not need. */
button { border-color: var(--line-strong); }
}
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
</style>
</head>
<body>
<!-- THE LEFT HALF on a desktop; the ground behind the column on a phone. -->
<div class="art" aria-hidden="true"></div>
<div class="side">
<main class="entry">
<h1>Holger</h1>
<p class="tagline">Immutable artifact repository in pure Rust</p>
<!-- ★ LOGIN-1 (owner 2026-10-27): /login IS the login. One "Log in", the passkey
sign-in (one press starts the prompt); NO sign-up here. Sign-up is its own page,
reached from the front page only. -->
<button id="login" class="primary">Log in</button>
<div id="msg" class="msg" role="status" aria-live="polite"></div>
<!-- ★ HOL-LOGIN (owner 2026-10-27): the newcomer's line is holger's word. A root whose front
page makes accounts points there (Gunnar's words); a private instance keeps the invite
sentence. Hidden and empty until `/-/signup` answers (login.js `newcomerLine`). -->
<p class="offer" id="invite-offer" hidden></p>
</main>
<details class="reg" id="reg">
<summary id="reg-summary">Register the first passkey</summary>
<div class="field" id="token-row">
<label for="token">Break-glass admin token</label>
<input id="token" type="password" autocomplete="off" spellcheck="false"
placeholder="the bootstrap bearer holger was installed with">
</div>
<div class="field">
<label for="user">User name</label>
<input id="user" type="text" autocomplete="username" spellcheck="false"
placeholder="lowercase, [a-z0-9._-]">
</div>
<div class="field">
<label for="label">Key label</label>
<input id="label" type="text" spellcheck="false" placeholder="yubikey">
</div>
<div class="field" id="role-row">
<label for="role">Role</label>
<select id="role">
<option value="viewer">viewer — read the console</option>
<option value="operator">operator — artifact work</option>
<option value="admin">admin — promotion, replication, passkeys</option>
</select>
</div>
<button id="register">Register this passkey</button>
</details>
<p class="by">
by
<a href="https://www.vetra.se" target="_blank" rel="noopener noreferrer"><strong>Vetra AB</strong></a>
&
<strong>Ignalina ApS</strong>
</p>
</div>
<script src="/login-helpers.js"></script>
<script src="/login.js"></script>
</body>
</html>