Gunnar

nordisk/holger-ui

assets/signup.html

<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>Holger — sign up</title>
<style>
  /* ── HOLGER'S SIGN-UP PAGE (A12, owner 2026-10-23): Gunnar's page, screen for screen ──
     gunnar-ui `auth/signup.html`, its markup and its rules as they are, in Holger's look
     (the same tokens as holger-ui's login page, Holger's picture on the left). Every call goes
     to this site's own buy doors (the shared monetize-site-buy relay on Holger's front); the
     script is `/signup.js` (no inline script: the bundle's CSP).

     Gunnar's notes below are kept as Gunnar wrote them.
     One page for every way in: a new private Gunnar, an account here, a
     bigger account, a bigger instance. The LEFT half is the front page's,
     byte for byte (`.art` and its phone rule are login.html's own lines, and
     `the_left_half_is_the_front_pages` holds them equal); the RIGHT half is
     the sign-up, laid out like the front page's entry block.

     Self-contained for login.html's reasons: served before any session, its
     ONE inline script hashed into the CSP at startup. LIGHT only, like the
     front page (`the_login_page_is_light`): the console's dark theme is a
     choice made inside the console.

     THIN, like every page here: every size, every price and every refusal on
     it is an answer from monetize (or gunnar) through this console's BFF.
     The page draws the three boxes from a step's PLACE on the ladder — never
     from a number it made up — and writes no price, no plan and no floor of
     its own. */
  :root {
    color-scheme: light;
    /* login.html's tokens, copied, not re-chosen: the visitor crosses from
       that page to this one and must not watch the page change colour. */
    --bg: #f4f4f4;
    --bg-rgb: 244 244 244;
    --surface: oklch(1 0 0);
    --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;
    --display: ui-serif, Georgia, "Iowan Old Style", "Palatino Linotype", Palatino, serif;
  }
  * { box-sizing: border-box; }
  html, body { height: 100%; }
  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;
  }
  /* The left half: login.html's own lines, from here to the end marker
     (`the_left_half_is_the_front_pages` holds every rule to that page). */
  /*@left-half*/
  :root { --backdrop-image: url("/holger.webp"); }
  .art {
    flex: 0 0 auto;
    height: 100dvh;
    aspect-ratio: 574 / 1024;
    max-width: 50vw;
    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;
  }
  /*@left-half-end*/
  /* The right half scrolls by itself when a phone-sized laptop window is
     shorter than the form: the picture keeps the viewport, the form keeps
     its whole height. */
  .side {
    flex: 1 1 50%; min-width: 0; height: 100dvh; overflow-y: auto;
    display: flex; flex-direction: column; gap: 1.1rem;
    padding: clamp(1.25rem, 3.2vw, 2.75rem);
  }
  .entry { flex: 0 0 auto; }
  .crumb { margin: 0 0 .9rem; font-size: .82rem; color: var(--ink-mute); }
  .crumb a { color: var(--ink-dim); text-underline-offset: 2px; }
  h1 {
    margin: 0 0 .9rem;
    font-family: var(--display);
    font-size: 2.1rem; font-weight: 500; letter-spacing: -.012em; line-height: 1.2;
  }
  /* ── The choice ────────────────────────────────────────────────────────
     Two or three words side by side, one of them pressed. Not tabs with a
     panel each: the page below is the same page for every choice, and only
     what it asks and what it answers change. */
  .choice { display: flex; flex-wrap: wrap; gap: .35rem; margin: 0 0 1.1rem; }
  .choice[hidden] { display: none; }
  .choice button, .choice a {
    width: auto; padding: .34rem .75rem; border-radius: 999px;
    border: 1px solid var(--line-strong); background: transparent; color: var(--ink-dim);
    font: inherit; font-size: .84rem; font-weight: 550; cursor: pointer;
  }
  .choice a { text-decoration: none; }
  .choice button[aria-pressed="true"] { background: var(--ink); border-color: var(--ink); color: var(--surface); }
  .choice button:hover:not([aria-pressed="true"]), .choice a:hover { border-color: var(--ink-mute); color: var(--ink); }
  /* ── The account's plans (owner 2026-10-15) ────────────────────────────
     A list to pick ONE from: the free account, then the plans the ONE box
     sells, each with its price as the ONE box words it. One row pressed. */
  .plans { display: flex; flex-direction: column; gap: .45rem; margin: .9rem 0 .9rem; }
  .plans[hidden] { display: none; }
  .plans button {
    display: grid; grid-template-columns: 1fr auto; align-items: baseline; gap: .1rem 1rem;
    width: 100%; padding: .6rem .8rem; border-radius: 8px; text-align: left;
    border: 1px solid var(--line-strong); background: var(--surface); color: var(--ink);
    font: inherit; cursor: pointer;
  }
  .plans button[aria-checked="true"] { border-color: var(--ink); box-shadow: inset 0 0 0 1px var(--ink); }
  .plans button:hover:not(:disabled):not([aria-checked="true"]) { border-color: var(--ink-mute); }
  .plans button:disabled { opacity: .55; cursor: not-allowed; }
  .plans .plan-name { font-weight: 600; font-size: .92rem; }
  .plans .plan-price { font-size: .86rem; color: var(--ink-dim); text-align: right; }
  .plans .plan-note { grid-column: 1 / -1; font-size: .78rem; color: var(--ink-mute); }
  .plans .plan-note:empty { display: none; }
  /* ── The form ──────────────────────────────────────────────────────────
     Two fields to a row on a desktop, one on a phone. Filled, not outlined,
     like the front page's controls. */
  form { display: grid; grid-template-columns: 1fr 1fr; gap: .2rem 1rem; margin: 0; }
  .field[hidden] { display: none; }
  label { display: block; margin: .55rem 0 .25rem; color: var(--ink-dim); font-size: .78rem; font-weight: 550; }
  input {
    width: 100%; padding: .48rem .6rem; border-radius: 6px;
    border: 1px solid transparent; background: var(--raised); color: var(--ink);
    font: inherit; font-size: .88rem;
  }
  input[readonly] { background: transparent; border-color: var(--line); color: var(--ink-dim); }
  .hint { margin: .2rem 0 0; min-height: 1.1rem; font-size: .76rem; color: var(--ink-mute); font-family: var(--mono); overflow-wrap: anywhere; }
  /* ── The picture: three hosts inside a double ring ──────────────────────
     About a sixth of the screen's height. The boxes grow with the step; the
     ring and the caption stay where they are. Geometry moves by CSS, so a
     step is seen to grow rather than to jump. */
  .estate { margin: 1rem 0 .3rem; }
  .estate[hidden] { display: none; }
  .estate svg { display: block; width: 100%; height: clamp(120px, 16.6vh, 190px); overflow: visible; }
  .estate .ring { fill: none; stroke: var(--ink-dim); stroke-width: 1.5; }
  .estate .ring.outer { stroke-dasharray: 9 5; }
  .estate .ring.inner { stroke-dasharray: 1.5 4.5; stroke-linecap: round; stroke-width: 1.8; }
  .estate .host { fill: var(--surface); stroke: var(--ink); stroke-width: 1.4; }
  .estate .host, .estate .slice { transition: x .28s cubic-bezier(.16,1,.3,1), y .28s cubic-bezier(.16,1,.3,1), width .28s cubic-bezier(.16,1,.3,1), height .28s cubic-bezier(.16,1,.3,1); }
  .estate .slice { fill: var(--line-strong); stroke: none; }
  .estate .slice[hidden] { display: none; }
  .estate .label { font: 650 12.5px var(--sans); letter-spacing: .1em; fill: var(--ink); text-anchor: middle; }
  .estate .res { font: 11.5px var(--mono); fill: var(--ink-dim); text-anchor: middle; }
  .estate .caption { font: italic 13.5px var(--display); fill: var(--ink-dim); text-anchor: middle; }
  .estate.coming .host { stroke: var(--ink-mute); stroke-dasharray: 3 3; }
  /* ── The one − / + for the whole setup ─────────────────────────────── */
  .size { display: grid; grid-template-columns: 2.6rem 1fr 2.6rem; align-items: center; gap: .8rem; max-width: 30rem; margin: .2rem auto 0; }
  .size[hidden] { display: none; }
  .size button {
    width: 2.6rem; height: 2.6rem; padding: 0; border-radius: 50%;
    border: 1px solid var(--line-strong); background: var(--surface); color: var(--ink);
    font: 500 1.35rem/1 var(--sans); cursor: pointer;
  }
  .size button:hover:not(:disabled) { border-color: var(--ink); }
  .size button:disabled { opacity: .35; cursor: not-allowed; }
  .step { text-align: center; min-width: 0; }
  .step strong { display: block; font-family: var(--display); font-size: 1.35rem; font-weight: 500; }
  .step span { display: block; font-size: .8rem; color: var(--ink-dim); font-family: var(--mono); overflow-wrap: anywhere; }
  .step .why { color: var(--bad); font-family: var(--sans); }
  .price { margin: .75rem 0 .9rem; text-align: center; min-height: 2.6rem; }
  .price strong { display: block; font-size: 1.05rem; font-weight: 650; }
  .price span { display: block; font-size: .8rem; color: var(--ink-dim); }
  .price[hidden] { display: none; }
  /* The front page's buttons, verbatim in spirit: filled, the primary
     inverted, busy turns it over rather than greying it. */
  button.primary {
    width: 100%; padding: .62rem .8rem; border-radius: 6px;
    border: 1px solid var(--ink); background: var(--ink); color: var(--surface);
    font: inherit; font-size: .92rem; font-weight: 550; cursor: pointer;
  }
  button.primary:hover:not(:disabled) { background: oklch(0 0 0); border-color: oklch(0 0 0); }
  button.primary:disabled { opacity: .45; cursor: not-allowed; }
  button.primary.working { background: var(--surface); color: var(--ink); }
  button.primary.working:disabled { opacity: 1; cursor: progress; }
  button.primary[hidden] { display: none; }
  .coming { margin: .2rem 0 0; color: var(--ink-dim); font-size: .9rem; }
  .coming[hidden] { display: none; }
  :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); overflow-wrap: anywhere; }
  .msg.err { color: var(--bad); }
  .msg.ok { color: var(--pass); }
  #state { margin: 0 0 1rem; padding: .7rem .8rem; border: 1px solid var(--line-strong); border-radius: 8px; font-size: .86rem; }
  #state[hidden] { display: none; }
  #state table { border-collapse: collapse; margin-top: .4rem; font: .8rem var(--mono); }
  #state td { border: 1px solid var(--line); padding: .2rem .45rem; }
  #state a { color: var(--ink); }
  .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.maker { display: inline-flex; align-items: center; gap: .38rem; color: inherit; text-decoration: none; border-radius: 3px; }
  .by a.maker:hover strong { text-decoration: underline; text-underline-offset: 3px; }
  .mark { display: block; border-radius: 3px; }
  /* ── One column, ≤ 720px: the front page's own rule ─────────────────── */
  @media (max-width: 720px) {
    body { display: block; }
    /*@left-half-phone*/
    .art {
      position: fixed; inset: 0; z-index: 0; pointer-events: none;
      opacity: .16; filter: saturate(.9);
      height: auto; aspect-ratio: auto; max-width: none; background-size: auto, cover;
    }
    /*@left-half-phone-end*/
    .side {
      position: relative; z-index: 1; width: 100%;
      height: auto; min-height: 100dvh; overflow: visible;
      padding: 1.25rem 1.1rem; gap: 1.2rem;
    }
    form { grid-template-columns: 1fr; }
    h1 { font-size: 1.6rem; }
    input, .size button, .choice 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">
    <p class="crumb"><a href="/login">Holger</a></p>
    <!-- The heading follows the situation (see HEADINGS in the script). -->
    <h1 id="heading">Sign up for an account</h1>

    <!-- What this visitor may do here, as the server answered it. -->
    <div class="choice" id="choice" role="group" aria-label="What to do" hidden></div>

    <!-- The account's plans (owner 2026-10-15): drawn only when the account
         is chosen; the server picture below only for a private Gunnar. -->
    <div class="plans" id="plans" role="radiogroup" aria-label="Account plans" hidden></div>

    <div id="state" role="status" aria-live="polite" hidden></div>

    <form id="form" autocomplete="on" novalidate>
      <p class="hint" id="first-account" hidden></p>
      <div class="field" id="f-account">
        <label for="account">Account name</label>
        <input id="account" name="account" type="text" autocomplete="username" autocapitalize="none"
               spellcheck="false" maxlength="32" placeholder="e.g. alice" required>
        <p class="hint" id="account-hint"></p>
      </div>
      <div class="field" id="f-email">
        <label for="email" id="email-label">E-mail for your welcome mail</label>
        <input id="email" name="email" type="email" autocomplete="email" autocapitalize="none"
               spellcheck="false" maxlength="254" placeholder="you@example.com" required>
      </div>
      <div class="field" id="f-company">
        <label for="company">Company</label>
        <input id="company" name="company" type="text" autocapitalize="none" spellcheck="false"
               maxlength="16" placeholder="e.g. acme" required>
        <p class="hint" id="company-hint"></p>
      </div>
      <div class="field" id="f-domain">
        <label for="domain">Own domain (optional)</label>
        <input id="domain" name="domain" type="text" autocapitalize="none" spellcheck="false"
               placeholder="e.g. git.acme.com">
        <p class="hint"></p>
      </div>
    </form>

    <!-- ★ The picture: UI, SERVER and REPLICA — the front, main and twin —
         inside the WireGuard overlay, drawn as two dashed rings. Its labels
         are HOST_LABELS in the script (one line each). -->
    <figure class="estate" id="estate" aria-label="Holger instance protected by VPN">
      <svg id="pic" viewBox="0 0 640 150" role="img" aria-labelledby="pic-caption">
        <rect class="ring outer" id="ring-outer" x="4" y="4" width="632" height="122" rx="28"></rect>
        <rect class="ring inner" id="ring-inner" x="12" y="12" width="616" height="106" rx="21"></rect>
        <g id="host-front">
          <rect class="host" id="box-front" x="60" y="34" width="100" height="44" rx="7"></rect>
          <text class="label" id="label-front" x="110" y="60"></text>
          <text class="res" id="res-front" x="110" y="104"></text>
        </g>
        <g id="host-main">
          <rect class="host" id="box-main" x="262" y="30" width="116" height="52" rx="7"></rect>
          <rect class="slice" id="slice-main" x="263" y="70" width="20" height="11" hidden></rect>
          <text class="label" id="label-main" x="320" y="60"></text>
          <text class="res" id="res-main" x="320" y="104"></text>
        </g>
        <g id="host-twin">
          <rect class="host" id="box-twin" x="480" y="34" width="100" height="44" rx="7"></rect>
          <rect class="slice" id="slice-twin" x="481" y="66" width="20" height="11" hidden></rect>
          <text class="label" id="label-twin" x="530" y="60"></text>
          <text class="res" id="res-twin" x="530" y="104"></text>
        </g>
        <text class="caption" id="pic-caption" x="320" y="144">Holger instance protected by VPN</text>
      </svg>
    </figure>

    <!-- ONE − / + for the whole setup. -->
    <div class="size" id="size">
      <button type="button" id="minus" aria-label="Smaller">−</button>
      <div class="step" aria-live="polite">
        <strong id="step-name">…</strong>
        <span id="step-res"></span>
      </div>
      <button type="button" id="plus" aria-label="Bigger">+</button>
    </div>
    <p class="price" id="price"><strong id="price-month"></strong><span id="price-total"></span></p>

    <p class="coming" id="coming" hidden></p>
    <button id="go" class="primary" type="button" disabled>Pay</button>
    <div id="msg" class="msg" role="status" aria-live="polite"></div>
  </main>

  <p class="by">
    brought to you by
    <a class="maker" href="https://www.vetra.se" target="_blank" rel="noopener noreferrer">
      <img class="mark" src="/vetra.svg" width="96" height="18" alt="" aria-hidden="true">
      <strong>Vetra AB</strong>
    </a>
    &amp;
    <img class="mark" src="/ignalina.png" width="18" height="18" alt="" aria-hidden="true">
    <strong>Ignalina ApS</strong>
  </p>
  </div>

<script src="/signup.js"></script>
</body>
</html>