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>
&
<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>