Gunnar

nordisk/holger-ui

assets/login.html

<!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&nbsp;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>
    &amp;
    <strong>Ignalina ApS</strong>
  </p>
</div>

<script src="/login-helpers.js"></script>
<script src="/login.js"></script>
</body>
</html>