Add configurable grid branding

This commit is contained in:
Jage9
2026-03-09 01:21:02 -04:00
parent 25a71e0a77
commit 6aaa49bed3
12 changed files with 83 additions and 6 deletions

View File

@@ -7,7 +7,7 @@
</head> </head>
<body> <body>
<main class="app"> <main class="app">
<h1>Chat Grid</h1> <h1 id="gridTitle">Chat Grid</h1>
<div id="connectionStatus" role="status" aria-live="polite" aria-atomic="true"></div> <div id="connectionStatus" role="status" aria-live="polite" aria-atomic="true"></div>
<section id="loginView" class="auth-panel"> <section id="loginView" class="auth-panel">
<h2>Login</h2> <h2>Login</h2>

View File

@@ -1,6 +1,6 @@
// Maintainer-controlled web client version metadata. // Maintainer-controlled web client version metadata.
window.CHGRID_RELEASE_VERSION = "0.1.0"; window.CHGRID_RELEASE_VERSION = "0.1.0";
window.CHGRID_BUILD_REVISION = "R346"; window.CHGRID_BUILD_REVISION = "R347";
window.CHGRID_WEB_VERSION = `${window.CHGRID_RELEASE_VERSION} ${window.CHGRID_BUILD_REVISION}`; window.CHGRID_WEB_VERSION = `${window.CHGRID_RELEASE_VERSION} ${window.CHGRID_BUILD_REVISION}`;
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit"; window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -98,6 +98,7 @@ declare global {
} }
type Dom = { type Dom = {
gridTitle: HTMLElement;
connectionStatus: HTMLElement; connectionStatus: HTMLElement;
appVersion: HTMLElement; appVersion: HTMLElement;
loginView: HTMLElement; loginView: HTMLElement;
@@ -135,6 +136,7 @@ type Dom = {
}; };
const dom: Dom = { const dom: Dom = {
gridTitle: requiredById('gridTitle'),
connectionStatus: requiredById('connectionStatus'), connectionStatus: requiredById('connectionStatus'),
appVersion: requiredById('appVersion'), appVersion: requiredById('appVersion'),
loginView: requiredById('loginView'), loginView: requiredById('loginView'),
@@ -225,6 +227,9 @@ const APP_VERSION = String(
dom.appVersion.textContent = APP_VERSION dom.appVersion.textContent = APP_VERSION
? `Another AI experiment with Jage. Version ${APP_VERSION}` ? `Another AI experiment with Jage. Version ${APP_VERSION}`
: 'Another AI experiment with Jage. Version unknown'; : 'Another AI experiment with Jage. Version unknown';
const DEFAULT_GRID_NAME = 'Chat Grid';
const DEFAULT_WELCOME_MESSAGE =
'Welcome to the Chat Grid, your immersive audio playground. Configure your audio, then Log in or register to join the grid.';
const APP_BASE_URL = import.meta.env.BASE_URL || '/'; const APP_BASE_URL = import.meta.env.BASE_URL || '/';
/** Resolves an app-relative path against the configured Vite base path. */ /** Resolves an app-relative path against the configured Vite base path. */
function withBase(path: string): string { function withBase(path: string): string {
@@ -259,6 +264,8 @@ let lastFocusedElement: Element | null = null;
let lastAnnouncementText = ''; let lastAnnouncementText = '';
let lastAnnouncementAt = 0; let lastAnnouncementAt = 0;
let outputMode = settings.loadOutputMode(); let outputMode = settings.loadOutputMode();
let activeGridName = DEFAULT_GRID_NAME;
let activeWelcomeMessage = DEFAULT_WELCOME_MESSAGE;
const messageBuffer: string[] = []; const messageBuffer: string[] = [];
let messageCursor = -1; let messageCursor = -1;
const radioRuntime = new RadioStationRuntime(audio, getItemSpatialConfig); const radioRuntime = new RadioStationRuntime(audio, getItemSpatialConfig);
@@ -360,6 +367,17 @@ void loadHelp();
void itemBehaviorRegistry.initialize(); void itemBehaviorRegistry.initialize();
void loadChangelog(); void loadChangelog();
function applyGridBranding(gridName: string | null | undefined, welcomeMessage: string | null | undefined): void {
const nextGridName = String(gridName ?? '').trim() || DEFAULT_GRID_NAME;
const nextWelcomeMessage = String(welcomeMessage ?? '').trim() || DEFAULT_WELCOME_MESSAGE;
activeGridName = nextGridName;
activeWelcomeMessage = nextWelcomeMessage;
document.title = nextGridName;
dom.gridTitle.textContent = nextGridName;
dom.focusGridButton.textContent = nextGridName;
dom.canvas.setAttribute('aria-label', `${nextGridName}, press question mark for help.`);
}
/** Fetches a required DOM element and casts it to the requested element type. */ /** Fetches a required DOM element and casts it to the requested element type. */
function requiredById<T extends HTMLElement>(id: string): T { function requiredById<T extends HTMLElement>(id: string): T {
const found = document.getElementById(id); const found = document.getElementById(id);
@@ -405,6 +423,7 @@ const adminController = createAdminController({
signalingSend: (message) => signaling.send(message), signalingSend: (message) => signaling.send(message),
announceMenuEntry, announceMenuEntry,
updateStatus, updateStatus,
getGridName: () => activeGridName,
sfxUiBlip: () => audio.sfxUiBlip(), sfxUiBlip: () => audio.sfxUiBlip(),
sfxUiCancel: () => audio.sfxUiCancel(), sfxUiCancel: () => audio.sfxUiCancel(),
applyTextInputEdit, applyTextInputEdit,
@@ -1345,6 +1364,7 @@ function sendAuthRequest(): void {
/** Handles server auth-required prompts prior to world welcome. */ /** Handles server auth-required prompts prior to world welcome. */
function handleAuthRequired(message: Extract<IncomingMessage, { type: 'auth_required' }>): void { function handleAuthRequired(message: Extract<IncomingMessage, { type: 'auth_required' }>): void {
applyGridBranding(message.gridName, message.welcomeMessage);
authController.handleAuthRequired(message); authController.handleAuthRequired(message);
} }
@@ -1563,6 +1583,7 @@ async function onSignalingMessage(message: IncomingMessage): Promise<void> {
let connectedAnnouncement: string | null = null; let connectedAnnouncement: string | null = null;
let playSelfLoginSound = false; let playSelfLoginSound = false;
if (message.type === 'welcome') { if (message.type === 'welcome') {
applyGridBranding(message.serverInfo?.gridName, message.serverInfo?.welcomeMessage);
const uiAdminActions = const uiAdminActions =
(message.uiDefinitions as { adminMenu?: { actions?: Array<{ id: string; label: string }> } } | undefined)?.adminMenu?.actions ?? (message.uiDefinitions as { adminMenu?: { actions?: Array<{ id: string; label: string }> } } | undefined)?.adminMenu?.actions ??
message.auth?.adminMenuActions; message.auth?.adminMenuActions;
@@ -2676,5 +2697,5 @@ updateDeviceSummary();
setConnectionStatus( setConnectionStatus(
isVersionReloadedSession() isVersionReloadedSession()
? 'Client updated, please reconnect.' ? 'Client updated, please reconnect.'
: 'Welcome to the Chat Grid, your immersive audio playground. Configure your audio, then Log in or register to join the grid.', : activeWelcomeMessage,
); );

View File

@@ -56,6 +56,8 @@ export const welcomeMessageSchema = z.object({
.object({ .object({
instanceId: z.string(), instanceId: z.string(),
version: z.string().optional(), version: z.string().optional(),
gridName: z.string().optional(),
welcomeMessage: z.string().optional(),
}) })
.optional(), .optional(),
auth: z auth: z
@@ -136,6 +138,8 @@ export const welcomeMessageSchema = z.object({
export const authRequiredSchema = z.object({ export const authRequiredSchema = z.object({
type: z.literal('auth_required'), type: z.literal('auth_required'),
message: z.string(), message: z.string(),
gridName: z.string().optional(),
welcomeMessage: z.string().optional(),
authPolicy: z authPolicy: z
.object({ .object({
usernameMinLength: z.number().int().positive(), usernameMinLength: z.number().int().positive(),

View File

@@ -24,6 +24,7 @@ type UiBindingsDeps = {
openSettings: () => void; openSettings: () => void;
closeSettings: () => void; closeSettings: () => void;
updateStatus: (message: string) => void; updateStatus: (message: string) => void;
getGridName: () => string;
sfxUiBlip: () => void; sfxUiBlip: () => void;
setupLocalMedia: (audioDeviceId: string) => Promise<void>; setupLocalMedia: (audioDeviceId: string) => Promise<void>;
setPreferredInput: (id: string, name: string) => void; setPreferredInput: (id: string, name: string) => void;
@@ -46,7 +47,7 @@ export function setupUiHandlers(deps: UiBindingsDeps): void {
deps.dom.focusGridButton.addEventListener('click', () => { deps.dom.focusGridButton.addEventListener('click', () => {
deps.dom.canvas.focus(); deps.dom.canvas.focus();
deps.updateStatus('Chat Grid focused.'); deps.updateStatus(`${deps.getGridName()} focused.`);
deps.sfxUiBlip(); deps.sfxUiBlip();
}); });

View File

@@ -95,6 +95,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
- `permissions` - `permissions`
- `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`) - `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`)
- `auth_required.authPolicy`: server auth limits advertised before login/register submit. - `auth_required.authPolicy`: server auth limits advertised before login/register submit.
- `auth_required.gridName` / `auth_required.welcomeMessage`: server-owned pre-login branding values.
- `auth_result.authPolicy`: server auth limits echoed on auth success/failure responses. - `auth_result.authPolicy`: server auth limits echoed on auth success/failure responses.
- `auth_result.sessionToken` is used by the client to call the instance-scoped HTTP endpoint `GET <base_path>auth/session/set` (`Authorization: Bearer <sessionToken>`, `X-Chgrid-Auth-Client: 1`) so the server can issue an instance-scoped `HttpOnly` session cookie. - `auth_result.sessionToken` is used by the client to call the instance-scoped HTTP endpoint `GET <base_path>auth/session/set` (`Authorization: Bearer <sessionToken>`, `X-Chgrid-Auth-Client: 1`) so the server can issue an instance-scoped `HttpOnly` session cookie.
- `welcome.worldConfig.gridSize`: server-authoritative grid size used by clients for bounds/drawing. - `welcome.worldConfig.gridSize`: server-authoritative grid size used by clients for bounds/drawing.
@@ -104,6 +105,8 @@ This is a behavior guide for packet semantics beyond raw schemas.
- `welcome.serverInfo`: server process identity/version metadata: - `welcome.serverInfo`: server process identity/version metadata:
- `instanceId`: unique id generated at server startup - `instanceId`: unique id generated at server startup
- `version`: server package version (or `unknown` fallback) - `version`: server package version (or `unknown` fallback)
- `gridName`: server-owned user-facing grid name
- `welcomeMessage`: server-owned pre-login welcome string
- `welcome.uiDefinitions`: server-provided item UI definitions: - `welcome.uiDefinitions`: server-provided item UI definitions:
- `itemTypeOrder`: add-item menu order - `itemTypeOrder`: add-item menu order
- `itemTypes[].tooltip`: item-level tooltip/help text - `itemTypes[].tooltip`: item-level tooltip/help text

View File

@@ -7,6 +7,7 @@
3. Client connects signaling websocket from the configured app origin. 3. Client connects signaling websocket from the configured app origin.
4. Server accepts the socket only on the configured instance websocket path and when the browser `Origin` matches `CHGRID_HOST_ORIGIN`, then attempts cookie-based session resume from the instance-scoped websocket handshake cookie. 4. Server accepts the socket only on the configured instance websocket path and when the browser `Origin` matches `CHGRID_HOST_ORIGIN`, then attempts cookie-based session resume from the instance-scoped websocket handshake cookie.
5. If resume does not authenticate, server sends `auth_required`. 5. If resume does not authenticate, server sends `auth_required`.
- includes `gridName` and `welcomeMessage` for pre-login branding.
- includes `authPolicy` limits for username/password. - includes `authPolicy` limits for username/password.
6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller). 6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller).
7. Server sends `auth_result`. 7. Server sends `auth_result`.
@@ -19,7 +20,7 @@
- applies `welcome.worldConfig.movementTickMs` as movement pacing guidance - applies `welcome.worldConfig.movementTickMs` as movement pacing guidance
- applies `welcome.worldConfig.movementMaxStepsPerTick` for movement-rate parity - applies `welcome.worldConfig.movementMaxStepsPerTick` for movement-rate parity
- uses `welcome.player` as authoritative starting position (restored from server-side account state when available) - uses `welcome.player` as authoritative starting position (restored from server-side account state when available)
- records `welcome.serverInfo` (`instanceId`, `version`) for restart detection - records `welcome.serverInfo` (`instanceId`, `version`, `gridName`, `welcomeMessage`) for restart detection and client branding
- if `welcome.serverInfo.version` differs from running client version, auto-reloads the page - if `welcome.serverInfo.version` differs from running client version, auto-reloads the page
- applies `welcome.uiDefinitions` for item menus/properties/options, server-backed command metadata, item-management metadata, and admin menu labels/order - applies `welcome.uiDefinitions` for item menus/properties/options, server-backed command metadata, item-management metadata, and admin menu labels/order
- sends initial `update_position` echo from server-assigned starting tile - sends initial `update_position` echo from server-assigned starting tile

View File

@@ -14,6 +14,11 @@ class ServerConfigSection(BaseModel):
bind_ip: str = "127.0.0.1" bind_ip: str = "127.0.0.1"
port: int = 8765 port: int = 8765
base_path: str = "/" base_path: str = "/"
grid_name: str = "Chat Grid"
welcome_message: str = (
"Welcome to the Chat Grid, your immersive audio playground. "
"Configure your audio, then Log in or register to join the grid."
)
class NetworkConfigSection(BaseModel): class NetworkConfigSection(BaseModel):

View File

@@ -242,6 +242,8 @@ class AuthRequiredPacket(BasePacket):
type: Literal["auth_required"] type: Literal["auth_required"]
message: str message: str
authPolicy: dict | None = None authPolicy: dict | None = None
gridName: str | None = None
welcomeMessage: str | None = None
class AuthResultPacket(BasePacket): class AuthResultPacket(BasePacket):

View File

@@ -165,6 +165,11 @@ class SignalingServer:
state_save_max_delay_ms: int = 1000, state_save_max_delay_ms: int = 1000,
host_origin: str | None = None, host_origin: str | None = None,
base_path: str = "/", base_path: str = "/",
grid_name: str = "Chat Grid",
welcome_message: str = (
"Welcome to the Chat Grid, your immersive audio playground. "
"Configure your audio, then Log in or register to join the grid."
),
): ):
"""Initialize runtime state, TLS context, and item service.""" """Initialize runtime state, TLS context, and item service."""
@@ -194,6 +199,11 @@ class SignalingServer:
self.server_version = self._resolve_server_version() self.server_version = self._resolve_server_version()
self.host_origin = normalize_origin(host_origin, field_name="host origin") if host_origin else None self.host_origin = normalize_origin(host_origin, field_name="host origin") if host_origin else None
self.base_path = self._normalize_base_path(base_path) self.base_path = self._normalize_base_path(base_path)
self.grid_name = str(grid_name).strip() or "Chat Grid"
self.welcome_message = (
str(welcome_message).strip()
or "Welcome to the Chat Grid, your immersive audio playground. Configure your audio, then Log in or register to join the grid."
)
self.auth_session_cookie_name = self._session_cookie_name_for_base_path(self.base_path) self.auth_session_cookie_name = self._session_cookie_name_for_base_path(self.base_path)
self.websocket_path = self._base_path_join(WEBSOCKET_PATH) self.websocket_path = self._base_path_join(WEBSOCKET_PATH)
self.auth_session_cookie_set_path = self._base_path_join(AUTH_SESSION_COOKIE_SET_PATH) self.auth_session_cookie_set_path = self._base_path_join(AUTH_SESSION_COOKIE_SET_PATH)
@@ -1493,6 +1503,8 @@ class SignalingServer:
type="auth_required", type="auth_required",
message="Authentication required.", message="Authentication required.",
authPolicy=self._auth_policy(), authPolicy=self._auth_policy(),
gridName=self.grid_name,
welcomeMessage=self.welcome_message,
), ),
) )
async for raw_message in websocket: async for raw_message in websocket:
@@ -1549,7 +1561,12 @@ class SignalingServer:
"movementMaxStepsPerTick": self.movement_max_steps_per_tick, "movementMaxStepsPerTick": self.movement_max_steps_per_tick,
}, },
uiDefinitions=self._build_ui_definitions(client), uiDefinitions=self._build_ui_definitions(client),
serverInfo={"instanceId": self.instance_id, "version": self.server_version}, serverInfo={
"instanceId": self.instance_id,
"version": self.server_version,
"gridName": self.grid_name,
"welcomeMessage": self.welcome_message,
},
auth={ auth={
"authenticated": client.authenticated, "authenticated": client.authenticated,
"userId": client.user_id, "userId": client.user_id,
@@ -3311,5 +3328,7 @@ def run() -> None:
state_save_max_delay_ms=config.storage.state_save_max_delay_ms, state_save_max_delay_ms=config.storage.state_save_max_delay_ms,
host_origin=host_origin, host_origin=host_origin,
base_path=config.server.base_path, base_path=config.server.base_path,
grid_name=config.server.grid_name,
welcome_message=config.server.welcome_message,
) )
asyncio.run(server.start()) asyncio.run(server.start())

View File

@@ -5,6 +5,10 @@ bind_ip = "127.0.0.1"
port = 8765 port = 8765
# Public base path for this grid instance. Examples: "/", "/chgrid/", "/ttgrid/". # Public base path for this grid instance. Examples: "/", "/chgrid/", "/ttgrid/".
base_path = "/" base_path = "/"
# User-facing grid name shown in the web client.
grid_name = "Chat Grid"
# User-facing pre-connect welcome text shown on the home screen.
welcome_message = "Welcome to the Chat Grid, your immersive audio playground. Configure your audio, then Log in or register to join the grid."
[network] [network]
# Maximum inbound websocket message size in bytes. # Maximum inbound websocket message size in bytes.

View File

@@ -59,3 +59,20 @@ base_path = "/ttgrid/"
) )
cfg = load_config(config_path) cfg = load_config(config_path)
assert cfg.server.base_path == "/ttgrid/" assert cfg.server.base_path == "/ttgrid/"
def test_load_config_reads_grid_name_and_welcome_message(tmp_path: Path) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text(
"""
[network]
allow_insecure_ws = true
[server]
grid_name = "TT Grid"
welcome_message = "Welcome to TT Grid."
""".strip()
)
cfg = load_config(config_path)
assert cfg.server.grid_name == "TT Grid"
assert cfg.server.welcome_message == "Welcome to TT Grid."