diff --git a/.github/prompts/bloom-l10.prompt.md b/.github/prompts/bloom-l10.prompt.md
index eba2cf0c3f05..117895643979 100644
--- a/.github/prompts/bloom-l10.prompt.md
+++ b/.github/prompts/bloom-l10.prompt.md
@@ -36,9 +36,13 @@ the string id may be used by translators as they try to understand context or tr
## Expose ID to translators
Add a note like this: `ID: LinkTargetChooser.URL.Paste.Tooltip`
+## Legacy strings
+Our localization build system is such that if we are no longer using a string ID in the next version, we cannot remove the ID from the XLF file immediately. This is because if we release a new version of the previous release, we will still need that old localization ID. The fact that its code base still has it will not be sufficient. Somehow the actual Crowdin database will lose the translations when it sees the string ID removed from a newer version. Therefore when we stop using a string ID we just add a note like this: "Obsolete as of 6.2". You can figure out the current version from the `Version` property on build/Bloom.proj.
+
## Add comments for translators
Although we don't want to fill in l10nComment in useL10n, we do want to fill in the note field to give context to translators. They don't know where the string appears in the UI, they also might need some explanation of what it means. For example, for the above string, we might add a note like `This is the text on a button in the Foobar dialog that brightens all images in the current book.`
# Tips
* Never use the word "Aria" in ids or comments. Translators don't know what that means.
* Stop processing immediately if I haven't told you what priority we want. After you have the priority, then you can continue.
+
diff --git a/.github/prompts/bloom-test-CURRENTPAGE.prompt.md b/.github/prompts/bloom-test-CURRENTPAGE.prompt.md
new file mode 100644
index 000000000000..231bfff7cb13
--- /dev/null
+++ b/.github/prompts/bloom-test-CURRENTPAGE.prompt.md
@@ -0,0 +1,4 @@
+---
+description: use browser tools to test and debug
+---
+The backend should already be running and serving a page at http://localhost:/bloom/CURRENTPAGE. is usually 8089. If i include a port number, use that, otherwise use 8089. You may use chrome-devtools-mcp, playwright-mcp, or other browser management tools. If you can't find any, use askQuestions tool to ask me to enable something for you to use.
diff --git a/.github/skills/bloom-automation/SKILL.md b/.github/skills/bloom-automation/SKILL.md
index 16919020fbf8..2d4283b41b13 100644
--- a/.github/skills/bloom-automation/SKILL.md
+++ b/.github/skills/bloom-automation/SKILL.md
@@ -1,6 +1,6 @@
---
name: bloom-automation
-description: Use when an agent needs to determine if Bloom is already running, detect whether the running Bloom came from a different worktree, kill Bloom or dotnet-watch parents, start Bloom from the current worktree, attach to the embedded WebView2 over CDP, inspect DOM/console/network, or run Playwright tests against the actual exe instead of CURRENTPAGE.
+description: Use when an agent needs to determine if Bloom is already running, detect whether the running Bloom came from a different worktree, kill Bloom or dotnet-watch parents, start Bloom from the current worktree, attach to the embedded WebView2 over CDP, inspect DOM/console/network, use dev-browser to inspect or run e2e tests against the actual exe instead of CURRENTPAGE.
argument-hint: "repo root or worktree, task such as status, restart, attach, run exe-backed tests"
user-invocable: true
---
@@ -219,8 +219,9 @@ These tests attach to the real Bloom.exe target over CDP and verify tab switchin
- Exact-target cleanup is intentionally strict: `killBloomProcess.mjs --http-port ` should only kill the instance that actually reports that HTTP port, and should fail without killing anything if that target cannot be resolved.
- When reporting work, include the helper commands you used so reviewers can confirm the workflow stayed on the supported path.
- Wrong-worktree detection is authoritative when a real `Bloom.exe` child exists or when `dotnet watch` was started with an absolute `--project` path.
-- A standalone `dotnet watch` started with a relative project path may not expose enough information to attribute it to a worktree. For current-worktree automation, start Bloom through `node scripts/watchBloomExe.mjs`, which always uses an absolute path. For the already-running Bloom workflow, use `--running-bloom` instead of trying to infer a worktree.
- When more than one Bloom is running from the same worktree, repo-root matching is not enough. Use the explicit HTTP port workflow.
+- For ad hoc local debugging in this workspace, `dev-browser --connect http://localhost:` can attach directly to the existing Bloom WebView2 target. Use it as a low-friction inspection client.
+- After attaching to Bloom's WebView2 target, if Bloom is on the Edit tab, the editable page content lives inside the iframe named `page`; the top-level document mostly hosts shell UI plus the root dialog container.
## Completion Checks
- Bloom's status is known: not running, running from current worktree, or running from different worktree.
@@ -243,7 +244,7 @@ Report:
- what browser-native evidence you collected: DOM state, console output, network request, tab state, or test results
## Example Prompts
-- `Use bloom-automation to determine whether Bloom is already running from this worktree and attach Playwright to the embedded browser.`
-- `Use bloom-automation to switch the already-running Bloom to the Edit tab.`
-- `Use bloom-automation to kill the wrong-worktree Bloom and start the current checkout with dotnet watch.`
-- `Use bloom-automation to run the exe-backed Playwright top bar smoke tests against the actual Bloom.exe window.`
+- `troubleshoot why the page is refreshing when we open page settings`
+
+## Debugging tips
+Use node or bash scripts. Avoid powershell. Use the "dev-browser" cli instead of playwright for interactive debugging/driving Bloom. Use "dev-browser --help" to see the available commands and options. If the user hasn't installed dev-browser, ask them for permission to install it (https://github.com/SawyerHood/dev-browser).
diff --git a/.github/skills/bloom-automation/bloomProcessCommon.mjs b/.github/skills/bloom-automation/bloomProcessCommon.mjs
index c2b4197976e1..9da076110fe5 100644
--- a/.github/skills/bloom-automation/bloomProcessCommon.mjs
+++ b/.github/skills/bloom-automation/bloomProcessCommon.mjs
@@ -107,6 +107,9 @@ export const extractRepoRoot = (text) => {
export const normalizeBloomInstanceInfo = (info, discoveredViaPort) => {
const httpPort = toTcpPort(info?.httpPort) ?? discoveredViaPort;
const cdpPort = toTcpPort(info?.cdpPort);
+ const executablePath = normalizePath(info?.executablePath);
+ const detectedRepoRoot = extractRepoRoot(executablePath);
+ const vitePort = toTcpPort(info?.vitePort);
return {
processId: toPositiveInteger(info?.processId),
@@ -114,6 +117,9 @@ export const normalizeBloomInstanceInfo = (info, discoveredViaPort) => {
httpPort,
origin: toLocalOrigin(httpPort),
cdpPort,
+ executablePath,
+ detectedRepoRoot,
+ vitePort,
};
};
diff --git a/.github/skills/react-useeffect/README.md b/.github/skills/react-useeffect/README.md
new file mode 100644
index 000000000000..6591ab8cab3d
--- /dev/null
+++ b/.github/skills/react-useeffect/README.md
@@ -0,0 +1,320 @@
+# React useEffect Best Practices
+
+A comprehensive guide teaching when to use `useEffect` in React, and more importantly, when NOT to use it. This skill is based on official React documentation and provides practical alternatives to common useEffect anti-patterns.
+
+## Purpose
+
+Effects are an **escape hatch** from React's reactive paradigm. They let you synchronize with external systems like browser APIs, third-party widgets, or network requests. However, many developers overuse Effects for tasks that React handles better through other means.
+
+This skill helps you:
+- Identify when you truly need an Effect vs. when you don't
+- Recognize common anti-patterns and their fixes
+- Apply better alternatives like `useMemo`, `key` prop, and event handlers
+- Write Effects that are clean, maintainable, and free from race conditions
+
+## When to Use This Skill
+
+Use this skill when you're:
+- Writing or reviewing `useEffect` code
+- Using `useState` to store derived values
+- Implementing data fetching or subscriptions
+- Synchronizing state between components
+- Facing bugs with stale data or race conditions
+- Wondering if your Effect is necessary
+
+**Trigger phrases:**
+- "Should I use useEffect for this?"
+- "How do I fix this useEffect?"
+- "My Effect is causing too many re-renders"
+- "Data fetching with useEffect"
+- "Reset state when props change"
+- "Derived state from props"
+
+## How It Works
+
+This skill provides guidance through four key resources:
+
+1. **Quick Reference Table** - Fast lookup for common scenarios with DO/DON'T patterns
+2. **Decision Tree** - Visual flowchart to determine the right approach
+3. **Detailed Anti-Patterns** - 9 common mistakes with explanations and fixes
+4. **Better Alternatives** - 8 proven patterns to replace unnecessary Effects
+
+The skill teaches you to ask the right questions:
+- Is there an external system involved?
+- Am I responding to a user event or component appearance?
+- Can this value be calculated during render?
+- Do I need to reset state when a prop changes?
+
+## Key Features
+
+### 1. Quick Reference Guide
+
+Visual table showing the DO/DON'T for common scenarios:
+- Derived state from props/state
+- Expensive calculations
+- Resetting state on prop change
+- User event responses
+- Notifying parent components
+- Data fetching
+
+### 2. Decision Tree
+
+Clear flowchart that guides you from "Need to respond to something?" to the correct solution:
+- User interaction → Event handler
+- Component appeared → Effect (for external sync/analytics)
+- Derived value needed → Calculate during render (+ useMemo if expensive)
+- Reset state on prop change → Key prop
+
+### 3. Anti-Pattern Recognition
+
+Detailed examples of 9 common mistakes:
+1. Redundant state for derived values
+2. Filtering/transforming data in Effect
+3. Resetting state on prop change
+4. Event-specific logic in Effect
+5. Chains of Effects
+6. Notifying parent via Effect
+7. Passing data up to parent
+8. Fetching without cleanup (race conditions)
+9. App initialization in Effect
+
+Each anti-pattern includes:
+- Bad example with explanation
+- Good example with fix
+- Why the anti-pattern is problematic
+
+### 4. Better Alternatives
+
+8 proven patterns to replace unnecessary Effects:
+1. Calculate during render for derived state
+2. `useMemo` for expensive calculations
+3. `key` prop to reset state
+4. Store ID instead of object for stable references
+5. Event handlers for user actions
+6. `useSyncExternalStore` for external stores
+7. Lifting state up for shared state
+8. Custom hooks for data fetching with cleanup
+
+## Usage Examples
+
+### Example 1: Derived State
+
+**Bad - Unnecessary Effect:**
+```tsx
+function Form() {
+ const [firstName, setFirstName] = useState('Taylor');
+ const [lastName, setLastName] = useState('Swift');
+ const [fullName, setFullName] = useState('');
+
+ useEffect(() => {
+ setFullName(firstName + ' ' + lastName);
+ }, [firstName, lastName]);
+}
+```
+
+**Good - Calculate during render:**
+```tsx
+function Form() {
+ const [firstName, setFirstName] = useState('Taylor');
+ const [lastName, setLastName] = useState('Swift');
+ const fullName = firstName + ' ' + lastName; // Just compute it
+}
+```
+
+### Example 2: Resetting State
+
+**Bad - Effect to reset:**
+```tsx
+function ProfilePage({ userId }) {
+ const [comment, setComment] = useState('');
+
+ useEffect(() => {
+ setComment('');
+ }, [userId]);
+}
+```
+
+**Good - Key prop:**
+```tsx
+function ProfilePage({ userId }) {
+ return ;
+}
+
+function Profile({ userId }) {
+ const [comment, setComment] = useState(''); // Resets automatically
+}
+```
+
+### Example 3: Data Fetching with Cleanup
+
+**Bad - Race condition:**
+```tsx
+function SearchResults({ query }) {
+ const [results, setResults] = useState([]);
+
+ useEffect(() => {
+ fetchResults(query).then(json => {
+ setResults(json); // "hello" response may arrive after "hell"
+ });
+ }, [query]);
+}
+```
+
+**Good - Cleanup flag:**
+```tsx
+function SearchResults({ query }) {
+ const [results, setResults] = useState([]);
+
+ useEffect(() => {
+ let ignore = false;
+
+ fetchResults(query).then(json => {
+ if (!ignore) setResults(json);
+ });
+
+ return () => { ignore = true; };
+ }, [query]);
+}
+```
+
+### Example 4: Event Handler Instead of Effect
+
+**Bad - Effect watching state:**
+```tsx
+function ProductPage({ product, addToCart }) {
+ useEffect(() => {
+ if (product.isInCart) {
+ showNotification(`Added ${product.name}!`);
+ }
+ }, [product]);
+
+ function handleBuyClick() {
+ addToCart(product);
+ }
+}
+```
+
+**Good - Handle in event:**
+```tsx
+function ProductPage({ product, addToCart }) {
+ function handleBuyClick() {
+ addToCart(product);
+ showNotification(`Added ${product.name}!`);
+ }
+}
+```
+
+## When You DO Need Effects
+
+Effects are appropriate for:
+
+- **Synchronizing with external systems** - Browser APIs, third-party widgets, non-React code
+- **Subscriptions** - WebSocket connections, global event listeners (prefer `useSyncExternalStore`)
+- **Analytics/logging** - Code that needs to run because the component displayed
+- **Data fetching** - With proper cleanup (or use your framework's built-in mechanism)
+
+## When You DON'T Need Effects
+
+Avoid Effects for:
+
+1. **Transforming data for rendering** - Calculate at the top level instead
+2. **Handling user events** - Use event handlers where you know exactly what happened
+3. **Deriving state** - Just compute it: `const fullName = firstName + ' ' + lastName`
+4. **Chaining state updates** - Calculate all next state in the event handler
+5. **Notifying parent components** - Call the callback in the same event handler
+6. **Resetting state** - Use the `key` prop to create a fresh component instance
+
+## Best Practices
+
+### 1. Start Without an Effect
+
+Before adding an Effect, ask: "Is there an external system involved?" If no, you probably don't need an Effect.
+
+### 2. Prefer Derived State
+
+If you can calculate a value from props or state, don't store it in state with an Effect updating it.
+
+### 3. Use the Right Tool
+
+- Expensive calculation → `useMemo`
+- User interaction → Event handler
+- Reset on prop change → `key` prop
+- External subscription → `useSyncExternalStore`
+- Shared state → Lift state up
+
+### 4. Always Clean Up
+
+If your Effect subscribes, fetches, or sets timers, return a cleanup function to prevent memory leaks and race conditions.
+
+### 5. Avoid Effect Chains
+
+Multiple Effects triggering each other causes unnecessary re-renders and makes code hard to follow. Calculate everything in one place (usually an event handler).
+
+### 6. Test in Strict Mode
+
+React 18+ Strict Mode mounts components twice in development to expose missing cleanup. If your Effect breaks, you need cleanup.
+
+### 7. Consider Framework Solutions
+
+For data fetching, prefer your framework's built-in solution (Next.js, Remix) or libraries (React Query, SWR) over manual Effects.
+
+## Reference Files
+
+This skill includes three detailed reference documents:
+
+1. **SKILL.md** - Quick reference table and decision tree
+2. **anti-patterns.md** - 9 common mistakes with detailed explanations
+3. **alternatives.md** - 8 better alternatives with code examples
+
+## Common Pitfalls
+
+### Multiple Re-renders
+
+**Symptom:** Component re-renders many times in quick succession.
+
+**Cause:** Effect that sets state based on state it depends on, creating a loop.
+
+**Fix:** Calculate the final value in an event handler or during render.
+
+### Stale Data
+
+**Symptom:** UI shows outdated values briefly before updating.
+
+**Cause:** Using Effect to update derived state causes an extra render pass.
+
+**Fix:** Calculate derived values during render instead of in state.
+
+### Race Conditions
+
+**Symptom:** Fast typing shows results for old queries after new ones.
+
+**Cause:** Missing cleanup in data fetching Effect.
+
+**Fix:** Use cleanup flag (`ignore` variable) or AbortController.
+
+### Runs Twice in Development
+
+**Symptom:** Effect runs twice on component mount in development.
+
+**Cause:** React 18 Strict Mode intentionally mounts components twice to expose bugs.
+
+**Fix:** Add proper cleanup. If it's app initialization that shouldn't run twice, use a module-level guard.
+
+## Resources
+
+This skill is based on:
+- [React Official Docs: You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect)
+- [React Official Docs: Synchronizing with Effects](https://react.dev/learn/synchronizing-with-effects)
+- [React Official Docs: Lifecycle of Reactive Effects](https://react.dev/learn/lifecycle-of-reactive-effects)
+
+## Summary
+
+The golden rule: **Effects are an escape hatch from React.** If you're not synchronizing with an external system, you probably don't need an Effect.
+
+Before writing `useEffect`, ask yourself:
+1. Is this responding to a user interaction? → Use event handler
+2. Is this a value I can calculate from props/state? → Calculate during render
+3. Is this resetting state when a prop changes? → Use key prop
+4. Is this synchronizing with an external system? → Use Effect with cleanup
+
+Follow these patterns, and your React code will be more maintainable, performant, and bug-free.
diff --git a/.github/skills/react-useeffect/SKILL.md b/.github/skills/react-useeffect/SKILL.md
new file mode 100644
index 000000000000..d7c6ffb23fe4
--- /dev/null
+++ b/.github/skills/react-useeffect/SKILL.md
@@ -0,0 +1,53 @@
+---
+name: react-useeffect
+description: React useEffect best practices from official docs. Use when writing/reviewing useEffect, useState for derived values, data fetching, or state synchronization. Teaches when NOT to use Effect and better alternatives.
+---
+
+# You Might Not Need an Effect
+
+Effects are an **escape hatch** from React. They let you synchronize with external systems. If there is no external system involved, you shouldn't need an Effect.
+
+## Quick Reference
+
+| Situation | DON'T | DO |
+|-----------|-------|-----|
+| Derived state from props/state | `useState` + `useEffect` | Calculate during render |
+| Expensive calculations | `useEffect` to cache | `useMemo` |
+| Reset state on prop change | `useEffect` with `setState` | `key` prop |
+| User event responses | `useEffect` watching state | Event handler directly |
+| Notify parent of changes | `useEffect` calling `onChange` | Call in event handler |
+| Fetch data | `useEffect` without cleanup | `useEffect` with cleanup OR framework |
+
+## When You DO Need Effects
+
+- Synchronizing with **external systems** (non-React widgets, browser APIs)
+- **Subscriptions** to external stores (use `useSyncExternalStore` when possible)
+- **Analytics/logging** that runs because component displayed
+- **Data fetching** with proper cleanup (or use framework's built-in mechanism)
+
+## When You DON'T Need Effects
+
+1. **Transforming data for rendering** - Calculate at top level, re-runs automatically
+2. **Handling user events** - Use event handlers, you know exactly what happened
+3. **Deriving state** - Just compute it: `const fullName = firstName + ' ' + lastName`
+4. **Chaining state updates** - Calculate all next state in the event handler
+
+## Decision Tree
+
+```
+Need to respond to something?
+├── User interaction (click, submit, drag)?
+│ └── Use EVENT HANDLER
+├── Component appeared on screen?
+│ └── Use EFFECT (external sync, analytics)
+├── Props/state changed and need derived value?
+│ └── CALCULATE DURING RENDER
+│ └── Expensive? Use useMemo
+└── Need to reset state when prop changes?
+ └── Use KEY PROP on component
+```
+
+## Detailed Guidance
+
+- [Anti-Patterns](./anti-patterns.md) - Common mistakes with fixes
+- [Better Alternatives](./alternatives.md) - useMemo, key prop, lifting state, useSyncExternalStore
diff --git a/.github/skills/react-useeffect/alternatives.md b/.github/skills/react-useeffect/alternatives.md
new file mode 100644
index 000000000000..791744ab7049
--- /dev/null
+++ b/.github/skills/react-useeffect/alternatives.md
@@ -0,0 +1,258 @@
+# Better Alternatives to useEffect
+
+## 1. Calculate During Render (Derived State)
+
+For values derived from props or state, just compute them:
+
+```tsx
+function Form() {
+ const [firstName, setFirstName] = useState('Taylor');
+ const [lastName, setLastName] = useState('Swift');
+
+ // Runs every render - that's fine and intentional
+ const fullName = firstName + ' ' + lastName;
+ const isValid = firstName.length > 0 && lastName.length > 0;
+}
+```
+
+**When to use**: The value can be computed from existing props/state.
+
+---
+
+## 2. useMemo for Expensive Calculations
+
+When computation is expensive, memoize it:
+
+```tsx
+import { useMemo } from 'react';
+
+function TodoList({ todos, filter }) {
+ const visibleTodos = useMemo(
+ () => getFilteredTodos(todos, filter),
+ [todos, filter]
+ );
+}
+```
+
+**How to know if it's expensive**:
+```tsx
+console.time('filter');
+const visibleTodos = getFilteredTodos(todos, filter);
+console.timeEnd('filter');
+// If > 1ms, consider memoizing
+```
+
+**Note**: React Compiler can auto-memoize, reducing manual useMemo needs.
+
+---
+
+## 3. Key Prop to Reset State
+
+To reset ALL state when a prop changes, use key:
+
+```tsx
+// Parent passes userId as key
+function ProfilePage({ userId }) {
+ return (
+
+ );
+}
+
+function Profile({ userId }) {
+ // All state here resets when userId changes
+ const [comment, setComment] = useState('');
+ const [likes, setLikes] = useState([]);
+}
+```
+
+**When to use**: You want a "fresh start" when an identity prop changes.
+
+---
+
+## 4. Store ID Instead of Object
+
+To preserve selection when list changes:
+
+```tsx
+// BAD: Storing object that needs Effect to "adjust"
+function List({ items }) {
+ const [selection, setSelection] = useState(null);
+
+ useEffect(() => {
+ setSelection(null); // Reset when items change
+ }, [items]);
+}
+
+// GOOD: Store ID, derive object
+function List({ items }) {
+ const [selectedId, setSelectedId] = useState(null);
+
+ // Derived - no Effect needed
+ const selection = items.find(item => item.id === selectedId) ?? null;
+}
+```
+
+**Benefit**: If item with selectedId exists in new list, selection preserved.
+
+---
+
+## 5. Event Handlers for User Actions
+
+User clicks/submits/drags should be handled in event handlers, not Effects:
+
+```tsx
+// Event handler knows exactly what happened
+function ProductPage({ product, addToCart }) {
+ function handleBuyClick() {
+ addToCart(product);
+ showNotification(`Added ${product.name}!`);
+ analytics.track('product_added', { id: product.id });
+ }
+
+ function handleCheckoutClick() {
+ addToCart(product);
+ showNotification(`Added ${product.name}!`);
+ navigateTo('/checkout');
+ }
+}
+```
+
+**Shared logic**: Extract a function, call from both handlers:
+
+```tsx
+function buyProduct() {
+ addToCart(product);
+ showNotification(`Added ${product.name}!`);
+}
+
+function handleBuyClick() { buyProduct(); }
+function handleCheckoutClick() { buyProduct(); navigateTo('/checkout'); }
+```
+
+---
+
+## 6. useSyncExternalStore for External Stores
+
+For subscribing to external data (browser APIs, third-party stores):
+
+```tsx
+// Instead of manual Effect subscription
+function useOnlineStatus() {
+ const [isOnline, setIsOnline] = useState(true);
+
+ useEffect(() => {
+ function update() { setIsOnline(navigator.onLine); }
+ window.addEventListener('online', update);
+ window.addEventListener('offline', update);
+ return () => {
+ window.removeEventListener('online', update);
+ window.removeEventListener('offline', update);
+ };
+ }, []);
+
+ return isOnline;
+}
+
+// Use purpose-built hook
+import { useSyncExternalStore } from 'react';
+
+function subscribe(callback) {
+ window.addEventListener('online', callback);
+ window.addEventListener('offline', callback);
+ return () => {
+ window.removeEventListener('online', callback);
+ window.removeEventListener('offline', callback);
+ };
+}
+
+function useOnlineStatus() {
+ return useSyncExternalStore(
+ subscribe,
+ () => navigator.onLine, // Client value
+ () => true // Server value (SSR)
+ );
+}
+```
+
+---
+
+## 7. Lifting State Up
+
+When two components need synchronized state, lift it to common ancestor:
+
+```tsx
+// Instead of syncing via Effects between siblings
+function Parent() {
+ const [value, setValue] = useState('');
+
+ return (
+ <>
+
+
+ >
+ );
+}
+```
+
+---
+
+## 8. Custom Hooks for Data Fetching
+
+Extract fetch logic with proper cleanup:
+
+```tsx
+function useData(url) {
+ const [data, setData] = useState(null);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ let ignore = false;
+ setLoading(true);
+
+ fetch(url)
+ .then(res => res.json())
+ .then(json => {
+ if (!ignore) {
+ setData(json);
+ setError(null);
+ }
+ })
+ .catch(err => {
+ if (!ignore) setError(err);
+ })
+ .finally(() => {
+ if (!ignore) setLoading(false);
+ });
+
+ return () => { ignore = true; };
+ }, [url]);
+
+ return { data, error, loading };
+}
+
+// Usage
+function SearchResults({ query }) {
+ const { data, error, loading } = useData(`/api/search?q=${query}`);
+}
+```
+
+**Better**: Use framework's data fetching (React Query, SWR, Next.js, etc.)
+
+---
+
+## Summary: When to Use What
+
+| Need | Solution |
+|------|----------|
+| Value from props/state | Calculate during render |
+| Expensive calculation | `useMemo` |
+| Reset all state on prop change | `key` prop |
+| Respond to user action | Event handler |
+| Sync with external system | `useEffect` with cleanup |
+| Subscribe to external store | `useSyncExternalStore` |
+| Share state between components | Lift state up |
+| Fetch data | Custom hook with cleanup / framework |
diff --git a/.github/skills/react-useeffect/anti-patterns.md b/.github/skills/react-useeffect/anti-patterns.md
new file mode 100644
index 000000000000..d35151fdc8db
--- /dev/null
+++ b/.github/skills/react-useeffect/anti-patterns.md
@@ -0,0 +1,290 @@
+# useEffect Anti-Patterns
+
+## 1. Redundant State for Derived Values
+
+```tsx
+// BAD: Extra state + Effect for derived value
+function Form() {
+ const [firstName, setFirstName] = useState('Taylor');
+ const [lastName, setLastName] = useState('Swift');
+ const [fullName, setFullName] = useState('');
+
+ useEffect(() => {
+ setFullName(firstName + ' ' + lastName);
+ }, [firstName, lastName]);
+}
+
+// GOOD: Calculate during rendering
+function Form() {
+ const [firstName, setFirstName] = useState('Taylor');
+ const [lastName, setLastName] = useState('Swift');
+ const fullName = firstName + ' ' + lastName; // Just compute it
+}
+```
+
+**Why it's bad**: Causes extra render pass with stale value, then re-renders with updated value.
+
+---
+
+## 2. Filtering/Transforming Data in Effect
+
+```tsx
+// BAD: Effect to filter list
+function TodoList({ todos, filter }) {
+ const [visibleTodos, setVisibleTodos] = useState([]);
+
+ useEffect(() => {
+ setVisibleTodos(getFilteredTodos(todos, filter));
+ }, [todos, filter]);
+}
+
+// GOOD: Filter during render (memoize if expensive)
+function TodoList({ todos, filter }) {
+ const visibleTodos = useMemo(
+ () => getFilteredTodos(todos, filter),
+ [todos, filter]
+ );
+}
+```
+
+---
+
+## 3. Resetting State on Prop Change
+
+```tsx
+// BAD: Effect to reset state
+function ProfilePage({ userId }) {
+ const [comment, setComment] = useState('');
+
+ useEffect(() => {
+ setComment('');
+ }, [userId]);
+}
+
+// GOOD: Use key prop
+function ProfilePage({ userId }) {
+ return ;
+}
+
+function Profile({ userId }) {
+ const [comment, setComment] = useState(''); // Resets automatically
+}
+```
+
+**Why key works**: React treats components with different keys as different components, recreating state.
+
+---
+
+## 4. Event-Specific Logic in Effect
+
+```tsx
+// BAD: Effect for button click result
+function ProductPage({ product, addToCart }) {
+ useEffect(() => {
+ if (product.isInCart) {
+ showNotification(`Added ${product.name}!`);
+ }
+ }, [product]);
+
+ function handleBuyClick() {
+ addToCart(product);
+ }
+}
+
+// GOOD: Handle in event handler
+function ProductPage({ product, addToCart }) {
+ function handleBuyClick() {
+ addToCart(product);
+ showNotification(`Added ${product.name}!`);
+ }
+}
+```
+
+**Why it's bad**: Effect fires on page refresh (isInCart is true), showing notification unexpectedly.
+
+---
+
+## 5. Chains of Effects
+
+```tsx
+// BAD: Effects triggering each other
+function Game() {
+ const [card, setCard] = useState(null);
+ const [goldCardCount, setGoldCardCount] = useState(0);
+ const [round, setRound] = useState(1);
+ const [isGameOver, setIsGameOver] = useState(false);
+
+ useEffect(() => {
+ if (card?.gold) setGoldCardCount(c => c + 1);
+ }, [card]);
+
+ useEffect(() => {
+ if (goldCardCount > 3) {
+ setRound(r => r + 1);
+ setGoldCardCount(0);
+ }
+ }, [goldCardCount]);
+
+ useEffect(() => {
+ if (round > 5) setIsGameOver(true);
+ }, [round]);
+}
+
+// GOOD: Calculate in event handler
+function Game() {
+ const [card, setCard] = useState(null);
+ const [goldCardCount, setGoldCardCount] = useState(0);
+ const [round, setRound] = useState(1);
+ const isGameOver = round > 5; // Derived!
+
+ function handlePlaceCard(nextCard) {
+ if (isGameOver) throw Error('Game ended');
+
+ setCard(nextCard);
+ if (nextCard.gold) {
+ if (goldCardCount < 3) {
+ setGoldCardCount(goldCardCount + 1);
+ } else {
+ setGoldCardCount(0);
+ setRound(round + 1);
+ if (round === 5) alert('Good game!');
+ }
+ }
+ }
+}
+```
+
+**Why it's bad**: Multiple re-renders (setCard -> setGoldCardCount -> setRound -> setIsGameOver). Also fragile for features like history replay.
+
+---
+
+## 6. Notifying Parent via Effect
+
+```tsx
+// BAD: Effect to notify parent
+function Toggle({ onChange }) {
+ const [isOn, setIsOn] = useState(false);
+
+ useEffect(() => {
+ onChange(isOn);
+ }, [isOn, onChange]);
+
+ function handleClick() {
+ setIsOn(!isOn);
+ }
+}
+
+// GOOD: Notify in same event
+function Toggle({ onChange }) {
+ const [isOn, setIsOn] = useState(false);
+
+ function updateToggle(nextIsOn) {
+ setIsOn(nextIsOn);
+ onChange(nextIsOn); // Same event, batched render
+ }
+
+ function handleClick() {
+ updateToggle(!isOn);
+ }
+}
+
+// BEST: Fully controlled component
+function Toggle({ isOn, onChange }) {
+ function handleClick() {
+ onChange(!isOn);
+ }
+}
+```
+
+---
+
+## 7. Passing Data Up to Parent
+
+```tsx
+// BAD: Child fetches, passes up via Effect
+function Parent() {
+ const [data, setData] = useState(null);
+ return ;
+}
+
+function Child({ onFetched }) {
+ const data = useSomeAPI();
+
+ useEffect(() => {
+ if (data) onFetched(data);
+ }, [onFetched, data]);
+}
+
+// GOOD: Parent fetches, passes down
+function Parent() {
+ const data = useSomeAPI();
+ return ;
+}
+```
+
+**Why**: Data should flow down. Upward flow via Effects makes debugging hard.
+
+---
+
+## 8. Fetching Without Cleanup (Race Condition)
+
+```tsx
+// BAD: No cleanup - race condition
+function SearchResults({ query }) {
+ const [results, setResults] = useState([]);
+
+ useEffect(() => {
+ fetchResults(query).then(json => {
+ setResults(json); // "hello" response may arrive after "hell"
+ });
+ }, [query]);
+}
+
+// GOOD: Cleanup ignores stale responses
+function SearchResults({ query }) {
+ const [results, setResults] = useState([]);
+
+ useEffect(() => {
+ let ignore = false;
+
+ fetchResults(query).then(json => {
+ if (!ignore) setResults(json);
+ });
+
+ return () => { ignore = true; };
+ }, [query]);
+}
+```
+
+---
+
+## 9. App Initialization in Effect
+
+```tsx
+// BAD: Runs twice in dev, may break auth
+function App() {
+ useEffect(() => {
+ loadDataFromLocalStorage();
+ checkAuthToken(); // May invalidate token on second call!
+ }, []);
+}
+
+// GOOD: Module-level guard
+let didInit = false;
+
+function App() {
+ useEffect(() => {
+ if (!didInit) {
+ didInit = true;
+ loadDataFromLocalStorage();
+ checkAuthToken();
+ }
+ }, []);
+}
+
+// ALSO GOOD: Module-level execution
+if (typeof window !== 'undefined') {
+ checkAuthToken();
+ loadDataFromLocalStorage();
+}
+```
diff --git a/.github/skills/reviewable-thread-replies/SKILL.md b/.github/skills/reviewable-thread-replies/SKILL.md
new file mode 100644
index 000000000000..466157a088c5
--- /dev/null
+++ b/.github/skills/reviewable-thread-replies/SKILL.md
@@ -0,0 +1,93 @@
+---
+name: reviewable-thread-replies
+description: 'Reply to GitHub and Reviewable PR discussion threads one-by-one. Use whenever the user asks you to respond to review comments with accurate in-thread replies and verification.'
+argument-hint: 'Repo/PR and target comments to reply to (for example: BloomBooks/BloomDesktop#7557 + specific discussion links/IDs)'
+note: it's not clear that this skill is adequately developed, it's not clear that it works.
+---
+
+# Reviewable Thread Replies
+
+## What This Skill Does
+Posts in-thread replies on both:
+- GitHub PR review comments (`discussion_r...`)
+- Reviewable-only discussion anchors quoted in review bodies
+
+## When To Use
+- The user asks you to respond to one or more PR comments.
+- Some comments are directly replyable on GitHub, while others only exist as Reviewable anchors.
+- You need one response per thread, posted in the right place.
+
+## Inputs
+- figure out the PR using the gh cli
+- Target links or IDs (GitHub `discussion_r...` or Reviewable `#-...` anchors), or enough context to discover them.
+- Reply text supplied by user, or instruction to compose replies from thread context.
+
+## Required Reply Format
+- Every posted reply must begin with `[]`.
+- Do not prepend workflow labels (for example `Will do, TODO`).
+
+## Procedure
+1. Collect and normalize targets.
+- Build a list of target threads with: `target`, `context`, `response`.
+- If response text is not provided, compose a concise response from the thread context.
+- Separate items into:
+ - GitHub direct thread comments (have comment IDs / `discussion_r...`).
+ - Reviewable-only threads (anchor IDs like `-Oko...`).
+
+2. Post direct GitHub thread replies first.
+- Use GitHub PR review comment reply API/tool for each direct comment ID.
+- Post exactly one response per thread.
+- Verify the new reply IDs/URLs are returned.
+
+3. Open Reviewable, give the user time to authenticate.
+- Navigate to the PR in Reviewable.
+- If the user session is not active, use Reviewable sign-in flow and confirm identity before posting.
+
+4. Reply to Reviewable-only threads one by one.
+- For each discussion anchor:
+ - Navigate to the anchor.
+ - Find the thread reply input for that discussion.
+ - Post response text with the required `[]` prefix.
+ - Avoid adding status macros or extra prefixes.
+- Wait for each post to render before moving to the next thread.
+
+5. Verification pass.
+- Re-check every target thread and confirm the expected response appears.
+- Confirm no target remains unreplied due to navigation/context loss.
+- Confirm no accidental text prefixes were added.
+
+## Decision Points
+- If target has GitHub comment ID: use GitHub API/tool reply path.
+- If target exists only in Reviewable anchor: use browser automation path.
+- If Reviewable shows sign-in or disabled reply controls: authenticate first, then retry.
+- Never click `resolve`, `done`, or `acknowledge` controls and never change discussion resolution state.
+- If reply input transitions into a temporary composer panel:
+ - Submit without modifying response text semantics.
+ - Keep the required `[]` prefix and avoid workflow labels.
+- If posted text does not match intended response: correct immediately before continuing.
+
+## Quality Criteria
+- Exactly one intended response posted per target thread.
+- Responses are correct for thread context and begin with `[]`.
+- No unwanted prefixes like `Will do, TODO`.
+- No unresolved posting errors left undocumented.
+- Final status includes: posted targets and skipped/failed targets.
+
+## Guardrails
+- Do not post broad summary comments when thread-level replies were requested.
+- Do not resolve, acknowledge, dismiss, or otherwise change PR discussion status; leave resolution actions to humans.
+- Do not rely on internal/private page APIs for mutation unless officially supported and permission-safe.
+- Do not assume draft state implies publication; verify thread-visible posted output.
+- Do not continue after repeated auth/permission failures without reporting the blocker.
+
+## Quick Command Hints
+- List PR review comments:
+```bash
+ gh api repos///pulls//comments --paginate
+```
+
+- List PR reviews (to inspect review-body quoted discussions):
+```bash
+ gh api repos///pulls//reviews --paginate
+```
+
diff --git a/.gitignore b/.gitignore
index 60a48e184ee6..b12e32ca1abe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -199,3 +199,5 @@ src/BloomBrowserUI/react_components/component-tester/test-results/
src/BloomBrowserUI/test-results/
critiqueAI.json
+
+*.stackdump
diff --git a/AGENTS.md b/AGENTS.md
index 023f84f1b76d..15fe61cce73a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -43,3 +43,9 @@ It is vital that you not run `yarn build` unless instructed to. If there is alre
- Localizations for translatable strings are kept in DistFiles/localizations; new ones are initially added to one of the files in the "en" subdirectory
- Mark new XLF entries translate="no"
- Don't change the content or ID of an existing XLF entry unless it is new (marked translate="no"). Instead, mark the old one with a note saying it is "obsolete as of " and make a new entry with a different ID.
+
+# Commenting
+All public methods should have a comment. So should most private ones!
+
+# Git Committing
+Always include a good description when creating a git commit.
diff --git a/DistFiles/localization/en/Bloom.xlf b/DistFiles/localization/en/Bloom.xlf
index 91a0aed7fb16..dfcd47828ea1 100644
--- a/DistFiles/localization/en/Bloom.xlf
+++ b/DistFiles/localization/en/Bloom.xlf
@@ -550,6 +550,10 @@
SettingsID: CollectionSettingsDialog.CollectionSettingsWindowTitle
+
+ Collection Settings
+ ID: CollectionSettingsDialog.Title
+ Change...ID: CollectionSettingsDialog.LanguageTab.ChangeLanguageLink
@@ -910,6 +914,11 @@
ID: ColorPicker.NewA background color selection that enables a color picker.
+
+ Sample Color
+ ID: ColorPicker.SampleColor
+ Tooltip text on the eyedropper button that samples a color from the page.
+ Book SettingsID: Common.BookSettings
diff --git a/DistFiles/localization/en/BloomMediumPriority.xlf b/DistFiles/localization/en/BloomMediumPriority.xlf
index 6e55ef939b95..73f64d2e34f9 100644
--- a/DistFiles/localization/en/BloomMediumPriority.xlf
+++ b/DistFiles/localization/en/BloomMediumPriority.xlf
@@ -731,6 +731,47 @@
Book SettingsBookSettings.Titlethe heading of the dialog
+ Obsolete as of 6.4
+
+
+ Book and Page Settings
+ ID: BookAndPageSettings.Title
+ the heading of the dialog
+
+
+ Book
+ ID: BookAndPageSettings.BookArea
+ Area label for tabs/pages that affect all pages in the current book.
+
+
+ Book settings apply to all of the pages of the current book.
+ ID: BookAndPageSettings.BookArea.Description
+ Description text shown for the Book area in the combined Book and Page Settings dialog.
+
+
+ Current Page
+ ID: BookAndPageSettings.PageArea
+ Area label for tabs/pages that affect only the current page.
+
+
+ Page settings apply to the current page.
+ ID: BookAndPageSettings.PageArea.Description
+ Description text shown for the Page area in the combined Book and Page Settings dialog.
+
+
+ Colors
+ ID: BookAndPageSettings.Colors
+ Label for the page-level Colors page within the combined Book and Page Settings dialog.
+
+
+ Page Settings
+ ID: PageSettings.Title
+ Title text for the standalone Page Settings dialog and the page settings button label above custom pages.
+
+
+ Open Page Settings...
+ ID: PageSettings.OpenTooltip
+ Tooltip shown when hovering over the Page Settings button above a custom page.Max Image Size
@@ -814,6 +855,21 @@
Background ColorCommon.CoverBackgroundColor
+
+ Outline Color
+ ID: PageSettings.OutlineColor
+ Label for the page number outline color control on the page Colors settings page.
+
+
+ Use an outline color when the page number needs more contrast against the page.
+ ID: PageSettings.PageNumberOutlineColor.Description
+ Help text for the page number outline color control on the page Colors settings page.
+
+
+ Use a page number background color when the theme puts the number inside a shape, for example a circle, and you want to specify the color of that shape.
+ ID: PageSettings.PageNumberBackgroundColor.Description
+ Help text for the page number background color control on the page Colors settings page.
+ Front CoverBookSettings.WhatToShowOnCover
@@ -987,7 +1043,7 @@
Unnumbered PageID: BookSettings.Fonts.UnnumberedPage
-
+ Customized Front and Back Matter Page LayoutID: PageLayout.CustomXMatterPage
diff --git a/scripts/watchBloomExe.mjs b/scripts/watchBloomExe.mjs
index 35a4ab603bc7..0b583c999bee 100644
--- a/scripts/watchBloomExe.mjs
+++ b/scripts/watchBloomExe.mjs
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
+ findRunningStandardBloomInstances,
requireOptionValue,
requireTcpPortOption,
} from "../.github/skills/bloom-automation/bloomProcessCommon.mjs";
@@ -83,6 +84,42 @@ if (!existsSync(projectPath)) {
process.exit(1);
}
+const normalizeComparablePath = (value) =>
+ path.resolve(value).replace(/\//g, "\\").toLowerCase();
+
+const tryInferVitePortFromRunningBloom = async () => {
+ const runningInstances = await findRunningStandardBloomInstances();
+ const expectedRepoRoot = normalizeComparablePath(options.repoRoot);
+ const vitePorts = [
+ ...new Set(
+ runningInstances
+ .filter(
+ (instance) =>
+ instance.detectedRepoRoot &&
+ normalizeComparablePath(instance.detectedRepoRoot) ===
+ expectedRepoRoot &&
+ instance.vitePort,
+ )
+ .map((instance) => instance.vitePort),
+ ),
+ ];
+
+ if (vitePorts.length === 1) {
+ return vitePorts[0];
+ }
+
+ if (vitePorts.length > 1) {
+ console.warn(
+ `Multiple running Bloom instances from this worktree reported different Vite ports (${vitePorts.join(", ")}). Launching without an inherited Vite port.`,
+ );
+ }
+
+ return undefined;
+};
+
+const effectiveVitePort =
+ options.vitePort ?? (await tryInferVitePortFromRunningBloom());
+
const dotnetArgs = [
"watch",
"run",
@@ -98,12 +135,18 @@ if (startupLabel) {
dotnetArgs.push("--label", startupLabel);
}
-if (options.vitePort) {
- dotnetArgs.push("--vite-port", String(options.vitePort));
+if (effectiveVitePort) {
+ dotnetArgs.push("--vite-port", String(effectiveVitePort));
}
-if (options.vitePort) {
- console.log(`Bloom Vite dev port: ${options.vitePort}`);
+if (effectiveVitePort) {
+ if (options.vitePort) {
+ console.log(`Bloom Vite dev port: ${effectiveVitePort}`);
+ } else {
+ console.log(
+ `Inherited Bloom Vite dev port from running worktree instance: ${effectiveVitePort}`,
+ );
+ }
}
const createForwardingLineWriter = (target, onLine) => {
diff --git a/src/BloomBrowserUI/AGENTS.md b/src/BloomBrowserUI/AGENTS.md
index 0a5ec0663e44..b30c51f8e02e 100644
--- a/src/BloomBrowserUI/AGENTS.md
+++ b/src/BloomBrowserUI/AGENTS.md
@@ -35,39 +35,10 @@ When working in the front-end, cd to src/BloomBrowserUI
## About React useEffect
-Rule 1 — Use useEffect when synchronizing with external systems:
-Subscriptions, timers, or event listeners.
+See {repository root}/.github/skills/react-useeffect
-API calls or other asynchronous external operations.
-
-Updates to things outside React control (e.g., document.title, localStorage).
-
-Any side effect that cannot be computed during render.
-
-Rule 2 — Avoid useEffect when data can be derived or handled internally:
-
-State can be derived from props, context, or other state — compute in render.
-
-User interactions can be handled directly in event handlers.
-
-Local state reset/initialization can be handled by component keys or conditional rendering.
-
-Computed values can use useMemo or useCallback instead of syncing in an effect.
-
-Rule 3 — Validation heuristic:
-
-If removing the effect does not break external behavior, the effect is unnecessary.
-
-Implementation Tip for AI:
-
-Prefer pure render computation first.
-
-Add useEffect only when necessary for external side effects.
-
-Keep effects minimal and specific to their purpose; avoid overuse.
-
-Always include a comment before a useEffect explaining what it does and why it is necessary.
+If you read that and decide that a useEffect is warranted, you must add a comment justifying why it is necessary.
## UI Tests
diff --git a/src/BloomBrowserUI/bookEdit/StyleEditor/AudioHilitePage.tsx b/src/BloomBrowserUI/bookEdit/StyleEditor/AudioHilitePage.tsx
index b48deadeb33a..5b09dc975e52 100644
--- a/src/BloomBrowserUI/bookEdit/StyleEditor/AudioHilitePage.tsx
+++ b/src/BloomBrowserUI/bookEdit/StyleEditor/AudioHilitePage.tsx
@@ -98,7 +98,7 @@ export const AudioHilitePage: React.FunctionComponent<{
initialColor={props.hiliteTextColor || props.color}
localizedTitle={chooserTitleText}
width={84}
- transparency={false}
+ transparency={true}
palette={BloomPalette.Text}
onClose={(result, newColor) =>
props.onHilitePropsChanged(
diff --git a/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts b/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts
index d4c136e5ca51..462340883269 100644
--- a/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts
+++ b/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts
@@ -2300,7 +2300,7 @@ export default class StyleEditor {
onChange: (s: string) => void,
) {
const colorPickerDialogProps: ISimpleColorPickerDialogProps = {
- transparency: false,
+ transparency: true,
localizedTitle: title,
initialColor: initialColor,
palette: BloomPalette.Text,
diff --git a/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.saving.test.tsx b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.saving.test.tsx
new file mode 100644
index 000000000000..ef58d8abddcc
--- /dev/null
+++ b/src/BloomBrowserUI/bookEdit/bookAndPageSettings/BookAndPageSettingsDialog.saving.test.tsx
@@ -0,0 +1,273 @@
+import * as React from "react";
+import ReactDOM from "react-dom";
+import { act } from "react-dom/test-utils";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const { mockPost, mockPostJson, mockCloseDialog, configrPaneRenderState } =
+ vi.hoisted(() => ({
+ mockPost: vi.fn(),
+ mockPostJson: vi.fn(),
+ mockCloseDialog: vi.fn(),
+ configrPaneRenderState: {
+ lastInitialValues: undefined as
+ | {
+ appearance?: { cssThemeName?: string };
+ page: {
+ backgroundColor: string;
+ pageNumberColor: string;
+ pageNumberOutlineColor: string;
+ pageNumberBackgroundColor: string;
+ };
+ }
+ | undefined,
+ },
+ }));
+
+vi.mock("../../utils/shared", () => ({
+ getPageIframeBody: () => document.body,
+ getBloomPageElement: () =>
+ document.body.querySelector(".bloom-page") as HTMLElement | null,
+ whenBloomPageIsReady: (callback: (page: HTMLElement) => void) => {
+ const page = document.body.querySelector(".bloom-page") as HTMLElement;
+ callback(page);
+ return () => {};
+ },
+}));
+
+vi.mock("../../utils/bloomApi", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+
+ return {
+ ...actual,
+ post: mockPost,
+ postJson: mockPostJson,
+ useApiBoolean: () => [true],
+ useApiObject: (endpoint: string, defaultValue: unknown) => {
+ if (endpoint === "book/settings/appearanceUIOptions") {
+ return {
+ themeNames: [
+ { label: "Default", value: "default" },
+ {
+ label: "Rounded Border",
+ value: "rounded-border-ebook",
+ },
+ ],
+ };
+ }
+
+ if (endpoint === "book/settings/overrides") {
+ return defaultValue;
+ }
+
+ return defaultValue;
+ },
+ useApiStringState: () => [
+ JSON.stringify({ appearance: { cssThemeName: "default" } }),
+ ],
+ };
+});
+
+vi.mock("../../react_components/l10nHooks", () => ({
+ useL10n: (englishText: string) => englishText,
+}));
+
+vi.mock("../../react_components/featureStatus", () => ({
+ useGetFeatureStatus: () => ({ enabled: true }),
+}));
+
+vi.mock("../../react_components/BloomDialog/BloomDialogPlumbing", () => ({
+ useSetupBloomDialog: () => ({
+ closeDialog: mockCloseDialog,
+ propsForBloomDialog: { open: true },
+ }),
+}));
+
+vi.mock("../../react_components/BloomDialog/BloomDialog", () => {
+ const MockBloomDialog = React.forwardRef<
+ HTMLDivElement,
+ React.PropsWithChildren