From d89fae0202c6075c3c48b894f94fb3672e0ed6d4 Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 22 Jun 2026 16:09:35 -0600 Subject: [PATCH 1/8] Add in-app Game Theme Editor Introduce a standalone Game Theme Editor web app (src/gameThemeEditor) and wire it into Bloom. The editor lets users view and customize the CSS color variables that drive game themes, with a color picker, a per-color outline, and a contrast checker that flags pairs failing WCAG/UI contrast minimums. - New React/TypeScript app under src/gameThemeEditor with its own build (package.json, tsconfig, host interface) hosted in a draggable/resizable frame. - Host integration from the Games toolbox: ThemeChooser launches the editor and gameThemeEditorHost bridges editor and Bloom. - C# GameThemeEditorApi to read/write theme variables, registered in ProjectContext. - BloomBrowserUI build wiring (vite.config, tsconfig, package.json, yarn.lock). --- .../bookEdit/toolbox/games/ThemeChooser.tsx | 241 ++++++++- .../toolbox/games/gameThemeEditorHost.ts | 512 ++++++++++++++++++ src/BloomBrowserUI/package.json | 1 + src/BloomBrowserUI/tsconfig.json | 4 +- src/BloomBrowserUI/vite.config.mts | 12 + src/BloomBrowserUI/yarn.lock | 27 + src/BloomExe/ProjectContext.cs | 2 + .../web/controllers/GameThemeEditorApi.cs | 277 ++++++++++ src/gameThemeEditor/README.md | 47 ++ src/gameThemeEditor/package.json | 21 + src/gameThemeEditor/src/ColorPicker.tsx | 410 ++++++++++++++ src/gameThemeEditor/src/ColorSwatch.tsx | 98 ++++ src/gameThemeEditor/src/ColorsOutline.tsx | 169 ++++++ src/gameThemeEditor/src/ContrastChecker.tsx | 449 +++++++++++++++ .../src/DraggableResizableFrame.tsx | 222 ++++++++ .../src/GameThemeEditorPanel.tsx | 502 +++++++++++++++++ src/gameThemeEditor/src/VariableRow.tsx | 160 ++++++ src/gameThemeEditor/src/bloomTheme.ts | 12 + src/gameThemeEditor/src/colorUtils.ts | 137 +++++ src/gameThemeEditor/src/contrastUtils.ts | 102 ++++ .../src/host/IGameThemeEditorHost.ts | 99 ++++ src/gameThemeEditor/src/index.tsx | 52 ++ src/gameThemeEditor/src/themeModel.ts | 222 ++++++++ src/gameThemeEditor/tsconfig.json | 18 + 24 files changed, 3765 insertions(+), 31 deletions(-) create mode 100644 src/BloomBrowserUI/bookEdit/toolbox/games/gameThemeEditorHost.ts create mode 100644 src/BloomExe/web/controllers/GameThemeEditorApi.cs create mode 100644 src/gameThemeEditor/README.md create mode 100644 src/gameThemeEditor/package.json create mode 100644 src/gameThemeEditor/src/ColorPicker.tsx create mode 100644 src/gameThemeEditor/src/ColorSwatch.tsx create mode 100644 src/gameThemeEditor/src/ColorsOutline.tsx create mode 100644 src/gameThemeEditor/src/ContrastChecker.tsx create mode 100644 src/gameThemeEditor/src/DraggableResizableFrame.tsx create mode 100644 src/gameThemeEditor/src/GameThemeEditorPanel.tsx create mode 100644 src/gameThemeEditor/src/VariableRow.tsx create mode 100644 src/gameThemeEditor/src/bloomTheme.ts create mode 100644 src/gameThemeEditor/src/colorUtils.ts create mode 100644 src/gameThemeEditor/src/contrastUtils.ts create mode 100644 src/gameThemeEditor/src/host/IGameThemeEditorHost.ts create mode 100644 src/gameThemeEditor/src/index.tsx create mode 100644 src/gameThemeEditor/src/themeModel.ts create mode 100644 src/gameThemeEditor/tsconfig.json diff --git a/src/BloomBrowserUI/bookEdit/toolbox/games/ThemeChooser.tsx b/src/BloomBrowserUI/bookEdit/toolbox/games/ThemeChooser.tsx index 9529b1e3bd29..745a8d1b1530 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/games/ThemeChooser.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/games/ThemeChooser.tsx @@ -8,9 +8,26 @@ import { toolboxMenuPopupTheme, } from "../../../bloomMaterialUITheme"; import MenuItem from "@mui/material/MenuItem"; +import Divider from "@mui/material/Divider"; import { Div } from "../../../react_components/l10nComponents"; import { InfoIconUrl } from "../../../react_components/icons/InfoIconUrl"; import BloomSelect from "../../../react_components/bloomSelect"; +import EditIcon from "@mui/icons-material/Edit"; +import IconButton from "@mui/material/IconButton"; +import { getAsync } from "../../../utils/bloomApi"; +import { + showGameThemeEditor, + showNewGameThemeEditor, + showCustomizeGameThemeEditor, + isGameThemeEditorOpen, + subscribeGameThemeEditorOpen, + isFactoryThemeSlug, + resolveThemeHeaderColors, +} from "./gameThemeEditorHost"; + +// Sentinel values for the "New…" and "Customize…" items in the theme dropdown (not real themes). +const kNewThemeValue = "__new_game_theme__"; +const kCustomizeThemeValue = "__customize_game_theme__"; const getPage = () => { const pageBody = ToolBox.getPage(); @@ -44,8 +61,117 @@ export const ThemeChooser: React.FunctionComponent<{ // gameThemePrefix, or "default" if there is none (but migration code and this tool makes sure // that game pages always do). const [currentTheme, setCurrentTheme] = useState(""); + // While the floating theme editor is open it owns theme changes, so we disable the + // dropdown to avoid switching themes out from under it. The editor can be closed from + // its own controls, so we track its open state via the host's subscription. + const [editorOpen, setEditorOpen] = useState(isGameThemeEditorOpen()); + // Bumped when the editor closes, so we re-scan the available themes: a save may have added + // or renamed one (a rename removes the old name's rule from the page). + const [themesRefreshKey, setThemesRefreshKey] = useState(0); + useEffect(() => { + setEditorOpen(isGameThemeEditorOpen()); + return subscribeGameThemeEditorOpen(() => { + const open = isGameThemeEditorOpen(); + setEditorOpen(open); + if (!open) setThemesRefreshKey((k) => k + 1); + }); + }, []); + // Whether Bloom is running from source (developers can edit factory themes). Non-developers + // don't get the edit button on factory themes, since they can't change them. + const [isDeveloper, setIsDeveloper] = useState(false); + useEffect(() => { + getAsync("gameThemeEditor/canSaveToFactorySource").then((result) => { + setIsDeveloper(!!(result && (result as { data?: boolean }).data)); + }); + }, []); + const currentThemeIsFactory = React.useMemo( + () => isFactoryThemeSlug(currentTheme), + // Re-evaluate when the page changes too, since the available stylesheets can change. + // eslint-disable-next-line react-hooks/exhaustive-deps + [currentTheme, props.pageGeneration], + ); + // Each dropdown item previews its theme by using that theme's header colors (the menu + // items only render while the dropdown is open, so this styling shows only then). + const headerColors = React.useMemo(() => { + const map: Record = {}; + for (const theme of themes) + map[theme] = resolveThemeHeaderColors(theme); + return map; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [themes, props.pageGeneration]); + // Which themes are factory (built-in) vs custom. We only run the localization system on + // factory names; custom theme names are user-defined and not in our localization system, so + // looking them up just clutters the screen with "untranslated" warnings. + const themeIsFactory = React.useMemo(() => { + const map: Record = {}; + for (const theme of themes) map[theme] = isFactoryThemeSlug(theme); + return map; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [themes, props.pageGeneration]); + // The dropdown lists factory themes first, then a divider and "New…", then the user's + // custom themes. Partition the (already sorted) themes accordingly. + const factoryThemes = React.useMemo( + () => themes.filter((theme) => themeIsFactory[theme]), + [themes, themeIsFactory], + ); + const customThemes = React.useMemo( + () => themes.filter((theme) => !themeIsFactory[theme]), + [themes, themeIsFactory], + ); + // Render one theme as a dropdown item, previewing it with its own header colors. + const renderThemeItem = (theme: string) => ( + + {themeIsFactory[theme] ? ( +
+ {isMissingTheme(theme) + ? `(Missing) ${getThemeLabel(theme)}` + : getThemeLabel(theme)} +
+ ) : ( + // Custom theme: render the name as-is; never run localization. + + {isMissingTheme(theme) + ? `(Missing) ${getThemeLabel(theme)}` + : getThemeLabel(theme)} + + )} +
+ ); + // A fresh, unused "Untitled Theme N" name for a brand-new or customized theme. + const nextUntitledName = () => { + let n = 1; + while (themes.includes(`untitled-theme-${n}`)) n++; + return `Untitled Theme ${n}`; + }; + // "New…": a brand-new theme based on the default factory theme (Blue On White). + const handleNewTheme = () => showNewGameThemeEditor(nextUntitledName()); + // "Customize…": a new theme that starts as a copy of the theme currently applied. + const handleCustomizeTheme = () => + showCustomizeGameThemeEditor(nextUntitledName()); const handleChooseTheme = (event) => { const newTheme = event.target.value; + if (newTheme === kNewThemeValue) { + handleNewTheme(); + return; + } + if (newTheme === kCustomizeThemeValue) { + handleCustomizeTheme(); + return; + } if (newTheme === currentTheme) { return; } @@ -143,8 +269,11 @@ export const ThemeChooser: React.FunctionComponent<{ ); // We don't need to run again if currentTheme changes, since it can only change to something - // that's already in the list (except just possibly when pageGeneration changes). - }, [props.pageGeneration]); + // that's already in the list (except just possibly when pageGeneration changes). We also + // re-run when the editor closes (themesRefreshKey), since a save/rename may have changed + // the set of themes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.pageGeneration, themesRefreshKey]); return (
- { - handleChooseTheme(event); - }} - inputProps={{ - name: "style", - id: "game-theme-dropdown", - }} +
- {themes.map((theme) => ( - -
- {isMissingTheme(theme) - ? `(Missing) ${getThemeLabel(theme)}` - : getThemeLabel(theme)} + { + handleChooseTheme(event); + }} + inputProps={{ + name: "style", + id: "game-theme-dropdown", + }} + css={css` + flex: 1; + // Allow the select to shrink below its content width so a long theme + // name truncates (with an ellipsis) instead of pushing the edit button + // out of the narrow toolbox. + min-width: 0; + .MuiSelect-select { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + // While the editor is open the dropdown is disabled (so themes can't be + // switched out from under it), but MUI's default disabled styling greys the + // text to near-invisible on the dark toolbox. Keep it readable. + .MuiSelect-select.Mui-disabled { + color: white !important; + -webkit-text-fill-color: white !important; + } + svg.MuiSvgIcon-root { + color: white !important; + } + ul { + background-color: ${kOptionPanelBackgroundColor} !important; + } + fieldset { + border-color: rgba(255, 255, 255, 0.5) !important; + } + `} + size="small" + > + {/* Factory themes first, then a divider, then "New…", then custom themes. */} + {factoryThemes.map(renderThemeItem)} + + {/* Not themes: "New…" starts from Blue On White; "Customize…" copies the + current theme. Both open the editor on a new, unsaved theme. */} + +
New…
+
+ +
+ Customize…
- ))} -
+ {customThemes.map(renderThemeItem)} + + {/* Non-developers can't change factory themes, so don't offer the edit button + for them on a factory theme (they can still use "New…" to make a copy). */} + {(isDeveloper || !currentThemeIsFactory) && ( + showGameThemeEditor()} + title="Edit theme colors" + css={css` + color: white !important; + margin-left: 4px; + flex-shrink: 0; + `} + > + + + )} +
); }; diff --git a/src/BloomBrowserUI/bookEdit/toolbox/games/gameThemeEditorHost.ts b/src/BloomBrowserUI/bookEdit/toolbox/games/gameThemeEditorHost.ts new file mode 100644 index 000000000000..1f88b66b2315 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/toolbox/games/gameThemeEditorHost.ts @@ -0,0 +1,512 @@ +// Bloom-side host for the self-contained game theme editor project (src/gameThemeEditor). +// +// This runs in the TOOLBOX iframe (which is served live by vite dev), and reaches the live +// editable page cross-frame via ToolBox.getPage() — exactly the pattern ThemeChooser already +// uses to apply theme classes. It mounts the editor panel into a bloom-ui container inside the +// editable-page document so the panel floats over the real game and can recolor it in real time. +// +// We deliberately do NOT route through the page-frame bundle (editablePageBundle): on this +// branch that bundle is not served by vite dev (see ViteReadMe.txt), so a page-frame export +// would be stale/missing during development. Driving from the toolbox works in dev and prod. +// +// Coupling to the editor is one-directional: we import its mount/unmount + types + the variable +// name list, and we hand it a concrete IGameThemeEditorHost. The editor imports nothing from Bloom. + +import { mount, unmount, themeVariableNames } from "gameThemeEditor"; +import type { IGameThemeEditorHost, Theme } from "gameThemeEditor"; +import { getAsync, postJsonAsync } from "../../../utils/bloomApi"; +import { ToolBox } from "../toolbox"; + +const kGameThemePrefix = "game-theme-"; +const kEditorContainerClass = "bloom-ui-game-theme-editor"; +// Live-preview