feat(ui): lobby site-style sidebar + profile screen (#47)

- Wrap lobby and profile in a shared `lobby-shell.svelte` chrome:
  page-list sidebar (Overview/Profile) and a top "Player-xxxx"
  identity strip mirroring the project site's monospace look.
- Strip the legacy `lobby.title`, device-session-id `<code>`, and
  `lobby.greeting` paragraph; the identity strip both names the user
  and opens the profile editor.
- Add a top-level `profile` AppScreen with a three-field form
  (`display_name`, `preferred_language`, `time_zone`) backed by a new
  `src/api/account.ts` wrapper around `user.account.get`,
  `user.profile.update`, and `user.settings.update`. Saving switches
  the active i18n locale in-place when the new preferred language is
  one the UI ships translations for.
- Update e2e fixture + auth-flow / lobby-flow specs to use the new
  `lobby-account-name` testid and wait for the loaded identity before
  releasing pending `SubscribeEvents` (webkit revocation race). New
  `profile-screen.spec.ts` covers navigation, edit-save, and cancel.
- Sync `ui/docs/lobby.md` and `ui/docs/navigation.md` to the new
  layout.

Closes #47
This commit is contained in:
Ilia Denisov
2026-05-26 13:42:10 +02:00
parent b03993fcb1
commit 2ecdecad1e
15 changed files with 1122 additions and 132 deletions
+7 -2
View File
@@ -22,7 +22,7 @@
import { pushState, replaceState } from "$app/navigation";
export type AppScreen = "login" | "lobby" | "lobby-create" | "game";
export type AppScreen = "login" | "lobby" | "lobby-create" | "profile" | "game";
export type GameView =
| "map"
@@ -51,6 +51,7 @@ const APP_SCREENS: readonly AppScreen[] = [
"login",
"lobby",
"lobby-create",
"profile",
"game",
];
const GAME_VIEWS: readonly GameView[] = [
@@ -183,7 +184,11 @@ class AppScreenStore {
#syncHistory(): void {
if (typeof window === "undefined") return;
const state: App.PageState = { screen: this.#screen, gameId: this.#gameId };
if (this.#screen === "game" || this.#screen === "lobby-create") {
if (
this.#screen === "game" ||
this.#screen === "lobby-create" ||
this.#screen === "profile"
) {
pushState("", state);
} else {
replaceState("", state);
+18 -3
View File
@@ -50,11 +50,11 @@ const en = {
"login.device_key_not_ready":
"device key is not ready, please reload the page",
"lobby.title": "you are logged in",
"lobby.device_session_id_label": "device session id",
"lobby.greeting": "hello, {name}!",
"lobby.account_loading": "loading account…",
"lobby.logout": "logout",
"lobby.nav.aria_label": "lobby pages",
"lobby.nav.overview": "Overview",
"lobby.nav.profile": "Profile",
"lobby.section.my_games": "my games",
"lobby.section.invitations": "pending invitations",
"lobby.section.applications": "my applications",
@@ -103,6 +103,21 @@ const en = {
"lobby.error.internal_error": "internal server error",
"lobby.error.unknown": "{message}",
"profile.title": "Profile",
"profile.loading": "loading account…",
"profile.field.user_name": "username",
"profile.field.email": "email",
"profile.field.display_name": "display name",
"profile.field.preferred_language": "preferred language",
"profile.field.time_zone": "time zone",
"profile.hint.display_name": "shown wherever Galaxy needs a friendlier name than the username handle. Leave empty to fall back to the username.",
"profile.hint.time_zone": "IANA time-zone name (e.g. Europe/Moscow, America/New_York). The placeholder shows your browser's current zone.",
"profile.save": "save",
"profile.saving": "saving…",
"profile.cancel": "cancel",
"profile.error.language_required": "language must not be empty",
"profile.error.time_zone_required": "time zone must not be empty",
"game.shell.unknown": "?",
"game.shell.connection.online": "online",
"game.shell.connection.reconnecting": "reconnecting…",
+18 -3
View File
@@ -51,11 +51,11 @@ const ru: Record<keyof typeof en, string> = {
"login.device_key_not_ready":
"ключ устройства ещё не готов, перезагрузите страницу",
"lobby.title": "вы вошли в систему",
"lobby.device_session_id_label": "идентификатор сессии устройства",
"lobby.greeting": "здравствуйте, {name}!",
"lobby.account_loading": "загрузка профиля…",
"lobby.logout": "выйти",
"lobby.nav.aria_label": "разделы лобби",
"lobby.nav.overview": "Обзор",
"lobby.nav.profile": "Профиль",
"lobby.section.my_games": "мои игры",
"lobby.section.invitations": "ожидающие приглашения",
"lobby.section.applications": "мои заявки",
@@ -104,6 +104,21 @@ const ru: Record<keyof typeof en, string> = {
"lobby.error.internal_error": "внутренняя ошибка сервера",
"lobby.error.unknown": "{message}",
"profile.title": "Профиль",
"profile.loading": "загрузка профиля…",
"profile.field.user_name": "идентификатор",
"profile.field.email": "электронная почта",
"profile.field.display_name": "отображаемое имя",
"profile.field.preferred_language": "язык интерфейса",
"profile.field.time_zone": "часовой пояс",
"profile.hint.display_name": "показывается там, где нужно более «человеческое» имя, чем системный идентификатор. Пустое значение — вернётся к идентификатору.",
"profile.hint.time_zone": "имя часового пояса IANA (например, Europe/Moscow, America/New_York). В подсказке — текущий пояс твоего браузера.",
"profile.save": "сохранить",
"profile.saving": "сохраняем…",
"profile.cancel": "отмена",
"profile.error.language_required": "язык не должен быть пустым",
"profile.error.time_zone_required": "часовой пояс не должен быть пустым",
"game.shell.unknown": "?",
"game.shell.connection.online": "онлайн",
"game.shell.connection.reconnecting": "переподключение…",
+42 -90
View File
@@ -17,8 +17,7 @@
type GameSummary,
type InviteSummary,
} from "../../api/lobby";
import { ByteBuffer } from "flatbuffers";
import { AccountResponse } from "../../proto/galaxy/fbs/user";
import { AccountError, getMyAccount } from "../../api/account";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import {
SyntheticReportError,
@@ -27,10 +26,10 @@
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { loadCore } from "../../platform/core/index";
import { session } from "$lib/session-store.svelte";
import { Builder } from "flatbuffers";
import { GetMyAccountRequest } from "../../proto/galaxy/fbs/user";
import LobbyShell from "./lobby-shell.svelte";
let displayName: string | null = $state(null);
let displayName = $state("");
let userName = $state("");
let configError: string | null = $state(null);
let listsLoading = $state(true);
let lobbyError: string | null = $state(null);
@@ -51,10 +50,6 @@
let client: GalaxyClient | null = null;
async function logout(): Promise<void> {
await session.signOut("user");
}
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
return new Uint8Array(digest);
@@ -163,24 +158,19 @@
}
}
async function loadGreeting(c: GalaxyClient): Promise<void> {
const builder = new Builder(32);
GetMyAccountRequest.startGetMyAccountRequest(builder);
builder.finish(GetMyAccountRequest.endGetMyAccountRequest(builder));
const result = await c.executeCommand("user.account.get", builder.asUint8Array());
if (result.resultCode !== "ok") {
return;
async function loadIdentity(c: GalaxyClient): Promise<void> {
try {
const account = await getMyAccount(c);
displayName = account.displayName;
userName = account.userName;
} catch (err) {
if (err instanceof AccountError) {
// Stay quiet: the lobby still works without a name; the
// identity strip falls back to a loading placeholder.
return;
}
throw err;
}
const response = AccountResponse.getRootAsAccountResponse(
new ByteBuffer(result.payloadBytes),
);
const account = response.account();
if (account === null) {
return;
}
const display = account.displayName();
const userName = account.userName();
displayName = display && display.length > 0 ? display : userName;
}
function gotoCreate(): void {
@@ -260,7 +250,7 @@
deviceSessionId: session.deviceSessionId,
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
});
loadGreeting(client).catch(() => {});
loadIdentity(client).catch(() => {});
await refreshAll();
} catch (err) {
lobbyError = describeLobbyError(err);
@@ -269,24 +259,7 @@
});
</script>
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
<main id="main-content" tabindex="-1">
<header>
<h1>{i18n.t("lobby.title")}</h1>
<p>
{i18n.t("lobby.device_session_id_label")}:
<code data-testid="device-session-id">{session.deviceSessionId ?? ""}</code>
</p>
{#if displayName !== null}
<p data-testid="account-greeting">
{i18n.t("lobby.greeting", { name: displayName })}
</p>
{/if}
<button onclick={logout} data-testid="lobby-logout">
{i18n.t("lobby.logout")}
</button>
</header>
<LobbyShell activePage="overview" {displayName} {userName}>
{#if configError !== null}
<p role="alert" data-testid="account-error">{configError}</p>
{:else if lobbyError !== null}
@@ -483,38 +456,16 @@
</ul>
{/if}
</section>
</main>
</LobbyShell>
<style>
main {
padding: 1.5rem 1rem;
max-width: 32rem;
margin: 0 auto;
font-family: system-ui, sans-serif;
}
header {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
header h1 {
margin: 0;
}
header button {
align-self: flex-start;
}
section {
margin-bottom: 2rem;
margin-bottom: var(--space-6);
}
section h2 {
font-size: 1.1rem;
margin: 0 0 0.75rem;
font-size: var(--text-lg);
margin: 0 0 var(--space-3);
}
.card-list {
@@ -523,16 +474,16 @@
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: var(--space-2);
}
.card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
gap: var(--space-1);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: 0.4rem;
border-radius: var(--radius-md);
background: var(--color-surface-raised);
text-align: left;
font: inherit;
@@ -552,54 +503,55 @@
.meta {
color: var(--color-text-muted);
font-size: 0.9rem;
font-size: var(--text-sm);
}
.status {
align-self: flex-start;
padding: 0.1rem 0.5rem;
border-radius: 999px;
padding: 0.1rem var(--space-2);
border-radius: var(--radius-pill);
background: var(--color-surface-raised);
font-size: 0.8rem;
font-size: var(--text-xs);
}
.actions {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
gap: var(--space-2);
margin-top: var(--space-1);
}
form {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
gap: var(--space-2);
margin-top: var(--space-2);
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: var(--space-1);
}
input[type="text"] {
font-size: 1rem;
padding: 0.4rem 0.5rem;
font: inherit;
font-size: var(--text-md);
padding: var(--space-1) var(--space-2);
}
.synthetic-loader {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
border: 1px dashed var(--color-text-muted);
border-radius: 0.4rem;
border-radius: var(--radius-md);
background: var(--color-surface-raised);
cursor: pointer;
font: inherit;
}
.synthetic-loader input[type="file"] {
font-size: 0.9rem;
font-size: var(--text-sm);
}
</style>
@@ -0,0 +1,220 @@
<!--
Shared chrome for the post-login "site"-style pages — the lobby
landing and the editable profile. Renders a left page-list sidebar
(mirroring the project site's VitePress layout) plus a top identity
strip ("Player-xxxx" → opens profile, logout). Children fill the
right-hand column. Pages mark themselves active via `activePage`.
-->
<script lang="ts">
import type { Snippet } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte";
type Page = "overview" | "profile";
interface Props {
activePage: Page;
displayName: string;
userName: string;
children: Snippet;
}
let { activePage, displayName, userName, children }: Props = $props();
const PAGES: ReadonlyArray<{ id: Page; labelKey: "lobby.nav.overview" | "lobby.nav.profile"; screen: "lobby" | "profile" }> = [
{ id: "overview", labelKey: "lobby.nav.overview", screen: "lobby" },
{ id: "profile", labelKey: "lobby.nav.profile", screen: "profile" },
];
let identityLabel = $derived.by(() => {
const trimmed = displayName.trim();
if (trimmed.length > 0) return trimmed;
if (userName.length > 0) return userName;
return i18n.t("lobby.account_loading");
});
function gotoPage(screen: "lobby" | "profile"): void {
if (appScreen.screen !== screen) {
appScreen.go(screen);
}
}
function gotoProfile(): void {
gotoPage("profile");
}
async function logout(): Promise<void> {
await session.signOut("user");
}
</script>
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
<div class="layout">
<header class="topbar">
<button
type="button"
class="identity"
onclick={gotoProfile}
data-testid="lobby-account-name"
>
{identityLabel}
</button>
<button type="button" class="logout" onclick={logout} data-testid="lobby-logout">
{i18n.t("lobby.logout")}
</button>
</header>
<div class="body">
<nav class="sidebar" aria-label={i18n.t("lobby.nav.aria_label")}>
<ul>
{#each PAGES as page (page.id)}
<li>
<button
type="button"
class="nav-link"
class:active={activePage === page.id}
aria-current={activePage === page.id ? "page" : undefined}
onclick={() => gotoPage(page.screen)}
data-testid="lobby-nav-{page.id}"
>
{i18n.t(page.labelKey)}
</button>
</li>
{/each}
</ul>
</nav>
<main id="main-content" tabindex="-1" class="content">
{@render children()}
</main>
</div>
</div>
<style>
.layout {
display: flex;
flex-direction: column;
min-height: 100vh;
font-family: var(--font-mono);
color: var(--color-text);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-5);
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface);
}
.identity {
font: inherit;
font-size: var(--text-md);
color: var(--color-text);
background: transparent;
border: none;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 0.2em;
}
.identity:hover {
background: var(--color-surface-hover);
}
.logout {
font: inherit;
font-size: var(--text-sm);
padding: var(--space-1) var(--space-3);
background: transparent;
color: inherit;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.logout:hover {
background: var(--color-surface-hover);
}
.body {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
.sidebar {
flex: 0 0 14rem;
border-right: 1px solid var(--color-border-subtle);
padding: var(--space-4) var(--space-3);
background: var(--color-surface);
}
.sidebar ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.nav-link {
display: block;
width: 100%;
text-align: left;
font: inherit;
font-size: var(--text-base);
color: var(--color-text-muted);
background: transparent;
border: none;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
cursor: pointer;
}
.nav-link:hover {
background: var(--color-surface-hover);
color: var(--color-text);
}
.nav-link.active {
color: var(--color-accent);
background: var(--color-accent-subtle);
font-weight: var(--weight-medium);
}
.content {
flex: 1 1 auto;
padding: var(--space-5) var(--space-6);
max-width: 48rem;
}
@media (max-width: 640px) {
.body {
flex-direction: column;
}
.sidebar {
flex: 0 0 auto;
border-right: none;
border-bottom: 1px solid var(--color-border-subtle);
padding: var(--space-2) var(--space-3);
}
.sidebar ul {
flex-direction: row;
gap: var(--space-2);
overflow-x: auto;
}
.nav-link {
white-space: nowrap;
padding: var(--space-1) var(--space-3);
}
.content {
padding: var(--space-4);
max-width: none;
}
}
</style>
@@ -0,0 +1,333 @@
<script lang="ts">
// Profile screen: a top-level appScreen (peer of `lobby` and
// `lobby-create`). Loads the caller's account aggregate, lets the
// user edit `display_name`, `preferred_language`, and `time_zone`,
// and posts the changes through `user.profile.update` /
// `user.settings.update`. Returns to the lobby on save or cancel.
import { onMount } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../api/galaxy-client";
import {
AccountError,
getMyAccount,
updateMyProfile,
updateMySettings,
type Account,
} from "../../api/account";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import {
i18n,
SUPPORTED_LOCALES,
type Locale,
type TranslationKey,
} from "$lib/i18n/index.svelte";
import { loadCore } from "../../platform/core/index";
import { session } from "$lib/session-store.svelte";
import LobbyShell from "./lobby-shell.svelte";
let loaded: Account | null = $state(null);
let displayNameInput = $state("");
let preferredLanguageInput = $state("");
let timeZoneInput = $state("");
let loadError: string | null = $state(null);
let configError: string | null = $state(null);
let saveError: string | null = $state(null);
let saving = $state(false);
let client: GalaxyClient | null = null;
const SUPPORTED_LOCALE_CODES: ReadonlySet<string> = new Set(
SUPPORTED_LOCALES.map((entry) => entry.code),
);
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
return new Uint8Array(digest);
}
function describe(err: unknown): string {
if (err instanceof AccountError) {
const key = `lobby.error.${err.code}` as TranslationKey;
const translated = i18n.t(key);
if (translated !== key) return translated;
return i18n.t("lobby.error.unknown", { message: err.message });
}
return err instanceof Error ? err.message : "request failed";
}
function browserTimeZone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? "";
} catch {
return "";
}
}
function applyAccount(account: Account): void {
loaded = account;
displayNameInput = account.displayName;
preferredLanguageInput = account.preferredLanguage;
timeZoneInput = account.timeZone;
}
async function loadAccount(c: GalaxyClient): Promise<void> {
try {
applyAccount(await getMyAccount(c));
} catch (err) {
loadError = describe(err);
}
}
async function save(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (client === null || loaded === null || saving) return;
const trimmedDisplay = displayNameInput.trim();
const trimmedLanguage = preferredLanguageInput.trim();
const trimmedZone = timeZoneInput.trim();
if (trimmedLanguage === "") {
saveError = i18n.t("profile.error.language_required");
return;
}
if (trimmedZone === "") {
saveError = i18n.t("profile.error.time_zone_required");
return;
}
saving = true;
saveError = null;
try {
let next: Account = loaded;
if (trimmedDisplay !== loaded.displayName) {
next = await updateMyProfile(client, trimmedDisplay);
}
if (
trimmedLanguage !== loaded.preferredLanguage ||
trimmedZone !== loaded.timeZone
) {
next = await updateMySettings(client, trimmedLanguage, trimmedZone);
}
applyAccount(next);
// When the user picks a language the UI supports, switch the
// active locale immediately so the rest of the session sees
// the change without a reload. Unsupported BCP 47 codes are
// saved on the account but leave the active locale alone.
if (SUPPORTED_LOCALE_CODES.has(next.preferredLanguage)) {
i18n.setLocale(next.preferredLanguage as Locale);
}
appScreen.go("lobby");
} catch (err) {
saveError = describe(err);
} finally {
saving = false;
}
}
function cancel(): void {
appScreen.go("lobby");
}
onMount(async () => {
if (
session.keypair === null ||
session.deviceSessionId === null ||
GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
) {
if (GATEWAY_RESPONSE_PUBLIC_KEY.length === 0) {
configError = "VITE_GATEWAY_RESPONSE_PUBLIC_KEY is not configured";
}
return;
}
const keypair = session.keypair;
try {
const core = await loadCore();
client = new GalaxyClient({
core,
edge: createGatewayClient(gatewayRpcBaseUrl()),
signer: (canonical) => keypair.sign(canonical),
sha256,
deviceSessionId: session.deviceSessionId,
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
});
await loadAccount(client);
} catch (err) {
loadError = describe(err);
}
});
</script>
<LobbyShell
activePage="profile"
displayName={loaded?.displayName ?? ""}
userName={loaded?.userName ?? ""}
>
<h1>{i18n.t("profile.title")}</h1>
{#if configError !== null}
<p role="alert" data-testid="profile-config-error">{configError}</p>
{:else if loaded === null && loadError === null}
<p role="status" data-testid="profile-loading">{i18n.t("profile.loading")}</p>
{:else if loadError !== null}
<p role="alert" data-testid="profile-load-error">{loadError}</p>
{:else if loaded !== null}
<dl class="identity" data-testid="profile-identity">
<dt>{i18n.t("profile.field.user_name")}</dt>
<dd>{loaded.userName}</dd>
<dt>{i18n.t("profile.field.email")}</dt>
<dd>{loaded.email}</dd>
</dl>
<form onsubmit={save} data-testid="profile-form">
<label>
<span>{i18n.t("profile.field.display_name")}</span>
<input
type="text"
bind:value={displayNameInput}
autocomplete="nickname"
data-testid="profile-display-name"
/>
<small>{i18n.t("profile.hint.display_name")}</small>
</label>
<label>
<span>{i18n.t("profile.field.preferred_language")}</span>
<select
bind:value={preferredLanguageInput}
data-testid="profile-preferred-language"
>
{#each SUPPORTED_LOCALES as entry (entry.code)}
<option value={entry.code}>{entry.nativeName}</option>
{/each}
{#if !SUPPORTED_LOCALE_CODES.has(preferredLanguageInput) && preferredLanguageInput !== ""}
<!--
Backend stores arbitrary BCP 47 tags, but the UI only
ships translations for the codes in `SUPPORTED_LOCALES`.
Preserve the saved value so saving the form unchanged
does not silently switch it.
-->
<option value={preferredLanguageInput}>{preferredLanguageInput}</option>
{/if}
</select>
</label>
<label>
<span>{i18n.t("profile.field.time_zone")}</span>
<input
type="text"
bind:value={timeZoneInput}
placeholder={browserTimeZone()}
autocomplete="off"
data-testid="profile-time-zone"
/>
<small>{i18n.t("profile.hint.time_zone")}</small>
</label>
{#if saveError !== null}
<p role="alert" data-testid="profile-save-error">{saveError}</p>
{/if}
<div class="actions">
<button type="submit" disabled={saving} data-testid="profile-save">
{saving ? i18n.t("profile.saving") : i18n.t("profile.save")}
</button>
<button
type="button"
onclick={cancel}
disabled={saving}
data-testid="profile-cancel"
>
{i18n.t("profile.cancel")}
</button>
</div>
</form>
{/if}
</LobbyShell>
<style>
h1 {
font-size: var(--text-xl);
margin: 0 0 var(--space-4);
}
.identity {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--space-1) var(--space-4);
margin: 0 0 var(--space-5);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
}
.identity dt {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.identity dd {
margin: 0;
font-size: var(--text-base);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
label {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
label > span {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
input[type="text"],
select {
font: inherit;
font-size: var(--text-md);
padding: var(--space-1) var(--space-2);
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
small {
color: var(--color-text-muted);
font-size: var(--text-xs);
}
.actions {
display: flex;
gap: var(--space-3);
margin-top: var(--space-2);
}
.actions button {
font: inherit;
font-size: var(--text-md);
padding: var(--space-2) var(--space-4);
background: var(--color-surface-raised);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.actions button[type="submit"] {
background: var(--color-accent);
color: var(--color-accent-contrast);
border-color: var(--color-accent);
}
.actions button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
</style>