feat(web): the console joins the shared design system
Server Deploy / deploy (push) Successful in 1m6s
Server Deploy / deploy (push) Successful in 1m6s
web/ was the last app on its own palette — a neutral #0f1117 ground with an indigo accent, unrelated to the logo navy that site/ and adminsite/ are built on. It now draws from the same tokens, so all three apps are one system. It stays locked to dark, taking site/'s dark values. That is what keeps adminsite/'s light default meaningful: an operator with both open tells them apart by the ground before clicking anything destructive, and now that both are the same palette, the ground is the only thing left doing that work. The colour names stay this app's own — text-primary, border, surface rather than ink, rule, panel — because every screen already reads that way, and adminsite/ already establishes that each app names the shared tokens after its own subject. Tokens are stored as RGB channels with the hex in a trailing comment. The console leans on Tailwind opacity modifiers far more than the other two (bg-danger/10, border-accent/50, ring-accent/30), and <alpha-value> only compiles against channels; the comments keep the three token blocks diffable by eye. Beyond colour: - radii collapse to site/'s 4px in tailwind.config.ts rather than rewriting ~140 rounded-lg classes; rounded-full is untouched for dots and pills - badges become site/'s chip — mono, uppercase, tracked, currentColor rule, no fill — keeping their dot so state is never colour alone - table column heads take the keyed-label idiom, at text-secondary rather than tertiary, which lands under 4.5:1 at that size - sidebar marks the active item with an accent bar, the device site/ uses for the chosen plan, instead of a filled pill that reads as pressable - filled accent and danger buttons take accent-ink; the dark accent is a light blue and danger a coral, and white on either was unreadable - login's packet pulses shift from green to the accent, so the sign-in screen is the same two blues as the marketing hero The last hex literals and stock-palette classes are gone; the only ones left are NetworkBackground's canvas fills, which cannot read a CSS variable and are commented with the token each came from. Only the web image rebuilds from this.
This commit is contained in:
@@ -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 `<alpha-value>` 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.
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ function PrivateKeyCard({ keyId }: { keyId: string }) {
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-[#0a0c14] p-3">
|
||||
<div className="rounded-lg border border-border bg-well p-3">
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs text-text-secondary leading-relaxed">
|
||||
{privateKey}
|
||||
</pre>
|
||||
@@ -337,7 +337,7 @@ export default function KeyDetailPage() {
|
||||
{copiedKey ? <span className="text-success">Copied!</span> : "Copy"}
|
||||
</button>
|
||||
</CardHeader>
|
||||
<div className="rounded-lg border border-border bg-[#0a0c14] p-3">
|
||||
<div className="rounded-lg border border-border bg-well p-3">
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs text-text-secondary leading-relaxed">
|
||||
{key.public_key}
|
||||
</pre>
|
||||
|
||||
@@ -492,7 +492,7 @@ export default function ServerDetailPage() {
|
||||
>
|
||||
{updateSuccess ? "Update Sent!" : "Update Agent"}
|
||||
</Button>
|
||||
<div className="relative flex-1 min-w-64 rounded-lg border border-border bg-[#0a0c14] px-4 py-2.5 font-mono text-sm">
|
||||
<div className="relative flex-1 min-w-64 rounded-lg border border-border bg-well px-4 py-2.5 font-mono text-sm">
|
||||
<span className="text-accent">{server.os_info?.toLowerCase().includes("windows") ? "PS>" : "$"}</span>{" "}
|
||||
<span className="text-text-primary">{api.getUpdateCommand(server.os_info)}</span>
|
||||
<button
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function NewServerPage() {
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="relative rounded-lg border border-border bg-[#0a0c14] p-4 font-mono text-sm">
|
||||
<div className="relative rounded-lg border border-border bg-well p-4 font-mono text-sm">
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-all text-text-secondary leading-relaxed">
|
||||
<span className="text-accent">{os === "windows" ? "PS>" : "$"}</span>{" "}
|
||||
<span className="text-text-primary">{command}</span>
|
||||
|
||||
@@ -16,10 +16,13 @@ function resolveStatus(server: Server, latestVersion: string | undefined): DotSt
|
||||
return "ok";
|
||||
}
|
||||
|
||||
// The palette has one amber, so the two update states split by meaning rather
|
||||
// than by shade: a stale agent is a warning, available OS packages are just
|
||||
// something waiting for you.
|
||||
const DOT_CLASSES: Record<DotStatus, string> = {
|
||||
offline: "bg-danger",
|
||||
"needs-update": "bg-orange-500",
|
||||
"has-package-updates": "bg-yellow-400",
|
||||
"needs-update": "bg-warning",
|
||||
"has-package-updates": "bg-accent",
|
||||
ok: "bg-success",
|
||||
};
|
||||
|
||||
|
||||
@@ -80,10 +80,10 @@ export default function LicensePage() {
|
||||
if (f) setBlob((await f.text()).trim());
|
||||
}}
|
||||
/>
|
||||
{error ? <p className="mt-2 text-sm text-red-400">{error}</p> : null}
|
||||
{error ? <p className="mt-2 text-sm text-danger">{error}</p> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 rounded bg-accent px-3 py-1.5 text-sm"
|
||||
className="mt-3 rounded bg-accent px-3 py-1.5 text-sm font-semibold text-accent-ink"
|
||||
disabled={!blob.trim() || save.isPending}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
|
||||
@@ -332,7 +332,7 @@ export default function WorkflowBuilder() {
|
||||
<div className="pointer-events-none sticky top-0 z-10 flex justify-center pt-4">
|
||||
<button
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="pointer-events-auto inline-flex items-center gap-2 rounded-[9px] bg-signal px-4 py-2.5 text-sm font-semibold text-signal-ink shadow-[0_6px_20px_rgba(245,165,36,0.28)] hover:bg-signal/90"
|
||||
className="pointer-events-auto inline-flex items-center gap-2 rounded bg-signal px-4 py-2.5 text-sm font-semibold text-signal-ink shadow-panel hover:bg-signal/90"
|
||||
>
|
||||
<span className="text-base leading-none">+</span> Add step
|
||||
</button>
|
||||
@@ -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"}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="grid h-5 w-5 place-items-center rounded border border-border font-mono text-[10px] text-text-secondary">{i + 1}</span>
|
||||
@@ -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
|
||||
</button>
|
||||
|
||||
@@ -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 (
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-[#0a0b10]">
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-well">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border bg-surface px-4 py-3">
|
||||
<span className="truncate font-mono text-[13px] font-semibold text-text-primary">
|
||||
{activeStep ? activeStep.name : "Output"} <span className="font-normal text-text-secondary">{server.hostname}</span>
|
||||
@@ -167,7 +167,7 @@ function LogLines({ text }: { text: string }) {
|
||||
const isMarker = body.startsWith("=====");
|
||||
return (
|
||||
<span key={i} className="block">
|
||||
<span className="select-none text-[#565b74]" title={m[1]}>
|
||||
<span className="select-none text-text-tertiary" title={m[1]}>
|
||||
{local}{" "}
|
||||
</span>
|
||||
<span className={isMarker ? "font-semibold text-accent" : ""}>{body || " "}</span>
|
||||
|
||||
+143
-23
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -13,8 +13,11 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className="bg-background text-text-primary">
|
||||
// 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.
|
||||
<html lang="en" className="dark" data-theme="dark">
|
||||
<body className="bg-background font-sans text-text-primary">
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -50,9 +50,10 @@ export default function LoginPage() {
|
||||
<NetworkBackground />
|
||||
<div className="relative w-full max-w-sm">
|
||||
<div className="mb-8 flex flex-col items-center gap-3">
|
||||
<Logo className="h-11 w-auto text-text-primary" />
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Sign in to Vantage</h1>
|
||||
<h4 className="font-semibold text-text-secondary">Instance: {instanceName}</h4>
|
||||
<Logo className="h-11 w-auto text-logo" />
|
||||
<h1 className="text-2xl font-extrabold tracking-[-0.03em] text-text-primary">Sign in to Vantage</h1>
|
||||
{/* A keyed label, not a heading: site/'s .tag treatment. */}
|
||||
<p className="font-mono text-[0.7rem] uppercase tracking-[0.15em] text-text-secondary">{instanceName}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function SetupPage() {
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-text-primary">Instance created</h1>
|
||||
<h1 className="text-xl font-extrabold tracking-[-0.03em] text-text-primary">Instance created</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Your owner account is ready. One more step to finish signing in.</p>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function SetupPage() {
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-text-primary">Welcome to Vantage</h1>
|
||||
<h1 className="text-xl font-extrabold tracking-[-0.03em] text-text-primary">Welcome to Vantage</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Create your instance and its owner account to get started.</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
<h1 className="text-base font-semibold text-text-primary">Can't load your session</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">{error ?? "Unable to load your session."}</p>
|
||||
<div className="mt-5 flex justify-center gap-2">
|
||||
<button onClick={() => window.location.reload()} className="rounded-lg bg-accent px-3 py-2 text-sm font-medium text-white">
|
||||
<button onClick={() => window.location.reload()} className="rounded bg-accent px-3 py-2 text-sm font-semibold text-accent-ink">
|
||||
Retry
|
||||
</button>
|
||||
<a href="/login" className="rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-secondary">
|
||||
|
||||
@@ -10,7 +10,7 @@ export function LicenseBanner() {
|
||||
if (license.state === "expired") {
|
||||
const when = license.expires_at ? new Date(license.expires_at).toLocaleDateString() : "recently";
|
||||
return (
|
||||
<div className="bg-amber-900/40 px-4 py-2 text-sm text-amber-100">
|
||||
<div className="border-b border-warning/40 bg-warning/10 px-4 py-2 text-sm text-warning">
|
||||
Your Vantage licence expired on {when}. Your servers and monitors are still running,
|
||||
but changes are disabled until it is renewed.{" "}
|
||||
<Link href="/settings/license" className="underline">Add a licence</Link>
|
||||
@@ -20,7 +20,7 @@ export function LicenseBanner() {
|
||||
|
||||
if (license.state === "invalid") {
|
||||
return (
|
||||
<div className="bg-red-900/40 px-4 py-2 text-sm text-red-100">
|
||||
<div className="border-b border-danger/40 bg-danger/10 px-4 py-2 text-sm text-danger">
|
||||
This instance has no valid licence. Changes are disabled.{" "}
|
||||
<Link href="/settings/license" className="underline">Add a licence</Link>
|
||||
</div>
|
||||
@@ -29,7 +29,7 @@ export function LicenseBanner() {
|
||||
|
||||
if (typeof license.days_remaining === "number" && license.days_remaining <= 14) {
|
||||
return (
|
||||
<div className="bg-amber-900/25 px-4 py-2 text-sm text-amber-100">
|
||||
<div className="border-b border-warning/25 bg-warning/[0.07] px-4 py-2 text-sm text-warning">
|
||||
Your licence expires in {license.days_remaining} day
|
||||
{license.days_remaining === 1 ? "" : "s"}.
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -167,10 +167,12 @@ export function Sidebar() {
|
||||
return (
|
||||
<aside className="flex h-screen w-60 flex-col border-r border-border bg-surface">
|
||||
<div className="flex h-16 items-center gap-3 border-b border-border px-5">
|
||||
<Logo className="h-8 w-8 text-text-primary" />
|
||||
<Logo className="h-8 w-8 text-logo" />
|
||||
<div className="min-w-0">
|
||||
<span className="block text-base font-semibold leading-tight text-text-primary">Vantage</span>
|
||||
{instance && <span className="block truncate text-xs text-text-secondary">{instance.name}</span>}
|
||||
<span className="block text-base font-extrabold leading-tight tracking-[-0.035em] text-text-primary">Vantage</span>
|
||||
{instance && (
|
||||
<span className="block truncate font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">{instance.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -182,9 +184,14 @@ export function Sidebar() {
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
// The active marker is an accent bar, the same device
|
||||
// site/ uses to mark the chosen plan. A filled pill
|
||||
// reads as a button you can press again.
|
||||
className={clsx(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
isActive ? "bg-accent/15 text-accent" : "text-text-secondary hover:bg-surface-2 hover:text-text-primary",
|
||||
"relative flex items-center gap-3 rounded px-3 py-2.5 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-surface-2 font-semibold text-text-primary before:absolute before:inset-y-1 before:left-0 before:w-[2px] before:rounded-full before:bg-accent before:content-['']"
|
||||
: "font-medium text-text-secondary hover:bg-surface-2 hover:text-text-primary",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
@@ -207,7 +214,7 @@ export function Sidebar() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-text-secondary">Vantage v1.0</p>
|
||||
<p className="font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">Vantage v1.0</p>
|
||||
{user && (
|
||||
<button type="button" onClick={handleLogout} className="text-xs text-text-secondary transition-colors hover:text-danger">
|
||||
Logout
|
||||
|
||||
+18
-15
@@ -8,32 +8,35 @@ interface BadgeProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* site/'s .chip: mono, uppercase, widely tracked, drawn in currentColor with
|
||||
* no fill. Every badge in this app holds one short state word, which is what
|
||||
* the treatment is for.
|
||||
*
|
||||
* State is never carried by colour alone — the word itself says it, and the
|
||||
* three state variants add a dot so the distinction survives a monochrome
|
||||
* screen as well as it survives colour blindness.
|
||||
*/
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
success: "bg-success/15 text-success border-success/30",
|
||||
warning: "bg-warning/15 text-warning border-warning/30",
|
||||
danger: "bg-danger/15 text-danger border-danger/30",
|
||||
neutral: "bg-surface-2 text-text-secondary border-border",
|
||||
accent: "bg-accent/15 text-accent border-accent/30",
|
||||
success: "text-success",
|
||||
warning: "text-warning",
|
||||
danger: "text-danger",
|
||||
neutral: "text-text-secondary",
|
||||
accent: "text-accent",
|
||||
};
|
||||
|
||||
export function Badge({ variant = "neutral", children, className }: BadgeProps) {
|
||||
const hasState = variant === "success" || variant === "warning" || variant === "danger";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium",
|
||||
"inline-flex items-center gap-1.5 whitespace-nowrap rounded-sm border border-current px-1.5 py-0.5 font-mono text-[0.65rem] uppercase tracking-[0.08em]",
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{(variant === "success" || variant === "warning" || variant === "danger") && (
|
||||
<span
|
||||
className={clsx("h-1.5 w-1.5 rounded-full", {
|
||||
"bg-success": variant === "success",
|
||||
"bg-warning": variant === "warning",
|
||||
"bg-danger": variant === "danger",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{hasState && <span className="h-1.5 w-1.5 rounded-full bg-current" />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -10,13 +10,18 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/*
|
||||
* site/'s .btn--solid and .btn--line, in Tailwind. Both filled variants take
|
||||
* accent-ink rather than white: on the dark ground the accent lifts to a light
|
||||
* blue and danger to a coral, and white on either is unreadable.
|
||||
*/
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
primary:
|
||||
"bg-accent hover:bg-accent-hover text-white border-transparent",
|
||||
"bg-accent hover:bg-accent-hover text-accent-ink border-accent hover:border-accent-hover",
|
||||
secondary:
|
||||
"bg-surface-2 hover:bg-[#2d3048] text-text-primary border-border",
|
||||
"bg-surface hover:border-text-tertiary text-text-primary border-border",
|
||||
danger:
|
||||
"bg-danger hover:bg-danger-hover text-white border-transparent",
|
||||
"bg-danger hover:bg-danger-hover text-accent-ink border-danger hover:border-danger-hover",
|
||||
ghost:
|
||||
"bg-transparent hover:bg-surface-2 text-text-secondary hover:text-text-primary border-transparent",
|
||||
};
|
||||
@@ -37,7 +42,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-2 rounded-lg border font-medium transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-background disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"inline-flex items-center gap-2 rounded border font-semibold transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-background disabled:opacity-50 disabled:cursor-not-allowed active:translate-y-px",
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className
|
||||
|
||||
@@ -9,7 +9,9 @@ export function Card({ className, padding = true, children, ...props }: CardProp
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"rounded-xl border border-border bg-surface",
|
||||
// Borders, not shadows. site/ builds its panels out of rules and the
|
||||
// console has far more panels per screen than the site does.
|
||||
"rounded border border-border bg-surface",
|
||||
padding && "p-6",
|
||||
className
|
||||
)}
|
||||
@@ -30,7 +32,7 @@ export function CardHeader({ className, children, ...props }: HTMLAttributes<HTM
|
||||
|
||||
export function CardTitle({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h2 className={clsx("text-lg font-semibold text-text-primary", className)} {...props}>
|
||||
<h2 className={clsx("text-lg font-extrabold tracking-[-0.03em] text-text-primary", className)} {...props}>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ export function Modal({
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
<div
|
||||
className={`relative z-10 w-full ${wide ? "max-w-2xl" : "max-w-md"} max-h-[90vh] overflow-auto rounded-xl border border-border bg-surface shadow-2xl`}
|
||||
className={`relative z-10 w-full ${wide ? "max-w-2xl" : "max-w-md"} max-h-[90vh] overflow-auto rounded border border-border bg-surface shadow-panel`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
|
||||
@@ -45,7 +45,11 @@ export function Th({ className, children, ...props }: ThHTMLAttributes<HTMLTable
|
||||
return (
|
||||
<th
|
||||
className={clsx(
|
||||
"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-text-secondary",
|
||||
// site/'s keyed-label idiom: mono, small, widely tracked, dimmed.
|
||||
// A column head is a key, not prose.
|
||||
// text-secondary, not tertiary: a column head is how you navigate the
|
||||
// table, and tertiary lands under 4.5:1 at this size.
|
||||
"px-4 py-3 text-left font-mono text-[0.68rem] uppercase tracking-[0.13em] text-text-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -32,7 +32,7 @@ function StepCard({ step, onAdd }: { step: WorkflowStep; onAdd: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="group relative rounded-[10px] border border-border bg-surface-2 p-3 text-left transition-colors hover:border-signal/55"
|
||||
className="group relative rounded border border-border bg-surface-2 p-3 text-left transition-colors hover:border-signal/55"
|
||||
>
|
||||
<span className="absolute right-3 top-3 text-xs font-semibold text-signal opacity-0 group-hover:opacity-100">
|
||||
+ Add
|
||||
@@ -132,13 +132,13 @@ export function StepPickerModal({
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<button
|
||||
onClick={onAddAdhoc}
|
||||
className="flex min-h-[74px] items-center justify-center gap-2 rounded-[10px] border border-dashed border-border text-sm text-text-secondary hover:border-signal/55 hover:text-signal"
|
||||
className="flex min-h-[74px] items-center justify-center gap-2 rounded border border-dashed border-border text-sm text-text-secondary hover:border-signal/55 hover:text-signal"
|
||||
>
|
||||
+ New ad-hoc step
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="flex min-h-[74px] items-center justify-center gap-2 rounded-[10px] border border-dashed border-border text-sm text-text-secondary hover:border-signal/55 hover:text-signal"
|
||||
className="flex min-h-[74px] items-center justify-center gap-2 rounded border border-dashed border-border text-sm text-text-secondary hover:border-signal/55 hover:text-signal"
|
||||
>
|
||||
⬆ Import ad-hoc from file
|
||||
</button>
|
||||
|
||||
+75
-27
@@ -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.
|
||||
*
|
||||
* `<alpha-value>` 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}) / <alpha-value>)`;
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user