ConduitDoku

2d. Panel UI/UX (Cloudflare-style design system)#

The panel's look is modeled on Cloudflare's dashboard ("Kumo") — built from their actual token dump.

Design tokens (dashboard/src/app/globals.css, Tailwind v4 @theme): NEUTRAL grayscale surfaces (chroma-0 OKLCH — canvas 0.10 → card 0.145 → control 0.205 → hover 0.23, hairline #313131), blue primary CTAs + Conduit-blue brand accent (--brand), status colors red/emerald/amber/blue, 6px base radius. Three themes — Slate (default) / AMOLED / Light — picked in Settings via mini-panel PREVIEW cards; applied pre-paint (conduit.theme in localStorage, classes on <html>).

Typography — hybrid: Inter for UI/nav/prose (CF's -0.01em tracking), JetBrains Mono for values (IDs, IPs, ports, hashes; <Mono> helper = font-mono tabular-nums).

Motion — framer-motion (dashboard/src/components/motion/): PageTransition (fade+rise per route), FadeIn/Stagger/StaggerItem, SkeletonRows (first-load shimmer), a CSS .stagger-children utility for panel cascades. Everything respects prefers-reduced-motion. CF's easing: default cubic-bezier(.4,0,.2,1), sidebar cubic-bezier(.77,0,.175,1) @250ms.

Sidebar (components/sidebar-nav.tsx) — collapsible 57px icon rail that hover-expands to 240px (floats over content; a pin persists it and shifts content via --sidebar-w). Nav is hierarchical like CF: Overview + 7 expandable categories (Servers/Players/Gameplay/Data/ Monitoring/Developer/Settings) with indented children + guide line; the active route's category auto-expands, an animated layoutId pill tracks the active row. Built as ONE permanent DOM tree — open only animates geometry/opacity (no remounts → no pops/layout shift; children height-animate in sync with the rail width). Brand header = account switcher (remembered accounts per browser; switch = logout + prefilled /login?user=). Quick search shows a plain Ctrl K hint.

Conventions — data is live (poll/SSE): no per-page Refresh buttons; page headers show a pulse dot. Overview stats render as connected bands (one panel, gap-px hairline dividers — KPI strip, cluster metrics, nodes) instead of floating boxes; cells are equal-height with placeholders. Settings is tabbed (General / Permissions & Chat / Alerts & Automation / Data & Credentials, last tab remembered). 5px scrollbars. Tables collapse low-value columns responsively (e.g. Overview services hides Software/Group/Node before Address/Players ever clip).

The overview counts the whole fleet (2026.8.313, .317). The front page is one view over this cluster and every cluster joined to it. Its counters (app/api/overview/route.ts) add the joined clusters' nodes, containers and memory to this cluster's, taken from the last good answer the fleet tick already holds (db.fleet.peers[], refreshed every twenty seconds); the route adds no network call of its own, so a cluster that stops answering cannot slow the page everybody opens first. A peer that is not answering is counted from what it last reported and marked, never dropped: a two-node pair that reads "1/1 nodes" looks healthy and is wrong. Containers are counted by the same rule the node cards use (lib/node-rows: tagged conduit, inside the managed vmid range), so a peer's count and the local count are one measurement. Players come from useFleetMetrics, which asks each reachable cluster for its own live numbers. The nodes band, the services table and the attention band are the shared overview parts under components/overview/.

The command palette knows every page and every section (2026.8.313, .317). What Ctrl-K can reach is data, not component code: lib/palette-index.ts holds NAV (every page, keyed on the words a person would type, the sidebar's own label always among them) and FEATURES (things found by what they do: "nobody can join", "526", "rotate a database password"), most pointing at a section with a #hash. palette-index.test.ts reads the page tree and the sidebar and holds two rules: every destination has a page, and every fragment names an element that exists. Before this the lists lived inside the palette component, where thirteen sidebar pages were missing and a hash pointing at a renamed section scrolled nowhere. components/hash-target.tsx takes a #section in the URL seriously: it scrolls the element into view and flashes it for 2.2 s, on load and on hashchange. Entity search (GET /api/search?q=, two characters or more) answers typed hits over online players, groups, blueprints, panel users (admin only) and, since clusters can be joined, the things that live on more than one of them: managed databases, hostnames, access grants (admin only), nodes and servers on every joined cluster (the far cluster's label rides in the hint, like "#204 · edge-02"), and alerts by title. rankHits orders them (exact label, then prefix, then substring) and caps the answer at 14. The only read that leaves the process is the local node list, and its failure costs one kind of hit rather than the search.

The console caret moves (2026.8.310). The service console's line editor keeps its own buffer and echoes locally, and for months Left and Right were silently dropped: Right existed only to accept the fish-style ghost suggestion, and anything else fell through to a printable check that an escape sequence never passes. lib/console-keys.ts is the decision as a pure function with a test: Left, Right, Home, End, Ctrl/Alt+arrow word jumps and the readline chords Ctrl-A / Ctrl-E, in both xterm cursor modes and for the <input>-based consoles. Right in the middle of the text moves; Right at the end with a ghost shown accepts it; Left only ever moves, because a suggestion is accepted by stepping into it and never by stepping away from it. Shift+arrow is a plain move, since a terminal line has no selection to extend.

The greeting knows your name (2026.8.310). The sign-in arrival (components/arrival-intro.tsx) plays over the dashboard while it loads, and greets by name on the first frame: the login reply carries the canonical name and the marker carries it across synchronously. It used to say "welcome back, operator" until /api/auth/me answered and then correct itself. If the name is somehow not there, the slot is a name-length skeleton, never a role.

Every page is built from the shared parts (2026.8.311). The rule: a page is made of the primitives every other page uses, PageHeader, a panel with an eyebrow, Table or hairline rows for lists, StatusChip for every state, Button variants for every action, Label + Input on a grid, Switch for toggles, Segmented for a choice of modes, CSS motion only (stagger-children, pop-in), and every request through readJson/mutate so a reply that is not JSON (Cloudflare's 5xx page, an empty 204) is described rather than thrown. /account was the last page that had grown its own controls and its own second motion system; it was rebuilt on the same parts as /domains and /nodes, one section per concern, each section carrying an id so the palette can land on it (#profile, #password, #two-factor, #passkeys, #tokens, #ssh-keys, #sessions). The rule is applied by hand and in review; there is no test that walks pages for it yet. The copy rule beside it (no em-dashes in UI text) is enforced by ui-copy.test.ts.


Auf GitHub bearbeitenFEATURES.mdAktualisiert