From fcc2a033a82007fa5b79885b73d05904dc152bd9 Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:35:48 -0400 Subject: [PATCH] Expose render scale and post-stage toggles in the Graphics settings tab Refs #1688 Co-Authored-By: Claude Fable 5.1 --- .claude/skills/jgengine-ui/api.md | 21 +++- CHANGELOG.md | 2 + .../src/settings/graphicsSettings.test.ts | 70 ++++++++++++ .../core/src/settings/graphicsSettings.ts | 101 ++++++++++++++++++ packages/core/src/settings/settingsModel.ts | 5 + packages/shell/src/Shell3dPresentation.tsx | 1 + .../shell/src/settings/appliedSettings.tsx | 11 +- .../shell/src/settings/settingsController.ts | 40 ++++++- scripts/drive-dev.ts | 10 +- scripts/export-manifest.json | 1 + 10 files changed, 245 insertions(+), 17 deletions(-) create mode 100644 packages/core/src/settings/graphicsSettings.test.ts create mode 100644 packages/core/src/settings/graphicsSettings.ts diff --git a/.claude/skills/jgengine-ui/api.md b/.claude/skills/jgengine-ui/api.md index 20cbc6098..27e4681e4 100644 --- a/.claude/skills/jgengine-ui/api.md +++ b/.claude/skills/jgengine-ui/api.md @@ -73,6 +73,21 @@ - `GraphicsProfile` (interface): interface GraphicsProfile — Serializable renderer budget selected by the player's graphics tier. - `resolveGraphicsProfile` (function): function resolveGraphicsProfile(quality: GraphicsQuality, overrides?: Partial): GraphicsProfile — Resolve one tier and apply game-authored field and post-stage overrides. +## @jgengine/core/settings/graphicsSettings + +- `GRAPHICS_POST_STAGES` (const): const GRAPHICS_POST_STAGES: readonly GraphicsPostStage[] — Ordered stage list used to build settings rows. +- `GRAPHICS_POST_STAGE_LABELS` (const): const GRAPHICS_POST_STAGE_LABELS: Record — Player-facing labels for each post stage. +- `GraphicsPostStage` (type): type GraphicsPostStage = keyof GraphicsProfile["postStages"] — The post-processing stages a player can toggle individually on top of the quality tier. +- `GraphicsProfileOverrides` (type): type GraphicsProfileOverrides = Partial>> — Per-tier profile overrides a game authors with `defineGame({ graphics })`. +- `GraphicsSettingsState` (interface): interface GraphicsSettingsState — The resolved player graphics choice: the tier plus the profile after per-stage and render-scale overrides. +- `RENDER_SCALE_MAX` (const): const RENDER_SCALE_MAX: 2 — Highest player render scale. +- `RENDER_SCALE_MIN` (const): const RENDER_SCALE_MIN: 0.5 — Lowest player render scale; the value is the device-pixel-ratio cap handed to the canvas. +- `RENDER_SCALE_STEP` (const): const RENDER_SCALE_STEP: 0.05 — Render-scale slider increment. +- `applyGraphicsQuality` (function): function applyGraphicsQuality(store: Pick, quality: GraphicsQuality, overrides?: GraphicsProfileOverrides): GraphicsProfile — Select a quality tier and re-apply its render scale and stage defaults, so picking a preset resets the individual overrides instead of leaving stale toggles behind. +- `graphicsPostStageSettingId` (function): function graphicsPostStageSettingId(stage: GraphicsPostStage): string — Setting id that stores one stage toggle (`graphics.post.`). +- `readGraphicsQuality` (function): function readGraphicsQuality(store: Pick): GraphicsQuality — The stored quality tier, falling back to the default when the value is missing or unknown. +- `readGraphicsSettings` (function): function readGraphicsSettings(store: Pick, overrides?: GraphicsProfileOverrides): GraphicsSettingsState — Resolve the player's graphics state from a settings store. The tier profile (with the game's overrides) supplies every default; a stored render scale or stage toggle replaces its field. + ## @jgengine/core/settings/settingsModel - `BUILT_IN_SETTING_CATEGORIES` (const): const BUILT_IN_SETTING_CATEGORIES: readonly BuiltInSettingCategory[] — ⚠ undocumented @@ -87,7 +102,7 @@ - `GameSettingsConfig` (interface): interface GameSettingsConfig — ⚠ undocumented - `GraphicsQuality` (type): type GraphicsQuality = "low" | "medium" | "high" — ⚠ undocumented - `SETTINGS_STORAGE_PREFIX` (const): const SETTINGS_STORAGE_PREFIX: "jgengine:setting:" — ⚠ undocumented -- `SETTING_IDS` (const): const SETTING_IDS: { readonly masterVolume: "sound.master"; readonly graphicsQuality: "graphics.quality"; readonly graphicsShadows: "graphics.shadows"; readonly graphicsUiScale: "graphics.uiScale"; readonly touchStyle: "controls.touchStyle"; readonly touchJoystick: "controls.touchJoystick"; } — ⚠ undocumented +- `SETTING_IDS` (const): const SETTING_IDS: { readonly masterVolume: "sound.master"; readonly graphicsQuality: "graphics.quality"; readonly graphicsShadows: "graphics.shadows"; readonly graphicsUiScale: "graphics.uiScale"; readonly graphicsRenderScale: "graphics.renderScale"; readonly graphicsPostAo: "graphics.post.ao"; rea… — ⚠ undocumented - `SettingCategory` (type): type SettingCategory = BuiltInSettingCategory | (string & {}) — Built-in category ids keep autocomplete; any other string makes a fresh category. - `SettingCategoryDef` (interface): interface SettingCategoryDef — Declares or relabels/reorders a category tab; use it for a custom category or to reshape the built-ins. - `SettingKind` (type): type SettingKind = "slider" | "toggle" | "select" — ⚠ undocumented @@ -192,7 +207,7 @@ - `RadialSlice` (interface): interface RadialSlice — One wedge of a radial menu — geometry for rendering a slice. Angles are radians from "up" (−Y), clockwise. - `RadialVectorOptions` (interface): interface RadialVectorOptions extends RadialArc — Options for {@link radialIndexFromVector}. - `ResolvedAction` (interface): interface ResolvedAction — A resolved action view model — availability computed once from cooldown, cost, `disabled`, and any caller reasons. `enabled` is the single truth a renderer gates interaction on; `reasons` is the ordered explanation (cooldown, then unmet costs, then caller reasons, then a generic disable). -- `SETTING_IDS` (const): const SETTING_IDS: { readonly masterVolume: "sound.master"; readonly graphicsQuality: "graphics.quality"; readonly graphicsShadows: "graphics.shadows"; readonly graphicsUiScale: "graphics.uiScale"; readonly touchStyle: "controls.touchStyle"; readonly touchJoystick: "controls.touchJoystick"; } — ⚠ undocumented +- `SETTING_IDS` (const): const SETTING_IDS: { readonly masterVolume: "sound.master"; readonly graphicsQuality: "graphics.quality"; readonly graphicsShadows: "graphics.shadows"; readonly graphicsUiScale: "graphics.uiScale"; readonly graphicsRenderScale: "graphics.renderScale"; readonly graphicsPostAo: "graphics.post.ao"; rea… — ⚠ undocumented - `STUDIO_STAGE_POST` (const): const STUDIO_STAGE_POST: PostProcessingConfig — A cinematic "product shot" post preset — the full chain on (contact-AO, soft bloom, a warm film grade with vignette + a touch of grain + chromatic aberration). Meant for a `StudioStage` where a single parametric asset is framed on a backdrop, so every studio reads shipped, not intern-tier. DoF is left off by default (it needs a per-scene focus distance); set `dof` to enable it. - `ScreenMarkerLayout` (interface): interface ScreenMarkerLayout — Where and how to draw one marker, from {@link layoutScreenMarker}. - `ScreenMarkerOptions` (interface): interface ScreenMarkerOptions — Options for {@link layoutScreenMarker}. @@ -2395,7 +2410,7 @@ - `AudioSettingsBridge` (function): function AudioSettingsBridge({ store, engine, buses, }: { store: SettingsStore; engine: AudioEngine; buses: Record | undefined; }): null — ⚠ undocumented - `TOUCH_STYLE_AUTO` (const): const TOUCH_STYLE_AUTO: "auto" — Sentinel Controls value meaning "defer to the game's suggested touch skin". -- `useGraphicsSettings` (function): function useGraphicsSettings(store: SettingsStore, shadowsDefault: boolean, overrides?: Partial>>): { shadows: boolean; dpr: number; uiScale: number; quality: GraphicsQuality; profile: GraphicsProfile } — ⚠ undocumented +- `useGraphicsSettings` (function): function useGraphicsSettings(store: SettingsStore, shadowsDefault: boolean, overrides?: GraphicsProfileOverrides): { shadows: boolean; dpr: number; uiScale: number; quality: GraphicsQuality; profile: GraphicsProfile } — ⚠ undocumented - `useSettingsRevision` (function): function useSettingsRevision(store: SettingsStore): number — ⚠ undocumented ## @jgengine/shell/settings/settingsController diff --git a/CHANGELOG.md b/CHANGELOG.md index 3644e30b2..f531634a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ between (`--json` for structured output). ### Added +- The built-in Graphics settings tab now exposes render scale plus ambient occlusion, bloom, depth-of-field, and SMAA toggles alongside the quality tier; picking a tier re-applies its defaults. `@jgengine/core/settings/graphicsSettings` (`readGraphicsSettings`, `applyGraphicsQuality`, `GRAPHICS_POST_STAGES`) resolves the stored choices onto the `GraphicsProfile` the shell renders with, and `SETTING_IDS` gains `graphics.renderScale` and `graphics.post.*`. Every game gets the rows in its existing settings menu with no wiring (#1688). + - Building palettes take textured surfaces: a `BuildingPalette` part (and `BuildingKitPart.material`) may be `{ color?, maps?, repeat?, roughness?, metalness? }` with `maps` straight from `buildMaterialCatalog(...).resolve(id)!.maps`, not only a hex colour. Generated facade boxes and kit models tile the PBR maps per slot; unbound kinds keep their flat colour. `buildingSurfaceColor` / `resolveBuildingSurface` read either form. ### Fixed diff --git a/packages/core/src/settings/graphicsSettings.test.ts b/packages/core/src/settings/graphicsSettings.test.ts new file mode 100644 index 000000000..c87f17530 --- /dev/null +++ b/packages/core/src/settings/graphicsSettings.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; + +import { DEFAULT_GRAPHICS_PROFILES } from "./graphicsProfile"; +import { + applyGraphicsQuality, + graphicsPostStageSettingId, + readGraphicsQuality, + readGraphicsSettings, + RENDER_SCALE_MAX, +} from "./graphicsSettings"; +import { createSettingsStore, SETTING_IDS } from "./settingsModel"; + +function memStorage() { + const map = new Map(); + return { + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => void map.set(k, v), + }; +} + +describe("graphicsSettings", () => { + test("empty store resolves to the default tier profile", () => { + const store = createSettingsStore(memStorage()); + const state = readGraphicsSettings(store); + expect(state.quality).toBe("high"); + expect(state.profile).toEqual(DEFAULT_GRAPHICS_PROFILES.high); + }); + + test("unknown stored quality falls back to the default", () => { + const store = createSettingsStore(memStorage()); + store.set(SETTING_IDS.graphicsQuality, "ultra"); + expect(readGraphicsQuality(store)).toBe("high"); + }); + + test("stored stage toggles and render scale override the tier", () => { + const store = createSettingsStore(memStorage()); + store.set(SETTING_IDS.graphicsQuality, "medium"); + store.set(graphicsPostStageSettingId("ao"), false); + store.set(SETTING_IDS.graphicsRenderScale, 0.75); + const state = readGraphicsSettings(store); + expect(state.profile.postStages).toEqual({ ao: false, bloom: true, dof: true, smaa: true }); + expect(state.profile.renderScale).toBe(0.75); + expect(state.profile.shadowMapSize).toBe(1024); + }); + + test("render scale is clamped to the slider bounds", () => { + const store = createSettingsStore(memStorage()); + store.set(SETTING_IDS.graphicsRenderScale, 9); + expect(readGraphicsSettings(store).profile.renderScale).toBe(RENDER_SCALE_MAX); + }); + + test("game overrides feed the tier defaults", () => { + const store = createSettingsStore(memStorage()); + store.set(SETTING_IDS.graphicsQuality, "low"); + const state = readGraphicsSettings(store, { low: { renderScale: 0.8, postStages: { bloom: false } } }); + expect(state.profile.renderScale).toBe(0.8); + expect(state.profile.postStages.bloom).toBe(false); + }); + + test("applying a tier resets stage and render-scale overrides", () => { + const store = createSettingsStore(memStorage()); + store.set(graphicsPostStageSettingId("dof"), false); + store.set(SETTING_IDS.graphicsRenderScale, 0.5); + applyGraphicsQuality(store, "low"); + const state = readGraphicsSettings(store); + expect(state.quality).toBe("low"); + expect(state.profile).toEqual(DEFAULT_GRAPHICS_PROFILES.low); + expect(store.get(graphicsPostStageSettingId("dof"), true)).toBe(false); + }); +}); diff --git a/packages/core/src/settings/graphicsSettings.ts b/packages/core/src/settings/graphicsSettings.ts new file mode 100644 index 000000000..2ffa072c1 --- /dev/null +++ b/packages/core/src/settings/graphicsSettings.ts @@ -0,0 +1,101 @@ +import { resolveGraphicsProfile, type GraphicsProfile } from "./graphicsProfile"; +import { + DEFAULT_GRAPHICS_QUALITY, + GRAPHICS_QUALITY_OPTIONS, + SETTING_IDS, + type GraphicsQuality, + type SettingsStore, +} from "./settingsModel"; + +/** The post-processing stages a player can toggle individually on top of the quality tier. */ +export type GraphicsPostStage = keyof GraphicsProfile["postStages"]; + +/** Ordered stage list used to build settings rows. */ +export const GRAPHICS_POST_STAGES: readonly GraphicsPostStage[] = ["ao", "bloom", "dof", "smaa"]; + +/** Player-facing labels for each post stage. */ +export const GRAPHICS_POST_STAGE_LABELS: Record = { + ao: "Ambient occlusion", + bloom: "Bloom", + dof: "Depth of field", + smaa: "Anti-aliasing (SMAA)", +}; + +/** Setting id that stores one stage toggle (`graphics.post.`). */ +export function graphicsPostStageSettingId(stage: GraphicsPostStage): string { + return `graphics.post.${stage}`; +} + +/** Lowest player render scale; the value is the device-pixel-ratio cap handed to the canvas. */ +export const RENDER_SCALE_MIN = 0.5; +/** Highest player render scale. */ +export const RENDER_SCALE_MAX = 2; +/** Render-scale slider increment. */ +export const RENDER_SCALE_STEP = 0.05; + +/** Per-tier profile overrides a game authors with `defineGame({ graphics })`. */ +export type GraphicsProfileOverrides = Partial>>; + +/** The resolved player graphics choice: the tier plus the profile after per-stage and render-scale overrides. */ +export interface GraphicsSettingsState { + quality: GraphicsQuality; + profile: GraphicsProfile; +} + +const QUALITY_VALUES = new Set(GRAPHICS_QUALITY_OPTIONS.map((option) => option.value)); + +function isGraphicsQuality(value: unknown): value is GraphicsQuality { + return typeof value === "string" && QUALITY_VALUES.has(value); +} + +function clampRenderScale(value: number): number { + return Math.min(RENDER_SCALE_MAX, Math.max(RENDER_SCALE_MIN, value)); +} + +/** The stored quality tier, falling back to the default when the value is missing or unknown. */ +export function readGraphicsQuality(store: Pick): GraphicsQuality { + const raw = store.get(SETTING_IDS.graphicsQuality, DEFAULT_GRAPHICS_QUALITY); + return isGraphicsQuality(raw) ? raw : DEFAULT_GRAPHICS_QUALITY; +} + +/** + * Resolve the player's graphics state from a settings store. The tier profile (with the game's + * overrides) supplies every default; a stored render scale or stage toggle replaces its field. + */ +export function readGraphicsSettings( + store: Pick, + overrides?: GraphicsProfileOverrides, +): GraphicsSettingsState { + const quality = readGraphicsQuality(store); + const tier = resolveGraphicsProfile(quality, overrides?.[quality]); + const postStages = { ...tier.postStages }; + for (const stage of GRAPHICS_POST_STAGES) { + postStages[stage] = store.get(graphicsPostStageSettingId(stage), tier.postStages[stage]); + } + return { + quality, + profile: { + ...tier, + renderScale: clampRenderScale(store.get(SETTING_IDS.graphicsRenderScale, tier.renderScale)), + postStages, + }, + }; +} + +/** + * Select a quality tier and re-apply its render scale and stage defaults, so picking a preset + * resets the individual overrides instead of leaving stale toggles behind. + */ +export function applyGraphicsQuality( + store: Pick, + quality: GraphicsQuality, + overrides?: GraphicsProfileOverrides, +): GraphicsProfile { + const tier = resolveGraphicsProfile(quality, overrides?.[quality]); + store.set(SETTING_IDS.graphicsQuality, quality); + store.set(SETTING_IDS.graphicsRenderScale, tier.renderScale); + for (const stage of GRAPHICS_POST_STAGES) { + store.set(graphicsPostStageSettingId(stage), tier.postStages[stage]); + } + return tier; +} diff --git a/packages/core/src/settings/settingsModel.ts b/packages/core/src/settings/settingsModel.ts index c7de1c51a..73a0a6075 100644 --- a/packages/core/src/settings/settingsModel.ts +++ b/packages/core/src/settings/settingsModel.ts @@ -85,6 +85,11 @@ export const SETTING_IDS = { graphicsQuality: "graphics.quality", graphicsShadows: "graphics.shadows", graphicsUiScale: "graphics.uiScale", + graphicsRenderScale: "graphics.renderScale", + graphicsPostAo: "graphics.post.ao", + graphicsPostBloom: "graphics.post.bloom", + graphicsPostDof: "graphics.post.dof", + graphicsPostSmaa: "graphics.post.smaa", touchStyle: "controls.touchStyle", touchJoystick: "controls.touchJoystick", } as const; diff --git a/packages/shell/src/Shell3dPresentation.tsx b/packages/shell/src/Shell3dPresentation.tsx index b7dcafc0f..01e14a403 100644 --- a/packages/shell/src/Shell3dPresentation.tsx +++ b/packages/shell/src/Shell3dPresentation.tsx @@ -462,6 +462,7 @@ export function Shell3dPresentation({ categories={settingsConfig.categories ?? []} hide={hideCategories} fovEnabled={fovControlEnabled} + graphics={playable.graphics} hideBindings={settingsConfig.hideBindings ?? []} touchStyle={ coarsePointer && diff --git a/packages/shell/src/settings/appliedSettings.tsx b/packages/shell/src/settings/appliedSettings.tsx index becc4af8d..2d1c58d53 100644 --- a/packages/shell/src/settings/appliedSettings.tsx +++ b/packages/shell/src/settings/appliedSettings.tsx @@ -3,17 +3,16 @@ import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; import type { AudioBusDef } from "@jgengine/core/audio/audioFalloff"; import { busVolumeSettingId, - DEFAULT_GRAPHICS_QUALITY, DEFAULT_MASTER_VOLUME, DEFAULT_UI_SCALE, - GRAPHICS_QUALITY_DPR, SETTING_IDS, UI_SCALE_MAX, UI_SCALE_MIN, type GraphicsQuality, type SettingsStore, } from "@jgengine/core/settings/settingsModel"; -import { resolveGraphicsProfile, type GraphicsProfile } from "@jgengine/core/settings/graphicsProfile"; +import type { GraphicsProfile } from "@jgengine/core/settings/graphicsProfile"; +import { readGraphicsSettings, type GraphicsProfileOverrides } from "@jgengine/core/settings/graphicsSettings"; import { DEFAULT_TOUCH_JOYSTICK_VARIANT, @@ -61,12 +60,10 @@ export function useSettingsRevision(store: SettingsStore): number { export function useGraphicsSettings( store: SettingsStore, shadowsDefault: boolean, - overrides?: Partial>>, + overrides?: GraphicsProfileOverrides, ): { shadows: boolean; dpr: number; uiScale: number; quality: GraphicsQuality; profile: GraphicsProfile } { useSettingsRevision(store); - const rawQuality = store.get(SETTING_IDS.graphicsQuality, DEFAULT_GRAPHICS_QUALITY) as GraphicsQuality; - const quality: GraphicsQuality = GRAPHICS_QUALITY_DPR[rawQuality] !== undefined ? rawQuality : "high"; - const profile = resolveGraphicsProfile(quality, overrides?.[quality]); + const { quality, profile } = readGraphicsSettings(store, overrides); const rawUiScale = store.get(SETTING_IDS.graphicsUiScale, DEFAULT_UI_SCALE); return { shadows: store.get(SETTING_IDS.graphicsShadows, shadowsDefault), diff --git a/packages/shell/src/settings/settingsController.ts b/packages/shell/src/settings/settingsController.ts index b8186d7f5..2f86f4d33 100644 --- a/packages/shell/src/settings/settingsController.ts +++ b/packages/shell/src/settings/settingsController.ts @@ -4,7 +4,6 @@ import type { AudioBusDef } from "@jgengine/core/audio/audioFalloff"; import { BUILT_IN_SETTING_CATEGORIES, busVolumeSettingId, - DEFAULT_GRAPHICS_QUALITY, DEFAULT_GRAPHICS_SHADOWS, DEFAULT_MASTER_VOLUME, DEFAULT_UI_SCALE, @@ -13,11 +12,23 @@ import { UI_SCALE_MAX, UI_SCALE_MIN, type GameSettingDef, + type GraphicsQuality, type SettingCategory, type SettingCategoryDef, type SettingOption, type SettingValue, } from "@jgengine/core/settings/settingsModel"; +import { + applyGraphicsQuality, + GRAPHICS_POST_STAGE_LABELS, + GRAPHICS_POST_STAGES, + graphicsPostStageSettingId, + readGraphicsSettings, + RENDER_SCALE_MAX, + RENDER_SCALE_MIN, + RENDER_SCALE_STEP, + type GraphicsProfileOverrides, +} from "@jgengine/core/settings/graphicsSettings"; import { DEFAULT_TOUCH_JOYSTICK_VARIANT, TOUCH_JOYSTICK_VARIANT_OPTIONS, @@ -53,6 +64,8 @@ export interface SettingsControllerInput { categories: readonly SettingCategoryDef[]; hide: readonly SettingCategory[]; fovEnabled: boolean; + /** The game's per-tier profile overrides (`defineGame({ graphics })`), so tier defaults match what renders. */ + graphics?: GraphicsProfileOverrides; hideBindings: readonly string[]; /** Show the touch-controls skin selector in Controls (touch device with a dock). */ touchStyle: boolean; @@ -116,14 +129,26 @@ export function useSettingsCategories(config: SettingsControllerInput): Settings ...extrasFor("sound"), ]; + const graphics = readGraphicsSettings(store, config.graphics); const graphicsRows: SettingsRow[] = [ { id: SETTING_IDS.graphicsQuality, label: "Quality", kind: "select", - value: store.get(SETTING_IDS.graphicsQuality, DEFAULT_GRAPHICS_QUALITY), + value: graphics.quality, options: GRAPHICS_QUALITY_OPTIONS, - set: (value) => store.set(SETTING_IDS.graphicsQuality, value), + set: (value) => applyGraphicsQuality(store, String(value) as GraphicsQuality, config.graphics), + }, + { + id: SETTING_IDS.graphicsRenderScale, + label: "Render scale", + kind: "slider", + value: graphics.profile.renderScale, + min: RENDER_SCALE_MIN, + max: RENDER_SCALE_MAX, + step: RENDER_SCALE_STEP, + format: percent, + set: (value) => store.set(SETTING_IDS.graphicsRenderScale, value), }, { id: SETTING_IDS.graphicsShadows, @@ -132,6 +157,15 @@ export function useSettingsCategories(config: SettingsControllerInput): Settings value: store.get(SETTING_IDS.graphicsShadows, DEFAULT_GRAPHICS_SHADOWS), set: (value) => store.set(SETTING_IDS.graphicsShadows, value), }, + ...GRAPHICS_POST_STAGES.map( + (stage): SettingsRow => ({ + id: graphicsPostStageSettingId(stage), + label: GRAPHICS_POST_STAGE_LABELS[stage], + kind: "toggle", + value: graphics.profile.postStages[stage], + set: (value) => store.set(graphicsPostStageSettingId(stage), value), + }), + ), { id: SETTING_IDS.graphicsUiScale, label: "UI scale", diff --git a/scripts/drive-dev.ts b/scripts/drive-dev.ts index e9a197e55..6b6a1c6a6 100644 --- a/scripts/drive-dev.ts +++ b/scripts/drive-dev.ts @@ -107,7 +107,8 @@ const HELP = `bun run drive [options] --click "TEXT" --shot name ... --mode capture mode (default play) --size half halves both dimensions (~1/4 the pixels) for cheap mid-loop judge shots — use full (default) for final/PR shots - --click "" click the first visible element containing this text + --click "" click the first visible element containing this text (or, for + icon-only buttons, this aria-label — e.g. "Settings") --wait pause before the next step --key hold a key (e.g. KeyW:2500) for the given milliseconds --shot screenshot to shots/-.png for a bare name, or to @@ -296,12 +297,13 @@ async function measureClickPoint(session: CdpSession, text: string): Promise<{ x const nodes = Array.from(document.querySelectorAll("button, [role=button], a, span, div, h1, h2, h3")); let best = null; for (const node of nodes) { - const own = (node.textContent ?? "").trim().toLowerCase(); + const own = ((node.textContent ?? "").trim() || node.getAttribute("aria-label") || "").toLowerCase(); if (own === "" || !own.includes(needle)) continue; - if (best === null || own.length < best.len) { + const interactive = node.matches("button, [role=button], [role=switch], a"); + if (best === null || own.length < best.len || (own.length === best.len && interactive && !best.interactive)) { const rect = node.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { - best = { len: own.length, x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + best = { len: own.length, interactive, x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; } } } diff --git a/scripts/export-manifest.json b/scripts/export-manifest.json index df34df9bd..f991c801b 100644 --- a/scripts/export-manifest.json +++ b/scripts/export-manifest.json @@ -404,6 +404,7 @@ "./session/roles", "./session/roundState", "./settings/graphicsProfile", + "./settings/graphicsSettings", "./settings/settingsModel", "./stats/accumulatorMeter", "./stats/eventMeter",