Update Healer Man server

This commit is contained in:
phenom committed 2026-09-20 13:40:23 -04:00
1 parent cc3b2fa4d5
commit daa417ec17
46 files changed
+1334 -172

No files matched your search

+16 -11
View File
@@ -1,38 +1,43 @@
import { useMemo, useState } from "react";
import { buildBossGuideCatalog } from "../game/bossGuide";
import { BOSS_GUIDE_OVERRIDES } from "../game/bossGuideOverrides";
import { DUNGEON_DEFINITIONS } from "../game/dungeonRegistry";
import { DUNGEON_DEFINITIONS, dungeonDefinitionById } from "../game/dungeonRegistry";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
const GUIDE_CATALOG = buildBossGuideCatalog(DUNGEON_DEFINITIONS, BOSS_GUIDE_OVERRIDES);
function BossGuideDialog() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const sessionRevision = useGameStore((state) => state.sessionRevision);
const bossTest = useGameStore((state) => state.bossTest);
const openOverlay = useGameStore((state) => state.openOverlay);
const initialDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === (bossTest?.sourceDungeonId ?? activeDungeonId)) ?? GUIDE_CATALOG[0];
const guideCatalog = useMemo(() => buildBossGuideCatalog(
DUNGEON_DEFINITIONS.map((definition) => (
definition.id === activeDungeonId ? dungeonDefinitionById(activeDungeonId) ?? definition : definition
)),
BOSS_GUIDE_OVERRIDES,
), [activeDungeonId, sessionRevision]);
const initialDungeon = guideCatalog.find((dungeon) => dungeon.id === (bossTest?.sourceDungeonId ?? activeDungeonId)) ?? guideCatalog[0];
const [selectedDungeonId, setSelectedDungeonId] = useState(initialDungeon?.id ?? "");
const selectedDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === selectedDungeonId) ?? initialDungeon;
const selectedDungeon = guideCatalog.find((dungeon) => dungeon.id === selectedDungeonId) ?? initialDungeon;
const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses.find((boss) => boss.entityId === bossTest?.entityId)?.id ?? selectedDungeon?.bosses[0]?.id ?? "");
const selectedBoss = selectedDungeon?.bosses.find((boss) => boss.id === selectedBossId)
?? selectedDungeon?.bosses[0];
const close = () => openOverlay("pause");
const selectDungeon = (dungeonId: string) => {
const dungeon = GUIDE_CATALOG.find((candidate) => candidate.id === dungeonId);
const dungeon = guideCatalog.find((candidate) => candidate.id === dungeonId);
setSelectedDungeonId(dungeonId);
setSelectedBossId(dungeon?.bosses[0]?.id ?? "");
};
const actions = useMemo<MenuAction[]>(() => {
const dungeonActions = GUIDE_CATALOG.map((dungeon, index): MenuAction => ({
const dungeonActions = guideCatalog.map((dungeon, index): MenuAction => ({
id: `boss-guide-dungeon:${dungeon.id}`,
run: () => selectDungeon(dungeon.id),
neighbors: {
up: `boss-guide-dungeon:${GUIDE_CATALOG[(index - 1 + GUIDE_CATALOG.length) % GUIDE_CATALOG.length].id}`,
down: `boss-guide-dungeon:${GUIDE_CATALOG[(index + 1) % GUIDE_CATALOG.length].id}`,
up: `boss-guide-dungeon:${guideCatalog[(index - 1 + guideCatalog.length) % guideCatalog.length].id}`,
down: `boss-guide-dungeon:${guideCatalog[(index + 1) % guideCatalog.length].id}`,
...(dungeon.id === selectedDungeon?.id && dungeon.bosses.length
? { right: `boss-guide-boss:${dungeon.bosses[0].id}` }
: {}),
@@ -49,7 +54,7 @@ function BossGuideDialog() {
},
}));
return [...dungeonActions, ...bossActions];
}, [selectedDungeon]);
}, [guideCatalog, selectedDungeon]);
const controller = useMenuController(actions, {
initialId: `boss-guide-dungeon:${selectedDungeon?.id ?? ""}`,
onBack: close,
@@ -74,7 +79,7 @@ function BossGuideDialog() {
<nav className="boss-guide-panel__dungeons" aria-label="Dungeons">
<h3>Dungeons</h3>
<div>
{GUIDE_CATALOG.map((dungeon) => {
{guideCatalog.map((dungeon) => {
const controlId = `boss-guide-dungeon:${dungeon.id}`;
return (
<ControllerButton
+4 -15
View File
@@ -36,6 +36,8 @@ import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore";
import { currentDisplayLayout } from "../platform/displayLayout";
import { DungeonCompanionHud } from "./DungeonCompanionHud";
import { LoginVirtualKeyboard } from "./LoginVirtualKeyboard";
import { useLoginInputStore } from "../app/loginInputStore";
function safePercent(current: number, maximum: number): number {
if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0;
@@ -161,21 +163,7 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
const active = snapshot.activeCharacter ?? selected;
if (snapshot.phase === "login") {
return (
<FrontSurface bottom className="companion-panel companion-login" ariaLabel="Healer Man companion login information">
<header><BrandMark compact /><b>SECONDARY DISPLAY</b></header>
<main>
<p className="companion-kicker">Ready on both screens</p>
<h2>One roster. Two ways to play.</h2>
<div className="companion-feature-grid">
<article><b>01</b><span><strong>Your roster</strong><small>Choose your adventurer after signing in</small></span></article>
<article><b>02</b><span><strong>Offline play</strong><small>No sign-in or network required</small></span></article>
<article><b>03</b><span><strong>PC fallback</strong><small>This panel opens as an overlay</small></span></article>
</div>
</main>
<footer><span>Sign in on the top screen</span><b>THOR / 3.92 INCHES</b></footer>
</FrontSurface>
);
return <LoginVirtualKeyboard snapshot={snapshot.login} notice={snapshot.notice} send={send} />;
}
if (snapshot.phase === "characters") {
@@ -526,6 +514,7 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
export function LocalCompanionPanel() {
const shell = useShellStore();
useLoginInputStore();
useGameStore();
useCombatStore();
usePartyStore();
+5 -2
View File
@@ -1,6 +1,7 @@
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
import type { DungeonRunMember } from "../game/dungeonRunTypes";
import { bossDifficultyLabel } from "../game/dungeonDifficulty";
import { leaderboardFinalBossIds } from "../game/dungeonLeaderboardRules";
const className = (id: string) => catalog.classes.find((entry) => entry.id === id)?.name ?? id;
@@ -12,7 +13,9 @@ export function DungeonRunDetails({ dungeonId, difficultyId, bossDifficulty, par
readonly killedBossIds: readonly string[];
}) {
const dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId);
const ids = dungeon?.difficulties.find((entry) => entry.id === difficultyId)?.bossIds ?? [];
const difficulty = dungeon?.difficulties.find((entry) => entry.id === difficultyId);
const variant = difficulty && leaderboardFinalBossIds(difficulty).length > 1;
const ids = variant ? killedBossIds : difficulty?.bossIds ?? [];
const bosses = dungeon?.bosses.filter((boss) => ids.includes(boss.id)) ?? [];
return <div className="dungeon-run-details">
<p>{bossDifficultyLabel(bossDifficulty)} · {difficultyId === "heroic" ? "Heroic" : "Normal"}</p>
@@ -24,7 +27,7 @@ export function DungeonRunDetails({ dungeonId, difficultyId, bossDifficulty, par
<small>{member.role === "damage" ? "Damage" : member.role === "healer" ? "Healer" : "Tank"}</small>
</li>)}
</ul>
<h4>Bosses defeated · {killedBossIds.length}/{bosses.length}</h4>
<h4>Bosses defeated · {Math.min(killedBossIds.length, bosses.length)}/{bosses.length}</h4>
<ul className="dungeon-run-bosses">
{bosses.map((boss) => <li key={boss.id} className={killedBossIds.includes(boss.id) ? "is-defeated" : ""}>
<span aria-hidden="true">{killedBossIds.includes(boss.id) ? "✓" : "—"}</span>
+3 -2
View File
@@ -10,6 +10,7 @@ import { useMenuController } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import { DungeonRunDetails } from "./DungeonRunDetails";
import "./dungeonRuns.css";
import { isLeaderboardFullClear } from "../game/dungeonLeaderboardRules";
export interface RunPlacementState {
readonly scope: LeaderboardScope;
@@ -25,8 +26,8 @@ export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, o
readonly onRetry: () => void;
}) {
const definition = catalog.dungeons.find((dungeon) => dungeon.id === progress.dungeonId);
const fullClear = definition?.difficulties.find((entry) => entry.id === progress.difficultyId)?.bossIds
.every((id) => progress.killedBossIds.includes(id)) ?? false;
const difficulty = definition?.difficulties.find((entry) => entry.id === progress.difficultyId);
const fullClear = difficulty ? isLeaderboardFullClear(difficulty, progress.killedBossIds) : false;
const controller = useMenuController([
{ id: "run-loot", run: onLoot }, { id: "run-finish", run: onFinish },
...(ranking.scope === "device" ? [{ id: "run-retry", run: onRetry }] : []),
+2 -1
View File
@@ -36,6 +36,7 @@ import { bossDifficultyLabel } from "../game/dungeonDifficulty";
import { useCompanionPartyPreferences } from "../game/companionPartyPreferences";
import { CompanionPoolControls } from "./CompanionPoolControls";
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
import { dungeonContentPackId } from "../game/dungeonContentSource";
installDungeonShellRuntime();
@@ -172,7 +173,7 @@ export function DungeonSelectScreen() {
setDownloading(true);
setDownloadError("");
try {
await installContentPack(`dungeon:${pendingDownload.dungeonId}`);
await installContentPack(dungeonContentPackId(pendingDownload.dungeonId));
if (!dungeonContentIsUsable(pendingDownload.dungeonId)) {
throw new Error("The downloaded expedition could not be verified on this device.");
}
+73 -42
View File
@@ -1,7 +1,8 @@
import { useMemo, useRef, useState } from "react";
import { useCallback, useEffect } from "react";
import {
createOfflineSession,
} from "../app/accountRepository";
import { useLoginInputStore, type LoginKeyboardDirection } from "../app/loginInputStore";
import {
createOnlineAccount,
hydrateOnlineSession,
@@ -9,22 +10,34 @@ import {
} from "../app/onlineAccountClient";
import { useShellStore } from "../app/shellStore";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { getControllerHeldTokens, subscribeControllerToken } from "../input/controller";
import { INPUT_ACTIONS, controllerBindingMatches } from "../input/keybinds";
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
import { ControllerButton } from "./ControllerButton";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { ControllerLegend, FrontSurface } from "./FrontSurface";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
export function LoginScreen() {
const beginSession = useShellStore((state) => state.beginSession);
const notice = useShellStore((state) => state.notice);
const setNotice = useShellStore((state) => state.setNotice);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState<"login" | "create" | null>(null);
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const username = useLoginInputStore((state) => state.username);
const password = useLoginInputStore((state) => state.password);
const activeField = useLoginInputStore((state) => state.activeField);
const selectedKeyId = useLoginInputStore((state) => state.selectedKeyId);
const busy = useLoginInputStore((state) => state.busy);
const request = useLoginInputStore((state) => state.request);
const setUsername = useLoginInputStore((state) => state.setUsername);
const setPassword = useLoginInputStore((state) => state.setPassword);
const setActiveField = useLoginInputStore((state) => state.setActiveField);
const selectKey = useLoginInputStore((state) => state.selectKey);
const moveSelection = useLoginInputStore((state) => state.moveSelection);
const pressSelected = useLoginInputStore((state) => state.pressSelected);
const consumeRequest = useLoginInputStore((state) => state.consumeRequest);
const setBusy = useLoginInputStore((state) => state.setBusy);
const resetLoginInput = useLoginInputStore((state) => state.reset);
const logIn = async () => {
const logIn = useCallback(async () => {
if (busy) return;
setBusy("login");
setNotice("Contacting the online server...");
@@ -33,15 +46,16 @@ export function LoginScreen() {
try {
setNotice("Synchronizing your cloud roster...");
await hydrateOnlineSession(result.session);
resetLoginInput();
beginSession(result.session);
} catch (error) {
setNotice(error instanceof Error ? error.message : "Cloud roster could not be loaded.");
}
} else setNotice(result.error ?? "Login failed.");
setBusy(null);
};
}, [beginSession, busy, password, resetLoginInput, setBusy, setNotice, username]);
const createAccount = async () => {
const createAccount = useCallback(async () => {
if (busy) return;
setBusy("create");
setNotice("Creating your online account...");
@@ -49,47 +63,63 @@ export function LoginScreen() {
if (result.ok && result.session) {
try {
await hydrateOnlineSession(result.session);
resetLoginInput();
beginSession(result.session);
} catch (error) {
setNotice(error instanceof Error ? error.message : "Cloud roster could not be initialized.");
}
} else setNotice(result.error ?? "Account could not be created.");
setBusy(null);
};
}, [beginSession, busy, password, resetLoginInput, setBusy, setNotice, username]);
const playOffline = () => beginSession(createOfflineSession());
const actions = useMemo<MenuAction[]>(() => [
{ id: "username", run: () => usernameRef.current?.focus() },
{ id: "password", run: () => passwordRef.current?.focus() },
{ id: "login", run: () => { void logIn(); }, enabled: !busy },
{ id: "create-account", run: () => { void createAccount(); }, enabled: !busy },
{ id: "offline", run: playOffline, enabled: !busy },
], [busy, password, username]);
const controller = useMenuController(actions);
const playOffline = useCallback(() => {
resetLoginInput();
beginSession(createOfflineSession());
}, [beginSession, resetLoginInput]);
useEffect(() => {
if (!request || busy) return;
consumeRequest(request.id);
if (request.action === "login") void logIn();
else if (request.action === "create") void createAccount();
else playOffline();
}, [busy, consumeRequest, createAccount, logIn, playOffline, request]);
useEffect(() => subscribeControllerToken(({ token, repeat, pressed }) => {
if (pressed === false || isCapturingInput()) return;
const bindings = getInputPreferences().controller;
const command = INPUT_ACTIONS
.filter((entry) => entry.context === "menu" && controllerBindingMatches(bindings[entry.id], token, getControllerHeldTokens()))
.sort((left, right) => (bindings[right.id]?.length ?? 0) - (bindings[left.id]?.length ?? 0))[0]
?.id.replace(/-alt$/, "");
const direction = ["menu-up", "menu-down", "menu-left", "menu-right"].includes(command ?? "")
? command!.slice(5) as LoginKeyboardDirection
: null;
if (direction) moveSelection(direction);
else if (!repeat && command === "menu-confirm") pressSelected();
}), [moveSelection, pressSelected]);
return (
<DualDisplayFrame
contextLabel="Roster info"
contextLabel="Controller keyboard"
top={
<FrontSurface className="login-screen" ariaLabel="Healer Man login">
<BrandMark />
<div className="login-hero">
<div className="login-rune" aria-hidden="true"><i /><span>+</span><i /></div>
<p className="front-kicker">Your next expedition awaits</p>
<h1>The party needs<br /><em>a healer.</em></h1>
<p>Sign in to your cloud roster, create an online account, or head in completely offline.</p>
<div className="login-pill-row"><span>Controller ready</span><span>Thor dual-screen</span><span>PC compatible</span></div>
</div>
<header className="login-title">
<span className="login-title__crest" aria-hidden="true"><i>+</i></span>
<p>Answer the call</p>
<h1>Healer <em>Man</em></h1>
<small>Your next expedition awaits</small>
</header>
<form className="login-form" onSubmit={(event) => { event.preventDefault(); void logIn(); }}>
<header><span>Account gate</span><strong>Begin your expedition</strong><small>Online accounts synchronize characters through iwanttoheal.phenomrom.com.</small></header>
<div className="login-form__ornament" aria-hidden="true"><i /><span>+</span><i /></div>
<header><span>Account gateway</span><strong>Enter the realm</strong><small>Continue with your cloud roster or answer the call offline.</small></header>
<label htmlFor="login-username">Username</label>
<input
ref={usernameRef}
id="login-username"
className={controller.isSelected("username") ? "is-controller-selected" : ""}
className={activeField === "username" ? "is-login-field-active" : ""}
value={username}
onChange={(event) => setUsername(event.target.value)}
onFocus={() => controller.select("username")}
onFocus={() => setActiveField("username")}
autoComplete="username"
inputMode="text"
maxLength={20}
@@ -97,30 +127,31 @@ export function LoginScreen() {
/>
<label htmlFor="login-password">Password</label>
<input
ref={passwordRef}
id="login-password"
className={controller.isSelected("password") ? "is-controller-selected" : ""}
className={activeField === "password" ? "is-login-field-active" : ""}
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
onFocus={() => controller.select("password")}
onFocus={() => setActiveField("password")}
autoComplete="current-password"
maxLength={128}
placeholder="8 characters minimum"
/>
<div className="login-form__actions">
<ControllerButton controlId="login" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--primary" type="submit" disabled={Boolean(busy)}>
<span>{busy === "login" ? "Checking..." : "Login"}</span><small>Load your cloud roster</small>
<ControllerButton controlId="action-login" selectedId={selectedKeyId} select={selectKey} className="front-button front-button--primary" type="submit" disabled={Boolean(busy)}>
<span>{busy === "login" ? "Opening gateway..." : "Enter world"}</span><small>Load your cloud roster</small>
</ControllerButton>
<ControllerButton controlId="create-account" selectedId={controller.selectedId} select={controller.select} className="front-button" type="button" onClick={() => { void createAccount(); }} disabled={Boolean(busy)}>
<span>{busy === "create" ? "Creating..." : "Create account"}</span><small>Start a synchronized roster</small>
<ControllerButton controlId="action-create" selectedId={selectedKeyId} select={selectKey} className="front-button" type="button" onClick={() => { void createAccount(); }} disabled={Boolean(busy)}>
<span>{busy === "create" ? "Forging account..." : "Create account"}</span><small>Start a synchronized roster</small>
</ControllerButton>
<ControllerButton controlId="offline" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--quiet" type="button" onClick={playOffline} disabled={Boolean(busy)}>
<ControllerButton controlId="action-offline" selectedId={selectedKeyId} select={selectKey} className="front-button front-button--quiet" type="button" onClick={playOffline} disabled={Boolean(busy)}>
<span>Play offline</span><small>Skip account login</small>
</ControllerButton>
</div>
{notice && <p className="front-notice" role="status" aria-live="polite">{notice}</p>}
<footer><span><i /> Online roster available</span><small>Secure account gateway</small></footer>
</form>
<p className="login-lore">Gather your party. Keep them standing.</p>
<ControllerLegend />
</FrontSurface>
}
+80
View File
@@ -0,0 +1,80 @@
import type { CSSProperties } from "react";
import {
loginKeyboardRows,
type LoginField,
type LoginInputSnapshot,
type LoginKeyDefinition,
} from "../app/loginInputStore";
import type { CompanionCommand } from "../platform/dualScreenSync";
import { BrandMark, FrontSurface } from "./FrontSurface";
function passwordPreview(length: number): string {
if (!length) return "Password";
return `${"•".repeat(Math.min(length, 18))}${length > 18 ? "…" : ""}`;
}
function keyLabel(key: LoginKeyDefinition, activeField: LoginField): string {
if (key.kind === "field") return activeField === "username" ? "Password" : "Username";
return key.label;
}
export function LoginVirtualKeyboard({ snapshot, notice, send }: {
snapshot: LoginInputSnapshot;
notice: string;
send: (command: CompanionCommand) => void;
}) {
const rows = loginKeyboardRows(snapshot.keyboardMode);
const chooseField = (field: LoginField) => send({ name: "selectLoginField", field });
return (
<FrontSurface bottom className="companion-panel companion-login companion-login-keyboard" ariaLabel="Controller login keyboard">
<header><BrandMark compact /><b>CONTROLLER KEYBOARD</b></header>
<main>
<div className="login-keyboard-fields" role="group" aria-label="Login fields">
<button
type="button"
className={snapshot.activeField === "username" ? "is-active" : ""}
aria-pressed={snapshot.activeField === "username"}
onClick={() => chooseField("username")}
>
<small>Username</small><strong>{snapshot.username || "Select to enter"}</strong>
</button>
<button
type="button"
className={snapshot.activeField === "password" ? "is-active" : ""}
aria-pressed={snapshot.activeField === "password"}
onClick={() => chooseField("password")}
>
<small>Password</small><strong>{passwordPreview(snapshot.passwordLength)}</strong>
</button>
</div>
<div className="login-keyboard-grid" role="group" aria-label={`${snapshot.keyboardMode} virtual keyboard`}>
{rows.flat().map((key) => {
const isAction = ["login", "create", "offline"].includes(key.kind);
const unavailableForUsername = snapshot.activeField === "username"
&& key.kind === "character"
&& !/^[A-Za-z0-9_]$/.test(key.value ?? "");
return (
<button
key={key.id}
type="button"
className={`login-keyboard-key login-keyboard-key--${key.kind} ${snapshot.selectedKeyId === key.id ? "is-controller-selected" : ""} ${unavailableForUsername ? "is-unavailable" : ""}`}
style={{ gridColumn: `span ${key.span ?? 1}` } as CSSProperties}
aria-label={key.ariaLabel}
aria-current={snapshot.selectedKeyId === key.id ? "true" : undefined}
disabled={isAction && snapshot.busy !== null}
onClick={() => send({ name: "pressLoginKey", keyId: key.id })}
>
{keyLabel(key, snapshot.activeField)}
</button>
);
})}
</div>
</main>
<footer>
<span><b>D-Pad</b> Move &nbsp; <b>A</b> Type</span>
<strong>{snapshot.busy ? `${snapshot.busy === "create" ? "Creating account" : "Opening gateway"}…` : notice || `${snapshot.activeField === "username" ? "Username" : "Password"} selected`}</strong>
</footer>
</FrontSurface>
);
}
+23 -3
View File
@@ -89,15 +89,35 @@
.companion-feature-grid article strong { font-size: 13px; }
.companion-feature-grid article small { font-size: 11px; }
.companion-login > footer { font-size: 11px; }
.companion-login-keyboard.front-surface--bottom { overflow: hidden; }
.companion-login-keyboard > main { grid-template-rows: 52px minmax(0, 1fr); gap: 6px; padding: 6px 0; overflow: hidden; }
.login-keyboard-fields button { min-height: 52px; }
.login-keyboard-fields small { font-size: 9px; }
.login-keyboard-fields strong { font-size: 15px; }
.login-keyboard-key { font-size: 14px; }
.companion-login-keyboard > footer { min-height: 30px; font-size: 10px; }
.companion-login-keyboard > footer strong { max-width: 52%; font-size: 9px; }
}
@container (min-width: 700px) and (max-width: 1000px) {
.front-surface--top { padding: 12px; }
.front-surface--top :is(p, small, label, dt, dd) { font-size: max(11px, 0.7rem); }
.front-surface--top button { min-height: 44px; }
.login-hero h1 { font-size: 44px; }
.login-form { padding: 20px; }
.login-form h2 { font-size: 25px; }
.login-title { padding-top: 0; }
.login-title__crest { width: 34px; height: 34px; }
.login-title p { margin-top: 7px; font-size: 6px; }
.login-title h1 { font-size: 38px; }
.login-title small { display: none; }
.login-screen { grid-template-rows: minmax(92px, 1fr) auto 24px; }
.login-form { width: min(560px, calc(100% - 28px)); padding: 16px 20px 12px; }
.login-form header { margin-bottom: 7px; }
.login-form header strong { font-size: 23px; }
.login-form > label { margin-top: 5px; }
.login-form > input { height: 38px; }
.login-form__actions { grid-template-columns: 1.15fr 1fr 1fr; margin-top: 9px; }
.login-form__actions .front-button--primary { grid-column: auto; }
.login-form__actions .front-button small { display: none; }
.login-form footer { display: none; }
.character-select-screen { grid-template-columns: 220px minmax(0, 1fr); }
.roster-header, .character-select-screen > .controller-legend { grid-column: 1 / 3; }
.roster-details { display: none; }