Render main menu surface in Electron

This commit is contained in:
Meta Vens 2026-07-01 10:26:04 +01:00
commit b4f7be9df2
7 changed files with 466 additions and 7 deletions

View file

@ -10,6 +10,46 @@ import type {
export type Viewport = CultMeshViewportRequest;
export type AetheriaMenuSurfaceRequest = {
surfaceId?: string;
inGame?: boolean;
canOpenRuntimeInputScreen?: boolean;
};
export type AetheriaMenuSurfaceDocument = {
providerId: string;
providerKind: string;
title: string;
version: number;
updatedAtUtc: string;
surface: AetheriaMenuSurfaceTree;
commands: AetheriaMenuSurfaceCommand[];
};
export type AetheriaMenuSurfaceTree = {
id: string;
root: AetheriaMenuSurfaceComponent;
styles: AetheriaMenuStyleToken[];
};
export type AetheriaMenuSurfaceComponent = {
id: string;
kind: string;
props: Record<string, string>;
children: AetheriaMenuSurfaceComponent[];
};
export type AetheriaMenuStyleToken = {
name: string;
value: string;
};
export type AetheriaMenuSurfaceCommand = {
command: string;
label: string;
transport: string;
};
export type AetheriaRuntimeSetMoveVectorRequest = {
actorEntityKey: string;
directionX: number;
@ -305,5 +345,6 @@ export type AetheriaRtsApi = {
setTarget(request: AetheriaRuntimeSetTargetRequest): Promise<AetheriaRuntimeDaemonCommandReceipt>;
surfaceCatalog(): Promise<CultMeshSurfaceCatalogDiagnostic>;
surfaceCatalogIndex(): Promise<CultMeshSurfaceCatalogIndexDiagnostic>;
mainMenuSurface(request: AetheriaMenuSurfaceRequest): Promise<AetheriaMenuSurfaceDocument>;
health(): Promise<unknown>;
};

View file

@ -1,5 +1,7 @@
import type {
AetheriaRtsApi,
AetheriaMenuSurfaceComponent,
AetheriaMenuSurfaceDocument,
AuthorityStatusDocument,
AetheriaRuntimeViewportFeedSnapshot,
BodyView,
@ -49,6 +51,7 @@ let latestReceivedAt = 0;
let latestPollMs = 0;
let refreshInFlight = false;
let viewportFeedUnsubscribe: (() => void) | null = null;
const mainMenuMode = window.location.hash === "#main-menu";
function requiredElement<TElement extends Element>(selector: string): TElement {
const element = document.querySelector<TElement>(selector);
@ -87,6 +90,117 @@ function setStatus(text: string): void {
statusEl.textContent = text;
}
async function showMainMenu(surfaceId = "aetheria.main_menu.root"): Promise<void> {
document.body.classList.add("main-menu-mode");
viewportFeedUnsubscribe?.();
const surface = await window.aetheriaRts.mainMenuSurface({
surfaceId,
inGame: false,
canOpenRuntimeInputScreen: false,
});
installMenuFonts(surface);
renderMainMenuSurface(surface);
}
function installMenuFonts(surface: AetheriaMenuSurfaceDocument): void {
const googleFont = surface.surface.styles.find(token => token.name === "font.web.google")?.value;
if (!googleFont || document.querySelector(`link[data-aetheria-menu-fonts="${googleFont}"]`)) {
return;
}
const preconnect = document.createElement("link");
preconnect.rel = "preconnect";
preconnect.href = "https://fonts.gstatic.com";
preconnect.crossOrigin = "anonymous";
document.head.append(preconnect);
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = googleFont;
link.dataset.aetheriaMenuFonts = googleFont;
document.head.append(link);
}
function renderMainMenuSurface(surface: AetheriaMenuSurfaceDocument): void {
let host = document.querySelector<HTMLElement>("#main-menu-host");
if (!host) {
host = document.createElement("section");
host.id = "main-menu-host";
host.className = "main-menu-host";
document.body.append(host);
}
host.replaceChildren(lowerMenuComponent(surface.surface.root, surface));
}
function lowerMenuComponent(
component: AetheriaMenuSurfaceComponent,
surface: AetheriaMenuSurfaceDocument,
): HTMLElement {
const kind = component.kind || "surface";
if (kind === "control.button") {
const button = document.createElement("button");
button.id = component.id;
button.className = "eve-control-button";
button.type = "button";
button.textContent = component.props.label ?? "";
button.addEventListener("click", () => handleMenuCommand(component.props.command ?? "", surface));
return button;
}
if (kind.startsWith("text")) {
const label = document.createElement("div");
label.id = component.id;
label.className = kind === "text.title" ? "eve-text-title" : "eve-text";
label.textContent = component.props.value ?? component.props.text ?? "";
return label;
}
if (kind === "metric") {
const metric = document.createElement("dl");
metric.id = component.id;
metric.className = "eve-metric";
const term = document.createElement("dt");
term.textContent = component.props.label ?? "";
const value = document.createElement("dd");
value.textContent = component.props.value ?? "";
metric.append(term, value);
return metric;
}
const element = document.createElement("div");
element.id = component.id;
element.className = [
kind === "surface" ? "eve-surface" : "",
kind === "column" ? "eve-column" : "",
kind === "row" ? "eve-row" : "",
].filter(Boolean).join(" ");
for (const child of component.children ?? []) {
element.append(lowerMenuComponent(child, surface));
}
return element;
}
function handleMenuCommand(command: string, surface: AetheriaMenuSurfaceDocument): void {
switch (command) {
case "aetheria.main_menu.root.show_settings":
void showMainMenu("aetheria.main_menu.settings");
return;
case "aetheria.main_menu.settings.show_input_settings":
void showMainMenu("aetheria.main_menu.input_settings");
return;
case "aetheria.main_menu.settings.back_to_settings":
void showMainMenu("aetheria.main_menu.settings");
return;
case "aetheria.main_menu.settings.back_to_main":
void showMainMenu("aetheria.main_menu.root");
return;
default:
setStatus(`${surface.title}: ${command || "no command"}`);
return;
}
}
function startViewportFeed(): void {
const viewport = currentViewport();
viewportFeedUnsubscribe?.();
@ -585,6 +699,10 @@ function roleText(session: StarbridgeSessionDocument): string {
.join(", ");
}
updateZoomLabel();
void refreshRuntimeSurfaces();
void refresh();
if (mainMenuMode) {
void showMainMenu();
} else {
updateZoomLabel();
void refreshRuntimeSurfaces();
void refresh();
}

View file

@ -70,6 +70,46 @@ export type AetheriaCultMeshClientOptions = {
snapshotTimeoutMs?: number;
};
export type AetheriaMenuSurfaceRequest = {
surfaceId?: string;
inGame?: boolean;
canOpenRuntimeInputScreen?: boolean;
};
export type AetheriaMenuSurfaceDocument = {
providerId: string;
providerKind: string;
title: string;
version: number;
updatedAtUtc: string;
surface: AetheriaMenuSurfaceTree;
commands: AetheriaMenuSurfaceCommand[];
};
export type AetheriaMenuSurfaceTree = {
id: string;
root: AetheriaMenuSurfaceComponent;
styles: AetheriaMenuStyleToken[];
};
export type AetheriaMenuSurfaceComponent = {
id: string;
kind: string;
props: Record<string, string>;
children: AetheriaMenuSurfaceComponent[];
};
export type AetheriaMenuStyleToken = {
name: string;
value: string;
};
export type AetheriaMenuSurfaceCommand = {
command: string;
label: string;
transport: string;
};
export type {
BodyView,
EntityStatus,
@ -232,6 +272,13 @@ export class AetheriaCultMeshClient {
return this.aetheria.daemon.assetManifest();
}
public async mainMenuSurface(request: AetheriaMenuSurfaceRequest = {}): Promise<AetheriaMenuSurfaceDocument> {
return buildMainMenuSurface(
request.surfaceId || "aetheria.main_menu.root",
request.canOpenRuntimeInputScreen === true,
request.inGame === true);
}
public queryDiagnostics(): Readonly<Record<string, AetheriaRuntimeRtsQueryDiagnostic>> {
return describeAetheriaRuntimeRtsQueryHandles(this.queries);
}
@ -449,6 +496,141 @@ export class AetheriaCultMeshClient {
}
}
function buildMainMenuSurface(
surfaceId: string,
canOpenRuntimeInputScreen: boolean,
inGame: boolean,
): AetheriaMenuSurfaceDocument {
const updatedAtUtc = new Date().toISOString();
if (surfaceId === "aetheria.main_menu.settings") {
return surfaceDocument(
surfaceId,
"Aetheria Settings",
updatedAtUtc,
[
command("aetheria.main_menu.settings.show_player_settings", "Player Settings"),
command("aetheria.main_menu.settings.show_verse_settings", "Verse"),
command("aetheria.main_menu.settings.show_input_settings", "Input"),
command("aetheria.main_menu.settings.back_to_main", "Back"),
],
text("aetheria.mainMenu.settings.title", "SETTINGS", "text.title"),
buttonColumn(
"aetheria.mainMenu.settings.actions",
button("aetheria.mainMenu.settings.playerSettings", "Player Settings", "aetheria.main_menu.settings.show_player_settings"),
button("aetheria.mainMenu.settings.verse", "Verse", "aetheria.main_menu.settings.show_verse_settings"),
button("aetheria.mainMenu.settings.input", "Input", "aetheria.main_menu.settings.show_input_settings"),
button("aetheria.mainMenu.settings.back", "Back", "aetheria.main_menu.settings.back_to_main"),
),
);
}
if (surfaceId === "aetheria.main_menu.input_settings") {
const children = [
text("aetheria.mainMenu.input.title", "INPUT", "text.title"),
metric("aetheria.mainMenu.input.bindingOverrides", "Binding Overrides", "0"),
metric("aetheria.mainMenu.input.actionBarInputs", "Action-Bar Inputs", "0"),
text(
"aetheria.mainMenu.input.note",
canOpenRuntimeInputScreen && inGame
? "The runtime Eve input screen owns low-level InputSystem rebinding and action-bar input edits."
: "This title shell reports typed player-settings state. Launch a run to open the runtime Eve input screen that owns low-level InputSystem rebinding."),
buttonColumn(
"aetheria.mainMenu.input.actions",
button("aetheria.mainMenu.input.back", "Back", "aetheria.main_menu.settings.back_to_settings"),
),
];
return surfaceDocument(
surfaceId,
"Aetheria Input Settings",
updatedAtUtc,
[command("aetheria.main_menu.settings.back_to_settings", "Back")],
...children);
}
const actionButtons = [
...(!inGame ? [button("aetheria.main_menu.root.continue", "Continue", "aetheria.main_menu.root.continue")] : []),
button("aetheria.main_menu.root.newGame", "New Game", "aetheria.main_menu.root.new_game"),
button("aetheria.main_menu.root.settings", "Settings", "aetheria.main_menu.root.show_settings"),
button("aetheria.main_menu.root.quit", "Quit", "aetheria.main_menu.root.quit"),
];
return surfaceDocument(
"aetheria.main_menu.root",
"Aetheria Terminus",
updatedAtUtc,
[
...(!inGame ? [command("aetheria.main_menu.root.continue", "Continue")] : []),
command("aetheria.main_menu.root.new_game", "New Game"),
command("aetheria.main_menu.root.show_settings", "Settings"),
command("aetheria.main_menu.root.quit", "Quit"),
],
text("aetheria.main_menu.root.title", "AETHERIA", "text.title"),
text("aetheria.main_menu.root.subtitle", "TERMINUS", "text.subtitle"),
buttonColumn("aetheria.main_menu.root.actions", ...actionButtons),
);
}
function surfaceDocument(
surfaceId: string,
title: string,
updatedAtUtc: string,
commands: AetheriaMenuSurfaceCommand[],
...children: AetheriaMenuSurfaceComponent[]
): AetheriaMenuSurfaceDocument {
return {
providerId: "aetheria",
providerKind: "game.menu",
title,
version: 1,
updatedAtUtc,
surface: {
id: surfaceId,
root: node(`${surfaceId}.root`, "surface", {}, ...children),
styles: [
{ name: "font.title.family", value: "Montserrat" },
{ name: "font.title.style", value: "Thin" },
{ name: "font.title.weight", value: "100" },
{ name: "font.body.family", value: "Ubuntu" },
{ name: "font.body.style", value: "Regular" },
{ name: "font.body.weight", value: "400" },
{
name: "font.web.google",
value: "https://fonts.googleapis.com/css2?family=Montserrat:wght@100&family=Ubuntu:wght@400&display=swap",
},
],
},
commands,
};
}
function command(commandId: string, label: string): AetheriaMenuSurfaceCommand {
return { command: commandId, label, transport: "cultmesh" };
}
function node(
id: string,
kind: string,
props: Record<string, string>,
...children: AetheriaMenuSurfaceComponent[]
): AetheriaMenuSurfaceComponent {
return { id, kind, props, children };
}
function text(id: string, value: string, kind = "text"): AetheriaMenuSurfaceComponent {
return node(id, kind, { value });
}
function metric(id: string, label: string, value: string): AetheriaMenuSurfaceComponent {
return node(id, "metric", { label, value });
}
function button(id: string, label: string, commandId: string): AetheriaMenuSurfaceComponent {
return node(id, "control.button", { label, command: commandId });
}
function buttonColumn(id: string, ...children: AetheriaMenuSurfaceComponent[]): AetheriaMenuSurfaceComponent {
return node(id, "column", {}, ...children);
}
function stableToken(value: string): string {
return value
.replace(/[^a-zA-Z0-9]+/gu, "-")

View file

@ -27,6 +27,7 @@ const rtsCultMeshAdvertiseHost = process.env.AETHERIA_RTS_CULTMESH_ADVERTISE_HOS
const peerCultMeshEndpoints = parseEndpointList(process.env.AETHERIA_RTS_PEER_CULTMESH_ENDPOINTS);
const electronSmoke = process.env.AETHERIA_RTS_ELECTRON_SMOKE === "1";
const electronSmokeResultPath = process.env.AETHERIA_RTS_ELECTRON_SMOKE_RESULT;
const rendererView = process.env.AETHERIA_RTS_VIEW?.trim() ?? "";
let daemonProcess: ChildProcessWithoutNullStreams | null = null;
let mainWindow: BrowserWindow | null = null;
@ -39,7 +40,10 @@ app.whenReady().then(async () => {
showStartup("Preparing Aetheria RTS", "Building daemon if needed.");
try {
if (launchLocalDaemon) {
if (rendererView === "main-menu") {
showStartup("Launching Aetheria Menu", "Lowering the CultUI main-menu surface.");
mkdirSync(runtimeRoot, { recursive: true });
} else if (launchLocalDaemon) {
await ensureDotnetBuild();
showStartup("Launching Aetheria RTS", "Starting the Aetheria daemon.");
mkdirSync(runtimeRoot, { recursive: true });
@ -73,9 +77,13 @@ app.whenReady().then(async () => {
"aetheria-rts-electron",
{ publicationMode: launchLocalDaemon ? "local" : "remote" });
showStartup("Launching Aetheria RTS", "Waiting for the daemon CultMesh frame.");
await rtsClient.waitForFrame(30000);
await mainWindow.loadFile(rendererIndex);
if (rendererView === "main-menu") {
await mainWindow.loadFile(rendererIndex, { hash: "main-menu" });
} else {
showStartup("Launching Aetheria RTS", "Waiting for the daemon CultMesh frame.");
await rtsClient.waitForFrame(30000);
await mainWindow.loadFile(rendererIndex);
}
if (electronSmoke) {
const result = await runElectronSmoke(mainWindow);
writeElectronSmokeResult({ ok: true, result });
@ -291,6 +299,8 @@ function registerIpc(): void {
daemonRunning: daemonProcess != null && !daemonProcess.killed,
daemonMode: launchLocalDaemon ? "local" : "remote",
}));
ipcMain.handle("aetheria-rts:main-menu-surface", async (_event, request) =>
requireClient().mainMenuSurface(request));
}
function parseEndpointList(value: string | undefined): string[] {

View file

@ -20,6 +20,7 @@ const channels = {
setTarget: "aetheria-rts:set-target",
surfaceCatalog: "aetheria-rts:surface-catalog",
surfaceCatalogIndex: "aetheria-rts:surface-catalog-index",
mainMenuSurface: "aetheria-rts:main-menu-surface",
health: "aetheria-rts:health",
};
@ -51,5 +52,6 @@ contextBridge.exposeInMainWorld("aetheriaRts", {
setTarget: request => ipcRenderer.invoke(channels.setTarget, request),
surfaceCatalog: () => ipcRenderer.invoke(channels.surfaceCatalog),
surfaceCatalogIndex: () => ipcRenderer.invoke(channels.surfaceCatalogIndex),
mainMenuSurface: request => ipcRenderer.invoke(channels.mainMenuSurface, request),
health: () => ipcRenderer.invoke(channels.health),
});

View file

@ -46,6 +46,7 @@
</section>
</aside>
</main>
<section id="main-menu-host" class="main-menu-host" aria-label="Aetheria main menu"></section>
<script type="module" src="app.js"></script>
</body>
</html>

View file

@ -19,6 +19,111 @@ body {
overflow: hidden;
}
.main-menu-host {
display: none;
}
body.main-menu-mode {
background:
radial-gradient(circle at 76% 66%, rgba(255, 213, 112, 0.74) 0, rgba(247, 121, 88, 0.36) 13%, transparent 31%),
radial-gradient(circle at 61% 37%, rgba(240, 45, 201, 0.54) 0, rgba(176, 38, 174, 0.28) 19%, transparent 45%),
radial-gradient(circle at 40% 45%, rgba(145, 21, 122, 0.42) 0, rgba(54, 8, 41, 0.4) 34%, transparent 62%),
#050407;
}
body.main-menu-mode .shell {
display: none;
}
body.main-menu-mode .main-menu-host {
display: flex;
align-items: flex-start;
justify-content: flex-start;
min-height: 100%;
padding: 92px 96px;
background:
radial-gradient(ellipse at 78% 64%, rgba(255, 194, 95, 0.36) 0, rgba(255, 148, 92, 0.18) 16%, transparent 35%),
radial-gradient(ellipse at 59% 38%, rgba(238, 56, 216, 0.28) 0, rgba(164, 37, 160, 0.2) 22%, transparent 52%),
linear-gradient(90deg, rgba(0, 0, 0, 0.5), transparent 48%);
}
body.main-menu-mode .eve-surface {
width: 720px;
display: flex;
flex-direction: column;
align-items: flex-start;
}
body.main-menu-mode .eve-text-title {
width: 720px;
color: rgba(209, 242, 255, 0.96);
font-family: "Montserrat", sans-serif;
font-weight: 100;
line-height: 0.95;
text-align: right;
}
body.main-menu-mode #aetheria\.main_menu\.root\.title {
margin: 0 0 -12px;
font-size: 86px;
}
body.main-menu-mode #aetheria\.main_menu\.root\.subtitle {
margin: 0 0 36px;
font-size: 39px;
}
body.main-menu-mode .eve-text,
body.main-menu-mode .eve-metric,
body.main-menu-mode .eve-control-button {
font-family: "Ubuntu", system-ui, sans-serif;
}
body.main-menu-mode .eve-column {
display: flex;
flex-direction: column;
}
body.main-menu-mode .eve-control-button {
width: 220px;
min-width: 0;
height: 32px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: rgba(219, 250, 255, 0.96);
font-size: 24px;
line-height: 32px;
text-align: left;
letter-spacing: 0;
transition: letter-spacing 160ms ease, color 160ms ease;
}
body.main-menu-mode .eve-control-button:hover,
body.main-menu-mode .eve-control-button:focus-visible {
background: transparent;
color: white;
letter-spacing: 8px;
outline: 0;
}
body.main-menu-mode .eve-metric {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 8px;
width: 520px;
margin: 0 0 8px;
}
body.main-menu-mode .eve-text:not(.eve-text-title) {
width: 520px;
margin: 0 0 14px;
color: rgba(219, 250, 255, 0.9);
font-size: 16px;
line-height: 1.25;
}
button {
min-width: 36px;
height: 32px;