diff --git a/claude.md b/claude.md index 7aa567e..a10ff45 100644 --- a/claude.md +++ b/claude.md @@ -491,17 +491,23 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a Next.js 16 (App Router) + React 18, Tailwind 3, TanStack Query. Guacamole client bundled locally in `web/lib/guacamole-common.js`. -There are **three separate visual identities**, and the split is deliberate: +All three apps are **one visual system**, anchored on the logo navy. What differs between them is which end of it they stand on: | App | Ground | Accent | Themes | | --- | --- | --- | --- | -| `web/` | `#0f1117` | indigo `#6366f1` | dark only, locked | -| `site/` | token-based | brand navy `#0b2a58` / `#5b9be8` | light + dark | -| `adminsite/` | **the same tokens as `site/`** | brand navy | light + dark, light default | +| `web/` | `--ground` dark, `#071628` | `#5b9be8` | dark only, locked | +| `site/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark | +| `adminsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default | -`adminsite/app/globals.css` holds `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values. **Change them in both files in the same commit; nothing enforces the match automatically**, the same shape of hazard as sitesvc's mirrored slug rules. Tailwind in `adminsite/` maps `var(--…)` references only, so no component may carry a hex value. `site/` names the semantic three `--up`/`--pend`/`--down` for monitor state; `adminsite/` aliases them to `valid`/`warn`/`expired` for licence state — same colours. +`adminsite/app/globals.css` holds `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values. `web/app/globals.css` holds the same tokens too, but only the **dark** values, since it does not switch. **Change a token in all three files in the same commit; nothing enforces the match automatically**, the same shape of hazard as sitesvc's mirrored slug rules. -`adminsite/` defaults to **light** on purpose: `web/` is locked to dark, and a staff member with both open should never mistake one for the other before clicking Reissue. In dark mode the shared accent lifts to `#5b9be8`, closer to web/'s indigo, so that distinction rests on the ground — do not make dark the default. Licence state never reads by colour alone: every pill carries a distinct shape and a text label. The same argument applies one level in: the **staff** masthead sits on `--panel-2` with a `STAFF` chip, so staff and customer screens are not identical either. +Tailwind in all three maps `var(--…)` references only, so **no component in any of them may carry a hex value**. The names differ per app on purpose, because each app has its own subject: `site/` calls the semantic three `--up`/`--pend`/`--down` for monitor state, `adminsite/` aliases them to `valid`/`warn`/`expired` for licence state, and `web/` to `success`/`warning`/`danger`. Same colours, honest names on each side. + +`web/` stores its tokens as **RGB channel triplets** with the hex in a trailing comment, and derives `--token: rgb(var(--token-rgb))` from them. That is not a style preference: the console leans on Tailwind's opacity modifiers (`bg-danger/10`, `border-accent/50`, `ring-accent/30`) in a way the other two do not, and `` only compiles against channels. Keep the hex comments — they are what lets the three token blocks still be diffed by eye. `web/` also adds three tokens site/ has no use for: `--accent-hover` and `--down-hover` (site/ brightens with a CSS `filter`, which a Tailwind colour token cannot do) and `--well`, the floor beneath the ground for install one-liners, key blobs and run logs — surfaces showing machine output rather than interface. + +`web/` is locked to dark and `adminsite/` defaults to **light**, and that pairing is the point: an operator with both open should never mistake one for the other before clicking Reissue. Now that both are drawn from the same palette the distinction rests **entirely** on the ground, so do not make dark the adminsite default and do not give web/ a light theme. State never reads by colour alone in either: every pill carries a distinct shape and a text label. The same argument applies one level in: the **staff** masthead sits on `--panel-2` with a `STAFF` chip, so staff and customer screens are not identical either. + +`web/` collapses Tailwind's radius scale — `md`, `lg` and `xl` all resolve to site/'s 4px — rather than rewriting the ~140 `rounded-lg` classes across its pages. Every one of them meant "a panel corner", and `tailwind.config.ts` is now where that decision lives. `rounded-full` is untouched: status dots and pills still need it. **The `adminsite/` shell.** `AppBar` is the single masthead — identity, nav, environment, account menu — and it belongs to the two authenticated layouts, never to `app/layout.tsx`, so `/login` and `/accept-invite` do not render navigation they cannot use. Nav active state is derived from `usePathname`; do not hardcode it. `PageHeader` gives every screen the same back link, title, actions and **record line** (the reference number in mono, click-to-copy) — the reference is what people paste into support tickets, so it has a fixed slot rather than a per-page treatment. `PageFrame` is the main-plus-320px-rail split; the rail carries only what is true account-wide, which is why there is no plan card in it — **tier, limits and expiry belong to a licence, and a licence belongs to one instance**, so an account holding a Free cloud instance and a Professional self-hosted one has no single plan. diff --git a/web/app/(app)/keys/[id]/page.tsx b/web/app/(app)/keys/[id]/page.tsx index 00eae67..4a9b9fc 100644 --- a/web/app/(app)/keys/[id]/page.tsx +++ b/web/app/(app)/keys/[id]/page.tsx @@ -170,7 +170,7 @@ function PrivateKeyCard({ keyId }: { keyId: string }) { ) : ( -
+
             {privateKey}
           
@@ -337,7 +337,7 @@ export default function KeyDetailPage() { {copiedKey ? Copied! : "Copy"} -
+
                 {key.public_key}
               
diff --git a/web/app/(app)/servers/[id]/page.tsx b/web/app/(app)/servers/[id]/page.tsx index d85707e..d0124cf 100644 --- a/web/app/(app)/servers/[id]/page.tsx +++ b/web/app/(app)/servers/[id]/page.tsx @@ -492,7 +492,7 @@ export default function ServerDetailPage() { > {updateSuccess ? "Update Sent!" : "Update Agent"} -
+
{server.os_info?.toLowerCase().includes("windows") ? "PS>" : "$"}{" "} {api.getUpdateCommand(server.os_info)} @@ -369,7 +369,7 @@ export default function WorkflowBuilder() { e.dataTransfer.setData("text/plain", JSON.stringify({ kind: "move", from: i })); }} onClick={() => setSelected(i)} - className={`w-[340px] cursor-pointer rounded-[10px] border bg-surface p-3 ${isSelected ? "border-signal ring-2 ring-signal/40" : "border-border"}`} + className={`w-[340px] cursor-pointer rounded border bg-surface p-3 ${isSelected ? "border-signal ring-2 ring-signal/40" : "border-border"}`} >
{i + 1} @@ -391,7 +391,7 @@ export default function WorkflowBuilder() { onDragOver={(e) => e.preventDefault()} onDrop={(e) => handleDrop(e, 0)} onClick={() => setPickerOpen(true)} - className="mt-2 w-full rounded-[10px] border border-dashed border-border bg-surface py-6 text-sm text-text-secondary hover:border-signal/50 hover:text-text-primary" + className="mt-2 w-full rounded border border-dashed border-border bg-surface py-6 text-sm text-text-secondary hover:border-signal/50 hover:text-text-primary" > + Add your first step diff --git a/web/app/(app)/workflows/[id]/runs/[runId]/page.tsx b/web/app/(app)/workflows/[id]/runs/[runId]/page.tsx index d1ea5ed..0612e36 100644 --- a/web/app/(app)/workflows/[id]/runs/[runId]/page.tsx +++ b/web/app/(app)/workflows/[id]/runs/[runId]/page.tsx @@ -127,7 +127,7 @@ function LogTerminal({ runId, server }: { runId: string; server: ServerRun }) { const activeStep = server.steps.find((s) => s.status === "running") ?? [...server.steps].reverse().find((s) => s.started_at); return ( -
+
{activeStep ? activeStep.name : "Output"} {server.hostname} @@ -167,7 +167,7 @@ function LogLines({ text }: { text: string }) { const isMarker = body.startsWith("====="); return ( - + {local}{" "} {body || " "} diff --git a/web/app/globals.css b/web/app/globals.css index de1e4fa..727889b 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -2,56 +2,176 @@ @tailwind components; @tailwind utilities; +/* ========================================================================== + Vantage control plane design tokens. + + These are site/app/globals.css's **dark** token values — same names, same + colours — so the marketing site, the HQ console and this app are one visual + system. Change them in all three apps in the same commit; nothing enforces + the match automatically. + + Two things differ from site/ and adminsite/, both deliberate: + + 1. There is no light block. web/ stays locked to dark. adminsite/ defaults + to light precisely so that a staff member holding both open can tell + them apart before clicking something destructive, and that argument + only holds while this app is the dark one. + + 2. Every colour is stored as RGB channels, with the hex it came from in a + trailing comment so it still diffs against site/ by eye. The plain + --token is derived from the channels rather than written twice. This app + leans hard on Tailwind's opacity modifiers (bg-danger/10, border-accent/50, + ring-accent/30 and so on), and those only compile when the colour reaches + Tailwind as channels it can drop an alpha into. + ========================================================================== */ + :root { - color-scheme: dark; + color-scheme: dark; + + --ground-rgb: 7 22 40; /* #071628 */ + --panel-rgb: 13 33 56; /* #0d2138 */ + --panel-2-rgb: 16 40 66; /* #102842 */ + --ink-rgb: 228 236 246; /* #e4ecf6 */ + --ink-2-rgb: 159 179 202; /* #9fb3ca */ + --ink-3-rgb: 113 135 159; /* #71879f */ + --rule-rgb: 30 56 85; /* #1e3855 */ + --rule-soft-rgb: 23 44 68; /* #172c44 */ + --accent-rgb: 91 155 232; /* #5b9be8 */ + --accent-ink-rgb: 4 16 31; /* #04101f */ + --up-rgb: 79 180 132; /* #4fb484 */ + --down-rgb: 226 112 90; /* #e2705a */ + --pend-rgb: 214 166 63; /* #d6a63f */ + --logo-rgb: 127 178 240; /* #7fb2f0 */ + + /* Not in site/: hover states and the well. + + site/ brightens the accent with a filter, which a Tailwind colour token + cannot do, so the two hovers are named values. --well is the floor the + ground sits on — install one-liners, key blobs and run logs, the places + showing machine output rather than interface. */ + --accent-hover-rgb: 127 178 240; /* #7fb2f0, the accent at site/'s hover brightness */ + --down-hover-rgb: 200 90 69; /* #c85a45 */ + --well-rgb: 4 16 31; /* #04101f */ + + --ground: rgb(var(--ground-rgb)); + --panel: rgb(var(--panel-rgb)); + --panel-2: rgb(var(--panel-2-rgb)); + --ink: rgb(var(--ink-rgb)); + --ink-2: rgb(var(--ink-2-rgb)); + --ink-3: rgb(var(--ink-3-rgb)); + --rule: rgb(var(--rule-rgb)); + --rule-soft: rgb(var(--rule-soft-rgb)); + --accent: rgb(var(--accent-rgb)); + --accent-ink: rgb(var(--accent-ink-rgb)); + --up: rgb(var(--up-rgb)); + --down: rgb(var(--down-rgb)); + --pend: rgb(var(--pend-rgb)); + --logo: rgb(var(--logo-rgb)); + --well: rgb(var(--well-rgb)); + + --shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85); + + --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; } * { - box-sizing: border-box; - margin: 0; - padding: 0; + box-sizing: border-box; + margin: 0; + padding: 0; } body { - background-color: #0f1117; - color: #e8eaf0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - -webkit-font-smoothing: antialiased; + background: var(--ground); + color: var(--ink); + font-family: var(--sans); + -webkit-font-smoothing: antialiased; +} + +/* site/'s heading treatment, which is what stands in for a display face. + Utilities still win where a component sets weight or tracking explicitly. */ +h1, +h2, +h3 { + font-weight: 800; + letter-spacing: -0.03em; + text-wrap: balance; +} + +code { + font-family: var(--mono); +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 2px; } ::-webkit-scrollbar { - width: 6px; - height: 6px; + width: 6px; + height: 6px; } ::-webkit-scrollbar-track { - background: #1a1d27; + background: var(--panel-2); } ::-webkit-scrollbar-thumb { - background: #2e3147; - border-radius: 3px; + background: var(--rule); + border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { - background: #3e4160; + background: var(--ink-3); } @keyframes led-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} +.led-pulse { + animation: led-pulse 1.4s ease-in-out infinite; } -.led-pulse { animation: led-pulse 1.4s ease-in-out infinite; } @keyframes cell-ring { - 0%, 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.5); } - 50% { box-shadow: 0 0 0 4px rgba(99, 102, 241, 0); } + 0%, + 100% { + box-shadow: 0 0 0 0 rgb(var(--accent-rgb) / 0.5); + } + 50% { + box-shadow: 0 0 0 4px rgb(var(--accent-rgb) / 0); + } +} +.cell-ring { + animation: cell-ring 1.4s ease-in-out infinite; } -.cell-ring { animation: cell-ring 1.4s ease-in-out infinite; } -@keyframes caret-blink { 50% { opacity: 0; } } -.caret-blink { animation: caret-blink 1s step-end infinite; } +@keyframes caret-blink { + 50% { + opacity: 0; + } +} +.caret-blink { + animation: caret-blink 1s step-end infinite; +} @media (prefers-reduced-motion: reduce) { - .led-pulse, .cell-ring, .caret-blink { animation: none; } + .led-pulse, + .cell-ring, + .caret-blink { + animation: none; + } + + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + transition-duration: 0.001ms !important; + } } diff --git a/web/app/layout.tsx b/web/app/layout.tsx index eabf01e..63a83ce 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -13,8 +13,11 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - - + // data-theme is what site/ and adminsite/ switch on. This app is locked to + // dark, so the attribute is fixed rather than toggleable — it is here so + // the three apps declare their ground the same way. + + {children} diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx index 076f2f9..cb149a5 100644 --- a/web/app/login/page.tsx +++ b/web/app/login/page.tsx @@ -50,9 +50,10 @@ export default function LoginPage() {
- -

Sign in to Vantage

-

Instance: {instanceName}

+ +

Sign in to Vantage

+ {/* A keyed label, not a heading: site/'s .tag treatment. */} +

{instanceName}

diff --git a/web/app/setup/page.tsx b/web/app/setup/page.tsx index 2922088..5fd25d5 100644 --- a/web/app/setup/page.tsx +++ b/web/app/setup/page.tsx @@ -83,7 +83,7 @@ export default function SetupPage() {
-

Instance created

+

Instance created

Your owner account is ready. One more step to finish signing in.

@@ -123,7 +123,7 @@ export default function SetupPage() {
-

Welcome to Vantage

+

Welcome to Vantage

Create your instance and its owner account to get started.

diff --git a/web/components/AuthProvider.tsx b/web/components/AuthProvider.tsx index d978bb5..4511e5f 100644 --- a/web/components/AuthProvider.tsx +++ b/web/components/AuthProvider.tsx @@ -77,7 +77,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {

Can't load your session

{error ?? "Unable to load your session."}

- diff --git a/web/components/LicenseBanner.tsx b/web/components/LicenseBanner.tsx index f7189ff..e124331 100644 --- a/web/components/LicenseBanner.tsx +++ b/web/components/LicenseBanner.tsx @@ -10,7 +10,7 @@ export function LicenseBanner() { if (license.state === "expired") { const when = license.expires_at ? new Date(license.expires_at).toLocaleDateString() : "recently"; return ( -
+
Your Vantage licence expired on {when}. Your servers and monitors are still running, but changes are disabled until it is renewed.{" "} Add a licence @@ -20,7 +20,7 @@ export function LicenseBanner() { if (license.state === "invalid") { return ( -
+
This instance has no valid licence. Changes are disabled.{" "} Add a licence
@@ -29,7 +29,7 @@ export function LicenseBanner() { if (typeof license.days_remaining === "number" && license.days_remaining <= 14) { return ( -
+
Your licence expires in {license.days_remaining} day {license.days_remaining === 1 ? "" : "s"}.
diff --git a/web/components/NetworkBackground.tsx b/web/components/NetworkBackground.tsx index c17fd46..19605c0 100644 --- a/web/components/NetworkBackground.tsx +++ b/web/components/NetworkBackground.tsx @@ -28,12 +28,18 @@ interface Pulse { life: number; // seconds since spawn } -const BG_TOP = "#0B2A58"; -const BG_BOTTOM = "#0B1120"; -const NODE_COLOR = "94, 122, 168"; // steel navy, rgb parts for alpha use -const LINE_COLOR = "30, 58, 95"; // #1E3A5F -const PULSE_COLOR = "93, 202, 165"; // #5DCAA5 -const PULSE_CORE = "29, 158, 117"; // #1D9E75 +/* + * Drawn from the shared tokens rather than read from them: this is canvas, so + * the values have to be literals. They are the light-theme accent falling to + * the app ground, with packets in the dark-theme accent — the same two blues + * the marketing site's hero is built on. + */ +const BG_TOP = "#0b2a58"; // --accent, light +const BG_BOTTOM = "#071628"; // --ground, dark +const NODE_COLOR = "113, 135, 159"; // --ink-3, rgb parts for alpha use +const LINE_COLOR = "30, 56, 85"; // --rule +const PULSE_COLOR = "91, 155, 232"; // --accent, dark +const PULSE_CORE = "228, 236, 246"; // --ink, a white-hot centre const MAX_DIST = 190; // connect nodes within this px const NODE_DENSITY = 1 / 22000; // nodes per px^2 diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx index f1bf21b..e69ed66 100644 --- a/web/components/Sidebar.tsx +++ b/web/components/Sidebar.tsx @@ -167,10 +167,12 @@ export function Sidebar() { return (
)}
-

Vantage v1.0

+

Vantage v1.0

{user && ( diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts index 000c73d..10df644 100644 --- a/web/tailwind.config.ts +++ b/web/tailwind.config.ts @@ -1,34 +1,82 @@ import type { Config } from "tailwindcss"; +/* + * Tokens are shared with site/ and adminsite/ — same names, same values, in + * app/globals.css. Nothing here may hold a hex value: if a colour needs to + * change it changes in globals.css, in all three apps, in one commit. + * + * The colours keep this app's own names rather than site/'s. site/ says ink + * and rule because it is a document; this is a console, so it says + * text-primary and border, and every screen already reads that way. Same + * colours, honest names on both sides — the aliasing adminsite/ does for + * licence state, one level further. + * + * `` is what makes bg-danger/10 and border-accent/50 compile. + * It only works against the RGB channel variables, which is why globals.css + * stores channels and derives the plain --token from them. + */ +const withAlpha = (channels: string) => `rgb(var(${channels}) / )`; + const config: Config = { - content: [ - "./pages/**/*.{js,ts,jsx,tsx,mdx}", - "./components/**/*.{js,ts,jsx,tsx,mdx}", - "./app/**/*.{js,ts,jsx,tsx,mdx}", - ], - theme: { - extend: { - colors: { - background: "#0f1117", - surface: "#1a1d27", - "surface-2": "#232635", - border: "#2e3147", - "text-primary": "#e8eaf0", - "text-secondary": "#9095a8", - accent: "#6366f1", - "accent-hover": "#4f52d4", - success: "#22c55e", - warning: "#f59e0b", - danger: "#ef4444", - "danger-hover": "#dc2626", - bash: "#3fb950", - pwsh: "#5b9bff", - signal: "#f5a524", - "signal-ink": "#241800", - }, + content: ["./pages/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}"], + theme: { + extend: { + colors: { + background: withAlpha("--ground-rgb"), + logo: withAlpha("--logo-rgb"), + surface: withAlpha("--panel-rgb"), + "surface-2": withAlpha("--panel-2-rgb"), + border: withAlpha("--rule-rgb"), + "border-soft": withAlpha("--rule-soft-rgb"), + "text-primary": withAlpha("--ink-rgb"), + "text-secondary": withAlpha("--ink-2-rgb"), + "text-tertiary": withAlpha("--ink-3-rgb"), + accent: withAlpha("--accent-rgb"), + "accent-hover": withAlpha("--accent-hover-rgb"), + "accent-ink": withAlpha("--accent-ink-rgb"), + success: withAlpha("--up-rgb"), + warning: withAlpha("--pend-rgb"), + danger: withAlpha("--down-rgb"), + "danger-hover": withAlpha("--down-hover-rgb"), + // The floor beneath the ground: install one-liners, key blobs, + // run logs — surfaces showing machine output, not interface. + well: withAlpha("--well-rgb"), + // Attention that is not yet failure — an unsaved workflow, a + // step about to run. Same amber as warning, because it is the + // same signal at a different distance. + signal: withAlpha("--pend-rgb"), + "signal-ink": withAlpha("--accent-ink-rgb"), + // Shell identity on step chips. Reusing the semantic pair is + // safe here because the chip is labelled "bash" or "pwsh" in + // text — the colour is recognition, never the whole message. + bash: withAlpha("--up-rgb"), + pwsh: withAlpha("--accent-rgb"), + }, + fontFamily: { + 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"], + }, + /* + * site/ is built on 4px, and the console inherits it. The scale + * steps are collapsed rather than the ~140 rounded-lg/md/xl classes + * across the app being rewritten: every one of them meant "a panel + * corner", and this is where that decision now lives. rounded-full + * is untouched — status dots and pills still need it. + */ + borderRadius: { + sm: "3px", + DEFAULT: "4px", + md: "4px", + lg: "4px", + xl: "6px", + "2xl": "6px", + }, + boxShadow: { + panel: "var(--shadow)", + }, + }, }, - }, - plugins: [], + plugins: [], }; export default config;