docs(plan): match the admin site to site/'s design tokens

Replaces the petrol-and-serif direction with site/app/globals.css's token
set copied verbatim: brand navy accent, the same neutrals, the same clamp
type scale, 1200px rail, 4px radii, and site/'s heading treatment of the
sans at weight 800 rather than a serif display face.

The semantic three are aliased rather than renamed -- globals.css carries
site/'s --up/--pend/--down, and Tailwind exposes them as valid/warn/expired
so each app names them for what it actually shows. Same colours either way.

Tailwind now holds var() references only, so no component or config can
carry a hex value and drift. Buttons match site/'s .btn--solid and
.btn--line, including the neutral border on the secondary variant.

Records the caveat this creates: site/'s dark accent (#5b9be8) sits nearer
web/'s indigo than the light navy does, so the "which app am I in" cue rests
on the light ground and dark must not become the default.

Drops the guilloche ornament -- site/ carries nothing like it. The ledger
stays, because it is information design rather than decoration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 20:31:55 +01:00
co-authored by Claude Opus 5
parent 8708bd9498
commit 79afcc2e16
+213 -96
View File
@@ -10,8 +10,11 @@
## Global Constraints
- **Design tokens are fixed by the approved direction.** Light ground primary with full dark support, petrol accent `#0d5f6e` / `#5cc6d8`, semantic `--valid #2f8a60` / `--warn #a4761b` / `--expired #c6462f` carried over from `site/`. Semantic hues are **never** reused as an accent, and the accent is never green, amber, red or indigo — indigo belongs to `web/`.
- **Type roles:** serif display (`"Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif`) for page titles, counts and the wordmark only; system sans for UI; mono with `tabular-nums` for every UUID, timestamp, blob and limit.
- **The token set is `site/`'s, copied verbatim.** `adminsite/app/globals.css` carries the same custom properties, with the same names and the same hex values, as `site/app/globals.css` — brand navy accent `#0b2a58` light / `#5b9be8` dark, `--up #2f8a60` / `--down #c6462f` / `--pend #b0801f`, the same `--shadow`, the same `--s--1``--s-4` clamp scale, `--rail: 1200px`. **When `site/`'s tokens change, change these in the same commit** — they are one visual system in two apps, and there is nothing that enforces the match automatically.
- **Tailwind maps those variables, it does not redefine them.** Colours in `tailwind.config.ts` are `var(--…)` references only. Never write a hex value in a component or in the Tailwind config.
- **Licence state uses `site/`'s semantic tokens.** Tailwind exposes them as `valid`/`warn`/`expired`, aliased onto `--up`/`--pend`/`--down` so this app names them for what it means while staying the same three colours the marketing site uses for up, pending and down. Semantic hues are **never** reused as an accent.
- **Type roles:** `--sans` for everything, `--mono` with `tabular-nums` for every UUID, timestamp, blob and limit. **There is no display face** — matching `site/` means headings are the sans at `font-weight: 800`, `letter-spacing: -0.03em`, `line-height: 1.03`.
- **Distinctness from `web/` rests on ground and hue together.** `web/` is locked to dark with an indigo `#6366f1` accent; this app defaults to light with navy. Note the caveat: in dark mode `site/`'s accent lifts to `#5b9be8`, which is nearer web/'s indigo, so the "which app am I in" cue leans on the light ground. Do not make dark the default.
- **State never reads by colour alone.** Every licence state renders as a stripe, a shaped-and-labelled pill, and copy.
- **Served at `vantage-hq.hostxtra.co.uk`, published on `3004`.** Spec 4 said 3002, but `deploy/docker-compose.site.yml` now maps `site` to `3002:3000`. Container port stays `3000`. Note the host is deliberately *not* under `*.vantage.hostxtra.co.uk`: that namespace is per-tenant instance subdomains, and the control plane's `APP_ROOT_LABEL` guard resolves an org from the label before `vantage`. A host like `hq.vantage.hostxtra.co.uk` would look like a tenant slug called `hq`.
- **`ADMIN_ORIGIN` must contain `https://vantage-hq.hostxtra.co.uk`** exactly — scheme included, no trailing slash. Admin echoes only origins on that list, so a mismatch blocks every browser request while curl from the server keeps working, which is what makes it confusing to diagnose.
@@ -75,7 +78,7 @@ Paddle portal deep-links are spec 5's. Until then the billing screen renders the
| `adminsite/lib/session.ts` | `useSession`, `useRequireKind` |
| `adminsite/lib/query-client.ts` | TanStack config, mirrors `web/` |
| `adminsite/lib/format.ts` | dates, days-remaining, licence state derivation |
| `adminsite/components/*` | `EnvBadge`, `StatePill`, `InstanceCard`, `Ledger`, `NotConnected`, `Guilloche`, `Field`, `Button`, `ConfirmPlanChange` |
| `adminsite/components/*` | `EnvBadge`, `StatePill`, `InstanceCard`, `Ledger`, `NotConnected`, `Field`, `Button`, `Queue`, `ConfirmPlanChange` |
**Modified:** `admin/internal/api/routes.go`, `admin/internal/api/customer.go`, `admin/internal/api/staff.go`, `admin/internal/auth/customer.go`, `admin/internal/models/models.go`, `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`, `CLAUDE.md`.
@@ -469,7 +472,7 @@ git commit -m "feat(admin): staff instance detail, subscriptions and richer sear
- Create: `adminsite/package.json`, `adminsite/tsconfig.json`, `adminsite/next.config.ts`, `adminsite/postcss.config.js`, `adminsite/tailwind.config.ts`, `adminsite/app/globals.css`, `adminsite/app/layout.tsx`, `adminsite/.gitignore`
**Interfaces:**
- Produces: Tailwind classes `bg-ground bg-panel bg-panel-2 text-ink text-ink-2 text-ink-3 border-rule border-rule-soft text-accent bg-accent text-valid text-warn text-expired`, fonts `font-display font-sans font-mono`
- Produces: Tailwind classes `bg-ground bg-panel bg-panel-2 text-ink text-ink-2 text-ink-3 border-rule border-rule-soft text-accent bg-accent text-valid text-warn text-expired`, fonts `font-sans font-mono` (no display face — headings are the sans at weight 800)
- [ ] **Step 1: package.json**
@@ -571,11 +574,21 @@ module.exports = {
};
```
`tailwind.config.ts` — colours point at CSS variables so one token set serves both themes:
`tailwind.config.ts` — every colour is a `var()` reference, so `site/`'s token file is the single source of truth and one set serves both themes:
```ts
import type { Config } from "tailwindcss";
/*
* Tokens are shared with site/ — same names, same values, copied verbatim into
* app/globals.css. Nothing here may hold a hex value: if a colour needs to
* change it changes in globals.css, in both apps, in one commit.
*
* The semantic three are aliased rather than renamed. site/ calls them up,
* down and pend because it shows monitor state; this app calls them valid,
* expired and warn because it shows licence state. Same colours, honest names
* on both sides.
*/
const config: Config = {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
theme: {
@@ -592,17 +605,18 @@ const config: Config = {
accent: "var(--accent)",
"accent-ink": "var(--accent-ink)",
"accent-wash": "var(--accent-wash)",
valid: "var(--valid)",
warn: "var(--warn)",
expired: "var(--expired)",
archival: "var(--archival)",
valid: "var(--up)",
warn: "var(--pend)",
expired: "var(--down)",
archival: "var(--ink-3)",
},
fontFamily: {
display: ["Iowan Old Style", "Palatino Linotype", "Palatino", "Book Antiqua", "Georgia", "serif"],
sans: ["ui-sans-serif", "system-ui", "-apple-system", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif"],
mono: ["ui-monospace", "Cascadia Mono", "SF Mono", "JetBrains Mono", "Menlo", "Consolas", "monospace"],
},
borderRadius: { DEFAULT: "3px" },
// site/ uses 4px on panels and buttons, 2px on focus rings.
borderRadius: { DEFAULT: "4px" },
maxWidth: { rail: "1200px" },
},
},
plugins: [],
@@ -613,74 +627,166 @@ export default config;
- [ ] **Step 5: app/globals.css**
Copy the token block out of `site/app/globals.css` unchanged — same names, same
values, all three theme selectors. The only addition is `--accent-wash`, which
`site/` has no need for.
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
/*
* Admin console tokens. Petrol accent, deliberately clear of indigo (web/) and
* of the three semantic hues this app says constantly. Light is home because
* web/ is locked to dark, and telling the two apps apart at a glance is what
* stops a Reissue landing in the wrong tab.
*/
/* ==========================================================================
Vantage admin console design tokens.
These are site/app/globals.css's tokens, copied verbatim: the marketing site
and this console are one visual system. Change them in both apps in the same
commit — nothing enforces the match automatically.
Light is the default because web/ is locked to dark, and telling the two
apart at a glance is what stops a Reissue landing in the wrong tab. In dark
mode the accent lifts to #5b9be8, which is nearer web/'s indigo, so the
distinction leans on the ground rather than the hue.
========================================================================== */
:root {
color-scheme: light dark;
--ground: #eaeff0;
--ground: #eaedf3;
--panel: #ffffff;
--panel-2: #f2f6f7;
--ink: #0f2429;
--ink-2: #43606a;
--ink-3: #6d868e;
--rule: #ccd9dc;
--rule-soft: #dfe8ea;
--accent: #0d5f6e;
--panel-2: #f4f6fa;
--ink: #0a1b33;
--ink-2: #41556f;
--ink-3: #6c7f96;
--rule: #cdd6e2;
--rule-soft: #e0e6ef;
--accent: #0b2a58;
--accent-ink: #ffffff;
--accent-wash: rgba(13, 95, 110, 0.07);
--up: #2f8a60;
--down: #c6462f;
--pend: #b0801f;
--shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
--logo: #0b2a58;
/* Semantic, shared with site/ so all three apps agree. Never an accent. */
--valid: #2f8a60;
--warn: #a4761b;
--expired: #c6462f;
--archival: #7d4a45;
/* Not in site/: the hatched sandbox badge and hover washes need a tinted
fill, and deriving it per-use would drift. */
--accent-wash: rgba(11, 42, 88, 0.07);
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, "Cascadia Mono", "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
--s--1: clamp(0.76rem, 0.74rem + 0.1vw, 0.81rem);
--s-0: clamp(1rem, 0.97rem + 0.14vw, 1.05rem);
--s-1: clamp(1.16rem, 1.09rem + 0.32vw, 1.36rem);
--s-2: clamp(1.5rem, 1.34rem + 0.74vw, 2rem);
--s-3: clamp(2rem, 1.66rem + 1.6vw, 3.1rem);
--s-4: clamp(2.6rem, 1.9rem + 3.3vw, 4.9rem);
--rail: 1200px;
}
/* Dark tokens are defined once and applied through three selectors: the OS
preference, and both explicit values of data-theme so an in-page toggle wins
in either direction. Same pattern as site/. */
@media (prefers-color-scheme: dark) {
:root {
--ground: #081a1f;
--panel: #0e262d;
--panel-2: #123039;
--ink: #e2eff1;
--ink-2: #9fbcc3;
--ink-3: #74949c;
--rule: #1e454f;
--rule-soft: #17363f;
--accent: #5cc6d8;
--accent-ink: #04171c;
--accent-wash: rgba(92, 198, 216, 0.1);
--valid: #4fb484;
--warn: #d6a63f;
--expired: #e2705a;
--archival: #c08a84;
--ground: #071628;
--panel: #0d2138;
--panel-2: #102842;
--ink: #e4ecf6;
--ink-2: #9fb3ca;
--ink-3: #71879f;
--rule: #1e3855;
--rule-soft: #172c44;
--accent: #5b9be8;
--accent-ink: #04101f;
--up: #4fb484;
--down: #e2705a;
--pend: #d6a63f;
--shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
--logo: #7fb2f0;
--accent-wash: rgba(91, 155, 232, 0.1);
}
}
* {
:root[data-theme="dark"] {
--ground: #071628;
--panel: #0d2138;
--panel-2: #102842;
--ink: #e4ecf6;
--ink-2: #9fb3ca;
--ink-3: #71879f;
--rule: #1e3855;
--rule-soft: #172c44;
--accent: #5b9be8;
--accent-ink: #04101f;
--up: #4fb484;
--down: #e2705a;
--pend: #d6a63f;
--shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
--logo: #7fb2f0;
--accent-wash: rgba(91, 155, 232, 0.1);
}
:root[data-theme="light"] {
--ground: #eaedf3;
--panel: #ffffff;
--panel-2: #f4f6fa;
--ink: #0a1b33;
--ink-2: #41556f;
--ink-3: #6c7f96;
--rule: #cdd6e2;
--rule-soft: #e0e6ef;
--accent: #0b2a58;
--accent-ink: #ffffff;
--up: #2f8a60;
--down: #c6462f;
--pend: #b0801f;
--shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
--logo: #0b2a58;
--accent-wash: rgba(11, 42, 88, 0.07);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--ground);
margin: 0;
background: var(--ground);
color: var(--ink);
font-family: var(--sans);
font-size: var(--s-0);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* site/'s heading treatment, which is what replaces a display face. */
h1,
h2,
h3 {
margin: 0;
font-weight: 800;
line-height: 1.03;
letter-spacing: -0.03em;
text-wrap: balance;
}
p {
margin: 0;
}
code {
font-family: var(--mono);
font-size: 0.92em;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
outline-offset: 3px;
border-radius: 2px;
}
@media (prefers-reduced-motion: reduce) {
@@ -693,6 +799,11 @@ body {
}
```
Because `h1``h3` carry the weight and tracking from this file, the Tailwind
classes on headings in later tasks (`text-3xl font-extrabold tracking-[-0.03em]`)
are belt-and-braces for elements that are not `h1``h3`. Leave them; they cost
nothing and keep a `<p>` used as a title looking right.
- [ ] **Step 6: app/layout.tsx**
```tsx
@@ -709,10 +820,10 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="font-sans text-[0.95rem] leading-relaxed">
<body>
<header className="border-b border-rule bg-panel">
<div className="mx-auto flex max-w-[1180px] flex-wrap items-center justify-between gap-4 px-4 py-4 sm:px-8">
<span className="flex items-baseline gap-2 font-display text-lg">
<div className="mx-auto flex max-w-rail flex-wrap items-center justify-between gap-4 px-4 py-4 sm:px-8">
<span className="flex items-baseline gap-2 text-lg font-extrabold tracking-[-0.03em]">
<b className="font-semibold">Vantage</b>
<span className="font-sans text-[0.72rem] uppercase tracking-[0.14em] text-ink-3">
Licensing
@@ -854,7 +965,7 @@ Expected: FAIL — `Failed to resolve import "./NotConnected"`.
export function NotConnectedPanel({ url }: { url: string }) {
return (
<div className="grid max-w-2xl gap-3 rounded border border-expired bg-panel p-5">
<h2 className="font-display text-xl text-expired">
<h2 className="text-xl font-extrabold tracking-[-0.03em] text-expired">
Not connected to the licensing service
</h2>
{url ? (
@@ -1375,18 +1486,24 @@ export function EnvBadge() {
```tsx
import clsx from "clsx";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "solid" | "ghost" };
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "solid" | "line" };
/*
* Matches site/'s .btn--solid and .btn--line exactly, including the neutral
* border on the secondary variant. site/ does not have an accent-outlined
* button and this app should not invent one.
*/
export function Button({ variant = "solid", className, ...rest }: Props) {
return (
<button
{...rest}
className={clsx(
"inline-flex items-center gap-2 rounded border px-3.5 py-2 text-[0.82rem] font-semibold",
"inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold",
"transition-[filter,border-color] duration-150 hover:brightness-110",
variant === "solid"
? "border-accent bg-accent text-accent-ink"
: "border-accent bg-transparent text-accent",
rest.disabled && "cursor-not-allowed border-rule bg-transparent text-ink-3",
: "border-rule bg-panel text-ink hover:border-ink-3",
rest.disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
className,
)}
/>
@@ -1470,7 +1587,7 @@ export default function LoginPage() {
return (
<Main>
<h1 className="font-display text-3xl font-semibold">Sign in</h1>
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Sign in</h1>
<form onSubmit={submit} className="mt-6 grid gap-4">
<Field
label="Email"
@@ -1507,7 +1624,7 @@ export default function LoginPage() {
}
function Main({ children }: { children: React.ReactNode }) {
return <main className="mx-auto max-w-[1180px] px-4 py-12 sm:px-8">{children}</main>;
return <main className="mx-auto max-w-rail px-4 py-12 sm:px-8">{children}</main>;
}
```
@@ -1546,10 +1663,10 @@ export default function SignupPage() {
}
return (
<main className="mx-auto max-w-[1180px] px-4 py-12 sm:px-8">
<main className="mx-auto max-w-rail px-4 py-12 sm:px-8">
{state === "sent" ? (
<div className="grid max-w-xl gap-3">
<h1 className="font-display text-3xl font-semibold">Check your email</h1>
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Check your email</h1>
<p className="text-ink-2">
We sent a link to {form.email}. Open it to finish setting up your account —
it expires in 24 hours. Nothing is created until you do.
@@ -1557,7 +1674,7 @@ export default function SignupPage() {
</div>
) : (
<>
<h1 className="font-display text-3xl font-semibold">Create an account</h1>
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Create an account</h1>
<p className="mt-2 max-w-xl text-ink-2">
For self-hosted licences. If you run on our cloud, sign in with the same
details you use for your Vantage instance.
@@ -1640,7 +1757,7 @@ function Verify() {
return (
<div className="grid max-w-xl gap-3">
<h1 className="font-display text-3xl font-semibold">Email verified</h1>
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Email verified</h1>
<p className="text-ink-2">Your account is ready.</p>
<Link href="/login" className="text-accent underline">Sign in</Link>
</div>
@@ -1650,7 +1767,7 @@ function Verify() {
function Message({ title, body }: { title: string; body: string }) {
return (
<div className="grid max-w-xl gap-3">
<h1 className="font-display text-3xl font-semibold">{title}</h1>
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">{title}</h1>
<p className="text-ink-2">{body}</p>
</div>
);
@@ -1658,7 +1775,7 @@ function Message({ title, body }: { title: string; body: string }) {
export default function VerifyPage() {
return (
<main className="mx-auto max-w-[1180px] px-4 py-12 sm:px-8">
<main className="mx-auto max-w-rail px-4 py-12 sm:px-8">
<Suspense fallback={null}>
<Verify />
</Suspense>
@@ -1681,13 +1798,13 @@ export default function CustomerLayout({ children }: { children: React.ReactNode
return (
<RequireKind kind="customer">
<nav className="border-b border-rule-soft bg-panel-2">
<div className="mx-auto flex max-w-[1180px] flex-wrap gap-5 px-4 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.06em] sm:px-8">
<div className="mx-auto flex max-w-rail flex-wrap gap-5 px-4 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.06em] sm:px-8">
<Link href="/" className="text-accent">Overview</Link>
<Link href="/instances/link" className="text-ink-3">Link an install</Link>
<Link href="/billing" className="text-ink-3">Billing</Link>
</div>
</nav>
<main className="mx-auto max-w-[1180px] px-4 py-8 sm:px-8">{children}</main>
<main className="mx-auto max-w-rail px-4 py-8 sm:px-8">{children}</main>
</RequireKind>
);
}
@@ -1702,8 +1819,8 @@ import Link from "next/link";
export default function NotFound() {
return (
<main className="mx-auto max-w-[1180px] px-4 py-12 sm:px-8">
<h1 className="font-display text-3xl font-semibold">Nothing here</h1>
<main className="mx-auto max-w-rail px-4 py-12 sm:px-8">
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Nothing here</h1>
<p className="mt-2 text-ink-2">
That page does not exist, or it belongs to an account you are not signed in to.
</p>
@@ -2019,7 +2136,7 @@ export default function OverviewPage() {
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="font-display text-3xl font-semibold">{data.account.name}</h1>
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">{data.account.name}</h1>
<p className="text-ink-2">{data.account.billing_email}</p>
</header>
@@ -2039,7 +2156,7 @@ export default function OverviewPage() {
{data.instances.length === 0 ? (
<div className="grid max-w-xl gap-3 rounded border border-rule bg-panel p-5">
<h2 className="font-display text-xl">No instances yet</h2>
<h2 className="text-xl font-extrabold tracking-[-0.03em]">No instances yet</h2>
<p className="text-ink-2">
There are two ways to run Vantage. Buy a cloud instance and we host it, and
your licence is applied automatically. Or buy a self-hosted licence, install
@@ -2166,7 +2283,7 @@ export function LicenceDelivery({
return (
<section className="grid gap-3">
<h2 className="font-display text-xl">Your licence</h2>
<h2 className="text-xl font-extrabold tracking-[-0.03em]">Your licence</h2>
<div className="flex flex-wrap items-center gap-3">
<a
href={downloadUrl}
@@ -2175,7 +2292,7 @@ export function LicenceDelivery({
>
Download licence
</a>
<Button variant="ghost" type="button" onClick={copy}>
<Button variant="line" type="button" onClick={copy}>
{copied ? "Copied" : "Copy to clipboard"}
</Button>
</div>
@@ -2232,7 +2349,7 @@ export function RelinkPanel({
return (
<section className="grid gap-3 border-t border-rule-soft pt-5">
<h2 className="font-display text-xl">Moved to a new server?</h2>
<h2 className="text-xl font-extrabold tracking-[-0.03em]">Moved to a new server?</h2>
<p className="text-[0.82rem] text-ink-2">
Relinking issues a replacement licence for the new install, covering the rest of
your current term.
@@ -2249,7 +2366,7 @@ export function RelinkPanel({
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="ghost"
variant="line"
disabled={exhausted || (open && !UUID_RE.test(value.trim()))}
onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}
>
@@ -2327,7 +2444,7 @@ export default function InstancePage() {
<div className="grid gap-8">
<header className="grid gap-3">
<div className="flex flex-wrap items-center gap-3">
<h1 className="font-display text-3xl font-semibold">{instance.name}</h1>
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">{instance.name}</h1>
<StatePill state={state} />
</div>
<p className="font-mono text-[0.82rem] tabular-nums text-ink-3">{instance.instance_id}</p>
@@ -2585,7 +2702,7 @@ export default function LinkPage() {
return (
<div className="grid max-w-2xl gap-6">
<header className="grid gap-2">
<h1 className="font-display text-3xl font-semibold">Link an install</h1>
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Link an install</h1>
<p className="text-ink-2">
Every licence is tied to one install, so we need its ID before we can issue
yours. Paste it below and your licence is ready on the next screen.
@@ -2639,7 +2756,7 @@ export default function BillingPage() {
return (
<div className="grid gap-6">
<h1 className="font-display text-3xl font-semibold">Billing</h1>
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Billing</h1>
{!data || data.length === 0 ? (
<p className="text-ink-2">
@@ -2772,7 +2889,7 @@ export function Queue({
return (
<section className={clsx("grid gap-2 rounded border border-rule border-l-4 bg-panel p-4", TONE[tone])}>
<h2 className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">{title}</h2>
<p className="font-display text-3xl leading-none tabular-nums">{count}</p>
<p className="text-3xl font-extrabold leading-none tabular-nums tracking-[-0.03em]">{count}</p>
{items.length === 0 ? (
<p className="text-[0.72rem] text-ink-3">Nothing to do here.</p>
) : (
@@ -2834,7 +2951,7 @@ export default function StaffDashboard() {
return (
<div className="grid gap-6">
<h1 className="font-display text-3xl font-semibold">Operations</h1>
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Operations</h1>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Queue
title="Failed injections"
@@ -3026,7 +3143,7 @@ import { AccountSearch } from "./AccountSearch";
export default function AccountsPage() {
return (
<div className="grid gap-6">
<h1 className="font-display text-3xl font-semibold">Accounts</h1>
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Accounts</h1>
<AccountSearch />
</div>
);
@@ -3056,7 +3173,7 @@ export default function AccountDetailPage() {
return (
<div className="grid gap-8">
<header className="grid gap-1">
<h1 className="font-display text-3xl font-semibold">{data.account.name}</h1>
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">{data.account.name}</h1>
<p className="font-mono text-[0.82rem] text-ink-3">
{data.account.billing_email} · {data.account.account_id}
</p>
@@ -3127,7 +3244,7 @@ export default function AccountDetailPage() {
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
<h2 className="font-display text-xl">{title}</h2>
<h2 className="text-xl font-extrabold tracking-[-0.03em]">{title}</h2>
{children}
</section>
);
@@ -3381,7 +3498,7 @@ export function IssuePanel({ instanceId, deployment }: { instanceId: string; dep
onChange={(e) => setNewId(e.target.value)}
hint="Staff relinks are not capped — the customer cap exists to put you in the loop."
/>
<Button type="button" variant="ghost" onClick={() => relink.mutate()} disabled={!newId.trim()}>
<Button type="button" variant="line" onClick={() => relink.mutate()} disabled={!newId.trim()}>
Relink
</Button>
</div>
@@ -3427,7 +3544,7 @@ export default function StaffInstancePage() {
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="font-display text-3xl font-semibold">{data.instance.name || data.instance.instance_id}</h1>
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">{data.instance.name || data.instance.instance_id}</h1>
<p className="font-mono text-[0.82rem] tabular-nums text-ink-3">{data.instance.instance_id}</p>
<p className="text-[0.82rem]">
<Link href={`/staff/accounts/${data.account.account_id}`} className="text-accent underline">
@@ -3444,7 +3561,7 @@ export default function StaffInstancePage() {
</header>
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
<h2 className="font-display text-xl">Licence history</h2>
<h2 className="text-xl font-extrabold tracking-[-0.03em]">Licence history</h2>
<Ledger licenses={data.licenses} />
<IssuePanel instanceId={data.instance.instance_id} deployment={data.instance.deployment} />
</section>
@@ -3491,7 +3608,7 @@ export default function LicensesPage() {
return (
<div className="grid gap-6">
<h1 className="font-display text-3xl font-semibold">Licences</h1>
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Licences</h1>
<div className="flex flex-wrap gap-3">
<select value={tier} onChange={(e) => setTier(e.target.value as Tier | "")} className="rounded border border-rule bg-panel-2 px-2.5 py-2">
<option value="">All tiers</option>
@@ -3564,7 +3681,7 @@ export default function AuditPage() {
return (
<div className="grid gap-6">
<h1 className="font-display text-3xl font-semibold">Audit</h1>
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Audit</h1>
<Field label="Filter" value={filter} onChange={(e) => setFilter(e.target.value)} hint="Action, actor or target." />
<ul className="grid gap-2 rounded border border-rule bg-panel p-5 font-mono text-[0.82rem]">
{rows.map((e, n) => (
@@ -3703,7 +3820,7 @@ export function ConfirmPlanChange({
return (
<div className="grid max-w-xl gap-3 rounded border border-warn bg-panel p-5">
<h2 className="font-display text-xl">Change what {plan.name} grants?</h2>
<h2 className="text-xl font-extrabold tracking-[-0.03em]">Change what {plan.name} grants?</h2>
<ul className="grid gap-1 font-mono text-[0.82rem]">
{rows.map((r) => (
<li key={r.field} className="flex flex-wrap gap-2">
@@ -3719,7 +3836,7 @@ export function ConfirmPlanChange({
</p>
<div className="flex flex-wrap gap-3">
<Button type="button" onClick={onConfirm}>Change plan</Button>
<Button type="button" variant="ghost" onClick={onCancel}>Keep as is</Button>
<Button type="button" variant="line" onClick={onCancel}>Keep as is</Button>
</div>
</div>
);
@@ -3772,7 +3889,7 @@ export default function PlansPage() {
return (
<div className="grid gap-6">
<h1 className="font-display text-3xl font-semibold">Plans</h1>
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Plans</h1>
{draft && original && (
<ConfirmPlanChange
@@ -3787,7 +3904,7 @@ export default function PlansPage() {
<div className="grid gap-4 lg:grid-cols-3">
{(plans.data ?? []).map((p) => (
<section key={p.tier} className="grid gap-3 rounded border border-rule bg-panel p-5">
<h2 className="font-display text-xl">{p.name}</h2>
<h2 className="text-xl font-extrabold tracking-[-0.03em]">{p.name}</h2>
<dl className="grid gap-1 font-mono text-[0.82rem] tabular-nums text-ink-2">
<div className="flex justify-between gap-2"><dt>servers</dt><dd>{limitLabel(p.limits.max_servers)}</dd></div>
<div className="flex justify-between gap-2"><dt>secret groups</dt><dd>{limitLabel(p.limits.max_secret_groups)}</dd></div>
@@ -3805,7 +3922,7 @@ export default function PlansPage() {
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="ghost"
variant="line"
onClick={() =>
setDraft({ ...p, limits: { ...p.limits, max_servers: 7 } })
}
@@ -3814,7 +3931,7 @@ export default function PlansPage() {
</Button>
<Button
type="button"
variant="ghost"
variant="line"
onClick={() => setDraft({ ...p, features: p.features.filter((f) => f !== "oidc") })}
>
Remove OIDC