diff --git a/package.json b/package.json
index bb5245dee..b551f636b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@openstax/ui-components",
-  "version": "1.18.7",
+  "version": "1.19.0",
   "license": "MIT",
   "source": "./src/index.ts",
   "types": "./dist/index.d.ts",
@@ -40,6 +40,7 @@
     "@types/dompurify": "^3.0.0",
     "@testing-library/jest-dom": "^6.4.8",
     "@testing-library/react": "^12.0.0",
+    "@testing-library/react-hooks": "^8.0.1",
     "@testing-library/user-event": "^14.5.2",
     "@types/jest": "^28.1.4",
     "@types/node": "^18.7.5",
diff --git a/src/components/HelpMenu.spec.tsx b/src/components/HelpMenu.spec.tsx
deleted file mode 100644
index db2d6cc2b..000000000
--- a/src/components/HelpMenu.spec.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { render } from '@testing-library/react';
-import { BodyPortalSlotsContext } from './BodyPortalSlotsContext';
-import { HelpMenu, HelpMenuItem } from './HelpMenu';
-import { NavBar } from './NavBar';
-
-describe('HelpMenu', () => {
-  let root: HTMLElement;
-
-  beforeEach(() => {
-    root = document.createElement('main');
-    root.id = 'root';
-    document.body.append(root);
-  });
-
-  it('matches snapshot', () => {
-    render(
-      
-        
-          
-             window.alert('Ran HelpMenu callback function')}>
-              Test Callback
-            
-          
-        
-      
-    );
-
-    expect(document.body).toMatchSnapshot();
-  });
-});
diff --git a/src/components/HelpMenu.stories.tsx b/src/components/HelpMenu.stories.tsx
deleted file mode 100644
index 4c7dd2cc3..000000000
--- a/src/components/HelpMenu.stories.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { createGlobalStyle } from 'styled-components';
-import { BodyPortalSlotsContext } from './BodyPortalSlotsContext';
-import { HelpMenu, HelpMenuItem } from './HelpMenu';
-import { NavBar } from './NavBar';
-
-const BodyPortalGlobalStyle = createGlobalStyle`
-  [data-portal-slot="nav"] {
-    position: fixed;
-    top: 0;
-    width: 100%;
-  }
-`;
-
-export const Default = () => {
-  return (
-    
-      
-      
-        
-           window.alert('Ran HelpMenu callback function')}>
-            Test Callback
-          
-        
-      
-    
-  );
-};
diff --git a/src/components/HelpMenu/HelpMenu.stories.tsx b/src/components/HelpMenu/HelpMenu.stories.tsx
new file mode 100644
index 000000000..db90653bb
--- /dev/null
+++ b/src/components/HelpMenu/HelpMenu.stories.tsx
@@ -0,0 +1,46 @@
+import { createGlobalStyle } from 'styled-components';
+import { BodyPortalSlotsContext } from '../BodyPortalSlotsContext';
+import { HelpMenu, HelpMenuItem, HelpMenuProps } from '.';
+import { NavBar } from '../NavBar';
+import { ChatConfiguration } from './hooks';
+
+const BodyPortalGlobalStyle = createGlobalStyle`
+  [data-portal-slot="nav"] {
+    position: fixed;
+    top: 0;
+    width: 100%;
+  }
+`;
+
+const happyHoursResponse: ChatConfiguration['businessHours'] = {
+  businessHoursInfo: {
+    businessHours: [
+      { startTime: Date.now() - 60_000, endTime: Date.now() + 1_440_000 }
+    ]
+  },
+  timestamp: Date.now(),
+};
+
+const contactParams: HelpMenuProps['contactFormParams'] = [
+  { key: 'userId', value: 'test' },
+  { key: 'userFirstName', value: 'test' },
+  { key: 'organizationName', value: 'org' },
+];
+
+const chatEmbedPath = 'https://localhost/assignable-chat';
+const chatEmbedParams: HelpMenuProps['chatConfig'] = {chatEmbedPath, businessHours: happyHoursResponse};
+
+export const Default = () => {
+  return (
+    
+      
+      
+        
+           window.alert('Ran HelpMenu callback function')}>
+            Test Callback
+          
+        
+      
+    
+  );
+};
diff --git a/src/components/__snapshots__/HelpMenu.spec.tsx.snap b/src/components/HelpMenu/__snapshots__/index.spec.tsx.snap
similarity index 61%
rename from src/components/__snapshots__/HelpMenu.spec.tsx.snap
rename to src/components/HelpMenu/__snapshots__/index.spec.tsx.snap
index 1cb9b0f48..19ed30bb2 100644
--- a/src/components/__snapshots__/HelpMenu.spec.tsx.snap
+++ b/src/components/HelpMenu/__snapshots__/index.spec.tsx.snap
@@ -3,6 +3,7 @@
 exports[`HelpMenu matches snapshot 1`] = `
 
   
   
-  
+  
+  
+  
+  
+  
 
 `;
diff --git a/src/components/HelpMenu/hooks.spec.tsx b/src/components/HelpMenu/hooks.spec.tsx
new file mode 100644
index 000000000..c71ddc283
--- /dev/null
+++ b/src/components/HelpMenu/hooks.spec.tsx
@@ -0,0 +1,574 @@
+// __tests__/useScript.test.tsx
+import { renderHook } from '@testing-library/react-hooks';
+import { BusinessHours, BusinessHoursResponse, formatBusinessHoursRange, getPreChatFields, useBusinessHours, useChatController, useHoursRange } from './hooks';
+import { act } from 'react-test-renderer';
+
+const makeBusinessHours = (startTime: number, endTime: number): BusinessHours => ({
+  startTime, endTime
+});
+const makeBusinessHoursResponse = (now: number, ...businessHours: BusinessHours[]): BusinessHoursResponse => ({
+  businessHoursInfo: { businessHours },
+  timestamp: now,
+});
+const makeResponse = ({ hours }: { hours: BusinessHoursResponse }) => ({
+  ...hours
+});
+
+describe('useBusinessHours', () => {
+  // Reset fetch before each test
+  beforeEach(() => {
+    global.fetch = undefined as any;
+    jest.clearAllMocks();
+  });
+
+  beforeAll(() => {
+    jest.useFakeTimers();
+    jest.setSystemTime(123456);
+  });
+
+  afterAll(() => {
+    jest.useRealTimers();
+  })
+
+  it('uses business hours', async () => {
+    const now = Date.now();
+    const active = makeBusinessHours(now - 1000, now + 5 * 60 * 1000);
+    const inactive = makeBusinessHours(now + 10000, now + 5 * 60 * 1000);
+    const hours = makeBusinessHoursResponse(now, inactive, active);
+    const response = makeResponse({ hours });
+    const timeoutSpy = jest.spyOn(global, 'setTimeout');
+
+    const { result } = renderHook(() => useBusinessHours(response, 0));
+
+    expect(timeoutSpy.mock.lastCall[1]).toBe(active.endTime - now);
+    expect(result.current).toEqual(active);
+  });
+
+  it('returns undefined when no hoursResponse is provided', () => {
+    const { result } = renderHook(() => useBusinessHours(undefined));
+    expect(result.current).toBeUndefined();
+  });
+
+  it('returns the hour while the window is active', () => {
+    const start = Date.now() - 1000;
+    const end   = Date.now() + 1000;
+    const response = makeResponse({
+      hours: makeBusinessHoursResponse(Date.now(), makeBusinessHours(start, end))
+    });
+  
+    const { result } = renderHook(() =>
+      useBusinessHours(response, 0)
+    );
+  
+    // The hook runs once immediately
+    expect(result.current).toEqual({ startTime: start, endTime: end });
+  
+    // The timeout is scheduled for max(end-now, 1000) → 1s
+    act(() => { jest.advanceTimersByTime(999) });
+    expect(result.current).toEqual({ startTime: start, endTime: end });
+  
+    act(() => { jest.advanceTimersByTime(1) });
+    expect(result.current).toBeUndefined();          // timeout cleared the state
+  });
+
+  it('has grace period that allows a match slightly before the start', () => {
+    const gracePeriod = 5000;
+    const start = Date.now() + 4000;
+    const end   = Date.now() + 10000;
+    const response = makeResponse({
+      hours: makeBusinessHoursResponse(Date.now(), makeBusinessHours(start, end))
+    });
+  
+    const { result } = renderHook(() =>
+      useBusinessHours(response, gracePeriod)
+    );
+  
+    // Because start – grace <= now, we should still match
+    expect(result.current).toEqual({
+      startTime: start, endTime: end
+    });
+  });
+
+  it('returns undefined when no hour matches the window', () => {
+    const start = Date.now() + 10000;
+    const end   = Date.now() + 20000;
+    const response = makeResponse({
+      hours: makeBusinessHoursResponse(Date.now(), makeBusinessHours(start, end))
+    });
+  
+    const { result } = renderHook(() =>
+      useBusinessHours(response, 0)
+    );
+  
+    expect(result.current).toBeUndefined();
+  });
+  
+  it('clears timeout on unmount', () => {
+    const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
+    const start = Date.now() - 1000;
+    const end   = Date.now() + 1000;
+    const response = makeResponse({
+      hours: makeBusinessHoursResponse(Date.now(), makeBusinessHours(start, end))
+    });
+  
+    const { unmount } = renderHook(() =>
+      useBusinessHours(response, 5000)
+    );
+  
+    unmount();
+    expect(clearTimeoutSpy).toHaveBeenCalled();        // ensure the cleanup cleared the timer
+  });
+});
+
+describe('formatBusinessHoursRange', () => {
+  beforeEach(() => {
+    jest.spyOn(console, 'warn').mockImplementation(() => {/* squelch */});
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  it('formats a normal range in 12-hour format with a short TZ for the end', () => {
+    const start = new Date('2023-01-01T09:00:00').getTime();   // 9 AM
+    const end   = new Date('2023-01-01T17:00:00').getTime();   // 5 PM
+
+    const result = formatBusinessHoursRange(start, end);
+
+    // We can’t pin down the exact TZ label (it depends on the CI machine),
+    // but we know the shape is:  -  .
+    //  Could be something like CST or maybe GMT-7
+    expect(result).toMatch(/^9\s*AM\s*-\s*5\s*PM\s*[A-Z0-9-]+$/);
+  });
+
+  it('handles noon to midnight correctly', () => {
+    const start = new Date('2023-01-01T12:00:00').getTime(); // 12 PM
+    const end   = new Date('2023-01-01T00:00:00').getTime(); // 12 AM next day
+
+    const result = formatBusinessHoursRange(start, end);
+    expect(result).toMatch(/^12\s*PM\s*-\s*12\s*AM\s*[A-Z0-9-]+$/);
+  });
+
+    it('returns an empty string when start or end is NaN', () => {
+    expect(formatBusinessHoursRange(NaN, 123456)).toBe('');
+    expect(formatBusinessHoursRange(123456, NaN)).toBe('');
+    expect(formatBusinessHoursRange(NaN, NaN)).toBe('');
+  });
+
+  it('returns an empty string when timestamps cannot be parsed to a Date', () => {
+    // A value that is a number but is outside the safe integer range
+    const big = Number.MAX_SAFE_INTEGER + 1;
+    expect(formatBusinessHoursRange(big, big)).toBe('');
+  });
+
+  it('falls back to raw hour numbers when Intl.DateTimeFormat throws', () => {
+    // 1.  Mock the constructor so that *any* call throws
+    jest.spyOn(Intl, 'DateTimeFormat').mockImplementation(() => {
+      throw new Error('forced failure');
+    });
+
+    const start = new Date('2023-01-01T09:00:00').getTime();
+    const end   = new Date('2023-01-01T17:00:00').getTime();
+
+    const result = formatBusinessHoursRange(start, end);
+
+    expect(result).toBe(`${new Date(start).getHours()} - ${new Date(end).getHours()}`);
+  });
+
+  it('calls console.warn with the expected message', () => {
+    const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { /* nothing */ });
+
+    // Force an error from Intl
+    jest.spyOn(Intl, 'DateTimeFormat').mockImplementation(() => {
+      throw new Error('boom');
+    })
+
+    formatBusinessHoursRange(
+      new Date('2023-01-01T09:00:00').getTime(),
+      new Date('2023-01-01T17:00:00').getTime(),
+    );
+
+    expect(warnSpy).toHaveBeenCalledTimes(1);
+    expect(warnSpy).toHaveBeenCalledWith(
+      expect.stringContaining('Intl.DateTimeFormat not available'),
+      expect.any(Error),
+    );
+  });
+});
+
+describe('useHoursRange', () => {
+  beforeAll(() => {
+    jest.useFakeTimers();
+    jest.setSystemTime(Date.UTC(2025, 1, 1, 5));
+  });
+
+  it('returns formatted range', () => {
+    const now = Date.now();
+    const active = makeBusinessHours(now - 1000, now + 5 * 60 * 1000);
+    const inactive = makeBusinessHours(now + 10000, now + 5 * 60 * 1000);
+    const hours = makeBusinessHoursResponse(now, inactive, active);
+    const response = makeResponse({ hours });
+    const { result } = renderHook(() => useHoursRange(response));
+
+    expect(result.current).toBe(
+      formatBusinessHoursRange(active.startTime, active.endTime)
+    );
+  });
+
+  it('memoizes correctly', async () => {
+    const now = Date.now();
+    const response1 = makeResponse(
+      {
+        hours: makeBusinessHoursResponse(now, makeBusinessHours(now - 1000, now + 2 * 3600 * 1000))
+      }
+    );
+    const response2 = makeResponse(
+      {
+        hours: makeBusinessHoursResponse(now, makeBusinessHours(now - 1000, now + 1 * 3600 * 1000))
+      }
+    );
+
+    const { result, rerender } = renderHook(
+      (props: Parameters) => useHoursRange(...props),
+      { initialProps: [response1, 0] },
+    );
+
+    rerender([response1, 0]);
+    const firstResult = result.current;
+    expect(typeof firstResult).toBe('string');
+    expect(firstResult?.length).toBeGreaterThan(0);
+
+    rerender([response2, 0]);
+    const secondResult = result.current;
+    expect(typeof secondResult).toBe('string');
+    expect(secondResult?.length).toBeGreaterThan(0);
+    expect(firstResult).not.toEqual(secondResult);
+  });
+});
+
+describe('getPreChatFields', () => {
+  it('returns preChat fields', () => {
+    const params: Parameters[0] = [
+      { key: 'assignmentId', value: '1' },
+      { key: 'userName', value: 'Thomas Andrews' },
+    ]
+    const result = getPreChatFields(params);
+    expect(result).toMatchInlineSnapshot(`
+Object {
+  "hiddenFields": Object {
+    "Assignment_Id": "1",
+  },
+  "visibleFields": Object {
+    "School": Object {
+      "isEditableByEndUser": true,
+      "value": "",
+    },
+    "_email": Object {
+      "isEditableByEndUser": true,
+      "value": "",
+    },
+    "_firstName": Object {
+      "isEditableByEndUser": true,
+      "value": "Thomas",
+    },
+    "_lastName": Object {
+      "isEditableByEndUser": true,
+      "value": "Andrews",
+    },
+  },
+}
+`);
+  });
+  it('makes visible fields readonly when set with info from accounts', () => {
+    const params: Parameters[0] = [
+      { key: 'assignmentId', value: '1' },
+      { key: 'userFirstName', value: 'Thomas' },
+      { key: 'userLastName', value: 'Andrews' },
+      { key: 'userEmail', value: 't@t' },
+      { key: 'organizationName', value: 'Some place' },
+    ]
+    const result = getPreChatFields(params);
+    expect(result).toMatchInlineSnapshot(`
+Object {
+  "hiddenFields": Object {
+    "Assignment_Id": "1",
+    "Email": "t@t",
+    "First_Name": "Thomas",
+    "Last_Name": "Andrews",
+    "School": "Some place",
+  },
+  "visibleFields": Object {
+    "School": Object {
+      "isEditableByEndUser": true,
+      "value": "Some place",
+    },
+    "_email": Object {
+      "isEditableByEndUser": false,
+      "value": "t@t",
+    },
+    "_firstName": Object {
+      "isEditableByEndUser": false,
+      "value": "Thomas",
+    },
+    "_lastName": Object {
+      "isEditableByEndUser": false,
+      "value": "Andrews",
+    },
+  },
+}
+`);
+  });
+});
+
+const createMockPopup = () => ({
+  postMessage: jest.fn(),
+  closed: false,
+  addEventListener: jest.fn(),
+  removeEventListener: jest.fn(),
+});
+
+describe('useChatController', () => {
+  const mockOpen = jest.fn();
+  const mockAddEventListener = jest.fn();
+  const mockRemoveEventListener = jest.fn();
+  const mockClearInterval = jest.fn();
+  const mockSetInterval = jest.fn();
+  const mockClearTimeout = jest.fn();
+  const mockSetTimeout = jest.fn();
+
+  beforeAll(() => {
+    global.window.open = mockOpen;
+    global.window.addEventListener = mockAddEventListener;
+    global.window.removeEventListener = mockRemoveEventListener;
+    global.setInterval = mockSetInterval as any;
+    global.clearInterval = mockClearInterval;
+    global.setTimeout = mockSetTimeout as any;
+    global.clearTimeout = mockClearTimeout;
+  });
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  const preChatFields = getPreChatFields([
+    { key: 'userEmail', value: 'alice@example.com' },
+  ]);
+
+  const path = 'https://example.com/chat';
+
+  /** 2.1. Hook returns an empty object when `path` is undefined */
+  it('returns {} when path is undefined', () => {
+    const { result } = renderHook(() => useChatController(undefined, preChatFields));
+    expect(result.current).toEqual({});
+  });
+
+  /** 2.2. `openChat` is defined only when a path is present */
+  it('exposes openChat when path is defined', () => {
+    const { result } = renderHook(() => useChatController(path, preChatFields));
+    expect(typeof result.current.openChat).toBe('function');
+  });
+
+  /** 2.3. `openChat` does nothing if a popup is already open */
+  it('does not open a new popup if one is already open', () => {
+    const mockPopup = createMockPopup();
+    // fake a previous open
+    mockOpen.mockReturnValue(mockPopup);
+    const { result } = renderHook(() => useChatController(path, preChatFields));
+
+    act(() => {
+      // simulate that the first call already created a popup
+      result.current.openChat?.();
+    });
+
+    // Second call – popup already exists
+    act(() => {
+      result.current.openChat?.();
+    });
+
+    expect(mockOpen).toHaveBeenCalledTimes(1);
+  });
+
+  /** 2.3. `openChat` creates a window with correct geometry */
+  it('opens a popup with the correct size and position', () => {
+    const mockWindow = createMockPopup();
+    mockOpen.mockReturnValue(mockWindow);
+
+    const { result } = renderHook(() => useChatController(path, preChatFields));
+
+    act(() => {
+      result.current.openChat?.();
+    });
+
+    // 1. `window.open` called once
+    expect(mockOpen).toHaveBeenCalledTimes(1);
+
+    // 2. Check the options string
+    const optionsString = mockOpen.mock.calls[0][2];
+    expect(optionsString).toMatch(/width=[^,]+/);
+    expect(optionsString).toMatch(/height=[^,]+/);
+    expect(optionsString).toMatch(/top=[^,]+/);   // bottom-right calc – exact values are hard to test reliably
+    expect(optionsString).toMatch(/left=[^,]+/);   // bottom-right calc – exact values are hard to test reliably
+    expect(optionsString).toContain('popup=true');
+  });
+
+  /** 2.4. `postMessage` flow – popup receives ready → preChatFields → open */
+  it('sends preChatFields and open messages when the child signals ready', () => {
+    const mockPopup = createMockPopup();
+    mockOpen.mockReturnValue(mockPopup);
+
+    const mockHandleMessage = jest.fn();
+    mockAddEventListener.mockImplementation((event: string, cb: () => void) => {
+      if (event === 'message') mockHandleMessage.mockImplementation(cb);
+    });
+
+    // Set up the interval that checks for `closed`
+    mockSetInterval.mockImplementation(() => 42 as any); // fake interval id
+
+    const { result } = renderHook(() => useChatController(path, preChatFields));
+
+    act(() => {
+      result.current.openChat?.();
+    });
+
+    // After opening the popup we register a `handleMessage` listener
+    expect(mockAddEventListener).toHaveBeenCalledWith('message', expect.any(Function), false);
+
+    // Simulate the chat window posting `"ready"`
+    const event: MessageEvent = {
+      source: mockPopup,
+      data: { type: 'ready' } as any,
+    } as any;
+
+    // Grab the actual `handleMessage` that was added
+    const handleMessage = mockAddEventListener.mock.calls.find(
+      (args) => args[0] === 'message'
+    )?.[1];
+    expect(handleMessage).toBeDefined();
+    act(() => {
+      handleMessage(event);
+    });
+
+    // `init` should have been called → sends `preChatFields` then `open`
+    expect(mockPopup.postMessage).toHaveBeenCalledTimes(2);
+    expect(mockPopup.postMessage).toHaveBeenNthCalledWith(
+      1,
+      { type: 'preChatFields', data: preChatFields },
+      new URL(path).origin
+    );
+    expect(mockPopup.postMessage).toHaveBeenNthCalledWith(
+      2,
+      { type: 'open' },
+      new URL(path).origin
+    );
+  });
+
+  /** 2.5. `openChat` cleans up the interval when the popup closes */
+  it('clears the polling interval and removes the message listener when popup closes', () => {
+    const mockPopup = createMockPopup();
+    const intervalId = 99;
+    mockOpen.mockReturnValue(mockPopup);
+    mockSetInterval.mockReturnValue(intervalId as any); // fake interval id
+
+    const { result } = renderHook(() => useChatController(path, preChatFields));
+
+    act(() => {
+      result.current.openChat?.();
+    });
+
+    // Simulate that the popup is closed after 1 tick
+    mockPopup.closed = true;  // set closed flag
+    act(() => {
+      // this calls the interval callback
+      const checkClosed = mockSetInterval.mock.calls[0][0];
+      checkClosed();
+    });
+
+    // Verify cleanup
+    expect(mockRemoveEventListener).toHaveBeenCalledWith('message', expect.any(Function), false);
+    expect(mockClearInterval).toHaveBeenCalledWith(intervalId);
+  });
+
+  /** 2.6. `sendMessage` respects the origin – if the origin does not match it does nothing */
+  it('does not postMessage if popup origin does not match path origin', () => {
+    const mismatchedPath = 'https://evil.com/evil';
+    const { result } = renderHook(() => useChatController(mismatchedPath, preChatFields));
+
+    act(() => {
+      result.current.openChat?.();
+    });
+
+    // popup opened with wrong origin – postMessage will not be called
+    const mockPopup = mockOpen.mock.results[0].value as any;
+    expect(mockPopup.postMessage).not.toBeCalled();
+  });
+
+  /** 2.7. The effect that watches `preChatFields` sends a message immediately on mount and whenever the preChatFields object changes */
+  it('re-sends preChatFields when the payload changes', () => {
+    const { result, rerender } = renderHook(
+      ({ path, fields }) => useChatController(path, fields),
+      {
+        initialProps: { path, fields: preChatFields },
+      }
+    );
+
+    // First render triggers the effect
+    const firstPopup = createMockPopup();
+    mockOpen.mockReturnValue(firstPopup);
+
+    act(() => {
+      result.current.openChat?.();
+    });
+
+    const event: MessageEvent = {
+      source: firstPopup,
+      data: { type: 'ready' } as any,
+    } as any;
+
+    // Grab the actual `handleMessage` that was added
+    const handleMessage = mockAddEventListener.mock.calls.find(
+      (args) => args[0] === 'message'
+    )?.[1];
+    expect(handleMessage).toBeDefined();
+    act(() => {
+      handleMessage(event);
+    });
+
+    // Now change the fields
+    const newFields = getPreChatFields([
+      { key: 'userEmail', value: 'bob@example.com' },
+    ]);
+
+    act(() => {
+      rerender({ path, fields: newFields });
+    });
+
+    expect(firstPopup.postMessage).toHaveBeenCalledTimes(3);
+    const lastCall = firstPopup.postMessage.mock.lastCall;
+    expect(lastCall[0]).toEqual({ type: 'preChatFields', data: newFields });
+  });
+
+  /** 2.8. `sendMessage` is a no-op if no popup has been opened */
+  it('does not sendMessage when popup.current is null', () => {
+    const { rerender } = renderHook(
+      ({ path, fields }) => useChatController(path, fields),
+      {
+        initialProps: { path, fields: preChatFields },
+      }
+    );
+
+    // First render triggers the effect
+    const firstPopup = createMockPopup();
+    mockOpen.mockReturnValue(firstPopup);
+
+    // Now change the fields
+    const newFields = getPreChatFields([
+      { key: 'userEmail', value: 'bob@example.com' },
+    ]);
+
+    act(() => {
+      rerender({ path, fields: newFields });
+    });
+
+    expect(firstPopup.postMessage).toHaveBeenCalledTimes(0);
+  });
+});
diff --git a/src/components/HelpMenu/hooks.ts b/src/components/HelpMenu/hooks.ts
new file mode 100644
index 000000000..9ecd1d8c4
--- /dev/null
+++ b/src/components/HelpMenu/hooks.ts
@@ -0,0 +1,230 @@
+import React from "react";
+
+export interface ApiError {
+  type: string;
+  detail: string;
+}
+
+export interface BusinessHours {
+  startTime: number;
+  endTime: number;
+}
+
+export interface BusinessHoursResponse {
+  businessHoursInfo: {
+    businessHours: BusinessHours[];
+  };
+  timestamp?: number;
+}
+
+export interface ChatConfiguration {
+  chatEmbedPath: string;
+  businessHours?: BusinessHoursResponse;
+  err?: ApiError;
+}
+
+// map assignable field name to Salesforce field name
+// These are currently defined in:
+// assignments/packages/frontend/src/components/SupportInfo.tsx
+const hiddenFieldsMapping = [
+  ["assignmentId", "Assignment_Id"],
+  ["contextId", "Context_Id"],
+  ["deploymentId", "Deployment_Id"],
+  ["platformId", "Platform_Id"],
+  ["registration", "Registration_Id"],
+  ["organizationName", "School"],
+  ["userEmail", "Email"],
+  ["userFirstName", "First_Name"],
+  ["userId", "OpenStax_UUID"],
+  ["userLastName", "Last_Name"],
+];
+
+const mapHiddenFields = (supportInfoMapping: { [key: string]: string }) =>
+  Object.fromEntries(
+    hiddenFieldsMapping
+      .map(([fromKey, toKey]) => [toKey, supportInfoMapping[fromKey]])
+      .filter(
+        (tuple): tuple is [string, string] =>
+          typeof tuple[0] === "string" && typeof tuple[1] === "string",
+      ),
+  );
+
+const mapVisibleFields = (supportInfoMapping: { [key: string]: string }) => {
+  // userFirstName, userLastName are from accounts
+  const { userName, userFirstName, userLastName, userEmail, organizationName } = supportInfoMapping;
+  const nameParts = userName?.split(" ") ?? [];
+  // Multiple first names?
+  const firstName = userFirstName ?? nameParts.slice(0, -1).join(" ");
+  // Hopefully no middle name
+  const lastName = userLastName ?? nameParts.slice(-1).join("");
+  // Fields that start with '_' are standard, non-custom fields
+  // If we don't get the info from accounts, then the field should be editable
+  const isValid = (s: unknown) => typeof s === 'string' && s.length > 0;
+  const visibleEntries: [string, string, boolean][] = [
+    ["_firstName", firstName, !isValid(userFirstName)],
+    ["_lastName", lastName, !isValid(userLastName)],
+    ["_email", userEmail ?? "", !isValid(userEmail)],
+    ["School", organizationName ?? "", true],
+  ];
+  return Object.fromEntries(
+    visibleEntries.map(([key, value, isEditableByEndUser]) => [
+      key,
+      { value, isEditableByEndUser },
+    ]),
+  );
+};
+
+export const getPreChatFields = (contactFormParams: { key: string; value: string }[]) => {
+  const supportInfoMapping = Object.fromEntries(
+    contactFormParams.map(({ key, value }) => [key, value]),
+  );
+  return {
+    visibleFields: mapVisibleFields(supportInfoMapping),
+    hiddenFields: mapHiddenFields(supportInfoMapping),
+  };
+};
+
+export const useBusinessHours = (
+  hoursResponse: ChatConfiguration["businessHours"] | undefined,
+  gracePeriod = 5_000,
+) => {
+  const timeoutRef = React.useRef();
+  const [hours, setHours] = React.useState();
+
+  React.useEffect(() => {
+    let nextState: BusinessHours | undefined;
+    if (hoursResponse !== undefined) {
+      const now = Date.now();
+      const { businessHoursInfo: { businessHours } } = hoursResponse;
+      nextState = businessHours.find(
+        (h) => h.startTime - gracePeriod <= now && now < h.endTime + gracePeriod,
+      );
+    }
+    clearTimeout(timeoutRef.current);
+    if (nextState !== undefined) {
+      const dT = Math.max(nextState.endTime - Date.now(), 1000);
+      // Unset business hours at the end time
+      timeoutRef.current = setTimeout(() => {
+        setHours(undefined);
+      }, dT);
+    }
+    setHours((prev) =>
+      prev !== undefined &&
+      prev.startTime === nextState?.startTime &&
+      prev.endTime === nextState?.endTime
+        ? prev
+        : nextState,
+    );
+    return () => {
+      clearTimeout(timeoutRef.current);
+    };
+  }, [hoursResponse, gracePeriod]);
+
+  return hours;
+};
+
+export const formatBusinessHoursRange = (startTime: number, endTime: number) => {
+  // Ensure we are working with a real Date instance
+  const startDate = new Date(startTime);
+  const endDate = new Date(endTime);
+
+  // Bail if the timestamps are not valid numbers
+  if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) return "";
+
+  try {
+    const baseOptions: Parameters[1] = {
+      hour: "numeric",
+      hour12: true,
+    };
+    const start = new Intl.DateTimeFormat(undefined, baseOptions).format(startDate);
+    const end = new Intl.DateTimeFormat(undefined, {
+      ...baseOptions,
+      timeZoneName: "short",
+    }).format(endDate);
+    // Ex: 9 AM - 5 PM CDT
+    return `${start} - ${end}`;
+  } catch (e) {
+    console.warn("Intl.DateTimeFormat not available, falling back to simple hours.", e);
+    // Ex: 9 - 17
+    return `${startDate.getHours()} - ${endDate.getHours()}`;
+  }
+};
+
+export const useHoursRange = (
+  businessHours: ChatConfiguration["businessHours"],
+  gracePeriod?: number,
+) => {
+  const hours = useBusinessHours(businessHours, gracePeriod);
+  return React.useMemo(() => (
+    hours ? formatBusinessHoursRange(hours.startTime, hours.endTime) : undefined
+  ), [hours]);
+};
+
+export const useChatController = (
+  path: string | undefined,
+  preChatFields: ReturnType,
+) => {
+  const popup = React.useRef(null);
+  const popupOrigin = React.useMemo(() => (
+    path ? new URL(path).origin : undefined
+  ), [path]);
+
+  const sendMessage = React.useCallback(
+    (message: { type: string; data?: T }) => {
+      if (!popup.current || !popupOrigin) return;
+      popup.current.postMessage(message, popupOrigin);
+    },
+    [popupOrigin],
+  );
+
+  const sendPreChatFields = React.useCallback(() => {
+    sendMessage({ type: "preChatFields", data: preChatFields });
+  }, [sendMessage, preChatFields]);
+
+  const init = React.useCallback(() => {
+    sendPreChatFields();
+    sendMessage({ type: "open" });
+  }, [sendMessage, sendPreChatFields]);
+
+  const openChat = React.useCallback(() => {
+    if (popup.current || !path) return;
+    const width = 500;
+    const height = 800;
+
+    // Calculate Bottom-Right Position
+    const rightX = (window.screenX || window.screenLeft) + window.outerWidth;
+    const bottomY = (window.screenY || window.screenTop) + window.outerHeight;
+    const top = bottomY - height;
+    const left = rightX - width;
+
+    const options = Object.entries({ popup: true, width, height, top, left })
+      .map(([k, v]) => `${k}=${v}`)
+      .join(",");
+    popup.current = window.open(path, "_blank", options);
+
+    if (!popup.current) return;
+
+    const handleMessage = (e: MessageEvent) => {
+      const { source, data: { type } } = e;
+      if (source !== popup.current) return;
+      if (type === "ready") init();
+    };
+
+    const checkClosed = setInterval(() => {
+      if (popup.current?.closed) {
+        window.removeEventListener("message", handleMessage, false);
+        popup.current = null;
+        clearInterval(checkClosed);
+      }
+    }, 500);
+
+    window.addEventListener("message", handleMessage, false);
+  }, [path, init]);
+
+  // Send pre-chat fields again immediately if they change
+  React.useEffect(() => {
+    sendPreChatFields();
+  }, [sendPreChatFields]);
+
+  return path ? { openChat } : {};
+};
diff --git a/src/components/HelpMenu/index.spec.tsx b/src/components/HelpMenu/index.spec.tsx
new file mode 100644
index 000000000..4d7909c07
--- /dev/null
+++ b/src/components/HelpMenu/index.spec.tsx
@@ -0,0 +1,97 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { BodyPortalSlotsContext } from '../BodyPortalSlotsContext';
+import { HelpMenu, HelpMenuItem, HelpMenuProps } from '.';
+import { NavBar } from '../NavBar';
+import { ChatConfiguration } from './hooks';
+
+describe('HelpMenu', () => {
+  let root: HTMLElement;
+
+  beforeAll(() => {
+    global.CSS = {
+      supports: () => true,
+      escape: jest.fn(),
+    } as any;
+    jest.useFakeTimers();
+    jest.setSystemTime(0);
+  });
+
+  beforeEach(() => {
+    root = document.createElement('main');
+    root.id = 'root';
+    document.body.append(root);
+  });
+
+  it('matches snapshot', async () => {
+    render(
+      
+        
+          
+             window.alert('Ran HelpMenu callback function')}>
+              Test Callback
+            
+          
+        
+      
+    );
+
+    // Reveal the default button in the help menu
+    fireEvent.click(await screen.findByText('Help'));
+    screen.getByText(/Report an issue/i);
+
+    expect(document.body).toMatchSnapshot();
+  });
+
+  it('errors if the service is unavailable', async () => {
+    const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {
+      // SILENCE
+    });
+    const errorResponse: ChatConfiguration['err'] = {
+      type: 'test',
+      detail: 'test'
+    };
+    const chatEmbedPath = 'https://example.com/';
+    const chatEmbedParams: HelpMenuProps['chatConfig'] = {chatEmbedPath, err: errorResponse};
+
+    render(
+      
+        
+          
+             window.alert('Ran HelpMenu callback function')}>
+              Test Callback
+            
+          
+        
+      
+    );
+    fireEvent.click(await screen.findByText('Help'));
+    expect(consoleSpy).toHaveBeenCalledTimes(1);
+  });
+
+  it('replaces button within hours', async () => {
+    const happyHoursResponse: ChatConfiguration['businessHours'] = {
+      businessHoursInfo: {
+        businessHours: [
+          { startTime: Date.now() - 60_000, endTime: Date.now() + 1_440_000 }
+        ]
+      },
+      timestamp: Date.now(),
+    };
+    const chatEmbedPath = 'https://example.com/';
+    const chatEmbedParams: HelpMenuProps['chatConfig'] = {chatEmbedPath, businessHours: happyHoursResponse};
+  
+    render(
+      
+        
+          
+             window.alert('Ran HelpMenu callback function')}>
+              Test Callback
+            
+          
+        
+      
+    );
+    fireEvent.click(await screen.findByText('Help'));
+    await screen.findByRole('menuitem', { name: /chat with us/i });
+  });
+});
diff --git a/src/components/HelpMenu.tsx b/src/components/HelpMenu/index.tsx
similarity index 77%
rename from src/components/HelpMenu.tsx
rename to src/components/HelpMenu/index.tsx
index 46e0bfefb..c4ad9c382 100644
--- a/src/components/HelpMenu.tsx
+++ b/src/components/HelpMenu/index.tsx
@@ -1,8 +1,9 @@
 import React from 'react';
-import { NavBarMenuButton, NavBarMenuItem } from './NavBarMenuButtons';
-import { colors } from '../theme';
+import { NavBarMenuButton, NavBarMenuItem } from '../NavBarMenuButtons';
+import { colors } from '../../theme';
 import styled from 'styled-components';
-import { BodyPortal } from './BodyPortal';
+import { BodyPortal } from '../BodyPortal';
+import { ChatConfiguration, getPreChatFields, useChatController, useHoursRange } from './hooks';
 
 export const HelpMenuButton = styled(NavBarMenuButton)`
   color: ${colors.palette.gray};
@@ -102,11 +103,20 @@ export const NewTabIcon = () => (
 
 export interface HelpMenuProps {
   contactFormParams: { key: string; value: string }[];
+  chatConfig?: Partial;
   children?: React.ReactNode;
 }
 
-export const HelpMenu: React.FC = ({ contactFormParams, children }) => {
+export const HelpMenu: React.FC = ({ contactFormParams, chatConfig, children }) => {
   const [showIframe, setShowIframe] = React.useState();
+  const { chatEmbedPath, businessHours, err: chatError } = React.useMemo(() => (
+    chatConfig ?? {}
+  ), [chatConfig]);
+  const hoursRange = useHoursRange(businessHours);
+  const preChatFields = React.useMemo(() => (
+    getPreChatFields(contactFormParams)
+  ), [contactFormParams]);
+  const { openChat } = useChatController(chatEmbedPath, preChatFields);
 
   const contactFormUrl = React.useMemo(() => {
     const formUrl = 'https://openstax.org/embedded/contact';
@@ -117,7 +127,7 @@ export const HelpMenu: React.FC = ({ contactFormParams, children
 
     return `${formUrl}?${params}`;
   }, [contactFormParams]);
-
+  
   React.useEffect(() => {
     const closeIt = ({data}: MessageEvent) => {
       if (data === 'CONTACT_FORM_SUBMITTED') {
@@ -129,12 +139,25 @@ export const HelpMenu: React.FC = ({ contactFormParams, children
     return () => window.removeEventListener('message', closeIt, false);
   }, []);
 
+  if (chatError) {
+    // Silently fail while leaving some indication as to why
+    console.error('Error getting chat config', chatError);
+  }
+
   return (
     <>
       
-         setShowIframe(contactFormUrl)}>
-          Report an issue
-        
+        {hoursRange && openChat
+          ? (
+             openChat()}>
+              Chat With Us ({hoursRange})
+            
+          ) : (
+             setShowIframe(contactFormUrl)}>
+              Report an issue
+            
+          )
+        }
         {children}
       
 
diff --git a/yarn.lock b/yarn.lock
index ae2050c41..b816cbe01 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5487,6 +5487,14 @@
     lodash "^4.17.21"
     redent "^3.0.0"
 
+"@testing-library/react-hooks@^8.0.1":
+  version "8.0.1"
+  resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12"
+  integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==
+  dependencies:
+    "@babel/runtime" "^7.12.5"
+    react-error-boundary "^3.1.0"
+
 "@testing-library/react@^12.0.0":
   version "12.1.5"
   resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b"
@@ -11550,6 +11558,13 @@ react-dom@^17.0.2:
     object-assign "^4.1.1"
     scheduler "^0.20.2"
 
+react-error-boundary@^3.1.0:
+  version "3.1.4"
+  resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0"
+  integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==
+  dependencies:
+    "@babel/runtime" "^7.12.5"
+
 react-frame-component@^5.2.3:
   version "5.2.3"
   resolved "https://registry.yarnpkg.com/react-frame-component/-/react-frame-component-5.2.3.tgz#2d5d1e29b23d5b915c839b44980d03bb9cafc453"