diff --git a/apps/zoom-app/src/b/LookRows.jsx b/apps/zoom-app/src/b/LookRows.jsx
new file mode 100644
index 0000000..716f2a9
--- /dev/null
+++ b/apps/zoom-app/src/b/LookRows.jsx
@@ -0,0 +1,58 @@
+import { ChevronRight, Image, Layers, Video, Volume2 } from 'lucide-react';
+
+const ROW =
+ 'flex w-full items-center gap-2.5 rounded-[10px] border border-gray-200 bg-white px-3 py-2.5 text-left text-sm text-gray-900 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-blue-500';
+
+/** Design B's names for the three display modes. */
+export const VIDEO_MODE_NOTES = Object.freeze({
+ camera: 'Camera + timer',
+ card: 'Timer only',
+ stage: 'Full-screen timer',
+});
+
+/**
+ * "Look & sound" (the BLook board): one wide row per setting, each showing
+ * what it is set to now and opening only its own sheet.
+ *
+ * @param {{
+ * video: string,
+ * cards: string,
+ * sounds: string,
+ * soundsLocked: boolean,
+ * between: string,
+ * onOpen: (sheet: 'video'|'cards'|'sounds'|'between') => void,
+ * }} props
+ */
+export default function LookRows({ video, cards, sounds, soundsLocked, between, onOpen }) {
+ const rows = [
+ { id: 'video', label: 'On your video', note: video, Icon: Video },
+ { id: 'cards', label: 'Cards', note: cards, Icon: Image },
+ { id: 'sounds', label: 'Sounds', note: soundsLocked ? null : sounds, Icon: Volume2, pro: soundsLocked },
+ { id: 'between', label: 'Between speeches', note: between, Icon: Layers },
+ ];
+
+ return (
+
+
+ Look & sound
+
+
+ {rows.map(({ id, label, note, Icon, pro }) => (
+ onOpen(id)}
+ aria-label={`${label}${pro ? ', Pro' : note ? `: ${note}` : ''}`}
+ className={ROW}
+ >
+
+ {label}
+ {pro && PRO }
+ {note && {note} }
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/zoom-app/src/b/LookSheets.jsx b/apps/zoom-app/src/b/LookSheets.jsx
new file mode 100644
index 0000000..89ecce4
--- /dev/null
+++ b/apps/zoom-app/src/b/LookSheets.jsx
@@ -0,0 +1,178 @@
+import { Check, Info } from 'lucide-react';
+import CardSetsPanel from '../components/CardSetsPanel';
+import SoundsPanel from '../components/SoundsPanel';
+import OwnBackgroundPicker from '../components/OwnBackgroundPicker';
+import { soundEngine } from '../hooks/useSounds';
+import Sheet from './Sheet';
+
+const CHOICE = 'flex w-full items-center gap-3 rounded-xl px-3 text-left';
+
+/** A 72×42 sketch of what the meeting sees, for the mode and background choices. */
+function Sketch({ kind }) {
+ const person = (
+ <>
+
+
+ >
+ );
+ const base = 'relative h-[42px] w-[72px] flex-shrink-0 overflow-hidden rounded-md';
+ if (kind === 'camera') return {person} ;
+ if (kind === 'card')
+ return (
+
+ 04:27
+
+ );
+ if (kind === 'stage')
+ return (
+
+ FULL
+
+ );
+ if (kind === 'card-idle') return {person} ;
+ return (
+
+
+
+
+ );
+}
+
+function Choice({ selected, onSelect, sketch, title, detail, children }) {
+ return (
+
+
+ {sketch && }
+
+ {title}
+ {detail}
+
+ {selected && }
+
+ {children}
+
+ );
+}
+
+const MODES = [
+ { mode: 'camera', title: 'Camera + timer', detail: 'The colour sits behind you' },
+ { mode: 'card', title: 'Timer only', detail: 'The colour replaces your video' },
+ { mode: 'stage', title: 'Full-screen timer', detail: 'A big timer to share or pop out' },
+];
+
+/** "On your video" (board B-L01): how the meeting sees the timer. */
+export function VideoSheet({ open, onClose, overlayMode, onSelect, onClear, isClearing }) {
+ return (
+
+
+ {MODES.map(({ mode, title, detail }) => (
+ onSelect(mode)}
+ sketch={mode}
+ title={title}
+ detail={detail}
+ />
+ ))}
+
+
+ Timer stuck on your video?
+
+ {isClearing ? 'Clearing…' : 'Clear from my video'}
+
+
+
+ );
+}
+
+/** "Cards" (boards B-L03, L04): the card sets, and nothing else. */
+export function CardsSheet({ open, onClose, onImagesChanged }) {
+ return (
+
+
+
+ );
+}
+
+/** "Sounds" (boards B-L05, L06): the Pro sounds, locked or not. */
+export function SoundsSheet({ open, onClose }) {
+ const close = () => {
+ soundEngine.stopMusic();
+ onClose();
+ };
+ return (
+ PRO}
+ description="For speakers who can't watch the card, and for a livelier break."
+ >
+
+
+ );
+}
+
+/**
+ * "Between speeches" (board B-L07): what the video shows when no timer runs.
+ * Either the blue timer card stays up (today's default), or the video is
+ * handed back after every speech, to the organizer's own background when they
+ * have saved one.
+ */
+export function BetweenSheet({ open, onClose, revealFaceWhenIdle, onChoose, onBackgroundChanged }) {
+ return (
+
+
+
onChoose(false)}
+ sketch="card-idle"
+ title="Timer card"
+ detail="The blue card stays on your video"
+ />
+ onChoose(true)}
+ sketch="video"
+ title="My video"
+ detail="The timer comes off after each speech"
+ >
+ {revealFaceWhenIdle && (
+
+
+
+ )}
+
+
+
+
+
+
+ {revealFaceWhenIdle
+ ? 'After each speech, the timer puts your own background back, or what Zoom had before when you have not saved one. Zoom may ask you to allow the change.'
+ : 'Between speeches the blue card stays up, so the meeting always sees the timer.'}
+
+
+
+ Saved in this browser only. Upload only images you have the right to use.
+
+
+ );
+}
diff --git a/apps/zoom-app/src/b/MoreMenu.jsx b/apps/zoom-app/src/b/MoreMenu.jsx
new file mode 100644
index 0000000..32d5550
--- /dev/null
+++ b/apps/zoom-app/src/b/MoreMenu.jsx
@@ -0,0 +1,182 @@
+import { lazy, Suspense, useRef, useState } from 'react';
+import { Eraser, KeyRound, MessageSquare, MoreHorizontal, PlayCircle, SlidersHorizontal, Star } from 'lucide-react';
+import {
+ FEEDBACK_SURVEY_ID,
+ REVIEW_PROMPT,
+ REVIEW_SURVEY_ID,
+ TUTORIALS_URL,
+ ZOOM_MARKETPLACE_REVIEW_URL,
+ markPromptAnswered,
+} from '@toastmaster-timer/shared';
+import { useEntitlement } from '../hooks/useEntitlement';
+import { useFlag } from '../hooks/useFlag';
+import { useClub } from '../hooks/useClub';
+import { useToast } from '../context/ToastContext';
+import { openExternalUrl } from '../utils/zoomSdk';
+import { trackEvent } from '../utils/posthog';
+import { moveFocusWithArrows, usePopover } from './usePopover';
+
+const FeedbackModal = lazy(() => import('../components/FeedbackModal'));
+const UpgradeModal = lazy(() => import('../components/UpgradeModal'));
+
+const ITEM =
+ 'flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-sm text-gray-900 hover:bg-slate-50 focus:bg-slate-50 focus:outline-none';
+const ICON = 'h-4 w-4 flex-shrink-0 text-slate-500';
+
+const idle = (fn) => (window.requestIdleCallback || setTimeout)(fn);
+
+/**
+ * The ⋯ menu at the end of the top bar: everything that used to sit in the
+ * footer or around the timer but is not needed during a speech.
+ *
+ * Your plan and the club code appear only once Pro is released, like every
+ * other door into Pro. "Clear the timer from my video" and "Roles & timing
+ * rules" are handed to the shell, which owns the Timer tab and the rules
+ * dialog; the rest (feedback, the review link, the tutorials) are handled
+ * here.
+ *
+ * @param {{onClearVideo: () => void, onOpenRules: () => void}} props
+ */
+export default function MoreMenu({ onClearVideo, onOpenRules }) {
+ const [open, setOpen] = useState(false);
+ const [upgradeSource, setUpgradeSource] = useState(null);
+ const [showFeedback, setShowFeedback] = useState(false);
+ const buttonRef = useRef(null);
+ const menuRef = useRef(null);
+ const { showToast } = useToast();
+ const { isPro, known } = useEntitlement();
+ const { enabled: proEnabled, known: flagsKnown } = useFlag('pro');
+ const { club } = useClub();
+ const proOn = flagsKnown && proEnabled && known;
+
+ const close = () => setOpen(false);
+ usePopover(open, close, [buttonRef, menuRef]);
+
+ const openMenu = () => {
+ setOpen(true);
+ idle(() => trackEvent('more_menu_opened'));
+ // Focus the first item once it is drawn, so arrows work straight away.
+ setTimeout(() => menuRef.current?.querySelector('[role="menuitem"]')?.focus(), 0);
+ };
+
+ const choose = (action) => () => {
+ close();
+ buttonRef.current?.focus();
+ action();
+ };
+
+ const handleFeedback = () => {
+ idle(() => {
+ trackEvent('feedback_button_clicked', { source: 'more_menu' });
+ trackEvent('survey shown', { $survey_id: FEEDBACK_SURVEY_ID });
+ });
+ setShowFeedback(true);
+ };
+
+ // As the old footer's Review button: the periodic ask retires only once the
+ // listing actually opened.
+ const handleRate = async () => {
+ trackEvent('review_prompt_accepted', { destination: 'zoom_marketplace', source: 'more_menu' });
+ if (!(await openExternalUrl(ZOOM_MARKETPLACE_REVIEW_URL))) {
+ trackEvent('review_link_failed', { destination: 'zoom_marketplace', source: 'more_menu' });
+ showToast('Could not open the browser. Search "Toastmaster Timer" in the Zoom App Marketplace.', 'error', 6000);
+ return;
+ }
+ if (REVIEW_SURVEY_ID) {
+ trackEvent('survey sent', {
+ $survey_id: REVIEW_SURVEY_ID,
+ $survey_response: 'opened_zoom_marketplace_review',
+ $survey_response_0: 'opened_zoom_marketplace_review',
+ });
+ }
+ markPromptAnswered(REVIEW_PROMPT);
+ };
+
+ const handleTutorials = async () => {
+ idle(() => trackEvent('tutorials_opened', { source: 'more_menu' }));
+ if (!(await openExternalUrl(TUTORIALS_URL))) {
+ showToast('Could not open the browser. The videos are at toastmusters.com/tutorials.', 'error', 6000);
+ }
+ };
+
+ return (
+
+
(open ? close() : openMenu())}
+ aria-label="More"
+ aria-haspopup="menu"
+ aria-expanded={open}
+ className={`flex h-8 w-8 items-center justify-center rounded-lg text-slate-600 hover:bg-slate-100 ${open ? 'bg-slate-200' : ''}`}
+ >
+
+
+
+ {open && (
+
moveFocusWithArrows(event, menuRef.current, '[role="menuitem"]')}
+ className="absolute right-0 top-full z-40 mt-1.5 w-[262px] overflow-hidden rounded-xl border border-gray-200 bg-white py-1 shadow-[0_12px_28px_rgba(15,23,42,0.22)]"
+ >
+ {proOn && (
+
+
+ Your plan
+ {isPro ? 'Pro' : 'Free'}
+
+ setUpgradeSource('more_plan'))}
+ className="rounded-lg bg-blue-600 px-2.5 py-1.5 text-xs font-semibold text-white hover:bg-blue-700"
+ >
+ {isPro ? 'Manage' : 'See Pro'}
+
+
+ )}
+ {proOn && !club && (
+
setUpgradeSource('more_join_club'))}>
+
+ Join my club with a code
+
+ )}
+
+
+ Roles & timing rules
+
+
+
+ Clear the timer from my video
+
+
+
+
+ How-to videos
+
+
+
+ Send feedback
+
+
+
+ Rate the app
+
+
+ )}
+
+ {upgradeSource && (
+
+ setUpgradeSource(null)} source={upgradeSource} />
+
+ )}
+ {showFeedback && (
+
+ setShowFeedback(false)} />
+
+ )}
+
+ );
+}
diff --git a/apps/zoom-app/src/b/NewLookShell.jsx b/apps/zoom-app/src/b/NewLookShell.jsx
index 36f17d9..799a005 100644
--- a/apps/zoom-app/src/b/NewLookShell.jsx
+++ b/apps/zoom-app/src/b/NewLookShell.jsx
@@ -1,13 +1,80 @@
-import { ClassicShell } from '../App';
+import { lazy, Suspense, useState } from 'react';
+import ZoomConnectionNotice from '../components/ZoomConnectionNotice';
+import ClubStatusBanner from '../components/ClubStatusBanner';
+import AnnouncementBanner from '../components/AnnouncementBanner';
+import PeriodicPrompts from '../components/PeriodicPrompts';
+import TimerSounds from '../components/TimerSounds';
+import ContactCapture from '../components/ContactCapture';
+import TopBar from './TopBar';
+
+const TimerTab = lazy(() => import('./TimerTab'));
+const AgendaTab = lazy(() => import('../components/AgendaTab'));
+const ReportTab = lazy(() => import('../components/ReportTab'));
+const EditRulesModal = lazy(() => import('../components/EditRulesModal'));
+
+function TabFallback() {
+ return (
+
+ );
+}
/**
- * Design B's panel: the top bar with the Agenda / Timer / Report switch, the
- * Pro chip and the ⋯ More menu, the timer card and the "Look & sound" sheets.
+ * Design B's panel (plan 13): notices on top, then the Agenda / Timer / Report
+ * switch with the Pro chip and the ⋯ menu, then the tab. No footer: what it
+ * held moved into the chip and the menu, which is what keeps the Timer tab's
+ * Start button high in a 400px panel.
*
- * Phase 0 of plan 13 only puts the `new_look` switch in place, so until the
- * Phase 1 shell lands this is today's panel unchanged. The flag is off
- * everywhere, so nobody sees the difference either way.
+ * The Agenda and Report tabs are today's until Phase 2 redraws them.
*/
export default function NewLookShell() {
- return ;
+ const [activeTab, setActiveTab] = useState('live');
+ const [showRules, setShowRules] = useState(false);
+ // "Clear the timer from my video" from the ⋯ menu. The clear belongs to the
+ // Timer tab, which owns the video, so the menu switches there and the tab
+ // runs it once for every bump of this counter.
+ const [clearRequest, setClearRequest] = useState(0);
+
+ const handleClearVideo = () => {
+ setActiveTab('live');
+ setClearRequest((count) => count + 1);
+ };
+
+ return (
+
+
+
+
+
setShowRules(true)}
+ />
+
+ }>
+
+ {activeTab === 'live' && (
+
setActiveTab('agenda')}
+ onOpenRules={() => setShowRules(true)}
+ />
+ )}
+ {activeTab === 'agenda' && setActiveTab('live')} />}
+ {activeTab === 'report' && }
+
+
+
+ {showRules && (
+
+ setShowRules(false)} />
+
+ )}
+
+
+
+
+ );
}
diff --git a/apps/zoom-app/src/b/NewLookShell.test.jsx b/apps/zoom-app/src/b/NewLookShell.test.jsx
new file mode 100644
index 0000000..45d30fc
--- /dev/null
+++ b/apps/zoom-app/src/b/NewLookShell.test.jsx
@@ -0,0 +1,188 @@
+import '@testing-library/jest-dom';
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { resetFlagsForTests, setFlags } from '@toastmaster-timer/shared';
+import { ToastProvider } from '../context/ToastContext';
+import { TimerProvider } from '../context/TimerContext';
+import { clearVideoPipelines, setOverlayMode } from '../utils/zoomSdk';
+import NewLookShell from './NewLookShell';
+import { COACH_SEEN_KEY } from './TimerTab';
+
+// Same stub as LiveTab.test.jsx: the real module pulls in @zoom/appssdk, which
+// hangs vitest under jsdom.
+vi.mock('../utils/zoomSdk', () => ({
+ getVideoState: vi.fn().mockResolvedValue(null),
+ setVideoState: vi.fn(),
+ applyOverlay: vi.fn(),
+ removeOverlay: vi.fn(),
+ clearVideoPipelines: vi.fn().mockResolvedValue({ ok: true, declined: false, ungranted: [], lostBackground: false }),
+ isOverlayActive: vi.fn(() => false),
+ getBackgroundUrl: vi.fn((color) => `/backgrounds/${color}.png`),
+ getSdkStatus: vi.fn(() => ({ initialized: true, available: false, sdkExists: false, missingApis: [], apiCount: 0 })),
+ setLogCallback: vi.fn(),
+ getOverlayMode: vi.fn(() => 'camera'),
+ setOverlayMode: vi.fn().mockResolvedValue(undefined),
+ getOverlayTimePosition: vi.fn(() => ({ x: 0.18, y: 0.3 })),
+ setOverlayTimePosition: vi.fn(),
+ getOverlayTimeScale: vi.fn(() => 0.18),
+ setOverlayTimeScale: vi.fn((scale) => scale),
+ isOverlayTimeVisible: vi.fn(() => true),
+ setOverlayTimeVisible: vi.fn(),
+ setOverlayTimeLabel: vi.fn(),
+ setPopoutChangeCallback: vi.fn(),
+ setShareChangeCallback: vi.fn(),
+ setAppShare: vi.fn(),
+ setAppPopout: vi.fn(),
+ isAppShareActive: vi.fn(() => false),
+ isAppPoppedOut: vi.fn(() => false),
+ isVideoOverlayMode: vi.fn((mode) => mode !== 'stage'),
+ getClubBadgePlacement: vi.fn(() => ({ x: 0.8, y: 0.12, scale: 0.12, visible: true })),
+ setClubBadgePlacement: vi.fn((patch) => ({ x: 0.8, y: 0.12, scale: 0.12, visible: true, ...patch })),
+ resetClubBadgePlacement: vi.fn(() => ({ x: 0.8, y: 0.12, scale: 0.12, visible: true })),
+ isClubBadgeMoved: vi.fn(() => false),
+ getZoomParticipants: vi.fn().mockResolvedValue({ participants: [], role: 'host', restricted: false }),
+ getCardFileUrl: vi.fn((file) => `/cards/${file}`),
+ notifyCardImagesChanged: vi.fn(),
+ preloadBackgroundImages: vi.fn(),
+ notifyOwnBackgroundChanged: vi.fn(),
+ applyOwnBackground: vi.fn(),
+ removeOwnBackground: vi.fn(),
+ openExternalUrl: vi.fn(async () => true),
+ OVERLAY_MODE_CARD: 'card',
+ OVERLAY_MODE_CAMERA: 'camera',
+ OVERLAY_MODE_STAGE: 'stage',
+}));
+vi.mock('../utils/posthog', () => ({ trackEvent: vi.fn() }));
+// Covered by their own tests; here they would only need more of the SDK stub.
+vi.mock('../components/ZoomConnectionNotice', () => ({ default: () => null }));
+vi.mock('../components/ContactCapture', () => ({ default: () => null }));
+vi.mock('../components/PeriodicPrompts', () => ({ default: () => null }));
+vi.mock('../utils/zoomIdentity', () => ({
+ resolveZoomIdentity: vi.fn(async () => ({ identified: false })),
+ getSessionToken: vi.fn(() => null),
+}));
+
+async function renderShell() {
+ render(
+
+
+
+
+
+ );
+ // The Timer tab is lazy; let it land.
+ await screen.findByTestId('timer-card');
+}
+
+const openRow = (name) => fireEvent.click(screen.getByRole('button', { name: new RegExp(`^${name}`) }));
+
+beforeEach(() => {
+ localStorage.clear();
+ resetFlagsForTests();
+ setFlags({ new_look: true, pro: false, sounds: true });
+ vi.clearAllMocks();
+});
+
+describe('the new-look panel', () => {
+ it('opens on the Timer tab, with the top bar and no footer', async () => {
+ await renderShell();
+ expect(screen.getByRole('navigation', { name: 'Sections' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument();
+ expect(screen.queryByRole('contentinfo')).toBeNull();
+ expect(screen.queryByRole('button', { name: /send us feedback/i })).toBeNull();
+ expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument();
+ });
+
+ it('opens one sheet per Look & sound row, never two', async () => {
+ await renderShell();
+ for (const [row, title] of [
+ ['On your video', 'On your video'],
+ ['Cards', 'Cards'],
+ ['Sounds', 'Sounds'],
+ ['Between speeches', 'Between speeches'],
+ ]) {
+ openRow(row);
+ const dialogs = await screen.findAllByRole('dialog');
+ expect(dialogs).toHaveLength(1);
+ expect(dialogs[0]).toHaveAccessibleName(title);
+ fireEvent.click(within(dialogs[0]).getByRole('button', { name: 'Close' }));
+ expect(screen.queryByRole('dialog')).toBeNull();
+ }
+ });
+
+ it('keeps the own-background picker out of the Cards sheet', async () => {
+ await renderShell();
+ openRow('Cards');
+ const sheet = await screen.findByRole('dialog', { name: 'Cards' });
+ expect(within(sheet).queryByText('My background')).toBeNull();
+ });
+
+ it('switches the display mode from its sheet, and confirms Timer only', async () => {
+ await renderShell();
+ openRow('On your video');
+ await act(async () => {
+ fireEvent.click(screen.getByRole('radio', { name: /Timer only/ }));
+ });
+ expect(setOverlayMode).toHaveBeenCalledWith('card', expect.anything());
+ expect(screen.queryByRole('dialog')).toBeNull();
+ expect(await screen.findByText(/Timer only: the colour replaces your camera/)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'On your video: Timer only' })).toBeInTheDocument();
+ });
+
+ it('chooses what shows between speeches, and keeps today\'s default', async () => {
+ await renderShell();
+ expect(screen.getByRole('button', { name: 'Between speeches: Timer card' })).toBeInTheDocument();
+ openRow('Between speeches');
+ fireEvent.click(screen.getByRole('radio', { name: /My video/ }));
+ expect(localStorage.getItem('toastmaster_reveal_face_when_idle')).toBe('true');
+ expect(screen.getByText('My background')).toBeInTheDocument();
+ });
+
+ it('clears the timer from the video from the ⋯ menu, from another tab too', async () => {
+ await renderShell();
+ fireEvent.click(screen.getByRole('button', { name: 'Report' }));
+ await waitFor(() => expect(screen.queryByTestId('timer-card')).toBeNull());
+ fireEvent.click(screen.getByRole('button', { name: 'More' }));
+ await act(async () => {
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Clear the timer from my video' }));
+ });
+ expect(await screen.findByTestId('timer-card')).toBeInTheDocument();
+ expect(clearVideoPipelines).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows the count-up hint once, until the chip is used', async () => {
+ await renderShell();
+ expect(screen.getByRole('note')).toHaveTextContent('Drag me to move');
+ fireEvent.mouseEnter(screen.getByRole('button', { name: /Drag to place the count-up/ }).parentElement);
+ expect(screen.queryByRole('note')).toBeNull();
+ expect(localStorage.getItem(COACH_SEEN_KEY)).toBe('1');
+ });
+
+ it('says what happened after Finish when the next row needs a role', async () => {
+ localStorage.setItem(
+ 'toastmaster_agenda',
+ JSON.stringify([
+ { id: 'a1', name: 'Bob', role: 'Ice Breaker', completed: false },
+ { id: 'a2', name: 'Alex', role: 'Table Topics Master', completed: false },
+ ])
+ );
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ try {
+ await renderShell();
+ fireEvent.click(screen.getByRole('button', { name: /speaker/i }));
+ fireEvent.click(screen.getByRole('option', { name: /Bob/ }));
+ expect(screen.getByTestId('up-next')).toHaveTextContent('Alex');
+ fireEvent.click(screen.getByRole('button', { name: 'Start' }));
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Finish' }));
+ expect(screen.getByText(/Bob's speech is saved to the report\. Next on the agenda:/)).toHaveTextContent(
+ "Bob's speech is saved to the report. Next on the agenda: Alex, who needs a role."
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Pick role' }));
+ await waitFor(() => expect(screen.queryByTestId('timer-card')).toBeNull());
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/apps/zoom-app/src/b/ProChip.jsx b/apps/zoom-app/src/b/ProChip.jsx
new file mode 100644
index 0000000..724d3f0
--- /dev/null
+++ b/apps/zoom-app/src/b/ProChip.jsx
@@ -0,0 +1,141 @@
+import { lazy, Suspense, useEffect, useId, useRef, useState } from 'react';
+import { Check, Lock, Sparkles } from 'lucide-react';
+import { isProLaunchBadgeDue, loadPromptState, subscribeToPromptState } from '@toastmaster-timer/shared';
+import { useEntitlement } from '../hooks/useEntitlement';
+import { useFlag } from '../hooks/useFlag';
+import { trackEvent } from '../utils/posthog';
+import { PRO_COMING_SOON_ITEMS, PRO_COMING_SOON_MORE } from '../components/ProComingSoon';
+import { usePopover } from './usePopover';
+
+const UpgradeModal = lazy(() => import('../components/UpgradeModal'));
+
+const CHIP = 'relative flex h-8 flex-shrink-0 items-center gap-1 rounded-lg px-2.5 text-xs font-bold transition-colors';
+
+/**
+ * Before Pro is released: the chip greyed with a lock. Pointing at it or
+ * pressing it lists what is coming, the same list the old footer button shows.
+ * Nothing can be bought yet, so nothing here sells.
+ */
+function ProSoonChip() {
+ const [hovered, setHovered] = useState(false);
+ const [pinned, setPinned] = useState(false);
+ const open = hovered || pinned;
+ const wrapperRef = useRef(null);
+ const trackedHover = useRef(false);
+ const panelId = useId();
+
+ usePopover(pinned, () => {
+ setPinned(false);
+ setHovered(false);
+ }, [wrapperRef]);
+
+ const handleEnter = () => {
+ setHovered(true);
+ if (!trackedHover.current) {
+ trackedHover.current = true;
+ trackEvent('pro_coming_soon_opened', { trigger: 'hover', surface: 'pro_chip' });
+ }
+ };
+
+ const handleClick = () => {
+ if (!pinned) trackEvent('pro_coming_soon_opened', { trigger: 'click', surface: 'pro_chip' });
+ setPinned(!pinned);
+ };
+
+ return (
+ setHovered(false)}>
+
+
+ Pro
+
+ {open && (
+
+
+
+ Pro is coming soon
+
+
+ {PRO_COMING_SOON_ITEMS.map((item) => (
+
+
+ {item}
+
+ ))}
+
+
{PRO_COMING_SOON_MORE}
+
The timer itself stays free.
+
+ )}
+
+ );
+}
+
+/**
+ * The Pro chip beside the tabs, where the old footer's Upgrade / Pro button
+ * went.
+ *
+ * - Flags not in yet: nothing, so no chip flashes and changes.
+ * - `pro` off: locked, "coming soon".
+ * - `pro` on, free plan: blue "Pro", with the red dot until the launch has
+ * been looked at. Opens the Upgrade dialog.
+ * - `pro` on, Pro plan: amber "Pro". Opens the same dialog, which then
+ * manages the plan.
+ */
+export default function ProChip() {
+ const { isPro, known } = useEntitlement();
+ const { enabled: proEnabled, known: flagsKnown } = useFlag('pro');
+ const [promptState, setPromptState] = useState(loadPromptState);
+ const [showUpgrade, setShowUpgrade] = useState(false);
+ useEffect(() => subscribeToPromptState(setPromptState), []);
+
+ if (!flagsKnown) return null;
+ if (!proEnabled) return ;
+ if (!known) return null;
+
+ const showDot = !isPro && isProLaunchBadgeDue(promptState);
+
+ return (
+ <>
+ {
+ trackEvent('pro_chip_clicked', { plan: isPro ? 'pro' : 'free', dot: showDot });
+ setShowUpgrade(true);
+ }}
+ aria-label={isPro ? 'Manage your Pro plan' : 'Upgrade to Pro'}
+ className={`${CHIP} ${isPro ? 'bg-amber-100 text-amber-800 hover:bg-amber-200' : 'bg-blue-50 text-blue-700 hover:bg-blue-100'}`}
+ >
+
+ Pro
+ {showDot && (
+ <>
+
+ New
+ >
+ )}
+
+ {showUpgrade && (
+
+ setShowUpgrade(false)} source="pro_chip" />
+
+ )}
+ >
+ );
+}
diff --git a/apps/zoom-app/src/b/RolePicker.jsx b/apps/zoom-app/src/b/RolePicker.jsx
new file mode 100644
index 0000000..cfca7bf
--- /dev/null
+++ b/apps/zoom-app/src/b/RolePicker.jsx
@@ -0,0 +1,110 @@
+import { useRef, useState } from 'react';
+import { Check, ChevronDown, Pencil } from 'lucide-react';
+import { BREAK_ROLE } from '@toastmaster-timer/shared';
+import { thresholdsLine } from './format';
+import { moveFocusWithArrows, usePopover } from './usePopover';
+
+const OPTION = 'flex w-full items-center gap-1.5 px-3 py-2 text-left text-sm hover:bg-slate-50 focus:bg-slate-50 focus:outline-none';
+
+/**
+ * The role button on design B's timer card, and the list it opens (board
+ * B-T04): every role with its three thresholds, then Custom and Take a Break,
+ * then the way to the rules themselves.
+ *
+ * @param {{
+ * role: string,
+ * roleOptions: string[],
+ * roleRules: Object,
+ * onChange: (role: string) => void,
+ * onEditRules: () => void,
+ * palette: {soft: string, fg: string},
+ * }} props
+ */
+export default function RolePicker({ role, roleOptions, roleRules, onChange, onEditRules, palette }) {
+ const [open, setOpen] = useState(false);
+ const buttonRef = useRef(null);
+ const listRef = useRef(null);
+ usePopover(open, () => setOpen(false), [buttonRef, listRef]);
+
+ const timed = roleOptions.filter((name) => name !== 'Custom' && name !== BREAK_ROLE);
+ const special = [
+ roleOptions.includes('Custom') && { name: 'Custom', note: 'set your own times' },
+ roleOptions.includes(BREAK_ROLE) && { name: BREAK_ROLE, note: 'counts down' },
+ ].filter(Boolean);
+
+ const openList = () => {
+ setOpen(true);
+ setTimeout(() => {
+ const selected = listRef.current?.querySelector('[aria-selected="true"]');
+ (selected || listRef.current?.querySelector('[role="option"]'))?.focus();
+ }, 0);
+ };
+
+ const choose = (name) => {
+ setOpen(false);
+ buttonRef.current?.focus();
+ if (name !== role) onChange(name);
+ };
+
+ const renderOption = (name, note) => {
+ const selected = name === role;
+ return (
+ choose(name)}
+ className={`${OPTION} ${selected ? 'bg-blue-50' : ''}`}
+ >
+ {name}
+ {note}
+ {selected && }
+
+ );
+ };
+
+ return (
+
+
(open ? setOpen(false) : openList())}
+ aria-haspopup="listbox"
+ aria-expanded={open}
+ aria-label={`Timing role: ${role}. Change role`}
+ style={{ background: palette.soft, color: palette.fg }}
+ className="flex h-9 w-full items-center justify-between gap-1.5 rounded-[10px] px-2.5 text-sm font-semibold"
+ >
+ {role}
+
+
+
+ {open && (
+
moveFocusWithArrows(event, listRef.current, '[role="option"], [data-edit-rules]')}
+ className="absolute right-0 top-full z-30 mt-1.5 w-[300px] max-w-[calc(100vw-24px)] overflow-hidden rounded-xl border border-gray-200 bg-white text-gray-900 shadow-[0_12px_28px_rgba(15,23,42,0.22)]"
+ >
+
+ {timed.map((name) => renderOption(name, thresholdsLine(roleRules[name])))}
+ {special.length > 0 &&
}
+ {special.map(({ name, note }) => renderOption(name, note))}
+
+
{
+ setOpen(false);
+ onEditRules();
+ }}
+ className="flex w-full items-center gap-1.5 border-t border-slate-100 bg-slate-50 px-3 py-2.5 text-left text-[13px] font-semibold text-blue-600 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
+ >
+
+ Edit roles & timing rules
+
+
+ )}
+
+ );
+}
diff --git a/apps/zoom-app/src/b/SpeakerPicker.jsx b/apps/zoom-app/src/b/SpeakerPicker.jsx
new file mode 100644
index 0000000..5c9a34b
--- /dev/null
+++ b/apps/zoom-app/src/b/SpeakerPicker.jsx
@@ -0,0 +1,229 @@
+import { useMemo, useRef, useState } from 'react';
+import { Check, ChevronDown, CornerDownLeft, Search, Users } from 'lucide-react';
+import useZoomParticipants, { participantsNotOnAgenda } from '../hooks/useZoomParticipants';
+import { moveFocusWithArrows, usePopover } from './usePopover';
+
+const OPTION = 'flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-slate-50 focus:bg-slate-50 focus:outline-none';
+const GROUP = 'px-3 pb-1 pt-2 text-[11px] font-bold tracking-wide text-slate-500';
+
+/**
+ * "Who's speaking?": the speaker button on design B's timer card, and the
+ * list it opens (board B-T03).
+ *
+ * The same two sources the old name field and the stage picker offer: the
+ * agenda, and everyone in the meeting who is not on it. Typing filters both;
+ * a name that matches nobody is added to the agenda under the current role,
+ * which is what Enter has always done with a typed name. A row that needs a
+ * role says so and is left to the Agenda tab (onSelectSpeaker refuses it).
+ *
+ * @param {{
+ * value: string,
+ * role: string,
+ * agendaItems: Array,
+ * activeSpeakerId: string|null,
+ * needsRole: (item: Object) => boolean,
+ * onSelectSpeaker: (id: string) => void,
+ * onAddSpeaker: (name: string) => void,
+ * onRenameSpeaker: (id: string, name: string) => void,
+ * palette: {soft: string, fg: string},
+ * }} props
+ */
+export default function SpeakerPicker({
+ value,
+ role,
+ agendaItems,
+ activeSpeakerId,
+ needsRole,
+ onSelectSpeaker,
+ onAddSpeaker,
+ onRenameSpeaker,
+ palette,
+}) {
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState('');
+ const buttonRef = useRef(null);
+ const popRef = useRef(null);
+ const inputRef = useRef(null);
+ const { participants, restricted } = useZoomParticipants();
+
+ const close = () => {
+ setOpen(false);
+ setQuery('');
+ };
+ usePopover(open, close, [buttonRef, popRef]);
+
+ const typed = query.trim();
+ const needle = typed.toLowerCase();
+ const upNext = useMemo(
+ () => (agendaItems || []).filter((item) => (!item.completed || item.id === activeSpeakerId) && item.name),
+ [agendaItems, activeSpeakerId]
+ );
+ const activeItem = upNext.find((item) => item.id === activeSpeakerId) || null;
+ const agendaMatches = needle ? upNext.filter((item) => item.name.toLowerCase().includes(needle)) : upNext;
+ const guests = participantsNotOnAgenda(participants, upNext).filter(
+ (person) => !needle || person.name.toLowerCase().includes(needle)
+ );
+ const exact = upNext.find((item) => item.name.trim().toLowerCase() === needle);
+ const canAdd = Boolean(typed) && !exact;
+ const canRename = Boolean(typed) && activeItem && typed !== activeItem.name.trim();
+
+ const openList = () => {
+ setOpen(true);
+ setTimeout(() => inputRef.current?.focus(), 0);
+ };
+
+ const finish = () => {
+ close();
+ buttonRef.current?.focus();
+ };
+
+ const pick = (item) => {
+ finish();
+ onSelectSpeaker(item.id);
+ };
+
+ const add = (name) => {
+ finish();
+ onAddSpeaker(name);
+ };
+
+ const handleInputKey = (event) => {
+ if (event.key === 'ArrowDown') {
+ event.preventDefault();
+ popRef.current?.querySelector('[role="option"]')?.focus();
+ return;
+ }
+ if (event.key !== 'Enter') return;
+ event.preventDefault();
+ if (exact) pick(exact);
+ else if (canAdd) add(typed);
+ };
+
+ return (
+
+
(open ? close() : openList())}
+ aria-haspopup="listbox"
+ aria-expanded={open}
+ aria-label={value ? `Speaker: ${value}. Change speaker` : "Who's speaking? Pick a speaker"}
+ style={{ background: palette.soft, color: palette.fg }}
+ className="flex h-9 w-full items-center justify-between gap-1.5 rounded-[10px] px-2.5 text-sm font-semibold"
+ >
+ {value || 'Who’s speaking?'}
+
+
+
+ {open && (
+
event.target !== inputRef.current && moveFocusWithArrows(event, popRef.current, '[role="option"]')}
+ className="absolute left-0 top-full z-30 mt-1.5 w-[300px] max-w-[calc(100vw-24px)] overflow-hidden rounded-xl border border-gray-200 bg-white text-gray-900 shadow-[0_12px_28px_rgba(15,23,42,0.22)]"
+ >
+
+
+ setQuery(event.target.value)}
+ onKeyDown={handleInputKey}
+ placeholder="Type a name, or pick one"
+ aria-label="Type a name, or pick one"
+ className="min-w-0 flex-1 border-0 text-sm outline-none"
+ />
+
+
+
+ {canAdd && (
+
add(typed)}>
+
+ Add {typed}
+
+ as {role}
+
+ )}
+ {canRename && (
+
{
+ finish();
+ onRenameSpeaker(activeItem.id, typed);
+ }}
+ >
+
+ Rename {activeItem.name} to {typed}
+
+
+ )}
+
+ {agendaMatches.length > 0 && (
+
+ AGENDA
+
+ )}
+ {agendaMatches.map((item) => {
+ const active = item.id === activeSpeakerId;
+ const blocked = needsRole(item);
+ return (
+
pick(item)}
+ className={`${OPTION} ${active ? 'bg-blue-50' : ''}`}
+ >
+ {item.name}
+ {blocked ? (
+ Pick a role first
+ ) : (
+ {item.role}
+ )}
+ {active && }
+
+ );
+ })}
+
+ {guests.length > 0 && (
+
+ IN THIS MEETING
+
+ )}
+ {guests.map((person) => (
+
add(person.name)}
+ className={OPTION}
+ >
+ {person.name}
+ Add as {role}
+
+ ))}
+
+ {!canAdd && agendaMatches.length === 0 && guests.length === 0 && (
+
No speakers yet. Type a name to add one.
+ )}
+ {restricted && (
+
+
+ Ask to be made host or co-host to pick speakers from the participant list.
+
+ )}
+
+
+
+
+ A new name and Enter adds them to the agenda
+
+
+ )}
+
+ );
+}
diff --git a/apps/zoom-app/src/b/TimerCard.jsx b/apps/zoom-app/src/b/TimerCard.jsx
new file mode 100644
index 0000000..30ae55c
--- /dev/null
+++ b/apps/zoom-app/src/b/TimerCard.jsx
@@ -0,0 +1,242 @@
+import { memo, useRef } from 'react';
+import { Check, Pause, Play, RotateCcw } from 'lucide-react';
+import { BREAK_ROLE, formatTime, getDisplaySeconds } from '@toastmaster-timer/shared';
+import VideoPreviewLayer from '../components/VideoPreviewLayer';
+import SpeakerPicker from './SpeakerPicker';
+import RolePicker from './RolePicker';
+import { CARD_PALETTE, PREVIEW_SWATCHES, cardStatusLine, rulesLine } from './format';
+
+const BUTTON = 'flex items-center justify-center gap-2 rounded-xl transition-opacity disabled:cursor-not-allowed';
+
+/**
+ * Design B's timer card (the BCard board): the whole card takes the colour the
+ * meeting sees. Speaker and role along the top, the rules under them, a 16:9
+ * preview of the video with the count-up chip and club badge to drag, the big
+ * clock and one status line, the preview swatches while nothing runs, and the
+ * buttons for the state the timer is in.
+ *
+ * Draws only. Every action is a prop, wired to useLiveTimer by TimerTab.
+ */
+export default memo(function TimerCard({
+ status,
+ previewColor,
+ elapsedTime,
+ isRunning,
+ rules,
+ speakerName,
+ selectedRole,
+ roleOptions,
+ roleRules,
+ agenda,
+ activeSpeakerId,
+ needsRole,
+ onSelectSpeaker,
+ onAddSpeaker,
+ onRenameSpeaker,
+ onRoleChange,
+ onEditRules,
+ readoutPosition,
+ readoutVisible,
+ onReadoutPositionChange,
+ onToggleReadoutVisible,
+ onAdjustReadoutScale,
+ clubKit,
+ badgePlacement,
+ badgeMoved,
+ onBadgePlacementChange,
+ onAdjustBadgeScale,
+ onToggleBadgeVisible,
+ onResetBadgePlacement,
+ coachHint,
+ onCoachSeen,
+ videoOff,
+ isClearingVideo,
+ onPreviewColor,
+ onStart,
+ onStop,
+ onContinue,
+ onReset,
+ onFinish,
+}) {
+ const insetRef = useRef(null);
+ const colour = status || 'blue';
+ const p = CARD_PALETTE[colour] || CARD_PALETTE.blue;
+ const idle = !isRunning && elapsedTime === 0;
+ const paused = !isRunning && elapsedTime > 0;
+ const shownRules = rules || roleRules[selectedRole] || null;
+ const showRulesLine = selectedRole !== 'Custom' && selectedRole !== BREAK_ROLE && shownRules;
+ const displayTime = formatTime(getDisplaySeconds(elapsedTime, shownRules));
+ const statusLine = cardStatusLine({ status: colour, rules: shownRules, isRunning, elapsedTime, previewColor, videoOff });
+
+ const white = { background: '#ffffff', color: '#111827' };
+ const ghost = { background: p.ghost, color: p.fg };
+ const playColour = '#16a34a';
+
+ return (
+
+
+
+
+
+
+ {showRulesLine && (
+
+ {rulesLine(shownRules)}
+
+ Edit rules
+
+
+ )}
+
+
+
+
+ {displayTime}
+
+ {statusLine && (
+
+ {statusLine}
+
+ )}
+
+
+ {!isRunning && (
+
+ Preview on video
+ {PREVIEW_SWATCHES.map(({ color, label, bg }) => (
+ onPreviewColor(color)}
+ disabled={videoOff}
+ aria-label={`Preview ${label.toLowerCase()} on your video`}
+ aria-pressed={previewColor === color}
+ className={`h-[26px] w-[26px] rounded-full p-0 transition-transform disabled:opacity-40 ${previewColor === color ? 'scale-110' : ''}`}
+ style={{ background: bg, border: `2px solid ${previewColor === color ? '#ffffff' : p.ring}` }}
+ />
+ ))}
+
+ )}
+
+ {idle && (
+
+ )}
+
+ {isRunning && (
+
+
+
+ Stop
+
+
+
+ Finish
+
+
+ )}
+
+ {paused && (
+
+
+
+ Continue
+
+
+
+
+ Reset
+
+
+
+ Finish
+
+
+
+ )}
+
+ );
+});
diff --git a/apps/zoom-app/src/b/TimerCard.test.jsx b/apps/zoom-app/src/b/TimerCard.test.jsx
new file mode 100644
index 0000000..bb77415
--- /dev/null
+++ b/apps/zoom-app/src/b/TimerCard.test.jsx
@@ -0,0 +1,239 @@
+import '@testing-library/jest-dom';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { BREAK_ROLE, DEFAULT_ROLE_RULES, deriveBreakRules } from '@toastmaster-timer/shared';
+import TimerCard from './TimerCard';
+import RolePicker from './RolePicker';
+import SpeakerPicker from './SpeakerPicker';
+import LookRows from './LookRows';
+import { BreakPanel, CustomTimesPanel, UpNext, parseTimeText } from './TimerPanels';
+
+vi.mock('../utils/zoomSdk', () => ({
+ getZoomParticipants: vi.fn(async () => ({ participants: [{ id: 'p1', name: 'Maria Chen' }], restricted: false })),
+}));
+
+const ROLE_RULES = { ...DEFAULT_ROLE_RULES, [BREAK_ROLE]: deriveBreakRules(600) };
+const ROLE_OPTIONS = [...Object.keys(DEFAULT_ROLE_RULES), BREAK_ROLE];
+const PALETTE = { soft: 'rgba(255,255,255,0.22)', fg: '#fff' };
+const AGENDA = [
+ { id: 'a1', name: 'Bob', role: 'Ice Breaker', completed: false },
+ { id: 'a2', name: 'Alex', role: 'Table Topics Master', completed: false },
+ { id: 'a3', name: 'Alice', role: 'Standard Speech', completed: false },
+];
+const needsRole = (item) => !ROLE_OPTIONS.includes(item.role);
+
+function renderCard(over = {}) {
+ const handlers = {
+ onStart: vi.fn(), onStop: vi.fn(), onContinue: vi.fn(), onReset: vi.fn(), onFinish: vi.fn(),
+ onPreviewColor: vi.fn(), onEditRules: vi.fn(), onRoleChange: vi.fn(),
+ onSelectSpeaker: vi.fn(), onAddSpeaker: vi.fn(), onRenameSpeaker: vi.fn(),
+ };
+ const props = {
+ status: 'blue', previewColor: null, elapsedTime: 0, isRunning: false,
+ rules: DEFAULT_ROLE_RULES['Ice Breaker'], speakerName: 'Bob', selectedRole: 'Ice Breaker',
+ roleOptions: ROLE_OPTIONS, roleRules: ROLE_RULES, agenda: AGENDA, activeSpeakerId: 'a1', needsRole,
+ readoutPosition: { x: 0.18, y: 0.3 }, readoutVisible: true, clubKit: null, badgePlacement: null,
+ videoOff: false, isClearingVideo: false, coachHint: null,
+ ...handlers, ...over,
+ };
+ const view = render( );
+ return { ...handlers, rerender: (next) => view.rerender( ) };
+}
+
+const button = (name) => screen.getByRole('button', { name });
+
+describe('TimerCard', () => {
+ it('ready: speaker, role, rules, preview swatches, Start and Reset', () => {
+ const { onStart, onReset, onEditRules } = renderCard();
+ expect(button('Speaker: Bob. Change speaker')).toBeInTheDocument();
+ expect(button('Timing role: Ice Breaker. Change role')).toBeInTheDocument();
+ expect(screen.getByText('Green 4:00 · Yellow 5:00 · Red 6:00')).toBeInTheDocument();
+ expect(screen.getByTestId('timer-status')).toHaveTextContent('Ready · green at 4:00');
+ expect(screen.getAllByRole('button', { name: /^Preview .* on your video$/ })).toHaveLength(4);
+ fireEvent.click(button('Start'));
+ fireEvent.click(button('Reset'));
+ fireEvent.click(button('Edit rules'));
+ expect(onStart).toHaveBeenCalled();
+ expect(onReset).toHaveBeenCalled();
+ expect(onEditRules).toHaveBeenCalled();
+ expect(screen.queryByRole('button', { name: 'Finish' })).toBeNull();
+ });
+
+ it('says who is speaking when nobody is', () => {
+ renderCard({ speakerName: '' });
+ expect(button("Who's speaking? Pick a speaker")).toHaveTextContent('Who’s speaking?');
+ });
+
+ it('running: the card takes the status colour, Stop and Finish only, no swatches', () => {
+ const { onStop, onFinish } = renderCard({ status: 'green', isRunning: true, elapsedTime: 267 });
+ expect(screen.getByTestId('timer-card')).toHaveAttribute('data-status', 'green');
+ expect(screen.getByTestId('timer-display')).toHaveTextContent('04:27');
+ expect(screen.getByTestId('timer-status')).toHaveTextContent('Green · yellow at 5:00');
+ expect(screen.queryByRole('button', { name: /^Preview/ })).toBeNull();
+ fireEvent.click(button('Stop'));
+ fireEvent.click(button('Finish'));
+ expect(onStop).toHaveBeenCalled();
+ expect(onFinish).toHaveBeenCalled();
+ });
+
+ it('paused: Continue, Reset and Finish', () => {
+ const { onContinue } = renderCard({ status: 'yellow', elapsedTime: 331 });
+ expect(screen.getByTestId('timer-status')).toHaveTextContent('Paused · red at 6:00');
+ fireEvent.click(button('Continue'));
+ expect(onContinue).toHaveBeenCalled();
+ expect(button('Reset')).toBeInTheDocument();
+ expect(button('Finish')).toBeInTheDocument();
+ });
+
+ it('video off: Start and the swatches wait for the video', () => {
+ renderCard({ videoOff: true });
+ expect(button('Start')).toBeDisabled();
+ expect(button('Preview green on your video')).toBeDisabled();
+ expect(screen.getByTestId('timer-status')).toHaveTextContent('Start works once your video is on');
+ });
+
+ it('holds a preview swatch and says so', () => {
+ const { onPreviewColor, rerender } = renderCard();
+ fireEvent.click(button('Preview green on your video'));
+ expect(onPreviewColor).toHaveBeenCalledWith('green');
+ rerender({ previewColor: 'green', status: 'green' });
+ expect(button('Preview green on your video')).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByTestId('timer-status')).toHaveTextContent('Previewing green on your video');
+ });
+
+ it('hides the rules line for Custom and Take a Break, whose times show below the card', () => {
+ renderCard({ selectedRole: BREAK_ROLE, rules: deriveBreakRules(300) });
+ expect(screen.queryByRole('button', { name: 'Edit rules' })).toBeNull();
+ expect(screen.getByTestId('timer-display')).toHaveTextContent('05:00');
+ });
+
+ it('keeps the count-up controls hidden until pointed at, and shows the one-time hint', () => {
+ const onCoachSeen = vi.fn();
+ renderCard({ coachHint: 'Drag me to move. Point at me to resize or hide.', onCoachSeen });
+ expect(screen.getByRole('note')).toHaveTextContent('Drag me to move');
+ const controls = button('Make the count-up larger').parentElement;
+ expect(controls.className).toContain('opacity-0');
+ expect(controls.className).toContain('group-hover/chip:opacity-100');
+ fireEvent.mouseEnter(button('Drag to place the count-up participants see on your video').parentElement);
+ expect(onCoachSeen).toHaveBeenCalled();
+ });
+
+ it('draws no count-up chip in the full-screen mode', () => {
+ renderCard({ readoutPosition: null });
+ expect(screen.queryByRole('button', { name: /count-up/ })).toBeNull();
+ });
+});
+
+describe('SpeakerPicker', () => {
+ function renderPicker(over = {}) {
+ const props = {
+ value: 'Bob', role: 'Ice Breaker', agendaItems: AGENDA, activeSpeakerId: 'a1', needsRole,
+ onSelectSpeaker: vi.fn(), onAddSpeaker: vi.fn(), onRenameSpeaker: vi.fn(), palette: PALETTE, ...over,
+ };
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /speaker/i }));
+ return props;
+ }
+
+ it('lists the agenda and the meeting, and marks a row that needs a role', async () => {
+ renderPicker();
+ expect(screen.getByRole('listbox', { name: 'Speakers' })).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: /Bob/ })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByRole('option', { name: /Alex/ })).toHaveTextContent('Pick a role first');
+ expect(await screen.findByRole('option', { name: /Maria Chen/ })).toHaveTextContent('Add as Ice Breaker');
+ });
+
+ it('picks an agenda row, and adds a meeting participant to the agenda', async () => {
+ const props = renderPicker();
+ fireEvent.click(screen.getByRole('option', { name: /Alice/ }));
+ expect(props.onSelectSpeaker).toHaveBeenCalledWith('a3');
+
+ fireEvent.click(screen.getByRole('button', { name: /speaker/i }));
+ fireEvent.click(await screen.findByRole('option', { name: /Maria Chen/ }));
+ expect(props.onAddSpeaker).toHaveBeenCalledWith('Maria Chen');
+ });
+
+ it('adds a new typed name on Enter, and offers to rename the current speaker', () => {
+ const props = renderPicker();
+ const input = screen.getByRole('textbox', { name: 'Type a name, or pick one' });
+ fireEvent.change(input, { target: { value: 'Priya' } });
+ expect(screen.getByRole('option', { name: /Rename Bob to Priya/ })).toBeInTheDocument();
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(props.onAddSpeaker).toHaveBeenCalledWith('Priya');
+ });
+
+ it('picks an existing name on Enter rather than adding it twice', () => {
+ const props = renderPicker();
+ const input = screen.getByRole('textbox', { name: 'Type a name, or pick one' });
+ fireEvent.change(input, { target: { value: 'alice' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(props.onSelectSpeaker).toHaveBeenCalledWith('a3');
+ expect(props.onAddSpeaker).not.toHaveBeenCalled();
+ });
+});
+
+describe('RolePicker', () => {
+ it('lists the roles with their thresholds, then Custom and Take a Break, then the rules', () => {
+ const onChange = vi.fn();
+ const onEditRules = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /Timing role/ }));
+ const options = screen.getAllByRole('option').map((option) => option.textContent);
+ expect(options[0]).toBe('Short Roles0:30 · 0:45 · 1:00');
+ expect(options.slice(-2)).toEqual(['Customset your own times', `${BREAK_ROLE}counts down`]);
+ expect(screen.getByRole('option', { name: /Ice Breaker/ })).toHaveAttribute('aria-selected', 'true');
+
+ fireEvent.click(screen.getByRole('option', { name: /Speech Evaluation/ }));
+ expect(onChange).toHaveBeenCalledWith('Speech Evaluation');
+
+ fireEvent.click(screen.getByRole('button', { name: /Timing role/ }));
+ fireEvent.click(screen.getByRole('button', { name: 'Edit roles & timing rules' }));
+ expect(onEditRules).toHaveBeenCalled();
+ });
+});
+
+describe('LookRows', () => {
+ it('shows each setting with its value, and opens only its own sheet', () => {
+ const onOpen = vi.fn();
+ render( );
+ expect(screen.getByRole('button', { name: 'On your video: Camera + timer' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Sounds, Pro' })).toHaveTextContent('PRO');
+ fireEvent.click(screen.getByRole('button', { name: 'Cards: Classic' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Between speeches: Timer card' }));
+ expect(onOpen.mock.calls).toEqual([['cards'], ['between']]);
+ });
+});
+
+describe('the panels', () => {
+ it('reads typed times the way they are written', () => {
+ expect(parseTimeText('1:30', 'minsec')).toBe(90);
+ expect(parseTimeText('2', 'minsec')).toBe(120);
+ expect(parseTimeText('90', 'seconds')).toBe(90);
+ expect(parseTimeText('1:75', 'minsec')).toBeNull();
+ expect(parseTimeText('', 'minsec')).toBeNull();
+ });
+
+ it('custom times commit on blur and warn about a wrong order', () => {
+ const onChange = vi.fn();
+ render( {}} onChange={onChange} />);
+ expect(screen.getByText('Yellow must be greater than Green')).toBeInTheDocument();
+ const green = screen.getByLabelText('Green');
+ fireEvent.change(green, { target: { value: '0:45' } });
+ fireEvent.blur(green);
+ expect(onChange).toHaveBeenCalledWith('green', 45);
+ });
+
+ it('break length: one tap per quick pick, and a custom length', () => {
+ const onChange = vi.fn();
+ render( {}} onChange={onChange} />);
+ expect(screen.getByRole('button', { name: '10 min' })).toHaveAttribute('aria-pressed', 'true');
+ fireEvent.click(screen.getByRole('button', { name: '5 min' }));
+ expect(onChange).toHaveBeenCalledWith(300);
+ fireEvent.click(screen.getByRole('button', { name: 'Custom' }));
+ expect(screen.getByLabelText('Length')).toBeInTheDocument();
+ });
+
+ it('up next says when that row still needs a role', () => {
+ render( );
+ expect(screen.getByTestId('up-next')).toHaveTextContent('Up nextAlexPick a role');
+ });
+});
diff --git a/apps/zoom-app/src/b/TimerPanels.jsx b/apps/zoom-app/src/b/TimerPanels.jsx
new file mode 100644
index 0000000..e6f9197
--- /dev/null
+++ b/apps/zoom-app/src/b/TimerPanels.jsx
@@ -0,0 +1,226 @@
+import { useEffect, useState } from 'react';
+import { Music, VideoOff, X } from 'lucide-react';
+import { BREAK_QUICK_PICKS, BREAK_TRACKS, BREAK_ROLE } from '@toastmaster-timer/shared';
+import { soundEngine, useSoundSettings, useSoundsAccess } from '../hooks/useSounds';
+import { formatThreshold } from './format';
+
+/**
+ * The pieces design B puts around the timer card (boards B-T02, T11, T12–T15):
+ * who is up next, what happened after Finish, video off, the break length and
+ * the custom times. Each draws only; TimerTab wires them to useLiveTimer.
+ */
+
+/** "Up next: Alex · Table Topics", with a tag when that row still needs a role. */
+export function UpNext({ item, needsRole }) {
+ if (!item) return null;
+ return (
+
+ Up next
+
+ {item.name}
+ {!needsRole && item.role ? ` · ${item.role}` : ''}
+
+ {needsRole && Pick a role }
+
+ );
+}
+
+/** After Finish, when the next row cannot be loaded until it has a role. */
+export function FinishNotice({ finishedName, nextName, onPickRole, onDismiss }) {
+ return (
+
+
+ {finishedName ? `${finishedName}'s speech is saved to the report. ` : 'Saved to the report. '}
+ Next on the agenda: {nextName || 'the next speaker'} , who needs a role.
+
+
+ Pick role
+
+
+
+
+
+ );
+}
+
+/** Video off: the colour has nowhere to go, so say so and offer the fix. */
+export function VideoOffNotice({ onTurnOn, busy }) {
+ return (
+
+
+ Your video is off. Turn it on to show the timer on it.
+
+ {busy ? 'Turning on…' : 'Turn video on'}
+
+
+ );
+}
+
+/**
+ * Parse what someone typed into a time field. In min:sec mode "1:30" is 90 and
+ * a bare "2" is two minutes; in seconds mode it is a count of seconds.
+ *
+ * @returns {number|null} seconds, or null for something unreadable
+ */
+export function parseTimeText(text, mode) {
+ const value = String(text ?? '').trim();
+ if (!value) return null;
+ if (mode === 'seconds') return /^\d+$/.test(value) ? Number(value) : null;
+ const match = /^(\d{1,3})(?::([0-5]?\d))?$/.exec(value);
+ if (!match) return null;
+ return Number(match[1]) * 60 + Number(match[2] ?? 0);
+}
+
+const showTime = (seconds, mode) => (mode === 'seconds' ? String(seconds) : formatThreshold(seconds));
+
+/** One compact time field: typed as text, committed on blur or Enter. */
+export function TimeField({ label, value, mode, onChange, tone = 'text-slate-600' }) {
+ const [text, setText] = useState(() => showTime(value, mode));
+ useEffect(() => setText(showTime(value, mode)), [value, mode]);
+
+ const commit = () => {
+ const seconds = parseTimeText(text, mode);
+ if (seconds === null) setText(showTime(value, mode));
+ else if (seconds !== value) onChange(seconds);
+ };
+
+ return (
+
+ {label}
+ setText(event.target.value)}
+ onBlur={commit}
+ onKeyDown={(event) => event.key === 'Enter' && event.currentTarget.blur()}
+ className="h-8 w-full min-w-0 rounded-lg border border-gray-300 px-1.5 text-[13px] font-normal text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+ );
+}
+
+/** min:sec | seconds, as a small segmented switch. */
+export function TimeModeSwitch({ mode, onModeChange }) {
+ return (
+
+ {[
+ ['minsec', 'min:sec'],
+ ['seconds', 'seconds'],
+ ].map(([id, label]) => (
+ onModeChange(id)}
+ className={`rounded-[5px] px-1.5 py-0.5 ${mode === id ? 'bg-white font-semibold text-gray-900' : 'text-slate-500'}`}
+ >
+ {label}
+
+ ))}
+
+ );
+}
+
+/** Custom role: four thresholds in one row (board B-T14). */
+export function CustomTimesPanel({ rules, mode, onModeChange, onChange }) {
+ const yellowLow = rules.yellow <= rules.green;
+ const redLow = rules.red <= rules.yellow;
+ return (
+
+
+ Custom times
+
+
+
+ onChange('green', v)} />
+ onChange('yellow', v)} />
+ onChange('red', v)} />
+ onChange('graceAfterRed', v)} />
+
+ {yellowLow &&
Yellow must be greater than Green
}
+ {redLow &&
Red must be greater than Yellow
}
+
Grace: how long after red before the report marks the speech over time.
+
+ );
+}
+
+/** Take a Break, before it starts: how long (board B-T12). */
+export function BreakPanel({ seconds, explanation, mode, onModeChange, onChange }) {
+ const [custom, setCustom] = useState(!BREAK_QUICK_PICKS.includes(seconds));
+ const pick = 'h-[34px] rounded-lg text-[13px]';
+ const on = `${pick} border-2 border-blue-600 bg-blue-50 font-bold text-blue-900`;
+ const off = `${pick} border border-gray-200 bg-white hover:bg-slate-50`;
+ return (
+
+
+ Break length
+ {custom && }
+
+
+ {BREAK_QUICK_PICKS.map((value) => (
+ {
+ setCustom(false);
+ onChange(value);
+ }}
+ className={!custom && seconds === value ? on : off}
+ >
+ {Math.round(value / 60)} min
+
+ ))}
+ setCustom(true)} className={custom ? on : off}>
+ Custom
+
+
+ {custom && (
+
+
+
+ )}
+ {explanation &&
{explanation}
}
+
+ );
+}
+
+/**
+ * While a break counts down with music on: say what is playing, and let the
+ * organizer stop it for this stretch (board B-T13). Pausing and continuing the
+ * break starts it again, as it always has.
+ */
+export function BreakMusicRow({ isRunning, role }) {
+ const settings = useSoundSettings();
+ const { usable } = useSoundsAccess();
+ const [stopped, setStopped] = useState(false);
+ useEffect(() => setStopped(false), [isRunning]);
+
+ if (!usable || !settings.music || !isRunning || role !== BREAK_ROLE) return null;
+ const track = BREAK_TRACKS.find((t) => t.id === settings.track) || BREAK_TRACKS[0];
+
+ return (
+
+
+ {stopped ? 'Break music stopped' : `Break music is playing: ${track.label}`}
+ {!stopped && (
+ {
+ soundEngine.stopMusic();
+ setStopped(true);
+ }}
+ className="flex-shrink-0 rounded-lg border border-gray-200 bg-white px-2.5 py-1 text-xs hover:bg-slate-50"
+ >
+ Stop music
+
+ )}
+
+ );
+}
+
diff --git a/apps/zoom-app/src/b/TimerTab.jsx b/apps/zoom-app/src/b/TimerTab.jsx
new file mode 100644
index 0000000..50a3eba
--- /dev/null
+++ b/apps/zoom-app/src/b/TimerTab.jsx
@@ -0,0 +1,300 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import {
+ BREAK_ROLE,
+ CLUB_CARD_SET_ID,
+ DEFAULT_CARD_SETS,
+ DEFAULT_CUSTOM_RULES,
+ activeCardSetId,
+ agendaItemNeedsRole,
+ getCardImageSettings,
+ hasOwnBackground,
+ loadClubCards,
+ saveTimeInputMode,
+} from '@toastmaster-timer/shared';
+import { useLiveTimer } from '../hooks/useLiveTimer';
+import { useTimer } from '../context/TimerContext';
+import { useToast } from '../context/ToastContext';
+import { useSoundSettings } from '../hooks/useSounds';
+import { isClubBadgeMoved, isVideoOverlayMode, OVERLAY_MODE_CARD } from '../utils/zoomSdk';
+import TimerStage from '../components/TimerStage';
+import TimerCard from './TimerCard';
+import LookRows, { VIDEO_MODE_NOTES } from './LookRows';
+import { BetweenSheet, CardsSheet, SoundsSheet, VideoSheet } from './LookSheets';
+import { BreakMusicRow, BreakPanel, CustomTimesPanel, FinishNotice, UpNext, VideoOffNotice } from './TimerPanels';
+
+
+/** Device-local: the "Drag me to move" note under the chip shows until it has been used once. */
+export const COACH_SEEN_KEY = 'toastmaster_b_coach_seen';
+
+function readCoachSeen() {
+ try {
+ return localStorage.getItem(COACH_SEEN_KEY) === '1';
+ } catch {
+ return true;
+ }
+}
+
+/** The name of the card set on show, as the Cards sheet lists it. */
+function cardSetLabel(clubLabel) {
+ const id = activeCardSetId();
+ if (id === CLUB_CARD_SET_ID) return loadClubCards()?.label || `${clubLabel || 'Club'} cards`;
+ const builtIn = DEFAULT_CARD_SETS.find((set) => set.id === id);
+ if (builtIn) return builtIn.label;
+ const index = getCardImageSettings().customSets.findIndex((set) => set.id === id);
+ return index >= 0 ? `Custom ${index + 1}` : DEFAULT_CARD_SETS[0].label;
+}
+
+function soundsNote({ cues, music }) {
+ if (cues && music) return 'Chime + music';
+ if (cues) return 'Chime on';
+ if (music) return 'Music on';
+ return 'Off';
+}
+
+/** The agenda row after the one on the timer that is still to speak. */
+function nextAgendaRow(agenda, activeSpeakerId) {
+ const index = activeSpeakerId ? agenda.findIndex((item) => item.id === activeSpeakerId) : -1;
+ return agenda.slice(index + 1).find((item) => !item.completed && item.id !== activeSpeakerId) || null;
+}
+
+/**
+ * Design B's Timer tab (boards B-T01 to B-T15): the timer card, what is next,
+ * and the "Look & sound" rows with their sheets. All of the timer's behaviour,
+ * the video included, comes from useLiveTimer, the same hook today's Live tab
+ * draws; this view adds no video logic of its own.
+ *
+ * @param {{clearRequest: number, onGoToAgenda: () => void, onOpenRules: () => void}} props
+ * `clearRequest` bumps when the ⋯ menu asks to clear the timer from the video;
+ * `onOpenRules` opens the shell's Roles & timing rules.
+ */
+export default function TimerTab({ clearRequest = 0, onGoToAgenda, onOpenRules }) {
+ const t = useLiveTimer();
+ const { roleRules } = useTimer();
+ const { showToast } = useToast();
+ const soundSettings = useSoundSettings();
+ const [sheet, setSheet] = useState(null);
+ const [coachSeen, setCoachSeen] = useState(readCoachSeen);
+ const [finishNotice, setFinishNotice] = useState(null);
+ const [backgroundGeneration, setBackgroundGeneration] = useState(0);
+
+ const needsRole = (item) => agendaItemNeedsRole(item, t.roleOptions);
+ const videoOverlay = isVideoOverlayMode(t.overlayMode);
+ const videoOff = t.videoState === false;
+ const status = t.previewColor || t.currentStatus;
+
+ // The ⋯ menu's clear, once per request, including one made before this tab
+ // was showing (the menu switches here first).
+ const handledClear = useRef(0);
+ useEffect(() => {
+ if (clearRequest > handledClear.current) {
+ handledClear.current = clearRequest;
+ t.handleClearVideo();
+ }
+ // Only a new request should run it, not a re-render with new handlers.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [clearRequest]);
+
+ // The after-Finish note goes once anything new is under way.
+ useEffect(() => {
+ if (t.isRunning || t.activeSpeakerId) setFinishNotice(null);
+ }, [t.isRunning, t.activeSpeakerId]);
+
+ const markCoachSeen = () => {
+ if (coachSeen) return;
+ setCoachSeen(true);
+ try {
+ localStorage.setItem(COACH_SEEN_KEY, '1');
+ } catch {
+ // Storage refused: the note comes back next time, which is harmless.
+ }
+ };
+
+ const handleFinish = () => {
+ const finishedName = t.speakerName;
+ const wasBreak = Boolean(t.currentSpeaker?.rules?.countdown);
+ const next = t.activeSpeakerId && !wasBreak ? nextAgendaRow(t.agenda, t.activeSpeakerId) : null;
+ t.handleFinish();
+ if (next && needsRole(next)) setFinishNotice({ finishedName, nextName: next.name });
+ markCoachSeen();
+ };
+
+ const handleModeSelect = async (mode) => {
+ setSheet(null);
+ await t.handleModeSwitch(mode);
+ if (mode === OVERLAY_MODE_CARD) {
+ showToast('Timer only: the colour replaces your camera until you clear it.', 'success');
+ }
+ };
+
+ const changeTimeMode = (mode) => {
+ saveTimeInputMode(mode);
+ t.setTimeInputMode(mode);
+ };
+
+ const upNext = useMemo(() => nextAgendaRow(t.agenda, t.activeSpeakerId), [t.agenda, t.activeSpeakerId]);
+ // Recomputed when the artwork changes (the hook bumps cardImagesGeneration)
+ // and when the club's set comes or goes.
+ const cards = useMemo(
+ () => cardSetLabel(t.clubKit?.name),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [t.clubKit?.name, t.cardImagesGeneration, sheet]
+ );
+ const between = useMemo(
+ () => (!t.revealFaceWhenIdle ? 'Timer card' : hasOwnBackground() ? 'My background' : 'My video'),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [t.revealFaceWhenIdle, backgroundGeneration, t.cardImagesGeneration, sheet]
+ );
+
+ const idle = !t.isRunning && t.elapsedTime === 0;
+ const isBreak = t.selectedRole === BREAK_ROLE;
+ const isCustom = t.selectedRole === 'Custom';
+
+ return (
+
+ {/* The stage covers the whole panel: in share mode every pixel is
+ broadcast, so nothing else may show. Restyled in Phase 3. */}
+ {!videoOverlay && (
+ t.handleModeSwitch(OVERLAY_MODE_CARD)}
+ clockHidden={t.stageClockHidden}
+ onToggleClock={t.handleToggleStageClock}
+ isSharing={t.isSharing}
+ onToggleShare={t.handleToggleShare}
+ isPoppedOut={t.isPoppedOut}
+ onTogglePopout={t.handleTogglePopout}
+ canPopout={t.canPopout}
+ onSpeakerNameChange={t.handleSpeakerChange}
+ agendaItems={t.agenda}
+ activeSpeakerId={t.activeSpeakerId}
+ onSelectSpeaker={t.handleSelectAgendaSpeaker}
+ onAddSpeaker={t.handleAddSpeaker}
+ onRenameSpeaker={t.handleRenameSpeaker}
+ clubKit={t.clubKit}
+ badgePlacement={t.badgePlacement}
+ />
+ )}
+
+ {videoOff && }
+
+ {
+ markCoachSeen();
+ t.handleStart();
+ }}
+ onStop={t.handleStop}
+ onContinue={t.handleContinue}
+ onReset={t.handleReset}
+ onFinish={handleFinish}
+ />
+
+ {isBreak && idle && (
+
+ )}
+
+ {isCustom && idle && (
+
+ )}
+
+ {finishNotice ? (
+ setFinishNotice(null)}
+ />
+ ) : (
+ !isBreak &&
+ )}
+
+
+
+ setSheet(null)}
+ overlayMode={t.overlayMode}
+ onSelect={handleModeSelect}
+ onClear={async () => {
+ setSheet(null);
+ await t.handleClearVideo();
+ }}
+ isClearing={t.isClearingVideo}
+ />
+ setSheet(null)}
+ onImagesChanged={() => t.setCardImagesGeneration((generation) => generation + 1)}
+ />
+ setSheet(null)} />
+ setSheet(null)}
+ revealFaceWhenIdle={t.revealFaceWhenIdle}
+ onChoose={(reveal) => reveal !== t.revealFaceWhenIdle && t.handleToggleRevealFaceWhenIdle()}
+ onBackgroundChanged={() => setBackgroundGeneration((generation) => generation + 1)}
+ />
+
+ );
+}
diff --git a/apps/zoom-app/src/b/TopBar.jsx b/apps/zoom-app/src/b/TopBar.jsx
new file mode 100644
index 0000000..f186416
--- /dev/null
+++ b/apps/zoom-app/src/b/TopBar.jsx
@@ -0,0 +1,59 @@
+import { memo } from 'react';
+import { trackEvent } from '../utils/posthog';
+import ProChip from './ProChip';
+import MoreMenu from './MoreMenu';
+
+// Agenda, Timer, Report: the meeting's own order. The ids stay what NavTabs
+// sends to PostHog ('live' for the timer), so tab_viewed compares across looks.
+export const TABS = Object.freeze([
+ { id: 'agenda', label: 'Agenda' },
+ { id: 'live', label: 'Timer' },
+ { id: 'report', label: 'Report' },
+]);
+
+/**
+ * Design B's top row (the BTop board): the Agenda / Timer / Report switch, the
+ * Pro chip and the ⋯ menu, on one line at 360px. The switch takes whatever the
+ * chip and the menu leave, so nothing wraps.
+ *
+ * @param {{
+ * activeTab: string,
+ * onTabChange: (id: string) => void,
+ * onClearVideo: () => void,
+ * onOpenRules: () => void,
+ * }} props
+ */
+export default memo(function TopBar({ activeTab, onTabChange, onClearVideo, onOpenRules }) {
+ const select = (id) => {
+ if (id === activeTab) return;
+ (window.requestIdleCallback || setTimeout)(() =>
+ trackEvent('tab_viewed', { tab_name: id, previous_tab: activeTab, look: 'b' })
+ );
+ onTabChange(id);
+ };
+
+ return (
+
+
+ {TABS.map((tab) => {
+ const active = tab.id === activeTab;
+ return (
+ select(tab.id)}
+ aria-current={active ? 'page' : undefined}
+ className={`min-w-0 flex-1 truncate rounded-[7px] py-1.5 text-center text-[13px] transition-colors ${
+ active ? 'bg-white font-semibold text-gray-900 shadow-[0_1px_2px_rgba(0,0,0,0.08)]' : 'text-slate-600 hover:text-gray-900'
+ }`}
+ >
+ {tab.label}
+
+ );
+ })}
+
+
+
+
+ );
+});
diff --git a/apps/zoom-app/src/b/TopBar.test.jsx b/apps/zoom-app/src/b/TopBar.test.jsx
new file mode 100644
index 0000000..f7f5793
--- /dev/null
+++ b/apps/zoom-app/src/b/TopBar.test.jsx
@@ -0,0 +1,147 @@
+import '@testing-library/jest-dom';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import {
+ TUTORIALS_URL,
+ resetClubForTests,
+ resetEntitlementForTests,
+ resetFlagsForTests,
+ setEntitlement,
+ setFlags,
+} from '@toastmaster-timer/shared';
+import { ToastProvider } from '../context/ToastContext';
+import { openExternalUrl } from '../utils/zoomSdk';
+import { trackEvent } from '../utils/posthog';
+import TopBar from './TopBar';
+
+vi.mock('../utils/zoomSdk', () => ({ openExternalUrl: vi.fn(async () => true) }));
+vi.mock('../utils/posthog', () => ({ trackEvent: vi.fn() }));
+
+const PRO = { plan: 'pro', status: 'active', entitled: true, source: 'subscription' };
+
+function renderBar(props = {}) {
+ const handlers = { onTabChange: vi.fn(), onClearVideo: vi.fn(), onOpenRules: vi.fn() };
+ render(
+
+
+
+ );
+ return handlers;
+}
+
+const openMore = () => fireEvent.click(screen.getByRole('button', { name: 'More' }));
+const menuItems = () => screen.getAllByRole('menuitem').map((item) => item.textContent.trim());
+
+beforeEach(() => {
+ localStorage.clear();
+ resetFlagsForTests();
+ resetEntitlementForTests();
+ resetClubForTests();
+ vi.clearAllMocks();
+});
+
+describe('the section switch', () => {
+ it('names the timer "Timer" and keeps its PostHog id', async () => {
+ vi.useFakeTimers();
+ try {
+ const { onTabChange } = renderBar();
+ expect(screen.getByRole('button', { name: 'Timer' })).toHaveAttribute('aria-current', 'page');
+ fireEvent.click(screen.getByRole('button', { name: 'Agenda' }));
+ expect(onTabChange).toHaveBeenCalledWith('agenda');
+ act(() => vi.runAllTimers());
+ expect(trackEvent).toHaveBeenCalledWith('tab_viewed', { tab_name: 'agenda', previous_tab: 'live', look: 'b' });
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
+
+describe('the Pro chip', () => {
+ it('shows nothing until the flags are in', () => {
+ renderBar();
+ expect(screen.queryByRole('button', { name: /pro/i })).toBeNull();
+ });
+
+ it('is locked and says what is coming while pro is off', () => {
+ setFlags({ pro: false });
+ renderBar();
+ const chip = screen.getByRole('button', { name: 'Pro (coming soon)' });
+ fireEvent.click(chip);
+ expect(screen.getByRole('dialog', { name: 'Pro is coming soon' })).toBeInTheDocument();
+ expect(trackEvent).toHaveBeenCalledWith('pro_coming_soon_opened', { trigger: 'click', surface: 'pro_chip' });
+ });
+
+ it('offers the upgrade with the launch dot to a free plan once pro is on', () => {
+ setFlags({ pro: true });
+ setEntitlement({ plan: 'free' });
+ renderBar();
+ expect(screen.getByRole('button', { name: 'Upgrade to Pro' })).toBeInTheDocument();
+ expect(screen.getByTestId('pro-new-dot')).toBeInTheDocument();
+ });
+
+ it('manages the plan for Pro, without the dot', () => {
+ setFlags({ pro: true });
+ setEntitlement(PRO);
+ renderBar();
+ expect(screen.getByRole('button', { name: 'Manage your Pro plan' })).toBeInTheDocument();
+ expect(screen.queryByTestId('pro-new-dot')).toBeNull();
+ });
+});
+
+describe('the ⋯ menu', () => {
+ it('holds the timer tools and the help while pro is off', () => {
+ setFlags({ pro: false });
+ renderBar();
+ openMore();
+ expect(menuItems()).toEqual([
+ 'Roles & timing rules',
+ 'Clear the timer from my video',
+ 'How-to videos',
+ 'Send feedback',
+ 'Rate the app',
+ ]);
+ });
+
+ it('adds your plan and the club code once pro is on', () => {
+ setFlags({ pro: true });
+ setEntitlement({ plan: 'free' });
+ renderBar();
+ openMore();
+ expect(screen.getByText('Your plan')).toBeInTheDocument();
+ expect(menuItems()).toEqual(expect.arrayContaining(['See Pro', 'Join my club with a code']));
+ });
+
+ it('hands Clear and the rules to the shell, and closes', () => {
+ const { onClearVideo, onOpenRules } = renderBar();
+ openMore();
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Clear the timer from my video' }));
+ expect(onClearVideo).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole('menu')).toBeNull();
+ openMore();
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Roles & timing rules' }));
+ expect(onOpenRules).toHaveBeenCalledTimes(1);
+ });
+
+ it('opens the tutorials and the Marketplace review in the browser', async () => {
+ renderBar();
+ openMore();
+ fireEvent.click(screen.getByRole('menuitem', { name: 'How-to videos' }));
+ expect(openExternalUrl).toHaveBeenCalledWith(TUTORIALS_URL);
+
+ openMore();
+ await act(async () => {
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Rate the app' }));
+ });
+ expect(openExternalUrl).toHaveBeenLastCalledWith(expect.stringContaining('marketplace.zoom.us'));
+ });
+
+ it('moves between items with the arrow keys and closes on Escape', () => {
+ renderBar();
+ openMore();
+ const first = screen.getAllByRole('menuitem')[0];
+ first.focus();
+ fireEvent.keyDown(first, { key: 'ArrowDown' });
+ expect(screen.getAllByRole('menuitem')[1]).toHaveFocus();
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(screen.queryByRole('menu')).toBeNull();
+ });
+});
diff --git a/apps/zoom-app/src/b/format.js b/apps/zoom-app/src/b/format.js
new file mode 100644
index 0000000..ae09dc3
--- /dev/null
+++ b/apps/zoom-app/src/b/format.js
@@ -0,0 +1,93 @@
+import { formatPhaseTextFor, getPhaseInfo } from '@toastmaster-timer/shared';
+
+/**
+ * Words and colours for design B's timer card (plan 13). Kept apart from the
+ * components so the card's sentences have one definition and one test.
+ */
+
+/** The card's colours per status, as drawn on the BCard board. */
+export const CARD_PALETTE = Object.freeze({
+ blue: { bg: '#4f86f7', fg: '#ffffff', shadow: 'rgba(37,99,235,0.28)', soft: 'rgba(255,255,255,0.22)', ghost: 'rgba(0,0,0,0.18)', inset: 'rgba(0,0,0,0.12)', ring: 'rgba(255,255,255,0.7)' },
+ green: { bg: '#16a34a', fg: '#ffffff', shadow: 'rgba(22,163,74,0.3)', soft: 'rgba(255,255,255,0.22)', ghost: 'rgba(0,0,0,0.2)', inset: 'rgba(0,0,0,0.12)', ring: 'rgba(255,255,255,0.7)' },
+ yellow: { bg: '#facc15', fg: '#422006', shadow: 'rgba(202,138,4,0.35)', soft: 'rgba(255,255,255,0.55)', ghost: 'rgba(66,32,6,0.12)', inset: 'rgba(66,32,6,0.08)', ring: 'rgba(66,32,6,0.35)' },
+ red: { bg: '#dc2626', fg: '#ffffff', shadow: 'rgba(220,38,38,0.3)', soft: 'rgba(255,255,255,0.22)', ghost: 'rgba(0,0,0,0.22)', inset: 'rgba(0,0,0,0.14)', ring: 'rgba(255,255,255,0.7)' },
+});
+
+/** The four preview swatches, in the order the meeting sees the colours. */
+export const PREVIEW_SWATCHES = Object.freeze([
+ { color: 'blue', label: 'Blue', bg: '#4f86f7' },
+ { color: 'green', label: 'Green', bg: '#22c55e' },
+ { color: 'yellow', label: 'Yellow', bg: '#eab308' },
+ { color: 'red', label: 'Red', bg: '#ef4444' },
+]);
+
+/**
+ * A threshold the way people say it: 4:00, 0:30, 12:05. Shorter than the
+ * clock's 04:00, which is what lets three of them share a line at 400px.
+ *
+ * @param {number} seconds
+ */
+export function formatThreshold(seconds) {
+ const safe = Math.max(0, Math.round(Number(seconds) || 0));
+ const mins = Math.floor(safe / 60);
+ const secs = safe % 60;
+ return `${mins}:${String(secs).padStart(2, '0')}`;
+}
+
+/** "Green 4:00 · Yellow 5:00 · Red 6:00", or '' without rules. */
+export function rulesLine(rules) {
+ if (!rules) return '';
+ return `Green ${formatThreshold(rules.green)} · Yellow ${formatThreshold(rules.yellow)} · Red ${formatThreshold(rules.red)}`;
+}
+
+/** "4:00 · 5:00 · 6:00", for the role picker's rows. */
+export function thresholdsLine(rules) {
+ if (!rules) return '';
+ return `${formatThreshold(rules.green)} · ${formatThreshold(rules.yellow)} · ${formatThreshold(rules.red)}`;
+}
+
+const lowerFirst = (text) => (text ? text.charAt(0).toLowerCase() + text.slice(1) : text);
+const NEXT_COLOUR = { blue: 'green', green: 'yellow', yellow: 'red' };
+const COLOUR_NAME = { blue: 'Blue', green: 'Green', yellow: 'Yellow', red: 'Red' };
+const THRESHOLD_FOR = { green: 'green', yellow: 'yellow', red: 'red' };
+
+/**
+ * The one line under the big clock: what is happening now and what comes
+ * next, in the card's own words.
+ *
+ * Ready · green at 4:00 idle speech
+ * Blue · green at 4:00 running
+ * Red phase running, past red
+ * Paused · red at 6:00 stopped mid-speech
+ * Counts down · green when 02:00 left idle break
+ * Start works once your video is on video off, nothing started
+ *
+ * @param {{
+ * status: 'blue'|'green'|'yellow'|'red'|null,
+ * rules: Object|null,
+ * isRunning: boolean,
+ * elapsedTime: number,
+ * previewColor?: string|null,
+ * videoOff?: boolean,
+ * }} state
+ * @returns {string}
+ */
+export function cardStatusLine({ status, rules, isRunning, elapsedTime, previewColor = null, videoOff = false }) {
+ const idle = !isRunning && elapsedTime === 0;
+ if (idle && videoOff) return 'Start works once your video is on';
+ if (idle && previewColor) return `Previewing ${previewColor} on your video`;
+ if (!rules) return '';
+
+ const current = status || 'blue';
+ if (rules.countdown) {
+ const phase = formatPhaseTextFor(getPhaseInfo(elapsedTime, rules, current), rules);
+ if (idle) return `Counts down · ${lowerFirst(phase)}`;
+ return isRunning ? phase : `Paused · ${lowerFirst(phase)}`;
+ }
+
+ if (idle) return `Ready · green at ${formatThreshold(rules.green)}`;
+ const next = NEXT_COLOUR[current];
+ const upcoming = next ? `${next} at ${formatThreshold(rules[THRESHOLD_FOR[next]])}` : null;
+ if (!isRunning) return upcoming ? `Paused · ${upcoming}` : 'Paused · red';
+ return upcoming ? `${COLOUR_NAME[current]} · ${upcoming}` : 'Red phase';
+}
diff --git a/apps/zoom-app/src/b/format.test.js b/apps/zoom-app/src/b/format.test.js
new file mode 100644
index 0000000..6b72057
--- /dev/null
+++ b/apps/zoom-app/src/b/format.test.js
@@ -0,0 +1,62 @@
+import { describe, it, expect } from 'vitest';
+import { deriveBreakRules } from '@toastmaster-timer/shared';
+import { cardStatusLine, formatThreshold, rulesLine, thresholdsLine } from './format';
+
+const ICE_BREAKER = { green: 240, yellow: 300, red: 360, graceAfterRed: 30 };
+
+describe('formatThreshold', () => {
+ it('says thresholds the short way', () => {
+ expect(formatThreshold(240)).toBe('4:00');
+ expect(formatThreshold(30)).toBe('0:30');
+ expect(formatThreshold(725)).toBe('12:05');
+ expect(formatThreshold(-5)).toBe('0:00');
+ });
+});
+
+describe('rulesLine / thresholdsLine', () => {
+ it('reads the three colours', () => {
+ expect(rulesLine(ICE_BREAKER)).toBe('Green 4:00 · Yellow 5:00 · Red 6:00');
+ expect(thresholdsLine(ICE_BREAKER)).toBe('4:00 · 5:00 · 6:00');
+ expect(rulesLine(null)).toBe('');
+ });
+});
+
+describe('cardStatusLine', () => {
+ const speech = (over) => cardStatusLine({ status: 'blue', rules: ICE_BREAKER, isRunning: false, elapsedTime: 0, ...over });
+
+ it('says when the first colour comes before anything starts', () => {
+ expect(speech()).toBe('Ready · green at 4:00');
+ });
+
+ it('names the colour on and the next one while running', () => {
+ expect(speech({ isRunning: true, elapsedTime: 72 })).toBe('Blue · green at 4:00');
+ expect(speech({ status: 'green', isRunning: true, elapsedTime: 267 })).toBe('Green · yellow at 5:00');
+ expect(speech({ status: 'yellow', isRunning: true, elapsedTime: 318 })).toBe('Yellow · red at 6:00');
+ expect(speech({ status: 'red', isRunning: true, elapsedTime: 369 })).toBe('Red phase');
+ });
+
+ it('says paused, with what is next', () => {
+ expect(speech({ status: 'yellow', elapsedTime: 331 })).toBe('Paused · red at 6:00');
+ expect(speech({ status: 'red', elapsedTime: 400 })).toBe('Paused · red');
+ });
+
+ it('tells a video-off organizer why Start will not work, before anything starts', () => {
+ expect(speech({ videoOff: true })).toBe('Start works once your video is on');
+ expect(speech({ videoOff: true, isRunning: true, elapsedTime: 10 })).toBe('Blue · green at 4:00');
+ });
+
+ it('says a preview is a preview', () => {
+ expect(speech({ previewColor: 'green' })).toBe('Previewing green on your video');
+ });
+
+ it('counts a break down', () => {
+ const rules = deriveBreakRules(300);
+ expect(cardStatusLine({ status: 'blue', rules, isRunning: false, elapsedTime: 0 })).toMatch(/^Counts down · green when \d\d:\d\d left$/);
+ expect(cardStatusLine({ status: 'green', rules, isRunning: true, elapsedTime: 200 })).toMatch(/^Yellow when \d\d:\d\d left$/);
+ expect(cardStatusLine({ status: 'green', rules, isRunning: false, elapsedTime: 200 })).toMatch(/^Paused · yellow when/);
+ });
+
+ it('says nothing without rules', () => {
+ expect(cardStatusLine({ status: 'blue', rules: null, isRunning: false, elapsedTime: 0 })).toBe('');
+ });
+});
diff --git a/apps/zoom-app/src/b/usePopover.js b/apps/zoom-app/src/b/usePopover.js
new file mode 100644
index 0000000..f501a24
--- /dev/null
+++ b/apps/zoom-app/src/b/usePopover.js
@@ -0,0 +1,54 @@
+import { useEffect, useRef } from 'react';
+
+/**
+ * Close an open popover on Escape or on a press outside the elements it owns
+ * (the button that opened it and the popover itself).
+ *
+ * @param {boolean} open
+ * @param {() => void} onClose
+ * @param {Array>} refs
+ */
+export function usePopover(open, onClose, refs) {
+ const onCloseRef = useRef(onClose);
+ onCloseRef.current = onClose;
+ const refsRef = useRef(refs);
+ refsRef.current = refs;
+
+ useEffect(() => {
+ if (!open) return undefined;
+ const inside = (target) => refsRef.current.some((ref) => ref.current?.contains(target));
+ const onPointer = (event) => {
+ if (!inside(event.target)) onCloseRef.current();
+ };
+ const onKey = (event) => {
+ if (event.key === 'Escape') onCloseRef.current();
+ };
+ document.addEventListener('mousedown', onPointer);
+ document.addEventListener('keydown', onKey);
+ return () => {
+ document.removeEventListener('mousedown', onPointer);
+ document.removeEventListener('keydown', onKey);
+ };
+ }, [open]);
+}
+
+/**
+ * Up / Down / Home / End between the items of an open menu or listbox.
+ *
+ * @param {KeyboardEvent} event
+ * @param {HTMLElement|null} container
+ * @param {string} selector - the focusable items, in order
+ */
+export function moveFocusWithArrows(event, container, selector) {
+ if (!container || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
+ const items = [...container.querySelectorAll(selector)].filter((el) => !el.disabled);
+ if (items.length === 0) return;
+ event.preventDefault();
+ const index = items.indexOf(document.activeElement);
+ let next;
+ if (event.key === 'Home') next = 0;
+ else if (event.key === 'End') next = items.length - 1;
+ else if (event.key === 'ArrowDown') next = index < 0 ? 0 : (index + 1) % items.length;
+ else next = index < 0 ? items.length - 1 : (index - 1 + items.length) % items.length;
+ items[next].focus();
+}
diff --git a/apps/zoom-app/src/components/VideoPreviewLayer.jsx b/apps/zoom-app/src/components/VideoPreviewLayer.jsx
index 18f6824..16b7a22 100644
--- a/apps/zoom-app/src/components/VideoPreviewLayer.jsx
+++ b/apps/zoom-app/src/components/VideoPreviewLayer.jsx
@@ -2,6 +2,10 @@ import { useState } from 'react';
import { Move, Plus, Minus, Eye, EyeOff, RotateCcw } from 'lucide-react';
import ClubBadge, { ClubBadgeLayer } from '@toastmaster-timer/ui/ClubBadge';
+// Written out in full, so Tailwind sees every class it has to generate.
+const CHIP_REVEAL = 'opacity-0 transition-opacity group-hover/chip:opacity-100 group-focus-within/chip:opacity-100';
+const BADGE_REVEAL = 'opacity-0 transition-opacity group-hover/badge:opacity-100 group-focus-within/badge:opacity-100';
+
/**
* What the organizer moves around on the timer preview: the count-up chip
* participants see on the video, and the club's badge. Each is dragged into
@@ -15,6 +19,12 @@ import ClubBadge, { ClubBadgeLayer } from '@toastmaster-timer/ui/ClubBadge';
*
* Rendered inside the positioned preview box; both pieces are absolutely
* placed within it.
+ *
+ * `revealControls` 'hover' (design B) keeps the − / + / eye / reset buttons
+ * out of sight until the pointer is on the chip or badge, or keyboard focus is
+ * inside it; 'always' (today's panel) shows them all the time. `chipHint` is a
+ * one-line note under the chip; `onChipEngaged` fires the first time the
+ * pointer reaches the chip, so a caller can retire that note.
*/
export default function VideoPreviewLayer({
containerRef,
@@ -31,7 +41,11 @@ export default function VideoPreviewLayer({
onAdjustBadgeScale,
onToggleBadgeVisible,
onResetBadgePlacement,
+ revealControls = 'always',
+ chipHint = null,
+ onChipEngaged,
}) {
+ const hover = revealControls === 'hover';
const [dragging, setDragging] = useState(false);
const [draggingBadge, setDraggingBadge] = useState(false);
@@ -51,6 +65,7 @@ export default function VideoPreviewLayer({
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
+ onChipEngaged?.();
};
const handlePointerMove = (event) => {
@@ -106,7 +121,7 @@ export default function VideoPreviewLayer({
transform: 'translate(-50%, -50%)',
maxWidth: '44%',
}}
- className="pointer-events-auto"
+ className={hover ? 'group/badge pointer-events-auto' : 'pointer-events-auto'}
>
{!draggingBadge && (
-
+
{badgePlacement.visible && (
<>
);
return nearLeftEdge ? (
-
+
{sizeButtons}
{eyeButton}
) : (
<>
{readoutVisible && (
-
+
{sizeButtons}
)}
-
+
{eyeButton}
>
);
})()}
+ {chipHint && !dragging && (
+
0.5 ? 'bottom-full mb-1.5' : 'top-full mt-1.5'
+ } ${readoutPosition.x > 0.6 ? 'right-0' : 'left-0'}`}
+ >
+ {chipHint}
+
+ )}
)}
>
diff --git a/apps/zoom-app/src/hooks/useLiveTimer.js b/apps/zoom-app/src/hooks/useLiveTimer.js
index 60672bd..d030c66 100644
--- a/apps/zoom-app/src/hooks/useLiveTimer.js
+++ b/apps/zoom-app/src/hooks/useLiveTimer.js
@@ -978,6 +978,7 @@ export function useLiveTimer() {
showSoundsModal,
setShowSoundsModal,
soundsLockReason,
+ cardImagesGeneration,
setCardImagesGeneration,
videoState,
isClearingVideo,
diff --git a/e2e/zoom-client/INDEX.md b/e2e/zoom-client/INDEX.md
index db43e97..43d373f 100644
--- a/e2e/zoom-client/INDEX.md
+++ b/e2e/zoom-client/INDEX.md
@@ -2,6 +2,9 @@
Read this first. Update it in the same PR as any spec change.
+Since plan 13 (design B), FLAGS_FORCE "1" also turns on `new_look`. Every spec except
+`new-look` describes today's panel, so where one says "1", deploy "1,new_look=0".
+
| Spec | Status | Verified | Covers |
|---|---|---|---|
| [timer-core](timer-core.md) | stale | 905d8d1, 2026-11-13 | start/colours/finish, resume after close; `audience_size` on `timer_started` for hosts, omitted for non-hosts (#107); the Live tab's suggestions, stage picker and FINISH refuse an agenda row that needs a role (#91 follow-up). TC-09 and TC-12 (human) not run. Stale since 2026-11-14: LiveTab's logic moved into `useLiveTimer` (plan 13 Phase 0); rerun blocked while the test device is held for #145's CC-10 |
@@ -18,6 +21,7 @@ Read this first. Update it in the same PR as any spec change.
| [club-renewal-reminder](club-renewal-reminder.md) | stale | 51534f9, 2026-11-14 | amber "
's Pro ends in N days" line: only in the last 60 days of a club that will not renew; a failed payment at once. CR-01 in Zoom; CR-02/CR-03 on the web app as a second club device (the Zoom client can't be made to refresh its club). CR-04 (nothing while `pro` is off) added 2026-11-14, not run yet (needs the club inside the 60-day window in dev KV) |
| [club-pro-gate](club-pro-gate.md) | current | 32ebeb7, 2026-11-14 | every club feature (badge, club card set, club timings, archive strip / History / share, report branding, renewal line) only while the club is active **and** `pro` is on; nothing deleted while off |
| [pro-coming-soon](pro-coming-soon.md) | current | 4d95d3d, 2026-11-14 | footer Pro button while `pro` is off: grey with a lock; hover or click lists what is coming (sounds, club card sets, voting, and more); the real Pro / Upgrade button once `pro` is on |
+| [new-look](new-look.md) | draft | never | design B behind `new_look` (plan 13): top row with the Pro chip and ⋯, no footer; the timer card drives the colour on real video; the 16:9 preview's chip and badge drags land where shown; each Look & sound sheet's setting reaches the video. Run with FLAGS_FORCE "1,pro=0". Held until plan 13's last phase is built |
| pro-club | **not migrated** | — | source: `docs/ZOOM_TEST_PLAN.md` P1–P7, plus the internal Club Pro plan (`06-zoom-client-test-plan-club-pro.md`); reference run `test-runs/2026-09-28-club-pro/run.md` |
| webhooks-lifecycle | **not migrated** | — | source: `docs/ZOOM_TEST_PLAN.md` Steps 4, 5, 6 |
@@ -26,5 +30,5 @@ yet. Migrate one at a time, running each once so `verified_at` is real.
## Run order for a full pass
Dependencies between specs, so the skill can batch human steps: `auth-identity` →
-`connection-notice` → `reauth-landing` → `contact-capture` → `announcement-banner` → `pro-coming-soon` → `timer-core` → `timer-sounds` → `agenda-import` → `pro-launch` → `pro-club` → `club-card-sets` → `club-renewal-reminder` → `club-pro-gate` → `webhooks-lifecycle` (deauth
+`connection-notice` → `reauth-landing` → `contact-capture` → `announcement-banner` → `pro-coming-soon` → `timer-core` → `new-look` → `timer-sounds` → `agenda-import` → `pro-launch` → `pro-club` → `club-card-sets` → `club-renewal-reminder` → `club-pro-gate` → `webhooks-lifecycle` (deauth
last, it removes the app).
diff --git a/e2e/zoom-client/agenda-import.md b/e2e/zoom-client/agenda-import.md
index 64c6030..db2a9ba 100644
--- a/e2e/zoom-client/agenda-import.md
+++ b/e2e/zoom-client/agenda-import.md
@@ -17,6 +17,8 @@ needs:
- club presets not live on this device (not in a club, or "Mine" in Edit Timing Rules)
- no custom role on the device shares a name with a role or bracket text below (note the device's custom roles in the run log; leave the rest, they are the user's own list)
- built-in roles not hidden on this device (Standard Speech, Ice Breaker and Table Topics Speech in the role dropdown); a hidden one is highlighted as "not one of your roles" in IM-02, IM-05 and IM-06. Then IM-03, IM-05 and IM-06 use a listed role with the same thresholds where possible in its place (see the 2026-11-13 run under IM-06)
+ - since plan 13, FLAGS_FORCE "1" also turns on `new_look` (design B); these steps are written for
+ today's panel, so wherever they say "1", deploy "1,new_look=0" ("0" already has it off)
verified_at: b029a29
verified_on: 2026-11-13 (Zoom 7.2.5, macOS 26.7.2, dev a249d5d7)
---
diff --git a/e2e/zoom-client/announcement-banner.md b/e2e/zoom-client/announcement-banner.md
index 271067f..8e97726 100644
--- a/e2e/zoom-client/announcement-banner.md
+++ b/e2e/zoom-client/announcement-banner.md
@@ -14,6 +14,8 @@ automated:
needs:
- dev deployed
- FLAGS_FORCE "1" on dev (`pro` on) for AB-01; "0" (`pro` off) for AB-02 to AB-04
+ - since plan 13, FLAGS_FORCE "1" also turns on `new_look` (design B); these steps are written for
+ today's panel, so wherever they say "1", deploy "1,new_look=0" ("0" already has it off)
verified_at: cb49176
verified_on: 2026-11-14 (Zoom 7.2.5, macOS 26.7.2, dev b5efb7d2 and fcf1ed01)
---
diff --git a/e2e/zoom-client/club-card-sets.md b/e2e/zoom-client/club-card-sets.md
index a035414..bdb17d4 100644
--- a/e2e/zoom-client/club-card-sets.md
+++ b/e2e/zoom-client/club-card-sets.md
@@ -24,6 +24,8 @@ needs:
- Zoom account A, an admin or editor of that club (club-member record)
- a second computer or browser profile, B, with no cards of its own
- ENTITLEMENT_ENFORCE "1" on dev for CC-08 only (ask first; revert after)
+ - since plan 13, FLAGS_FORCE "1" also turns on `new_look` (design B); these steps are written for
+ today's panel, so wherever they say "1", deploy "1,new_look=0" ("0" already has it off)
verified_at: 905d8d1
verified_on: 2026-11-13 (Zoom 7.2.5, macOS 26.7.2, dev 3ce801a6)
---
diff --git a/e2e/zoom-client/club-pro-gate.md b/e2e/zoom-client/club-pro-gate.md
index a0b4bf8..2bd235b 100644
--- a/e2e/zoom-client/club-pro-gate.md
+++ b/e2e/zoom-client/club-pro-gate.md
@@ -17,6 +17,8 @@ needs:
- FLAGS_FORCE "0" on dev (`pro` off) for GP-01 and GP-02; "1" for GP-03
- a club device whose club is active (Claude Test Club on the test Mac), with a club card
set, club timings and the badge shown on cards
+ - since plan 13, FLAGS_FORCE "1" also turns on `new_look` (design B); these steps are written for
+ today's panel, so wherever they say "1", deploy "1,new_look=0" ("0" already has it off)
verified_at: 32ebeb7
verified_on: 2026-11-14 (Zoom 7.2.5, macOS 26.7.2, dev with FLAGS_FORCE "0" then "1")
---
diff --git a/e2e/zoom-client/club-renewal-reminder.md b/e2e/zoom-client/club-renewal-reminder.md
index c9a6a46..616019a 100644
--- a/e2e/zoom-client/club-renewal-reminder.md
+++ b/e2e/zoom-client/club-renewal-reminder.md
@@ -18,6 +18,8 @@ needs:
that can be made to refresh: a device re-reads its club only once its cache is 24 h old,
so use the web app in a browser (set `toastmaster_club.lastRefreshAt` to 0, reload)
rather than the Zoom client, whose localStorage cannot be reached
+ - since plan 13, FLAGS_FORCE "1" also turns on `new_look` (design B); these steps are written for
+ today's panel, so wherever they say "1", deploy "1,new_look=0" ("0" already has it off)
verified_at: 51534f9
verified_on: 2026-11-14 (CR-01 in Zoom 7.2.5 on 4d95d3d; CR-02/CR-03 on the web app as a second club device, dev dd1b9ab7 restore)
---
diff --git a/e2e/zoom-client/connection-notice.md b/e2e/zoom-client/connection-notice.md
index fe45917..3a4e532 100644
--- a/e2e/zoom-client/connection-notice.md
+++ b/e2e/zoom-client/connection-notice.md
@@ -11,6 +11,8 @@ needs:
- dev deployed
- a second Zoom account in the `authenticated` state, in a meeting with the owner (docs/ZOOM_AUTH_AND_REDIRECTS.md, "Reaching the authenticated state deliberately")
- camera on, Timer + Camera mode
+ - since plan 13, FLAGS_FORCE "1" also turns on `new_look` (design B); these steps are written for
+ today's panel, so wherever they say "1", deploy "1,new_look=0" ("0" already has it off)
verified_at: never
verified_on: never
---
diff --git a/e2e/zoom-client/contact-capture.md b/e2e/zoom-client/contact-capture.md
index a370ce9..c858381 100644
--- a/e2e/zoom-client/contact-capture.md
+++ b/e2e/zoom-client/contact-capture.md
@@ -20,6 +20,8 @@ needs:
- authorize and onAuthorized in the dev app's API list
- wrangler access to the PROFILES KV namespace (dev)
- in a meeting, an account that has not approved the updated scopes
+ - since plan 13, FLAGS_FORCE "1" also turns on `new_look` (design B); these steps are written for
+ today's panel, so wherever they say "1", deploy "1,new_look=0" ("0" already has it off)
verified_at: never
verified_on: never
---
diff --git a/e2e/zoom-client/new-look.md b/e2e/zoom-client/new-look.md
new file mode 100644
index 0000000..1834ba5
--- /dev/null
+++ b/e2e/zoom-client/new-look.md
@@ -0,0 +1,130 @@
+---
+id: new-look
+title: Design B, the redesigned panel (new_look)
+status: draft
+covers:
+ - apps/zoom-app/src/b
+ - apps/zoom-app/src/App.jsx
+ - apps/zoom-app/src/hooks/useNewLook.js
+ - apps/zoom-app/src/components/VideoPreviewLayer.jsx
+automated:
+ - e2e/zoom.new-look.spec.js
+ - apps/zoom-app/src/b/NewLookShell.test.jsx
+ - apps/zoom-app/src/b/TimerCard.test.jsx
+ - apps/zoom-app/src/b/TopBar.test.jsx
+ - apps/zoom-app/src/b/Sheet.test.jsx
+ - apps/zoom-app/src/hooks/useNewLook.test.js
+needs:
+ - dev deployed
+ - camera on, in a meeting
+ - FLAGS_FORCE "1,pro=0" on dev (`new_look` on, `pro` off, the mix production will launch with); "1" for NL-09's second half and NL-10
+ - the test device in a club with Pro for NL-10 (it is: Claude Test Club)
+verified_at: never
+verified_on: never
+---
+
+# Design B, the redesigned panel (new_look)
+
+## Purpose
+Plan 13 redraws the whole Zoom panel behind the `new_look` flag: one timer card whose
+colour is the meeting's, a 16:9 preview with the count-up chip and club badge to drag, a
+"Look & sound" section of sheets, the ⋯ menu, no footer. The plain-browser e2e proves the
+layout; this proves what only the real client shows: that the card's buttons still drive
+the colour on the organizer's real video, that a drag on the 16:9 preview lands where the
+preview says, that each sheet's setting reaches the video, and that the panel fits Zoom's
+own width.
+
+## Steps
+
+### NL-01 · auto · The new look appears, fits, and has no footer
+Do: deploy dev with FLAGS_FORCE "1,pro=0", open Toastmasters Timer from Apps.
+Expect: one top row: Agenda / Timer / Report, a grey "Pro" chip with a lock, and ⋯. No
+footer. The Timer tab shows the blue card with "Who's speaking?", the role, the rules line,
+the preview, 00:00, "Ready · green at …", the four swatches, Start and Reset, then the four
+"Look & sound" rows. No horizontal scrollbar; Start is visible without scrolling.
+Evidence: screenshot; `localStorage.toastmaster_new_look_last` is "1".
+
+### NL-02 · auto · The card drives the colour on real video
+Do: open "Who's speaking?", type `NL Test`, Enter. Pick Short Roles in the role list. Start,
+and let it run past 1:00. Finish.
+Expect: the card and the video turn green at 0:30, yellow at 0:45, red at 1:00; the status
+line follows ("Green · yellow at 0:45", …, "Red phase"). After Finish the card is blue and
+empty again, and the Report tab lists NL Test.
+Evidence: screenshots at each colour; `toastmaster_reports` holds NL Test.
+
+### NL-03 · auto · A chip drag lands on the video where the preview shows it
+Do: with a swatch held (Preview green), drag the count-up chip to the bottom right of the
+preview and release.
+Expect: the count-up on the video moves to the bottom right. The push happens once, on
+release.
+Evidence: one `setVirtualBackground` / filter push in the debug log for the drag;
+`toastmaster_overlay_time_readout` holds x > 0.7, y > 0.7.
+
+### NL-04 · auto · The chip's controls show on hover only
+Do: move the pointer away from the preview, then onto the chip. Press +, then the eye.
+Expect: − / + / eye are hidden until the pointer is on the chip. + makes the count-up on the
+video larger; the eye hides it from the video (the chip dims).
+Evidence: screenshots before and after hover; readout scale and visibility in
+`toastmaster_overlay_time_readout`.
+
+### NL-05 · auto · On your video: each mode from its sheet
+Do: open "On your video". Pick Timer only; then reopen and pick Camera + timer; then
+Full-screen timer, and close the stage with its ×.
+Expect: Timer only shows the toast "Timer only: the colour replaces your camera until you
+clear it." and the colour replaces the camera; Camera + timer puts the camera back over the
+colour; Full-screen timer covers the panel with the stage. The row's note follows the mode.
+Evidence: screenshots; `toastmaster_overlay_mode`.
+
+### NL-06 · auto · Clear the timer from the video, from the sheet and from ⋯
+Do: hold a swatch so the colour is on the video. "On your video" → Clear from my video.
+Hold the swatch again, switch to the Report tab, ⋯ → Clear the timer from my video.
+Expect: both times the colour leaves the video; the second also switches back to the Timer
+tab. Zoom may ask to confirm in Camera + timer.
+Evidence: screenshots; "Cleared the timer from your video." toast.
+
+### NL-07 · auto · Cards: the card set reaches the video, and the sheet holds only cards
+Do: open "Cards", pick Modern, close. Hold the green swatch.
+Expect: the sheet lists the card sets and "Add your own", and no background picker. The
+video shows the Modern green card; the row's note reads Modern.
+Evidence: screenshot; `toastmaster_custom_card_images` names the active set.
+
+### NL-08 · auto · Between speeches: timer card or my video
+Do: open "Between speeches". With "Timer card" chosen, run and Finish a short speech; then
+choose "My video" and run and Finish another.
+Expect: with Timer card the blue card stays on the video after Finish; with My video the
+timer comes off the video after Finish (Zoom may ask to allow it). The row's note reads
+Timer card, then My video.
+Evidence: screenshots after each Finish; `toastmaster_reveal_face_when_idle`.
+
+### NL-09 · auto · Sounds: locked before the launch, open with Pro
+Do: open "Sounds" with FLAGS_FORCE "1,pro=0". Then redeploy with "1" on the club Pro device
+and open it again.
+Expect: first the row shows PRO and the sheet says "Hold tight! Sounds are coming soon." with
+the controls disabled; then the controls work and the row's note reads "Off" (or what is on).
+Evidence: screenshots.
+
+### NL-10 · auto · Club badge: hover controls and drag reach the video
+Do: with FLAGS_FORCE "1" on the club device, point at the club badge on the preview, press +,
+then drag it to the bottom left and release; press reset.
+Expect: − / + / eye / reset appear on hover only; the badge on the video grows, moves on
+release, and returns to the club's position on reset.
+Evidence: screenshots; `toastmaster_club_badge`.
+
+### NL-11 · auto · The ⋯ menu's help items
+Do: ⋯ → How-to videos; ⋯ → Send feedback (close it); ⋯ → Rate the app.
+Expect: the browser opens www.toastmusters.com/tutorials; the feedback dialog opens in the
+panel; the browser opens the Marketplace listing.
+Evidence: screenshots of the browser tabs.
+
+### NL-12 · human · Video off
+Do: turn the camera off in Zoom.
+Expect: an amber "Your video is off" notice above the card with "Turn video on", Start
+disabled, and the status line "Start works once your video is on". Turn video on brings the
+camera back and enables Start.
+Evidence: screenshots. Human: the camera toggle is the user's.
+
+## Not covered
+- Today's panel with `new_look` off: the other specs, run with FLAGS_FORCE "1,new_look=0".
+- Audio of the sounds: timer-sounds.
+- Agenda and Report tabs in the new look, the Roles & timing rules sheet, the Pro sheets,
+ the banners and the stage restyle: steps added with plan 13's Phases 2 and 3.
diff --git a/e2e/zoom-client/pro-coming-soon.md b/e2e/zoom-client/pro-coming-soon.md
index 5ceae45..35d44dc 100644
--- a/e2e/zoom-client/pro-coming-soon.md
+++ b/e2e/zoom-client/pro-coming-soon.md
@@ -12,6 +12,8 @@ automated:
needs:
- dev deployed
- FLAGS_FORCE "0" on dev (`pro` off) for PC-01 to PC-03; "1" for PC-04
+ - since plan 13, FLAGS_FORCE "1" also turns on `new_look` (design B); these steps are written for
+ today's panel, so wherever they say "1", deploy "1,new_look=0" ("0" already has it off)
verified_at: 4d95d3d
verified_on: 2026-11-14 (Zoom 7.2.5, macOS 26.7.2, dev 3e3f2561 and 2fcb1b39)
---
diff --git a/e2e/zoom-client/pro-launch.md b/e2e/zoom-client/pro-launch.md
index 822c88b..9f0cc01 100644
--- a/e2e/zoom-client/pro-launch.md
+++ b/e2e/zoom-client/pro-launch.md
@@ -17,6 +17,8 @@ needs:
- FLAGS_FORCE "1" on dev (the announcement is behind `pro`)
- camera on, in a meeting
- PL-04 only, a Zoom account on the Free plan with no club on the device
+ - since plan 13, FLAGS_FORCE "1" also turns on `new_look` (design B); these steps are written for
+ today's panel, so wherever they say "1", deploy "1,new_look=0" ("0" already has it off)
verified_at: eeaa840
verified_on: 2026-11-13 (Zoom 7.2.5, macOS 26.7.2, dev c8774483)
---
diff --git a/e2e/zoom-client/timer-core.md b/e2e/zoom-client/timer-core.md
index de7c5f1..e741045 100644
--- a/e2e/zoom-client/timer-core.md
+++ b/e2e/zoom-client/timer-core.md
@@ -16,6 +16,8 @@ automated:
needs:
- dev deployed
- camera on, in a meeting
+ - since plan 13, FLAGS_FORCE "1" also turns on `new_look` (design B); these steps are written for
+ today's panel, so wherever they say "1", deploy "1,new_look=0" ("0" already has it off)
verified_at: 905d8d1
verified_on: 2026-11-13 (Zoom 7.2.5, macOS 26.7.2, dev 3ce801a6)
---
diff --git a/e2e/zoom-client/timer-sounds.md b/e2e/zoom-client/timer-sounds.md
index d11f5ba..038f0e8 100644
--- a/e2e/zoom-client/timer-sounds.md
+++ b/e2e/zoom-client/timer-sounds.md
@@ -23,6 +23,8 @@ needs:
- a free account, then a Pro one (club code via /pro/, or a subscription)
- speakers or headphones on the test computer
- a second participant on another computer, for TS-07 only
+ - since plan 13, FLAGS_FORCE "1" also turns on `new_look` (design B); these steps are written for
+ today's panel, so wherever they say "1", deploy "1,new_look=0" ("0" already has it off)
verified_at: never
verified_on: never
---
diff --git a/e2e/zoom-client/zoom-deeplink.md b/e2e/zoom-client/zoom-deeplink.md
index b96be54..784df05 100644
--- a/e2e/zoom-client/zoom-deeplink.md
+++ b/e2e/zoom-client/zoom-deeplink.md
@@ -67,3 +67,5 @@ Evidence: `appLinks.test.js`; curl 302.
- The "Open in Zoom" / "Open Zoom app" buttons on `/oauth/redirect` and `/billing/success` (they need a real
install and a Stripe return); they share `zoomAppDeeplink()` with the landing page.
- Where Zoom shows its "Open Zoom?" browser prompt, which depends on the browser.
+- `TUTORIALS_URL` in `packages/shared/appLinks.js` (added for design B's ⋯ menu, plan 13) is a
+ plain web link, not a deeplink; `new-look` NL-11 opens it.
diff --git a/e2e/zoom.new-look.spec.js b/e2e/zoom.new-look.spec.js
new file mode 100644
index 0000000..d17ff4b
--- /dev/null
+++ b/e2e/zoom.new-look.spec.js
@@ -0,0 +1,126 @@
+import { test, expect } from '@playwright/test';
+
+// Design B, the redesigned panel (plan 13), behind the new_look flag. Plain
+// browser, Zoom SDK absent: what is proven here is the layout and the flow,
+// not the video (that is e2e/zoom-client/new-look.md).
+
+const PRODUCTION_TODAY = { new_look: true, pro: false, sounds: true };
+
+function session(page, flags, entitlement = { plan: 'free' }) {
+ return page.route('**/api/zoom/session', (route) =>
+ route.fulfill({ json: { identified: true, uid: 'e2e-new-look', entitlement, flags } })
+ );
+}
+
+async function openPanel(page, { flags = PRODUCTION_TODAY, width = 400, entitlement } = {}) {
+ await page.setViewportSize({ width, height: 860 });
+ await session(page, flags, entitlement);
+ await page.goto('/zoom/');
+ await expect(page.getByTestId('timer-card')).toBeVisible();
+}
+
+const noSidewaysScroll = (page) =>
+ page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth);
+
+test('with new_look off, the panel is the one shipped today', async ({ page }) => {
+ await session(page, { new_look: false, pro: false, sounds: true });
+ await page.goto('/zoom/');
+ await expect(page.getByRole('button', { name: 'START' })).toBeVisible();
+ await expect(page.locator('footer')).toBeVisible();
+ await expect(page.getByTestId('timer-card')).toHaveCount(0);
+});
+
+for (const width of [360, 400]) {
+ test(`fits ${width}px: one top row, no footer, nothing scrolls sideways`, async ({ page }) => {
+ await openPanel(page, { width });
+ const nav = page.getByRole('navigation', { name: 'Sections' });
+ await expect(nav.getByRole('button')).toHaveText(['Agenda', 'Timer', 'Report']);
+ await expect(page.getByRole('button', { name: 'Pro (coming soon)' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'More' })).toBeVisible();
+ await expect(page.locator('footer')).toHaveCount(0);
+
+ // The switch, the chip and the menu share one line.
+ const boxes = await Promise.all([
+ nav.boundingBox(),
+ page.getByRole('button', { name: 'Pro (coming soon)' }).boundingBox(),
+ page.getByRole('button', { name: 'More' }).boundingBox(),
+ ]);
+ const tops = boxes.map((box) => Math.round(box.y + box.height / 2));
+ expect(Math.max(...tops) - Math.min(...tops)).toBeLessThanOrEqual(2);
+ expect(await noSidewaysScroll(page)).toBe(true);
+
+ // Start sits high: above the fold of a short laptop panel.
+ const start = await page.getByRole('button', { name: 'Start' }).boundingBox();
+ expect(start.y + start.height).toBeLessThan(560);
+ });
+}
+
+test('a speech from start to finish lands in the report', async ({ page }) => {
+ await openPanel(page);
+ await page.getByRole('button', { name: "Who's speaking? Pick a speaker" }).click();
+ await page.getByRole('textbox', { name: 'Type a name, or pick one' }).fill('Priya');
+ await page.keyboard.press('Enter');
+ await expect(page.getByRole('button', { name: 'Speaker: Priya. Change speaker' })).toBeVisible();
+
+ await page.getByRole('button', { name: 'Start' }).click();
+ await expect(page.getByRole('button', { name: 'Stop' })).toBeVisible();
+ await expect(page.getByTestId('timer-display')).not.toHaveText('00:00', { timeout: 4000 });
+ await page.getByRole('button', { name: 'Stop' }).click();
+ await expect(page.getByRole('button', { name: 'Continue' })).toBeVisible();
+ await page.getByRole('button', { name: 'Finish' }).click();
+ await expect(page.getByRole('button', { name: 'Start' })).toBeVisible();
+
+ await page.getByRole('navigation', { name: 'Sections' }).getByRole('button', { name: 'Report' }).click();
+ await expect(page.getByText('Priya').first()).toBeVisible();
+});
+
+test('the role list shows thresholds and changes the rules line', async ({ page }) => {
+ await openPanel(page);
+ await page.getByRole('button', { name: /Timing role/ }).click();
+ const list = page.getByRole('listbox', { name: 'Timing role' });
+ await expect(list.getByRole('option', { name: /Ice Breaker/ })).toContainText('4:00 · 5:00 · 6:00');
+ await list.getByRole('option', { name: /Ice Breaker/ }).click();
+ await expect(page.getByText('Green 4:00 · Yellow 5:00 · Red 6:00')).toBeVisible();
+});
+
+test('each Look & sound row opens only its own sheet', async ({ page }) => {
+ await openPanel(page);
+ for (const [row, title] of [
+ ['On your video', 'On your video'],
+ ['Cards', 'Cards'],
+ ['Sounds', 'Sounds'],
+ ['Between speeches', 'Between speeches'],
+ ]) {
+ await page.getByRole('button', { name: new RegExp(`^${row}`) }).click();
+ await expect(page.getByRole('dialog')).toHaveCount(1);
+ await expect(page.getByRole('dialog', { name: title })).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(page.getByRole('dialog')).toHaveCount(0);
+ }
+ // Sounds stay locked with the pre-launch line while pro is off.
+ await page.getByRole('button', { name: /^Sounds/ }).click();
+ await expect(page.getByRole('dialog', { name: 'Sounds' })).toContainText('Hold tight! Sounds are coming soon.');
+});
+
+test('the count-up chip hides its controls until pointed at', async ({ page }) => {
+ await openPanel(page);
+ // The fade sits on the cluster that holds − / + / eye.
+ const controls = page.getByRole('button', { name: 'Make the count-up larger' }).locator('..');
+ await page.mouse.move(5, 5);
+ await expect(controls).toHaveCSS('opacity', '0');
+ await page.getByRole('button', { name: 'Drag to place the count-up participants see on your video' }).hover();
+ await expect(controls).toHaveCSS('opacity', '1');
+});
+
+test('the ⋯ menu holds the footer\'s old buttons, and Pro once it is released', async ({ page }) => {
+ await openPanel(page, { flags: { new_look: true, pro: true, sounds: true } });
+ await expect(page.getByRole('button', { name: 'Upgrade to Pro' })).toBeVisible();
+ await page.getByRole('button', { name: 'More' }).click();
+ const menu = page.getByRole('menu', { name: 'More' });
+ await expect(menu).toContainText('Your plan');
+ for (const item of ['Join my club with a code', 'Roles & timing rules', 'Clear the timer from my video', 'How-to videos', 'Send feedback', 'Rate the app']) {
+ await expect(menu.getByRole('menuitem', { name: item })).toBeVisible();
+ }
+ await menu.getByRole('menuitem', { name: 'Send feedback' }).click();
+ await expect(menu).toHaveCount(0);
+});
diff --git a/packages/shared/appLinks.js b/packages/shared/appLinks.js
index 027869d..11c6c55 100644
--- a/packages/shared/appLinks.js
+++ b/packages/shared/appLinks.js
@@ -24,6 +24,13 @@ export const ZOOM_MARKETPLACE_LISTING_URL = ZOOM_MARKETPLACE_REVIEW_URL;
*/
export const TIMER_APP_URL = 'https://www.toastmusters.com/timer/app';
+/**
+ * The short how-to videos (apps/web/public/tutorials.html). The Zoom app's
+ * ⋯ menu opens it with zoomSdk.openUrl, on the same allow-listed domain as
+ * TIMER_APP_URL.
+ */
+export const TUTORIALS_URL = 'https://www.toastmusters.com/tutorials';
+
/**
* Every tool in the Toastmusters suite, each a path on www.toastmusters.com. Footers and nav
* render from this list so a new tool is one entry here. URLs have no