diff --git a/docs/superpowers/plans/2026-07-25-admin-site.md b/docs/superpowers/plans/2026-07-25-admin-site.md index d2401ed..8c1f33b 100644 --- a/docs/superpowers/plans/2026-07-25-admin-site.md +++ b/docs/superpowers/plans/2026-07-25-admin-site.md @@ -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 `
` 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 ( -
+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() {
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 (
Your account is ready.
Sign in{body}