diff --git a/adminsite/app/(customer)/billing/page.tsx b/adminsite/app/(customer)/billing/page.tsx
index b5f1fc1..fe3a2ab 100644
--- a/adminsite/app/(customer)/billing/page.tsx
+++ b/adminsite/app/(customer)/billing/page.tsx
@@ -81,7 +81,7 @@ export default function BillingPage() {
body="Cloud instances and self-hosted licences are both bought from the plan page, and each one bills separately."
/>
) : (
-
+
Instance
@@ -97,14 +97,18 @@ export default function BillingPage() {
const name = nameFor(s.instance_id);
return (
-
+
{name ?? Not linked yet}
{name && {s.instance_id?.slice(0, 8)}}
Users for this install are managed inside it, in Settings → Instance. We have no access to your own deployment.
-
- )}
-
+ {/*
+ * On a self-hosted instance the licence is the errand: someone
+ * opens this page to fetch the blob and paste it. It sits
+ * directly under the term, above the panels that only explain
+ * things.
+ */}
{lic && !cloud && }
+ {cloud && }
+
{/*
* "Moves" rather than "Relinks": the count is rationed, so the
* headline is how many are left, and the panel explains what
@@ -201,12 +201,33 @@ export default function InstancePage() {
* so the panel is absent rather than present and refusing.
*/}
{!cloud && (
-
-
A licence binds to one install. Moving it to different hardware is a relink, and each account gets {maxRelinks}.
+
+
+ A licence binds to one install. Rebuilding the host, or moving to different hardware, needs a replacement licence bound to the new ID —
+ that is a move, and it covers the rest of your current term.
+
relink.mutate(newId)} />
)}
+ {/*
+ * A panel holding one sentence has not decided what it is for.
+ * For a self-hosted install the useful content is not "we don't
+ * do this" but where the thing they came looking for actually
+ * lives — and why the people on their HQ account are not it.
+ */}
+ {!cloud && (
+
+
+ You run this deployment, so its users live inside it rather than here. Add and remove them in the instance’s own settings.
+
+
+ People on your Vantage HQ account can see billing and this licence. That is separate from who can sign in to the instance, and granting
+ one never grants the other.
+
+
+ )}
+
{!lic && (
Get a licence} />
diff --git a/adminsite/app/(customer)/users/InvitePanel.tsx b/adminsite/app/(customer)/users/InvitePanel.tsx
index 4e95500..7b4e202 100644
--- a/adminsite/app/(customer)/users/InvitePanel.tsx
+++ b/adminsite/app/(customer)/users/InvitePanel.tsx
@@ -5,7 +5,7 @@ import { useState } from "react";
import { API_BASE, ApiError, NotConnected, api, type AccountRole } from "@/lib/api";
import { useSession } from "@/lib/session";
import { NotConnectedPanel } from "@/components/NotConnected";
-import { Button } from "@/components/Button";
+import { Button, controlClass } from "@/components/Button";
import { Field } from "@/components/Field";
import { PageFrame, RailCard } from "@/components/PageFrame";
import { formatDate } from "@/lib/format";
@@ -89,11 +89,7 @@ export function InvitePanel() {
Account role
-
diff --git a/adminsite/app/(staff)/staff/audit/page.tsx b/adminsite/app/(staff)/staff/audit/page.tsx
index 1bb2c5c..365ed99 100644
--- a/adminsite/app/(staff)/staff/audit/page.tsx
+++ b/adminsite/app/(staff)/staff/audit/page.tsx
@@ -5,6 +5,7 @@ import { useState } from "react";
import { api } from "@/lib/api";
import { formatDate, formatStamp } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
+import { controlClass } from "@/components/Button";
import { EmptyState, Panel } from "@/components/Panel";
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
@@ -30,7 +31,7 @@ export default function AuditPage() {
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Action, actor or target"
- className="w-full rounded border border-rule bg-panel-2 px-3 py-2 text-[0.9rem] text-ink focus:border-accent focus:outline-none"
+ className={controlClass()}
/>
diff --git a/adminsite/app/(staff)/staff/licenses/page.tsx b/adminsite/app/(staff)/staff/licenses/page.tsx
index 893dc6f..5e62a32 100644
--- a/adminsite/app/(staff)/staff/licenses/page.tsx
+++ b/adminsite/app/(staff)/staff/licenses/page.tsx
@@ -6,11 +6,12 @@ import { useState } from "react";
import { api, type Tier } from "@/lib/api";
import { formatDate, licenceState } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
+import { controlClass } from "@/components/Button";
import { EmptyState, Panel } from "@/components/Panel";
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
import { TermSpark } from "@/components/TermBar";
-const SELECT = "rounded border border-rule bg-panel-2 px-2.5 py-2 text-[0.9rem] text-ink focus:border-accent focus:outline-none";
+const SELECT = controlClass("w-auto");
export default function LicensesPage() {
const [tier, setTier] = useState<"" | Tier>("");
diff --git a/adminsite/components/Button.tsx b/adminsite/components/Button.tsx
index 1904b65..b416464 100644
--- a/adminsite/components/Button.tsx
+++ b/adminsite/components/Button.tsx
@@ -8,9 +8,34 @@ type Variant = "solid" | "line";
* border on the secondary variant. site/ does not have an accent-outlined
* button and this app should not invent one.
*/
+/*
+ * The height every form control resolves to, buttons included.
+ *
+ * Padding alone cannot align them: a select is mono at 0.84rem and a button is
+ * sans at 0.94rem, so identical padding still leaves them ~7px apart and a
+ * filter row looks assembled from two different kits. It is the height the
+ * button's own padding already computed to, so buttons do not move — everything
+ * else comes up to meet them.
+ */
+export const CONTROL_HEIGHT = "h-11";
+
+/*
+ * An input or select that sits on a form row with a button. Mono, because in
+ * this product the values typed into these are addresses, UUIDs and price IDs.
+ */
+export function controlClass(className?: string) {
+ return clsx(
+ CONTROL_HEIGHT,
+ "w-full rounded border border-rule bg-panel-2 px-2.5 font-mono text-[0.88rem] text-ink",
+ "focus:border-accent focus:outline-none",
+ className,
+ );
+}
+
export function buttonClass(variant: Variant = "solid", disabled = false, className?: string) {
return clsx(
- "inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold",
+ "inline-flex items-center gap-2 rounded border px-4 text-[0.94rem] font-semibold",
+ CONTROL_HEIGHT,
"transition-[filter,border-color] duration-150 hover:brightness-110",
variant === "solid" ? "border-accent bg-accent text-accent-ink" : "border-rule bg-panel text-ink hover:border-ink-3",
disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
diff --git a/adminsite/components/Field.tsx b/adminsite/components/Field.tsx
index 04f07af..852eb79 100644
--- a/adminsite/components/Field.tsx
+++ b/adminsite/components/Field.tsx
@@ -1,7 +1,10 @@
+import { controlClass } from "./Button";
+
export function Field({
label,
hint,
error,
+ className,
...input
}: React.InputHTMLAttributes & {
label: string;
@@ -10,18 +13,15 @@ export function Field({
}) {
return (
);
}
diff --git a/adminsite/components/LicenceDelivery.tsx b/adminsite/components/LicenceDelivery.tsx
index 26c009f..92543d1 100644
--- a/adminsite/components/LicenceDelivery.tsx
+++ b/adminsite/components/LicenceDelivery.tsx
@@ -1,56 +1,86 @@
"use client";
import { useState } from "react";
-import { Button } from "./Button";
+import { Panel } from "./Panel";
/*
- * A licence blob is signed public data, not a secret it is useless on any
+ * A licence blob is signed public data, not a secret — it is useless on any
* instance other than the one it names. So it is safe to show inline, and
* showing it is what stops a blocked download from blocking a paying customer.
+ * That is also why it is never collapsed behind a toggle: someone whose
+ * clipboard and download are both blocked has to be able to select it by hand.
+ *
+ * It is evidence rather than content, so it is set in a well with a keyed strip
+ * saying what it is and how much of it there is, and given a fixed height. It
+ * used to run to 250px of base64 and was the largest thing on the page, which
+ * is a strange amount of room to give a string nobody reads.
+ *
+ * The download lives in the page header beside Renew, not here — it was in both
+ * places, which is one button too many for one file.
*/
-export function LicenceDelivery({ instanceId, blob, downloadUrl }: { instanceId: string; blob: string; downloadUrl: string }) {
+export function LicenceDelivery({ blob }: { instanceId: string; blob: string; downloadUrl: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
- await navigator.clipboard.writeText(blob);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
+ try {
+ await navigator.clipboard.writeText(blob);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ // Clipboard is refused without a secure context or a gesture the
+ // browser trusts. The blob is on screen and selectable either way,
+ // so this needs no error state.
+ }
}
const steps = [
<>
- Open Settings → Licence on your install.
+ Open Settings → Licence on your install.
>,
<>Paste the licence into the box and save.>,
<>
- The page reports Valid straight away no restart.
+ The page reports Valid straight away — no restart.
>,
];
return (
-
-
+ {/* Dashed, because this is data to be carried somewhere else
+ rather than a surface to read. */}
+
+ {blob}
+
+
+
-
{blob}
+
+ {/* Numbered because this is an actual sequence — each step is only
+ possible once the one before it is done. */}
{steps.map((body, i) => (
-
- {i + 1}
- {body}
+
+ {i + 1}
+ {body}
))}
-
+
);
}
+
+function Code({ children }: { children: React.ReactNode }) {
+ return {children};
+}
diff --git a/adminsite/components/MembersPanel.tsx b/adminsite/components/MembersPanel.tsx
index 59a47f1..d9e6823 100644
--- a/adminsite/components/MembersPanel.tsx
+++ b/adminsite/components/MembersPanel.tsx
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { ApiError, api, type InstanceRole } from "@/lib/api";
import { useSession } from "@/lib/session";
-import { Button } from "@/components/Button";
+import { Button, controlClass } from "@/components/Button";
import { EmptyState, Panel } from "@/components/Panel";
const ROLES: InstanceRole[] = ["owner", "admin", "member"];
@@ -23,7 +23,8 @@ const ROLE_GRANTS: Record = {
const SELECT_QUIET =
"rounded border border-transparent bg-transparent px-2 py-1 font-mono text-[0.78rem] uppercase tracking-[0.08em] text-ink-2 hover:border-rule focus:border-accent focus:text-ink focus:outline-none";
-const SELECT = "rounded border border-rule bg-panel px-2.5 py-2 font-mono text-[0.84rem] text-ink focus:border-accent focus:outline-none";
+/* Same height as the Grant access button beside it — see controlClass. */
+const SELECT = controlClass("bg-panel");
/*
* The access roster for one instance.
@@ -110,34 +111,46 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
/>
) : (
+ {/*
+ * Two columns on a phone — monogram and address — with the
+ * controls dropping to their own full-width row beneath;
+ * three columns from sm up, controls right-aligned. As one
+ * wrapping flex row the address competed with a select and
+ * two buttons for 320px and lost, and the confirm step put
+ * three more elements into the same row.
+ */}
{rows.map((m) => (
-
+ {/* Stacked and full width on a phone; one row from sm up.
+ Three controls side by side left the person select about
+ 90px wide, which is not enough to read an address in. */}
diff --git a/adminsite/components/PageHeader.tsx b/adminsite/components/PageHeader.tsx
index 0bdc66e..56e6aff 100644
--- a/adminsite/components/PageHeader.tsx
+++ b/adminsite/components/PageHeader.tsx
@@ -15,6 +15,8 @@ function CopyButton({ value }: { value: string }) {
return (
@@ -76,9 +78,13 @@ export function PageHeader({
{(record?.length || status) && (
{record?.map((f) => (
-
- {f.key}
- {f.value}
+ // min-w-0 and break-all because the commonest value here
+ // is a 36-character UUID with a Copy button beside it,
+ // which does not fit a 320px screen as one unbreakable
+ // token and pushed the whole page sideways.
+
+ {f.key}
+ {f.value}
{f.copy && }
))}
diff --git a/adminsite/components/Table.tsx b/adminsite/components/Table.tsx
index 3be9bc6..277a25d 100644
--- a/adminsite/components/Table.tsx
+++ b/adminsite/components/Table.tsx
@@ -13,12 +13,24 @@ import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
* The head is the keyed idiom — mono, small, uppercase, widely tracked — which
* is what a column head is: a key above a value, exactly as the record line is
* a key beside one.
+ *
+ * MOBILE. `stack` collapses the table into one card per row below sm, each cell
+ * becoming a label/value pair drawn from TD's `label`. The variants below hang
+ * off a `stacked` class on the
, so a table that does not opt in is
+ * untouched at every width.
+ *
+ * It is opt-in rather than automatic because a stacked row whose cells have no
+ * labels is worse than a scrolling one — the values lose the only thing naming
+ * them. Customer screens stack; the staff console's wide registry tables scroll
+ * sideways instead, which is the right trade for eight columns read at a desk.
*/
-export function Table({ className, children, ...props }: HTMLAttributes) {
+const STACK = "max-sm:[.stacked_&]:block";
+
+export function Table({ stack, className, children, ...props }: HTMLAttributes & { stack?: boolean }) {
return (
);
diff --git a/adminsite/components/TermBar.tsx b/adminsite/components/TermBar.tsx
index 86ab0c9..5f2d56f 100644
--- a/adminsite/components/TermBar.tsx
+++ b/adminsite/components/TermBar.tsx
@@ -66,10 +66,15 @@ export function TermBar({
-
- Issued {formatDate(issuedAt)}
- {remaining}
- Expires {formatDate(expiresAt)}
+ {/*
+ * On a phone the three ends stack, and the figure someone actually
+ * came for goes first — wrapping a justify-between row left "9 days
+ * left" marooned between two dates in the middle of the stack.
+ */}
+