diff --git a/DistFiles/localization/en/BloomLowPriority.xlf b/DistFiles/localization/en/BloomLowPriority.xlf index ea32e7a4e41e..b09b7d476b53 100644 --- a/DistFiles/localization/en/BloomLowPriority.xlf +++ b/DistFiles/localization/en/BloomLowPriority.xlf @@ -353,6 +353,21 @@ ID: CollectionTab.BookMenu.MoveToCurrentCollection {0} will be replaced with the name of the collection that the user currently has open for editing. + + New… + ID: EditTab.Toolbox.Games.NewTheme + An item in the game "Theme" dropdown in the Games tool of the Edit tab's toolbox. Choosing it starts a brand-new game theme in the theme editor. The trailing ellipsis (…) signals that an editor will open. + + + Customize… + ID: EditTab.Toolbox.Games.CustomizeTheme + An item in the game "Theme" dropdown in the Games tool of the Edit tab's toolbox. Choosing it creates a new game theme that starts as a copy of the theme currently applied, then opens it in the theme editor. The trailing ellipsis (…) signals that an editor will open. + + + Edit theme colors + ID: EditTab.Toolbox.Games.EditThemeColors + Tooltip (hover text) on the pencil/edit button next to the game "Theme" dropdown in the Games tool of the Edit tab's toolbox. Clicking the button opens the game theme editor to change the current theme's colors. + Bloom was not able to copy that. ID: EditTab.CopyTextFailed diff --git a/PAPERCUTS.md b/PAPERCUTS.md index 4bd3f3489843..b3c072fbe907 100644 --- a/PAPERCUTS.md +++ b/PAPERCUTS.md @@ -84,9 +84,21 @@ House rules: complete runs, re-triaged from scratch a third time in one day. Developer chose "log it as a papercut so the wrapper gets fixed", so this is now waiting on a fix rather than another re-triage. +- seen again: 2026-07-27, preflight of game-theme-editor (PR #8086) — **the count is no longer 9, + it is 19**, so "the known 9" is now a misleading baseline that costs time rather than saving it. + The extra 10 are the same class of problem (the private output tree lacks files the fixtures + expect): 8 × `CompressBookForDevice_*` and 2 × `AddAudioOverlay_NoSubElementPlaybackModes_*`, + failing with `DirectoryNotFoundException`, `ArgumentException: The value cannot be an empty + string (Parameter 'path')`, `ArgumentException: Drive name must be a root directory`, and one + stylesheet-hash mismatch. Enumerating them needed `--logger "trx;LogFileName=cs.trx"` and + parsing `src/BloomTests/TestResults/cs.trx`, because the console summary only prints the count + and piping the run through `tail` discards the per-test lines. **Tip for the next agent:** use + the trx logger from the start, and treat the expected-failure set as "PdfMaker + XMatter + + BloomPub/publish path fixtures", not a fixed number. - **Idea:** Make the wrapper copy/link `BloomPdfMaker.exe` into its private output tree and fix - the xmatter file-locator path (or document the 9 known failures in AGENTS.md as expected under - the wrapper). + the xmatter file-locator path (or document the known failures in AGENTS.md as expected under + the wrapper). Whatever the fix, record the expected set *by test-class pattern* rather than by + count, since the count grows as master adds tests in the same areas. - **Context:** BloomDesktop, found during `/preflight` of PR #8067 (speedUpCSharpTests). @@ -102,6 +114,18 @@ House rules: - **Idea:** Regenerate/commit the lockfile once with the pinned pnpm so committed state matches `packageManager` output, or document the exact pnpm invocation the team uses so installs are format-stable. Until then, hash bumps need a manual lock edit. +- **Correction + much cheaper workaround (2026-07-27, preflight of PR #8086):** the committed + style is not an "older pnpm serialization" — it is simply **prettier's** output. `pnpm-lock.yaml` + is not in `.prettierignore`, so prettier owns the file, and running + `node_modules/.bin/prettier --write pnpm-lock.yaml` right after any `pnpm install` restores the + committed style exactly (verified: `--check` then passes, and the diff drops from ~30,400 lines + to only the lines the install actually changed). So there is **no need to hand-patch hash + occurrences** — install normally, then run prettier on the lock. This branch had arrived with an + un-prettified lock, which is what made its diff 30,400 lines; one prettier run reduced it to 58. +- **Better idea, given the above:** add `pnpm-lock.yaml` to the pre-commit prettier/lint-staged + step so the reformat can never be forgotten, or add it to `.prettierignore` and accept pnpm's + own style. Either removes the drift permanently; the current state (prettier owns it, pnpm + rewrites it, nothing enforces re-running prettier) is the worst of both. - **Context:** BL image-chooser integration PR (BloomDesktop #8059); local pnpm 11.5.2. ## 2026-07-11 — Can't screenshot Bloom's WinForms modal dialogs via CDP diff --git a/src/BloomBrowserUI/bookEdit/toolbox/games/ThemeChooser.tsx b/src/BloomBrowserUI/bookEdit/toolbox/games/ThemeChooser.tsx index 9529b1e3bd29..523858e7306f 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/games/ThemeChooser.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/games/ThemeChooser.tsx @@ -8,9 +8,28 @@ import { toolboxMenuPopupTheme, } from "../../../bloomMaterialUITheme"; import MenuItem from "@mui/material/MenuItem"; +import Divider from "@mui/material/Divider"; import { Div } from "../../../react_components/l10nComponents"; +import { useL10n } from "../../../react_components/l10nHooks"; 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 { useMountEffect } from "../../../utils/useMountEffect"; +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 +63,122 @@ 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); + useMountEffect(() => { + 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); + useMountEffect(() => { + getAsync("gameThemeEditor/canSaveToFactorySource").then((result) => { + setIsDeveloper(!!(result && (result as { data?: boolean }).data)); + }); + }); + // Tooltip for the edit (pencil) button that opens the game theme editor. + const editThemeColorsTitle = useL10n( + "Edit theme colors", + "EditTab.Toolbox.Games.EditThemeColors", + ); + 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 +276,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={editThemeColorsTitle} + 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