From d66d2463abd403e44f4f72612b29271cfe4f685b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:43:30 +0000 Subject: [PATCH 1/7] fix(web): match design spacing and type on three visual-diff screens Bring the Authentication settings section (uMiwd), the server Overview never-sleeps state (IzuY2) and the Modules catalog (kK8Ji) in line with their design.pen exports: card insets, section label style, row and chip styling, Sleep card first in the right column, outline Create module button and natural-height module cards. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- web/src/components/modules/ModuleCard.tsx | 20 ++- .../components/server/ServerActionsCard.tsx | 26 ++-- web/src/components/server/ServerSleepCard.tsx | 72 +++++------ .../components/server/ServerStatusCard.tsx | 24 ++-- web/src/routes/AdminSettings.tsx | 121 ++++++++++++------ web/src/routes/Modules.tsx | 6 +- web/src/routes/tabs/Overview.tsx | 64 ++++----- 7 files changed, 190 insertions(+), 143 deletions(-) diff --git a/web/src/components/modules/ModuleCard.tsx b/web/src/components/modules/ModuleCard.tsx index 5cc4c0d31..9266a3a7f 100644 --- a/web/src/components/modules/ModuleCard.tsx +++ b/web/src/components/modules/ModuleCard.tsx @@ -16,6 +16,7 @@ import { useGameCodes } from "@/lib/useGameCodes"; import { resolveCategories } from "@/lib/games"; import type { EntryVerify } from "@/lib/verify"; import type { CatalogEntry } from "@/types"; +import { cn } from "@/lib/utils"; interface ModuleCardProps { entry: CatalogEntry; @@ -95,7 +96,7 @@ export function ModuleCard({ const digest = entry.appliedDigest ?? entry.digest; return ( - +
-
+
{entry.displayName ?? entry.name}
{versionLabel}
@@ -130,7 +131,7 @@ export function ModuleCard({ {resolveCategories(entry.categories, entry.game ?? entry.name).length > 0 && (
{resolveCategories(entry.categories, entry.game ?? entry.name).map((c) => ( - + {c} ))} @@ -159,7 +160,12 @@ export function ModuleCard({
) : null} - + {entry.sources.length === 1 ? `${entry.sources[0].name} (${entry.sources[0].type})` @@ -261,7 +267,7 @@ function VerifyBadge({ verify }: { verify: EntryVerify }) { size="sm" variant="soft" title={keyless ? "keyless (Fulcio) signature verified" : "signature verified"} - className="font-mono text-[10px] uppercase inline-flex items-center gap-1" + className="font-mono text-[10px] uppercase font-normal inline-flex items-center gap-1" data-color="success" > @@ -275,7 +281,7 @@ function VerifyBadge({ verify }: { verify: EntryVerify }) { size="sm" variant="soft" title={keyless ? "keyless (Fulcio) signature policy declared" : "signature policy declared"} - className="font-mono text-[10px] uppercase inline-flex items-center gap-1" + className="font-mono text-[10px] uppercase font-normal inline-flex items-center gap-1" data-color="success" > @@ -327,7 +333,7 @@ function StatusPill({ entry }: { entry: CatalogEntry }) { color={color} size="sm" variant="soft" - className="font-mono text-[10px] uppercase" + className="font-mono text-[10px] uppercase font-normal" > {label} diff --git a/web/src/components/server/ServerActionsCard.tsx b/web/src/components/server/ServerActionsCard.tsx index b5ec8d260..0e49c11dd 100644 --- a/web/src/components/server/ServerActionsCard.tsx +++ b/web/src/components/server/ServerActionsCard.tsx @@ -210,9 +210,9 @@ export function ServerActionsCard({ const running = phase === "Running"; const canStart = (phase === "Stopped" || phase === "Suspended" || phase === "Failed") && !asleep; return ( - - -

Quick actions

+ + +

Quick actions

{lifecycle.error && ( @@ -289,10 +289,10 @@ export function ServerActionsCard({ className="flex w-full items-start gap-3 rounded-md px-2 py-2 text-left hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50" > - +
-
+
{a.displayName}
{a.description && ( @@ -304,11 +304,11 @@ export function ServerActionsCard({ }; return ( - - -

Quick actions

+ + +

Quick actions

- + {actions.some((a) => !transportReady(a)) && (

{consoleAttachable @@ -350,16 +350,16 @@ export function ServerActionsCard({ {groupOrder.length > 0 ? ( <> {groupOrder.map((groupName) => ( -

-
+
+
{groupName}
{grouped.get(groupName)!.map(renderActionButton)}
))} {ungrouped.length > 0 && ( -
-
+
+
Actions
{ungrouped.map(renderActionButton)} diff --git a/web/src/components/server/ServerSleepCard.tsx b/web/src/components/server/ServerSleepCard.tsx index 017db5766..a92ecdfd9 100644 --- a/web/src/components/server/ServerSleepCard.tsx +++ b/web/src/components/server/ServerSleepCard.tsx @@ -1,5 +1,5 @@ -import { Clock } from "lucide-react"; -import { Card, Alert } from "@heroui/react"; +import { CalendarClock, Megaphone, Moon } from "lucide-react"; +import { Card } from "@heroui/react"; import type { GameServer } from "@/types"; import { formatRelative, capitalize } from "@/lib/utils"; @@ -29,17 +29,15 @@ export function ServerSleepCard({ gs }: { gs?: GameServer }) { const neverSleeps = reason === "this game reports no player count"; return ( - + {/* Header */} -
-
- -

Sleep

-
+
+

Sleep

+
{/* Content */} -
+
{/* Asleep state */} {asleep && ( <> @@ -96,20 +94,18 @@ export function ServerSleepCard({ gs }: { gs?: GameServer }) { {!asleep && !emptySince && reason && ( <> {neverSleeps ? ( - - - ⚠ - - -
-
Will never sleep
-
- {capitalize(reason)}. Idle auto-sleep needs a live player count. This game - exposes none over RCON or a query protocol, so the countdown never starts. -
+ <> +
+ +
+
Will never sleep
+
{capitalize(reason)}.
- - +
+

+ Idle auto-sleep needs a live player count. This game exposes none over RCON or a query protocol, so the countdown never starts. +

+ ) : (
State
@@ -131,32 +127,30 @@ export function ServerSleepCard({ gs }: { gs?: GameServer }) { {/* Configuration */} {spec?.enabled && ( <> -
-
+
+
Configured
-
-
Sleep after
-
+
+
Sleep after
+
{afterMinutes} minute{afterMinutes === 1 ? "" : "s"}
-
-
Wake windows
+
{wakeWindows.length > 0 ? ( -
- {wakeWindows.map((cron, i) => ( -
- {cron} -
- ))} -
+ wakeWindows.map((cron, i) => ( +
+ + {cron} +
+ )) ) : (
Not configured
)} diff --git a/web/src/components/server/ServerStatusCard.tsx b/web/src/components/server/ServerStatusCard.tsx index 70da696d9..4315d4f08 100644 --- a/web/src/components/server/ServerStatusCard.tsx +++ b/web/src/components/server/ServerStatusCard.tsx @@ -53,13 +53,13 @@ export function ServerStatusCard({ ? String(status.agent.playersOnline) : "—"; return ( - -
-

Game status

+ +
+

Game status

-
-
+
+
[r.id, r])); return ( - -
-

Game status

+ +
+

Game status

-
-
+
+
{metrics.map((m) => { const reading = byId.get(m.id); const value = reading?.value?.trim(); return (
{m.displayName}
@@ -115,7 +115,7 @@ export function ServerStatusCard({ // summary — same visual row shape as the declared-metric rows above. function GenericStatusRow({ label, value }: { label: string; value: string }) { return ( -
+
{label}
{value}
diff --git a/web/src/routes/AdminSettings.tsx b/web/src/routes/AdminSettings.tsx index 75f8f8445..783f2fe75 100644 --- a/web/src/routes/AdminSettings.tsx +++ b/web/src/routes/AdminSettings.tsx @@ -1,7 +1,6 @@ import { useState, useEffect, useRef, type ChangeEvent, type ComponentType, type ReactNode, type SetStateAction } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { - AlertTriangle, Archive, Bell, BellRing, @@ -154,21 +153,35 @@ function SectionCard({ footer, children, className, + headerClassName, + titleClassName, + subtitleClassName, + contentClassName, + footerClassName, }: { title: string; subtitle?: string; footer?: ReactNode; children: ReactNode; className?: string; + headerClassName?: string; + titleClassName?: string; + subtitleClassName?: string; + contentClassName?: string; + footerClassName?: string; }) { return ( - -
{title}
- {subtitle &&
{subtitle}
} + +
{title}
+ {subtitle &&
{subtitle}
}
- {children} - {footer && {footer}} + {children} + {footer && ( + + {footer} + + )}
); } @@ -458,6 +471,11 @@ function AuthSection({ initial, general, installTimeSettings }: { initial?: Auth @@ -471,10 +489,10 @@ function AuthSection({ initial, general, installTimeSettings }: { initial?: Auth
    {f.draft.providers.map((p, idx) => (
  • -
    +
    -
    +
    {p.displayName || p.name}
    {p.kind} @@ -491,8 +509,8 @@ function AuthSection({ initial, general, installTimeSettings }: { initial?: Auth : undefined } className={cn( - "rounded px-2 py-0.5 text-[10px] font-mono uppercase disabled:cursor-not-allowed disabled:opacity-60", - p.enabled ? "bg-success/15 text-success" : "bg-muted/15 text-muted", + "rounded-full px-1 py-0.5 text-[11px] font-medium font-sans uppercase disabled:cursor-not-allowed disabled:opacity-60", + p.enabled ? "bg-success-soft text-success-soft-foreground" : "bg-default text-muted", )} > {p.enabled ? "Enabled" : "Disabled"} @@ -511,23 +529,23 @@ function AuthSection({ initial, general, installTimeSettings }: { initial?: Auth ))} {helm && (
  • -
    +
    -
    +
    {helm.label}
    oidc · configured via Helm — manage in values.yaml
    - + Enabled
  • )}
{lastToggleLocked && ( -

+

At least one identity provider must stay enabled — the last enabled provider can't be turned off.

@@ -546,8 +564,13 @@ function AuthSection({ initial, general, installTimeSettings }: { initial?: Auth onClose={() => setAdding(false)} /> ) : ( - )} @@ -1927,38 +1950,47 @@ function HelmOIDCProviderCard({ provider }: { provider: OIDCHelmProvider }) {
Provider
-

+

Groups claim and default role are seeded from the same Helm values and are not editable here.

Groups claim - {provider.groupsClaim} + {provider.groupsClaim}
Default role - + {provider.defaultRole}
{hasRoleMappings ? ( -
+
Helm-seeded role mappings
{["admin", "operator", "viewer"].map((role) => { const groups = provider.roleMappings?.[role as keyof typeof provider.roleMappings]; return ( -
- {role} -
+
+ {role} +
{groups && groups.length > 0 ? ( groups.map((group) => ( {group} @@ -1983,9 +2015,9 @@ function HelmOIDCProviderCard({ provider }: { provider: OIDCHelmProvider }) {
)} {adminMapped && ( -
- -
+
+ +
Helm-configured admin mapping

The group(s) on the Admin row above were mapped to admin via Helm values, not the dashboard, so there @@ -2134,9 +2166,14 @@ function RoleMappingOverridesCard({ title="Role mapping overrides" subtitle="Override the Helm-seeded OIDC group mappings per role from here. A change takes effect the next time an affected user logs in — no restart or reinstall needed." className="bg-card" + titleClassName="text-sm font-medium" + subtitleClassName="text-sm" + headerClassName="px-5 pt-5 pb-4 space-y-1" + contentClassName="px-5 pb-5 space-y-4" + footerClassName="px-5 pt-2 pb-5 justify-between" footer={

-

+

Saved changes take effect on next login for affected users — no restart or reinstall needed.

@@ -2146,14 +2183,20 @@ function RoleMappingOverridesCard({ same text (e.g. a save error) appears twice in the DOM, breaking any singular getByText/findByText query for it — see the "surfaces a backend rejection when saving the auth section" test. */} -
} > -
+
{["admin", "operator", "viewer"].map((role) => { const typedRole = role as "admin" | "operator" | "viewer"; const groups = getRoleGroups(typedRole); @@ -2171,16 +2214,17 @@ function RoleMappingOverridesCard({ return (
{role !== "admin" &&
} -
+
- {role} + {role}
{hasOverride && (
{groups.length > 0 ? ( -
+
{groups.map((group) => ( ) : ( -
+
No groups mapped — nobody signs in as {role} via OIDC.
)} -
+
diff --git a/web/src/routes/Modules.tsx b/web/src/routes/Modules.tsx index dbdb09c0e..ffb30cd51 100644 --- a/web/src/routes/Modules.tsx +++ b/web/src/routes/Modules.tsx @@ -135,7 +135,7 @@ export function ModulesPage() { subtitle="Pre-packaged game-server templates pulled from your configured module sources." actions={
- {uploadSources.length > 0 && ( @@ -168,7 +168,7 @@ export function ModulesPage() { ))}
-
+
{catChips.map((c) => (
-
- - -

Connection

+
+ + + + +

Connection

- + {/* Tunnel row — only when a tunnel endpoint actually exists; per design (EZFW0) this row doesn't appear at all otherwise. */} {primary?.tunnelProvider && ( @@ -287,8 +289,6 @@ export function OverviewTab({ - -
@@ -333,34 +333,34 @@ function ResourceCard({ }) { const accentClass = { primary: "bg-accent", - success: "bg-success", + success: "bg-success-soft-foreground", warning: "bg-warning", violet: "bg-violet", }[accent ?? "primary"]; const accentText = { primary: "text-accent", - success: "text-success", + success: "text-success-soft-foreground", warning: "text-warning", violet: "text-violet", }[accent ?? "primary"]; const history = useMetricHistory(trend); return ( - -
+ +
{label} {icon}
-
{value}
- +
{value}
+ {progress !== undefined && ( -
+
)} - {sub &&
{sub}
} + {sub &&
{sub}
} ); } @@ -369,9 +369,9 @@ function ResourceCard({ // bordered rows for each connection value, most with a copy affordance. function EndpointRow({ label, children }: { label: string; children: ReactNode }) { return ( -
- {label} -
{children}
+
+ {label} +
{children}
); } @@ -392,29 +392,29 @@ function PlayersCard({ roster === undefined || roster.capabilities !== undefined; return ( - - + +
-

+

Players online ·{" "} - {onlineUnknown ? "—" : online} + {onlineUnknown ? "—" : online}

{rows.length > 0 && ( {rows.length} listed )}
- + {!supported ? ( -

+

Player list not supported for this game.

) : onlineUnknown ? ( -

Player count unknown.

+

Player count unknown.

) : online === 0 ? ( -

No players connected.

+

No players connected.

) : rows.length === 0 ? ( -

+

{online} online · names not yet available.

) : ( From 40296f29f9cc23ceb6e43d4898b207310b484c81 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:44:44 +0000 Subject: [PATCH 2/7] test(web): seed IzuY2 never-sleeps variant with the design's data Sleep after 30 minutes with the base wake windows, the design's memory and disk figures, and no recent events, all gated on the never-sleeps cookie that only the IzuY2 screenshot uses. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- web/src/test/handlers.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/web/src/test/handlers.ts b/web/src/test/handlers.ts index dd6249304..f21649818 100644 --- a/web/src/test/handlers.ts +++ b/web/src/test/handlers.ts @@ -974,11 +974,18 @@ export function buildScreenshotHandlers() { ...server, spec: { ...server.spec, - idle: { enabled: true, afterMinutes: 60 }, + idle: { ...server.spec.idle, enabled: true, afterMinutes: 30 }, }, status: { ...server.status, idle: { reason: "this game reports no player count" }, + agent: { + ...server.status?.agent, + memoryBytes: 1610612736, // 1.5 GiB -> "1.5 GB", 38% of limit + memoryLimitBytes: 4294967296, // 4.0 GiB -> "4.0 GB" + diskUsedBytes: 3972844749, // 3.7 GiB -> "3.7 GB", 13% of total + diskTotalBytes: 31138512896, // 29 GiB -> "29 GB" + }, }, }); } @@ -1383,8 +1390,11 @@ export function buildScreenshotHandlers() { http.delete("/modules/:name", () => new HttpResponse(null, { status: 204 })), // Server events: return events for the requested server - http.get("/servers/:name/events", ({ params }) => { + http.get("/servers/:name/events", ({ params, cookies }) => { const name = String(params.name); + if (name === "mc-survival" && cookies.e2e_server_variant === "never-sleeps") { + return HttpResponse.json([]); + } const serverEvents = data.events.filter((e) => e.object === name); return HttpResponse.json(serverEvents); }), From da11cb593f632c8a46a4d6c01b49b1e7987bad8d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 20:10:51 +0000 Subject: [PATCH 3/7] fix(web): drop HeroUI card padding on auth cards and pin group input width The auth SectionCards got HeroUI's own p-4 on top of their px-5 insets, and the global .input width rule overrode the 250px add-group input. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- web/src/routes/AdminSettings.tsx | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/web/src/routes/AdminSettings.tsx b/web/src/routes/AdminSettings.tsx index 783f2fe75..99726079f 100644 --- a/web/src/routes/AdminSettings.tsx +++ b/web/src/routes/AdminSettings.tsx @@ -469,6 +469,7 @@ function AuthSection({ initial, general, installTimeSettings }: { initial?: Auth return (
0; return ( - setInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleAddGroup(typedRole, input); - } - }} - className="h-8 w-[250px] text-[13px]" - /> +
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddGroup(typedRole, input); + } + }} + className="h-8 text-[13px]" + /> +