diff --git a/apps/zoom-app/src/App.jsx b/apps/zoom-app/src/App.jsx
index 1b7da4a..ee615cf 100644
--- a/apps/zoom-app/src/App.jsx
+++ b/apps/zoom-app/src/App.jsx
@@ -9,45 +9,64 @@ import ContactCapture from './components/ContactCapture';
import ZoomConnectionNotice from './components/ZoomConnectionNotice';
import ClubStatusBanner from './components/ClubStatusBanner';
import AnnouncementBanner from './components/AnnouncementBanner';
+import { useNewLook } from './hooks/useNewLook';
import './App.css';
const LiveTab = lazy(() => import('./components/LiveTab'));
const AgendaTab = lazy(() => import('./components/AgendaTab'));
const ReportTab = lazy(() => import('./components/ReportTab'));
+// Design B. Its own chunk, so today's panel does not ship the new one's code.
+const NewLookShell = lazy(() => import('./b/NewLookShell'));
-function App() {
+/** The panel shipped today: tabs, the Live tab, the footer. */
+export function ClassicShell() {
const [activeTab, setActiveTab] = useState('live');
+ return (
+
+ {/* The dimmed panel. A press here closes, like the close button; it is
+ not a control of its own, so it stays out of the tab order. */}
+
onCloseRef.current()}
+ />
+
+
+
+
+
+
+ {title}
+
+ {badge}
+
+ onCloseRef.current()}
+ aria-label="Close"
+ className="flex h-[30px] w-[30px] items-center justify-center rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200"
+ >
+
+
+
+ {description && {description}
}
+ {children}
+
+
+ );
+}
diff --git a/apps/zoom-app/src/b/Sheet.test.jsx b/apps/zoom-app/src/b/Sheet.test.jsx
new file mode 100644
index 0000000..2d7f728
--- /dev/null
+++ b/apps/zoom-app/src/b/Sheet.test.jsx
@@ -0,0 +1,97 @@
+import '@testing-library/jest-dom';
+import { useState } from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import Sheet from './Sheet';
+
+function Harness({ onClose = () => {}, children }) {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+
setOpen(true)}>
+ Cards
+
+
{
+ onClose();
+ setOpen(false);
+ }}
+ title="Cards"
+ description="The card the timer shows at each colour."
+ >
+ {children ?? (
+ <>
+ Classic
+ Modern
+ >
+ )}
+
+ >
+ );
+}
+
+const openSheet = () => fireEvent.click(screen.getByRole('button', { name: 'Cards' }));
+
+describe('Sheet', () => {
+ it('renders nothing while closed', () => {
+ render(
);
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('is a labelled modal dialog with its description', () => {
+ render(
);
+ openSheet();
+ const dialog = screen.getByRole('dialog', { name: 'Cards' });
+ expect(dialog).toHaveAttribute('aria-modal', 'true');
+ expect(screen.getByText('The card the timer shows at each colour.')).toBeInTheDocument();
+ });
+
+ it('moves focus in when it opens, and back to the opener when it closes', () => {
+ render(
);
+ const opener = screen.getByRole('button', { name: 'Cards' });
+ opener.focus();
+ openSheet();
+ expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }));
+ expect(screen.queryByRole('dialog')).toBeNull();
+ expect(opener).toHaveFocus();
+ });
+
+ it('closes on Escape, on the close button and on the dimmed panel', () => {
+ const onClose = vi.fn();
+ render(
);
+
+ openSheet();
+ fireEvent.keyDown(document, { key: 'Escape' });
+ openSheet();
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }));
+ openSheet();
+ fireEvent.click(screen.getByTestId('sheet-backdrop'));
+
+ expect(onClose).toHaveBeenCalledTimes(3);
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('keeps Tab inside the sheet', () => {
+ render(
);
+ openSheet();
+ const close = screen.getByRole('button', { name: 'Close' });
+ const last = screen.getByRole('button', { name: 'Modern' });
+
+ last.focus();
+ fireEvent.keyDown(document, { key: 'Tab' });
+ expect(close).toHaveFocus();
+
+ fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
+ expect(last).toHaveFocus();
+ });
+
+ it('does not close for a press inside the sheet', () => {
+ const onClose = vi.fn();
+ render(
);
+ openSheet();
+ fireEvent.click(screen.getByRole('button', { name: 'Classic' }));
+ expect(onClose).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/zoom-app/src/components/CardImagesModal.jsx b/apps/zoom-app/src/components/CardImagesModal.jsx
index f398f98..4fe869e 100644
--- a/apps/zoom-app/src/components/CardImagesModal.jsx
+++ b/apps/zoom-app/src/components/CardImagesModal.jsx
@@ -1,261 +1,14 @@
-import { useEffect, useRef, useState, lazy, Suspense } from 'react';
-import { useEntitlement } from '../hooks/useEntitlement';
-import { useFlag } from '../hooks/useFlag';
-import { X, Plus, Check, Trash2, Share2 } from 'lucide-react';
-import {
- CARD_COLORS,
- CLUB_CARD_SET_ID,
- DEFAULT_CARD_SETS,
- getCardImageSettings,
- activeCardSetId,
- clubCardSetAvailable,
- clubFeaturesOn,
- getClubCardImage,
- selectCardSet,
- addCustomCardSet,
- deleteCustomCardSet,
- getCustomCardImage,
- fileToCardBlob,
- initCardImages,
- canPublishCards,
- publishCardSet,
- loadClubCards,
- subscribeCardImages,
-} from '@toastmaster-timer/shared';
-import { getCardFileUrl, notifyCardImagesChanged, preloadBackgroundImages } from '../utils/zoomSdk';
-import { getSessionToken } from '../utils/zoomIdentity';
-import { useClub } from '../hooks/useClub';
+import { X } from 'lucide-react';
import OwnBackgroundPicker from './OwnBackgroundPicker';
-import { useToast } from '../context/ToastContext';
-const UpgradeModal = lazy(() => import('./UpgradeModal'));
-
-const COLOR_LABELS = {
- blue: 'Idle (blue)',
- green: 'Green',
- yellow: 'Yellow',
- red: 'Red',
-};
-
-// Faint status tint behind each empty upload slot, so the row reads as
-// blue/green/yellow/red before any image is picked.
-const SLOT_TINTS = {
- blue: '#eff6ff',
- green: '#f0fdf4',
- yellow: '#fefce8',
- red: '#fef2f2',
-};
-
-const CLASSIC_FILES = DEFAULT_CARD_SETS[0].files;
-const STORAGE_FULL_MESSAGE =
- 'Could not save — browser storage is full. Try smaller images or delete another custom set.';
-
-const SHARE_ERRORS = {
- forbidden: 'Only your club’s admins and editors can share cards with the club.',
- upgrade_required: 'Your club’s Pro has ended, so it can’t take new cards.',
- storage_limit: 'Your club’s card storage is full.',
- image_too_large: 'One of these images is too large to share.',
- network: 'Could not reach the server. Check your connection and try again.',
-};
-
-/**
- * One selectable set: a labeled row of the four cards. The row itself is the
- * radio; a custom set also carries a delete button, which must not double as
- * a click on the row.
- */
-function CardSetRow({ label, selected, onSelect, thumbSrc, onDelete, onShare, sharing }) {
- return (
-
{
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- onSelect();
- }
- }}
- className={`rounded-lg border p-2 cursor-pointer transition-colors ${
- selected ? 'border-blue-500 ring-1 ring-blue-500' : 'border-gray-200 hover:border-gray-300'
- }`}
- >
-
-
- {label}
- {selected && }
-
-
- {onShare && (
- {
- e.stopPropagation();
- onShare();
- }}
- disabled={sharing}
- className="flex items-center gap-1 px-1.5 py-1 rounded text-xs text-blue-600 hover:bg-blue-50 disabled:opacity-50 transition-colors"
- aria-label={`Share the ${label} card set with my club`}
- >
-
- {sharing ? 'Sharing…' : 'Share with my club'}
-
- )}
- {onDelete && (
- {
- e.stopPropagation();
- onDelete();
- }}
- className="p-1 rounded text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
- aria-label={`Delete the ${label} card set`}
- >
-
-
- )}
-
-
-
- {CARD_COLORS.map((color) => (
-
- ))}
-
-
- );
-}
+import CardSetsPanel from './CardSetsPanel';
/**
- * Card set picker: the two built-in sets, every custom set the organizer has
- * added, and a row of uploaders for adding another. Custom sets are stored in
- * this browser only, and the built-in sets stay available as the defaults a
- * delete returns to — so the choice of artwork, and the rights to it, sit
- * with the user.
+ * The Card Images dialog: the card set picker, and the organizer's own
+ * background, which is the other image their video shows.
*/
export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
- const { showToast } = useToast();
- const { isPro, known } = useEntitlement();
- // The Upgrade link opens the purchase, which stays dark until pro is
- // released. Until then the note reads as it does while the plan is unknown.
- const { enabled: proEnabled, known: flagsKnown } = useFlag('pro');
- const [showUpgrade, setShowUpgrade] = useState(false);
- const [settings, setSettings] = useState(() => getCardImageSettings());
- // The club's set is not in the metadata, so which set shows is asked of the
- // shared module rather than read off `settings`.
- const [activeSetId, setActiveSetId] = useState(() => activeCardSetId());
- const { club, clubName } = useClub();
- // The kit carries the name when there is one; the club record always does.
- const clubLabel = clubName || club?.club?.name || null;
- const [sharingSetId, setSharingSetId] = useState(null);
- // Re-render when the club's images arrive, so its thumbnails fill in.
- const [, setClubGeneration] = useState(0);
- // The set being assembled in the uploader row, color -> {blob, url}. Kept
- // only in this modal until the organizer confirms it into a real set.
- const [draft, setDraft] = useState({});
- const draftRef = useRef(draft);
- draftRef.current = draft;
- const fileInputRef = useRef(null);
- const draftColorRef = useRef(null);
-
- // The stored Blobs load asynchronously; if the modal opens before they are
- // in, refresh once they are so custom thumbnails fill in. On unmount,
- // release the preview URLs of an unconfirmed draft.
- useEffect(() => {
- let cancelled = false;
- initCardImages().then(() => {
- if (!cancelled) {
- setSettings(getCardImageSettings());
- setActiveSetId(activeCardSetId());
- }
- });
- const unsubscribe = subscribeCardImages(() => {
- setActiveSetId(activeCardSetId());
- setClubGeneration((generation) => generation + 1);
- });
- return () => {
- cancelled = true;
- unsubscribe();
- Object.values(draftRef.current).forEach((entry) => URL.revokeObjectURL(entry.url));
- };
- }, []);
-
if (!isOpen) return null;
- const changed = () => {
- setSettings(getCardImageSettings());
- setActiveSetId(activeCardSetId());
- notifyCardImagesChanged();
- onImagesChanged?.();
- // The notify above dropped every decoded overlay frame; re-decode the new
- // set's four cards now, off the critical path, so the coming status
- // switches (blue -> green -> yellow -> red) push straight from cache.
- preloadBackgroundImages();
- };
-
- const handleSelect = (setId) => {
- if (setId === activeSetId) return;
- if (selectCardSet(setId)) changed();
- else showToast(STORAGE_FULL_MESSAGE, 'error');
- };
-
- const handleDraftUpload = async (color, file) => {
- if (!file) return;
- try {
- const blob = await fileToCardBlob(file);
- setDraft((prev) => {
- if (prev[color]) URL.revokeObjectURL(prev[color].url);
- return { ...prev, [color]: { blob, url: URL.createObjectURL(blob) } };
- });
- } catch {
- showToast('That file could not be read as an image.', 'error');
- }
- };
-
- const handleAddDraft = async () => {
- const images = {};
- for (const color of CARD_COLORS) {
- if (draft[color]) images[color] = draft[color].blob;
- }
- const id = await addCustomCardSet(images);
- if (!id) {
- showToast(STORAGE_FULL_MESSAGE, 'error');
- return;
- }
- Object.values(draft).forEach((entry) => URL.revokeObjectURL(entry.url));
- setDraft({});
- changed();
- showToast('Custom card set added', 'success');
- };
-
- const handleDelete = async (setId) => {
- if (await deleteCustomCardSet(setId)) {
- changed();
- showToast('Custom card set deleted', 'success');
- }
- };
-
- // No label: "Custom 1" is this device's slot name, and every other club device
- // has its own "Custom 1". Unlabelled, the set reads "
cards" everywhere.
- const handleShare = async (setId) => {
- setSharingSetId(setId);
- const result = await publishCardSet(setId, { getToken: getSessionToken });
- setSharingSetId(null);
- if (result.ok) {
- showToast(`Shared with ${clubLabel || 'your club'}. Every club device gets these cards.`, 'success');
- } else {
- showToast(SHARE_ERRORS[result.error] || 'Could not share these cards. Try again.', 'error');
- }
- };
-
- const pickDraftFile = (color) => {
- draftColorRef.current = color;
- fileInputRef.current?.click();
- };
-
- const draftHasImages = CARD_COLORS.some((color) => draft[color]);
-
return (
@@ -270,136 +23,13 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
does — including on your video.
-
- {clubCardSetAvailable() && (
-
handleSelect(CLUB_CARD_SET_ID)}
- thumbSrc={(color) => getClubCardImage(color) || getCardFileUrl(CLASSIC_FILES[color])}
- />
- )}
-
- {DEFAULT_CARD_SETS.map((set) => (
- handleSelect(set.id)}
- thumbSrc={(color) => getCardFileUrl(set.files[color])}
- />
- ))}
-
- {settings.customSets.map((set, index) => (
- handleSelect(set.id)}
- // A color the set has no upload for shows — and times with —
- // the Classic card.
- thumbSrc={(color) => getCustomCardImage(set.id, color) || getCardFileUrl(CLASSIC_FILES[color])}
- onDelete={() => handleDelete(set.id)}
- onShare={club && canPublishCards() ? () => handleShare(set.id) : undefined}
- sharing={sharingSetId === set.id}
- />
- ))}
-
-
-
- Add your own
- {draftHasImages && (
-
- Add set
-
- )}
-
-
- {CARD_COLORS.map((color) =>
- draft[color] ? (
-
pickDraftFile(color)}
- title={`Replace the ${COLOR_LABELS[color]} image`}
- aria-label={`Replace the ${COLOR_LABELS[color]} image`}
- className="w-full aspect-video rounded overflow-hidden border border-gray-200"
- >
-
-
- ) : (
-
pickDraftFile(color)}
- title={`Upload the ${COLOR_LABELS[color]} image`}
- aria-label={`Upload the ${COLOR_LABELS[color]} image`}
- style={{ backgroundColor: SLOT_TINTS[color] }}
- className="w-full aspect-video rounded border-2 border-dashed border-gray-300 flex items-center justify-center text-gray-400 hover:border-gray-400 hover:text-gray-600 transition-colors"
- >
-
-
- )
- )}
-
-
- Upload an image for each color, then Add set. Colors you skip use the Classic card.
-
+
+ {/* Not a card, but the other image the organizer's video shows — and
+ the one they have to go looking for otherwise. */}
+
+
-
-
- {/* Not a card, but the other image the organizer's video shows — and
- the one they have to go looking for otherwise. */}
-
-
-
-
- {
- handleDraftUpload(draftColorRef.current, e.target.files?.[0]);
- // Same file re-picked later must fire change again.
- e.target.value = '';
- }}
- />
-
-
- {clubFeaturesOn(club) ? (
- <>
- Custom images are stored only in this browser.
- {canPublishCards() ? ' Share a set to put it on every club device. ' : ' '}
- >
- ) : isPro ? (
- <>Custom images are backed up and follow you to your other devices. >
- ) : known && flagsKnown && proEnabled ? (
- <>
- Custom images are stored only in this browser.{' '}
- setShowUpgrade(true)}
- className="text-blue-600 hover:text-blue-700 underline"
- >
- Upgrade to Pro
- {' '}
- to back them up and use them on your other devices.{' '}
- >
- ) : (
- <>Custom images are stored only in this browser. >
- )}
- Please upload only images you have the right to use — for example your club's own
- materials. This app is independent and cannot grant permission for third-party logos or
- artwork.
-
- {showUpgrade && (
-
- setShowUpgrade(false)} source="card_images" />
-
- )}
+
);
diff --git a/apps/zoom-app/src/components/CardImagesModal.test.jsx b/apps/zoom-app/src/components/CardImagesModal.test.jsx
index 3009ff4..60efc9e 100644
--- a/apps/zoom-app/src/components/CardImagesModal.test.jsx
+++ b/apps/zoom-app/src/components/CardImagesModal.test.jsx
@@ -10,6 +10,7 @@ import {
} from '@toastmaster-timer/shared';
import { publishCardSet } from '@toastmaster-timer/shared';
import CardImagesModal from './CardImagesModal';
+import CardSetsPanel from './CardSetsPanel';
import { ToastProvider } from '../context/ToastContext';
// Stubbed rather than imported: the real module pulls in @zoom/appssdk, which
@@ -192,3 +193,20 @@ describe('the card images storage note', () => {
expect(upgradeLink()).toBeNull();
});
});
+
+// The dialog's card picker on its own, as design B's Cards sheet shows it: the
+// sets only, with the own-background picker left to its own sheet.
+describe('the card set panel', () => {
+ it('shows the sets and the storage note, and nothing about the background', async () => {
+ render(
+
+
+
+ );
+ await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
+ expect(screen.getByRole('radiogroup', { name: 'Card set' })).toBeInTheDocument();
+ expect(screen.getByRole('radio', { name: /classic/i })).toBeInTheDocument();
+ expect(storageNote()).toBeInTheDocument();
+ expect(screen.queryByText(/background/i)).toBeNull();
+ });
+});
diff --git a/apps/zoom-app/src/components/CardSetsPanel.jsx b/apps/zoom-app/src/components/CardSetsPanel.jsx
new file mode 100644
index 0000000..634f58b
--- /dev/null
+++ b/apps/zoom-app/src/components/CardSetsPanel.jsx
@@ -0,0 +1,390 @@
+import { useEffect, useRef, useState, lazy, Suspense } from 'react';
+import { useEntitlement } from '../hooks/useEntitlement';
+import { useFlag } from '../hooks/useFlag';
+import { Plus, Check, Trash2, Share2 } from 'lucide-react';
+import {
+ CARD_COLORS,
+ CLUB_CARD_SET_ID,
+ DEFAULT_CARD_SETS,
+ getCardImageSettings,
+ activeCardSetId,
+ clubCardSetAvailable,
+ clubFeaturesOn,
+ getClubCardImage,
+ selectCardSet,
+ addCustomCardSet,
+ deleteCustomCardSet,
+ getCustomCardImage,
+ fileToCardBlob,
+ initCardImages,
+ canPublishCards,
+ publishCardSet,
+ loadClubCards,
+ subscribeCardImages,
+} from '@toastmaster-timer/shared';
+import { getCardFileUrl, notifyCardImagesChanged, preloadBackgroundImages } from '../utils/zoomSdk';
+import { getSessionToken } from '../utils/zoomIdentity';
+import { useClub } from '../hooks/useClub';
+import { useToast } from '../context/ToastContext';
+const UpgradeModal = lazy(() => import('./UpgradeModal'));
+
+const COLOR_LABELS = {
+ blue: 'Idle (blue)',
+ green: 'Green',
+ yellow: 'Yellow',
+ red: 'Red',
+};
+
+// Faint status tint behind each empty upload slot, so the row reads as
+// blue/green/yellow/red before any image is picked.
+const SLOT_TINTS = {
+ blue: '#eff6ff',
+ green: '#f0fdf4',
+ yellow: '#fefce8',
+ red: '#fef2f2',
+};
+
+const CLASSIC_FILES = DEFAULT_CARD_SETS[0].files;
+const STORAGE_FULL_MESSAGE =
+ 'Could not save — browser storage is full. Try smaller images or delete another custom set.';
+
+const SHARE_ERRORS = {
+ forbidden: 'Only your club’s admins and editors can share cards with the club.',
+ upgrade_required: 'Your club’s Pro has ended, so it can’t take new cards.',
+ storage_limit: 'Your club’s card storage is full.',
+ image_too_large: 'One of these images is too large to share.',
+ network: 'Could not reach the server. Check your connection and try again.',
+};
+
+/**
+ * One selectable set: a labeled row of the four cards. The row itself is the
+ * radio; a custom set also carries a delete button, which must not double as
+ * a click on the row.
+ */
+function CardSetRow({ label, selected, onSelect, thumbSrc, onDelete, onShare, sharing }) {
+ return (
+
{
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onSelect();
+ }
+ }}
+ className={`rounded-lg border p-2 cursor-pointer transition-colors ${
+ selected ? 'border-blue-500 ring-1 ring-blue-500' : 'border-gray-200 hover:border-gray-300'
+ }`}
+ >
+
+
+ {label}
+ {selected && }
+
+
+ {onShare && (
+ {
+ e.stopPropagation();
+ onShare();
+ }}
+ disabled={sharing}
+ className="flex items-center gap-1 px-1.5 py-1 rounded text-xs text-blue-600 hover:bg-blue-50 disabled:opacity-50 transition-colors"
+ aria-label={`Share the ${label} card set with my club`}
+ >
+
+ {sharing ? 'Sharing…' : 'Share with my club'}
+
+ )}
+ {onDelete && (
+ {
+ e.stopPropagation();
+ onDelete();
+ }}
+ className="p-1 rounded text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
+ aria-label={`Delete the ${label} card set`}
+ >
+
+
+ )}
+
+
+
+ {CARD_COLORS.map((color) => (
+
+ ))}
+
+
+ );
+}
+
+/**
+ * Card set picker: the two built-in sets, every custom set the organizer has
+ * added, and a row of uploaders for adding another. Custom sets are stored in
+ * this browser only, and the built-in sets stay available as the defaults a
+ * delete returns to — so the choice of artwork, and the rights to it, sit
+ * with the user.
+ *
+ * The body of the Card Images dialog, without the dialog, so design B's Cards
+ * sheet (plan 13) shows the same picker. `children` render between the sets
+ * and the note under them; the dialog puts the own-background picker there.
+ */
+export default function CardSetsPanel({ onImagesChanged, children }) {
+ const { showToast } = useToast();
+ const { isPro, known } = useEntitlement();
+ // The Upgrade link opens the purchase, which stays dark until pro is
+ // released. Until then the note reads as it does while the plan is unknown.
+ const { enabled: proEnabled, known: flagsKnown } = useFlag('pro');
+ const [showUpgrade, setShowUpgrade] = useState(false);
+ const [settings, setSettings] = useState(() => getCardImageSettings());
+ // The club's set is not in the metadata, so which set shows is asked of the
+ // shared module rather than read off `settings`.
+ const [activeSetId, setActiveSetId] = useState(() => activeCardSetId());
+ const { club, clubName } = useClub();
+ // The kit carries the name when there is one; the club record always does.
+ const clubLabel = clubName || club?.club?.name || null;
+ const [sharingSetId, setSharingSetId] = useState(null);
+ // Re-render when the club's images arrive, so its thumbnails fill in.
+ const [, setClubGeneration] = useState(0);
+ // The set being assembled in the uploader row, color -> {blob, url}. Kept
+ // only in this modal until the organizer confirms it into a real set.
+ const [draft, setDraft] = useState({});
+ const draftRef = useRef(draft);
+ draftRef.current = draft;
+ const fileInputRef = useRef(null);
+ const draftColorRef = useRef(null);
+
+ // The stored Blobs load asynchronously; if the modal opens before they are
+ // in, refresh once they are so custom thumbnails fill in. On unmount,
+ // release the preview URLs of an unconfirmed draft.
+ useEffect(() => {
+ let cancelled = false;
+ initCardImages().then(() => {
+ if (!cancelled) {
+ setSettings(getCardImageSettings());
+ setActiveSetId(activeCardSetId());
+ }
+ });
+ const unsubscribe = subscribeCardImages(() => {
+ setActiveSetId(activeCardSetId());
+ setClubGeneration((generation) => generation + 1);
+ });
+ return () => {
+ cancelled = true;
+ unsubscribe();
+ Object.values(draftRef.current).forEach((entry) => URL.revokeObjectURL(entry.url));
+ };
+ }, []);
+
+ const changed = () => {
+ setSettings(getCardImageSettings());
+ setActiveSetId(activeCardSetId());
+ notifyCardImagesChanged();
+ onImagesChanged?.();
+ // The notify above dropped every decoded overlay frame; re-decode the new
+ // set's four cards now, off the critical path, so the coming status
+ // switches (blue -> green -> yellow -> red) push straight from cache.
+ preloadBackgroundImages();
+ };
+
+ const handleSelect = (setId) => {
+ if (setId === activeSetId) return;
+ if (selectCardSet(setId)) changed();
+ else showToast(STORAGE_FULL_MESSAGE, 'error');
+ };
+
+ const handleDraftUpload = async (color, file) => {
+ if (!file) return;
+ try {
+ const blob = await fileToCardBlob(file);
+ setDraft((prev) => {
+ if (prev[color]) URL.revokeObjectURL(prev[color].url);
+ return { ...prev, [color]: { blob, url: URL.createObjectURL(blob) } };
+ });
+ } catch {
+ showToast('That file could not be read as an image.', 'error');
+ }
+ };
+
+ const handleAddDraft = async () => {
+ const images = {};
+ for (const color of CARD_COLORS) {
+ if (draft[color]) images[color] = draft[color].blob;
+ }
+ const id = await addCustomCardSet(images);
+ if (!id) {
+ showToast(STORAGE_FULL_MESSAGE, 'error');
+ return;
+ }
+ Object.values(draft).forEach((entry) => URL.revokeObjectURL(entry.url));
+ setDraft({});
+ changed();
+ showToast('Custom card set added', 'success');
+ };
+
+ const handleDelete = async (setId) => {
+ if (await deleteCustomCardSet(setId)) {
+ changed();
+ showToast('Custom card set deleted', 'success');
+ }
+ };
+
+ // No label: "Custom 1" is this device's slot name, and every other club device
+ // has its own "Custom 1". Unlabelled, the set reads "
cards" everywhere.
+ const handleShare = async (setId) => {
+ setSharingSetId(setId);
+ const result = await publishCardSet(setId, { getToken: getSessionToken });
+ setSharingSetId(null);
+ if (result.ok) {
+ showToast(`Shared with ${clubLabel || 'your club'}. Every club device gets these cards.`, 'success');
+ } else {
+ showToast(SHARE_ERRORS[result.error] || 'Could not share these cards. Try again.', 'error');
+ }
+ };
+
+ const pickDraftFile = (color) => {
+ draftColorRef.current = color;
+ fileInputRef.current?.click();
+ };
+
+ const draftHasImages = CARD_COLORS.some((color) => draft[color]);
+
+ return (
+ <>
+
+ {clubCardSetAvailable() && (
+
handleSelect(CLUB_CARD_SET_ID)}
+ thumbSrc={(color) => getClubCardImage(color) || getCardFileUrl(CLASSIC_FILES[color])}
+ />
+ )}
+
+ {DEFAULT_CARD_SETS.map((set) => (
+ handleSelect(set.id)}
+ thumbSrc={(color) => getCardFileUrl(set.files[color])}
+ />
+ ))}
+
+ {settings.customSets.map((set, index) => (
+ handleSelect(set.id)}
+ // A color the set has no upload for shows — and times with —
+ // the Classic card.
+ thumbSrc={(color) => getCustomCardImage(set.id, color) || getCardFileUrl(CLASSIC_FILES[color])}
+ onDelete={() => handleDelete(set.id)}
+ onShare={club && canPublishCards() ? () => handleShare(set.id) : undefined}
+ sharing={sharingSetId === set.id}
+ />
+ ))}
+
+
+
+ Add your own
+ {draftHasImages && (
+
+ Add set
+
+ )}
+
+
+ {CARD_COLORS.map((color) =>
+ draft[color] ? (
+
pickDraftFile(color)}
+ title={`Replace the ${COLOR_LABELS[color]} image`}
+ aria-label={`Replace the ${COLOR_LABELS[color]} image`}
+ className="w-full aspect-video rounded overflow-hidden border border-gray-200"
+ >
+
+
+ ) : (
+
pickDraftFile(color)}
+ title={`Upload the ${COLOR_LABELS[color]} image`}
+ aria-label={`Upload the ${COLOR_LABELS[color]} image`}
+ style={{ backgroundColor: SLOT_TINTS[color] }}
+ className="w-full aspect-video rounded border-2 border-dashed border-gray-300 flex items-center justify-center text-gray-400 hover:border-gray-400 hover:text-gray-600 transition-colors"
+ >
+
+
+ )
+ )}
+
+
+ Upload an image for each color, then Add set. Colors you skip use the Classic card.
+
+
+
+
+ {children}
+
+ {
+ handleDraftUpload(draftColorRef.current, e.target.files?.[0]);
+ // Same file re-picked later must fire change again.
+ e.target.value = '';
+ }}
+ />
+
+
+ {clubFeaturesOn(club) ? (
+ <>
+ Custom images are stored only in this browser.
+ {canPublishCards() ? ' Share a set to put it on every club device. ' : ' '}
+ >
+ ) : isPro ? (
+ <>Custom images are backed up and follow you to your other devices. >
+ ) : known && flagsKnown && proEnabled ? (
+ <>
+ Custom images are stored only in this browser.{' '}
+ setShowUpgrade(true)}
+ className="text-blue-600 hover:text-blue-700 underline"
+ >
+ Upgrade to Pro
+ {' '}
+ to back them up and use them on your other devices.{' '}
+ >
+ ) : (
+ <>Custom images are stored only in this browser. >
+ )}
+ Please upload only images you have the right to use — for example your club's own
+ materials. This app is independent and cannot grant permission for third-party logos or
+ artwork.
+
+ {showUpgrade && (
+
+ setShowUpgrade(false)} source="card_images" />
+
+ )}
+ >
+ );
+}
diff --git a/apps/zoom-app/src/components/LiveTab.jsx b/apps/zoom-app/src/components/LiveTab.jsx
index ae849e2..7110d03 100644
--- a/apps/zoom-app/src/components/LiveTab.jsx
+++ b/apps/zoom-app/src/components/LiveTab.jsx
@@ -1,22 +1,17 @@
-import { useState, useEffect, useRef, useMemo, memo, lazy, Suspense } from 'react';
-import { useTimer, useTimerTick } from '../context/TimerContext';
-import { useToast } from '../context/ToastContext';
-import { Play, Square, RotateCcw, Eraser, Video, Image, Volume2, Lock } from 'lucide-react';
+import { memo, lazy, Suspense } from 'react';
+import { useLiveTimer } from '../hooks/useLiveTimer';
+import { Play, Square, RotateCcw, Eraser, Video, Image, Volume2, Lock, AlertTriangle, ChevronDown, ChevronUp } from 'lucide-react';
import SpeakerInput from './SpeakerInput';
import TimerDisplay from './TimerDisplay';
import TimerStage from './TimerStage';
import OverlayModeMenu, { MODE_LABELS } from './OverlayModeMenu';
+import TimeInput, { TimeInputModeToggle } from './TimeInput';
+import { DEFAULT_CUSTOM_RULES, saveTimeInputMode, BREAK_ROLE, BREAK_QUICK_PICKS } from '@toastmaster-timer/shared';
+import { isVideoOverlayMode, isClubBadgeMoved, OVERLAY_MODE_CARD } from '../utils/zoomSdk';
+import { SOUNDS_LOCK_MESSAGES } from '../hooks/useSounds';
const EditRulesModal = lazy(() => import('./EditRulesModal'));
const CardImagesModal = lazy(() => import('./CardImagesModal'));
const SoundsModal = lazy(() => import('./SoundsModal'));
-import TimeInput, { TimeInputModeToggle } from './TimeInput';
-import { DEFAULT_ROLE_RULES, DEFAULT_CUSTOM_RULES, loadTimeInputMode, saveTimeInputMode, BREAK_ROLE, BREAK_QUICK_PICKS, DEFAULT_BREAK_SECONDS, deriveBreakRules, getDisplaySeconds, initCardImages, subscribeCardImages, agendaItemNeedsRole } from '@toastmaster-timer/shared';
-import { getVideoState, setVideoState, applyOverlay, removeOverlay, clearVideoPipelines, isOverlayActive, getBackgroundUrl, getSdkStatus, setLogCallback, getOverlayMode, setOverlayMode, getOverlayTimePosition, setOverlayTimePosition, getOverlayTimeScale, setOverlayTimeScale, isOverlayTimeVisible, setOverlayTimeVisible, setOverlayTimeLabel, setPopoutChangeCallback, setShareChangeCallback, setAppShare, setAppPopout, isAppShareActive, isAppPoppedOut, isVideoOverlayMode, getClubBadgePlacement, setClubBadgePlacement, resetClubBadgePlacement, isClubBadgeMoved, getZoomParticipants, OVERLAY_MODE_CARD, OVERLAY_MODE_STAGE, notifyCardImagesChanged, preloadBackgroundImages } from '../utils/zoomSdk';
-import { useClub } from '../hooks/useClub';
-import { SOUNDS_LOCK_MESSAGES, useSoundsAccess } from '../hooks/useSounds';
-import { formatTime, saveOverlayMode, saveStageClockHidden, loadStageClockHidden, saveRevealFaceWhenIdle, loadRevealFaceWhenIdle } from '@toastmaster-timer/shared';
-import { AlertTriangle, ChevronDown, ChevronUp } from 'lucide-react';
-import { trackEvent } from '../utils/posthog';
// Always on when running the dev server; in a build it takes an explicit
// VITE_ENABLE_DEBUG_PANEL=true. Fail-closed on purpose: the old `!== 'false'`
@@ -37,941 +32,84 @@ const PREVIEW_COLORS = [
];
export default memo(function LiveTab() {
- const { isRunning, elapsedTime, currentStatus } = useTimerTick();
const {
+ isRunning,
+ elapsedTime,
+ currentStatus,
currentSpeaker,
- startTimer,
- stopTimer,
- resetTimer,
- setCurrentSpeaker,
- updateSpeakerName,
- clearActiveSpeaker,
- finishCurrentSpeech,
- roleRules,
roleOptions,
agenda,
activeSpeakerId,
- loadSpeakerFromAgenda,
- addToAgenda,
- renameAgendaSpeaker,
- } = useTimer();
- const { showToast } = useToast();
-
- // The role the picker starts on: Standard Speech, unless this device or its
- // club removed it from the list. A whose value is not among its
- // options shows the first option while the state keeps the removed role, and
- // START then finds no rules for it ("Please set timing rules first").
- const defaultRole = roleOptions.includes('Standard Speech') ? 'Standard Speech' : roleOptions[0] || 'Standard Speech';
-
- // An agenda row whose role is not one the organizer can pick (#91). The
- // Agenda tab highlights it and asks for the role; nothing here may load it,
- // because loading it would time the speaker under a role nobody chose.
- const needsRole = (item) => agendaItemNeedsRole(item, roleOptions);
- const askForRole = (item) =>
- showToast(`Pick a role for ${item.name || 'this speaker'} on the Agenda tab first`, 'warning');
-
- const [speakerName, setSpeakerName] = useState(currentSpeaker?.name || '');
- const [selectedRole, setSelectedRole] = useState(currentSpeaker?.role || defaultRole);
- const [customRules, setCustomRules] = useState({ ...DEFAULT_CUSTOM_RULES });
- // Break Time counts down from this. Not persisted: 10 minutes is the meeting
- // norm, and a club that broke for 5 yesterday should not silently break for
- // 5 forever.
- const [breakSeconds, setBreakSeconds] = useState(DEFAULT_BREAK_SECONDS);
- const [timeInputMode, setTimeInputMode] = useState(loadTimeInputMode);
- const [showEditRulesModal, setShowEditRulesModal] = useState(false);
- const [showCardImagesModal, setShowCardImagesModal] = useState(false);
- const [showSoundsModal, setShowSoundsModal] = useState(false);
- const { lockReason: soundsLockReason } = useSoundsAccess();
- // Bumped whenever a custom card image is uploaded or reset, so the overlay
- // effect below re-pushes whatever is currently showing with the new artwork.
- const [cardImagesGeneration, setCardImagesGeneration] = useState(0);
-
- // Custom card images load from IndexedDB asynchronously at startup; until
- // then a selected custom set resolves to the built-in fallback. Bump the
- // generation once they are in so the overlay re-pushes the real artwork.
- useEffect(() => {
- let cancelled = false;
- initCardImages().then(() => {
- if (!cancelled) setCardImagesGeneration((generation) => generation + 1);
- });
- return () => { cancelled = true; };
- }, []);
-
- // The club's card set arrives, changes or goes away while the app is open.
- // Drop the decoded frames and re-push, as the picker does after a change.
- useEffect(() => subscribeCardImages(() => {
- notifyCardImagesChanged();
- preloadBackgroundImages();
- setCardImagesGeneration((generation) => generation + 1);
- }), []);
-
- const [videoState, setVideoStateLocal] = useState(null); // null = unknown, true = on, false = off
- // A clear is in flight. Disables both the eraser and RESET, because in Timer +
- // Camera the wait is Zoom's confirmation dialog, and a second press behind the
- // first would raise a second dialog for a background already on its way out.
- const [isClearingVideo, setIsClearingVideo] = useState(false);
- // Bumped by anything that hands the video back — the clear button, RESET.
- // Guarantees the overlay effect re-runs exactly once afterwards, which is the
- // run it skips — see clearTimerFromVideo.
- const [clearGeneration, setClearGeneration] = useState(0);
-
- // What the app itself is doing, mirrored from the SDK so Zoom's own controls
- // move these too. Seeded from the module because it outlives this component:
- // switching tabs unmounts the Live tab without ending a share.
- const [isSharing, setIsSharing] = useState(isAppShareActive);
- const [isPoppedOut, setIsPoppedOut] = useState(isAppPoppedOut);
- const [isEnablingVideo, setIsEnablingVideo] = useState(false);
- const [previewColor, setPreviewColor] = useState(null);
- // Unlike the mode itself, this preference is remembered: it is a club's stance
- // on whether a ticking clock helps or distracts their speakers.
- const [stageClockHidden, setStageClockHidden] = useState(loadStageClockHidden);
-
- // Whether the video modes hand the organizer their face back between speeches.
- // On by default; off restores the older always-on behavior for clubs that want
- // the color up for the whole meeting.
- const [revealFaceWhenIdle, setRevealFaceWhenIdle] = useState(loadRevealFaceWhenIdle);
-
- // Seeded from the SDK module, which resolves the organizer's saved mode
- // itself at load time. The menu and the pipeline must never disagree about
- // the mode: showing "Timer + Camera" while the module pushes card-mode
- // filters is a full card over the organizer's face.
- const [overlayMode, setOverlayModeLocal] = useState(getOverlayMode);
-
- // Where the count-up sits on the pushed frame, mirrored from the SDK module
- // so the drag badge follows the pointer without a bridge push per move.
- const [readoutPosition, setReadoutPosition] = useState(getOverlayTimePosition);
- const [readoutScale, setReadoutScale] = useState(getOverlayTimeScale);
- const [readoutVisible, setReadoutVisible] = useState(isOverlayTimeVisible);
-
- // The club's badge: its kit comes from the club cache, its placement from
- // this device. Mirrored into state for the same reason the readout is — the
- // drag handle has to follow the pointer without a bridge push per move.
- const { kit: clubKit } = useClub();
- const [badgePlacement, setBadgePlacement] = useState(getClubBadgePlacement);
- const badgeOnCards = Boolean(clubKit?.showOnCards);
-
- /** Follow the drag live; hand the move to Zoom only on release. */
- const handleBadgePlacementChange = (position, { commit } = {}) => {
- setBadgePlacement((current) => ({ ...current, ...position }));
- if (commit) {
- const applied = setClubBadgePlacement(position);
- setBadgePlacement(applied);
- (window.requestIdleCallback || setTimeout)(() => trackEvent('brand_badge_moved', {
- x: Math.round(applied.x * 100) / 100,
- y: Math.round(applied.y * 100) / 100,
- scale: Math.round(applied.scale * 100) / 100,
- was_reset: false,
- overlay_mode: overlayMode,
- }));
- }
- };
-
- const handleAdjustBadgeScale = (direction) => {
- setBadgePlacement(setClubBadgePlacement({ scale: badgePlacement.scale + direction * 0.02 }));
- };
-
- const handleToggleBadgeVisible = () => {
- setBadgePlacement(setClubBadgePlacement({ visible: !badgePlacement.visible }));
- };
-
- /** Back to wherever the club's admin put it; this device's move is dropped. */
- const handleResetBadgePlacement = () => {
- setBadgePlacement(resetClubBadgePlacement());
- (window.requestIdleCallback || setTimeout)(() => trackEvent('brand_badge_moved', {
- was_reset: true,
- overlay_mode: overlayMode,
- }));
- };
-
- /** Follow the drag live; hand the position to Zoom only on release. */
- const handleReadoutPositionChange = (position, { commit } = {}) => {
- setReadoutPosition(position);
- if (commit) {
- setOverlayTimePosition(position);
- (window.requestIdleCallback || setTimeout)(() => trackEvent('readout_position_moved', {
- x: Math.round(position.x * 100) / 100,
- y: Math.round(position.y * 100) / 100,
- overlay_mode: overlayMode,
- }));
- }
- };
-
- /** One +/- press; the SDK clamps and reports what it actually applied. */
- const handleAdjustReadoutScale = (direction) => {
- const applied = setOverlayTimeScale(readoutScale + direction * 0.03);
- setReadoutScale(applied);
- (window.requestIdleCallback || setTimeout)(() => trackEvent('readout_scale_adjusted', {
- scale: Math.round(applied * 100) / 100,
- overlay_mode: overlayMode,
- }));
- };
-
- const handleToggleReadoutVisible = () => {
- const visible = !readoutVisible;
- setReadoutVisible(visible);
- setOverlayTimeVisible(visible);
- (window.requestIdleCallback || setTimeout)(() => trackEvent('readout_visibility_toggled', {
- visible,
- overlay_mode: overlayMode,
- }));
- };
-
- // A speech is under way. A pause counts as active — the speech is on hold, not
- // over — which is why this tracks elapsedTime rather than isRunning alone.
- const speechActive = isRunning || elapsedTime > 0;
-
- // The selected role left the list (removed in Edit Rules, or by a club
- // publish): move to the default rather than leave START with no rules. Never
- // under a speech in progress, which carries its own rules on the speaker.
- //
- // Except for an agenda row that now needs a role: moving it to the default
- // would time that speaker under a role nobody picked, which is the guess #91
- // removed. Let go of the row instead, and say where its role is picked. The
- // pickers and FINISH refuse such a row before it loads; this catches a row
- // whose role goes away while it is up.
- useEffect(() => {
- if (speechActive || roleOptions.length === 0 || roleOptions.includes(selectedRole)) return;
- const activeItem = activeSpeakerId ? agenda.find((item) => item.id === activeSpeakerId) : null;
- if (activeItem && needsRole(activeItem)) {
- clearActiveSpeaker();
- setSpeakerName('');
- setSelectedRole(defaultRole);
- setCurrentSpeaker({ name: '', role: defaultRole });
- askForRole(activeItem);
- return;
- }
- setSelectedRole(defaultRole);
- setCurrentSpeaker({ name: speakerName || '', role: defaultRole });
- }, [speechActive, roleOptions, selectedRole, defaultRole, speakerName, setCurrentSpeaker, activeSpeakerId, agenda, clearActiveSpeaker, showToast]);
-
- // Whether a color belongs on the organizer's tile right now. The video modes
- // run only while a speech is being timed: nothing before START, nothing once it
- // is finished or reset, so the organizer gets their normal video back between
- // speakers rather than a card sitting on them for the whole meeting. Clubs that
- // prefer the old always-on behavior turn the reveal-when-idle option off.
- //
- // A held preview swatch counts too, or the preview buttons would do nothing
- // while the timer sits idle.
- const wantsColorNow = speechActive || Boolean(previewColor) || !revealFaceWhenIdle;
-
- // The color that belongs on the tile: a held preview swatch wins over the
- // timer's own status, which is the point of previewing.
- const desiredStatus = previewColor || currentStatus || 'blue';
-
- // Debug panel state - collapsed by default, remember user preference in localStorage
- const [debugPanelExpanded, setDebugPanelExpanded] = useState(() => {
- const saved = localStorage.getItem('debugPanelExpanded');
- return saved ? saved === 'true' : false; // Default to collapsed
- });
- const [sdkStatus, setSdkStatus] = useState(null);
- const [lastError, setLastError] = useState(null);
- const [debugLogs, setDebugLogs] = useState([]);
- const initializedRef = useRef(false);
- const isLocalNameEdit = useRef(false);
- const lastClearGenerationRef = useRef(0);
-
- // Desktop only. Outside Zoom nothing is granted, so offer it anyway rather than
- // hiding a button the whole stage layout is being developed around.
- const canPopout =
- !sdkStatus?.available || !sdkStatus?.missingApis?.some((api) => api.name === 'appPopout');
-
- // Nothing to report: connected, and this client offers every API we call.
- const sdkHealthy =
- sdkStatus?.initialized &&
- sdkStatus?.available &&
- sdkStatus?.sdkExists &&
- sdkStatus?.missingApis?.length === 0;
-
- // Save expanded state to localStorage
- const toggleDebugPanel = () => {
- const newState = !debugPanelExpanded;
- setDebugPanelExpanded(newState);
- localStorage.setItem('debugPanelExpanded', String(newState));
- };
-
- // Add log entry
- const addDebugLog = (message, type = 'info') => {
- const timestamp = new Date().toLocaleTimeString();
- const logEntry = { timestamp, message, type };
- setDebugLogs(prev => {
- const newLogs = [...prev, logEntry];
- // Keep only last 100 logs to prevent memory issues
- return newLogs.slice(-100);
- });
- };
-
-
- // Initialize currentSpeaker on mount if it's null
- useEffect(() => {
- if (!initializedRef.current && !currentSpeaker && selectedRole && roleRules && Object.keys(roleRules).length > 0) {
- // Initialize with default role (Standard Speech)
- // setCurrentSpeaker will automatically add rules from roleRules
- setCurrentSpeaker({
- name: '',
- role: selectedRole,
- });
- initializedRef.current = true;
- }
- }, [roleRules, selectedRole, currentSpeaker, setCurrentSpeaker]); // Include all dependencies
-
- // Update local state when currentSpeaker changes (but preserve custom rules if Custom role)
- useEffect(() => {
- if (currentSpeaker) {
- if (!isLocalNameEdit.current) {
- setSpeakerName(currentSpeaker.name || '');
- }
- isLocalNameEdit.current = false;
- setSelectedRole(currentSpeaker.role);
- // If custom role and has custom rules, update local state (merge so missing graceAfterRed is filled)
- if (currentSpeaker.role === 'Custom' && currentSpeaker.rules) {
- setCustomRules({ ...DEFAULT_CUSTOM_RULES, ...currentSpeaker.rules });
- }
- // A break loaded from the agenda carries its length in its rules; the
- // picker has to show that length, not whatever was picked last.
- if (currentSpeaker.role === BREAK_ROLE && currentSpeaker.rules?.red) {
- setBreakSeconds(currentSpeaker.rules.red);
- }
- // If switching to Custom role but no rules yet, keep current customRules (don't reset)
- } else {
- setSpeakerName('');
- setSelectedRole(defaultRole);
- // Don't reset custom rules here - they should persist until explicitly changed
- }
- }, [currentSpeaker]);
-
- // When switching to Custom role for the first time, use the default from roleRules (merge for graceAfterRed)
- useEffect(() => {
- if (selectedRole === 'Custom' && roleRules['Custom']) {
- setCustomRules({ ...DEFAULT_CUSTOM_RULES, ...roleRules['Custom'] });
- }
- }, [selectedRole, roleRules]);
-
- // Set up log callback for zoomSdk
- useEffect(() => {
- setLogCallback(addDebugLog);
- addDebugLog('Debug panel initialized', 'info');
- return () => setLogCallback(null);
- }, []);
-
- // Follow Zoom's own controls. The organizer can pop the app out from the
- // ellipsis menu or stop a share from the sharing toolbar, neither of which goes
- // near our buttons — so the stage reads these rather than what it last asked
- // for. Both are just state now: docking no longer means leaving the stage, any
- // more than stopping a share does.
- useEffect(() => {
- setPopoutChangeCallback(setIsPoppedOut);
- setShareChangeCallback(setIsSharing);
- return () => {
- setPopoutChangeCallback(null);
- setShareChangeCallback(null);
- };
- }, []);
-
- // Check SDK status on mount and periodically
- useEffect(() => {
- const updateSdkStatus = () => {
- try {
- const status = getSdkStatus();
- setSdkStatus(status);
-
- // Set error if SDK is not available or key functions are missing
- if (status.lastError) {
- setLastError(status.lastError);
- } else if (!status.initialized) {
- setLastError('Zoom SDK not initialized');
- } else if (!status.available) {
- setLastError('Zoom SDK not available - Make sure you are running inside Zoom client');
- } else if (!status.hasSetVideoFilter) {
- setLastError('setVideoFilter is not available. Available methods: ' + (status.availableMethods?.join(', ') || 'none'));
- } else {
- setLastError(null);
- }
- } catch (error) {
- console.error('Failed to get SDK status:', error);
- setLastError('Failed to get SDK status: ' + error.message);
- }
- };
-
- // Check immediately
- updateSdkStatus();
-
- // Update periodically every 2 seconds
- const interval = setInterval(updateSdkStatus, 2000);
-
- return () => clearInterval(interval);
- }, []);
-
- // Check video state on mount and periodically
- useEffect(() => {
- const checkVideoState = async () => {
- try {
- const videoStateResult = await getVideoState();
- // Only update if we got a definitive answer (true or false)
- // null means we can't determine, so don't update the state
- if (videoStateResult !== null) {
- setVideoStateLocal(videoStateResult);
- }
- } catch (error) {
- console.error('Failed to check video state:', error);
- setLastError('Failed to check video state: ' + error.message);
- // Don't update state on error - keep current state
- }
- };
-
- // Check immediately
- checkVideoState();
-
- // Check periodically every 2-3 seconds
- const interval = setInterval(checkVideoState, 2500);
-
- return () => clearInterval(interval);
- }, []);
-
- // Owns what the video modes have on screen. Stage modes are excluded: they push
- // no pixels, and removeOverlay there would tear down the share or the
- // popped-out window itself.
- useEffect(() => {
- // The run triggered by a clear — the eraser, or RESET: leave the video as it
- // was just cleared to. Bumping the generation is what makes this run happen at
- // all, so the skip is always consumed and never swallows a later push.
- if (lastClearGenerationRef.current !== clearGeneration) {
- lastClearGenerationRef.current = clearGeneration;
- return;
- }
- if (!isVideoOverlayMode(overlayMode)) return;
- if (wantsColorNow) {
- // A held swatch previews everything a live speech will show, the
- // count-up included, so the organizer can place and size it before
- // anyone speaks. While the timer runs, TimerContext owns the label.
- if (previewColor && !isRunning) {
- setOverlayTimeLabel(formatTime(getDisplaySeconds(elapsedTime, currentSpeaker?.rules)));
- } else if (!speechActive) {
- // Idle with no swatch held: nothing to count, so no readout.
- setOverlayTimeLabel(null);
- }
- const imageUrl = getBackgroundUrl(desiredStatus);
- // A custom card is a data: URL that can run to a megabyte; never log it whole.
- addDebugLog(`Applying overlay: ${desiredStatus} -> ${imageUrl.startsWith('data:') ? 'custom image' : imageUrl}`, 'info');
- // Usually redundant with TimerContext's own push on status change;
- // applyOverlay joins it onto that push while it is queued or running,
- // so the duplicate costs no second SDK call.
- applyOverlay(imageUrl);
- // Idle, so the organizer gets their video back: whichever pipeline holds
- // the card comes off, and neither is touched when nothing of ours is up.
- // Not on mount, not on a speaker change, not on arriving in a mode with
- // nothing showing — each of those would otherwise cost a confirmation
- // dialog in camera mode, or delete the user's own video filter in card.
- } else if (isOverlayActive()) {
- addDebugLog('Removing overlay (no speech in progress)', 'info');
- // A preview may have set the readout; the teardown must not leave it
- // behind for the next push. Clearing never re-pushes on its own.
- setOverlayTimeLabel(null);
- removeOverlay();
- }
- // previewColor is listed on its own because desiredStatus can hide a
- // swatch change: previewing the color the timer already shows.
- // cardImagesGeneration re-runs the push when the artwork behind the same
- // status changes; the already-showing guard sees a new URL and pushes it.
- }, [overlayMode, wantsColorNow, desiredStatus, previewColor, clearGeneration, cardImagesGeneration]);
-
- // Log when status changes
- useEffect(() => {
- if (currentStatus) {
- addDebugLog(`Timer status changed to: ${currentStatus}`, 'info');
- }
- }, [currentStatus]);
-
- // Clear preview when the timer starts running
- useEffect(() => {
- if (isRunning) setPreviewColor(null);
- }, [isRunning]);
-
- // Log when timer starts/stops
- useEffect(() => {
- if (isRunning) {
- addDebugLog('Timer started', 'info');
- } else if (isRunning === false && elapsedTime === 0) {
- addDebugLog('Timer stopped/reset', 'info');
- }
- }, [isRunning]);
-
- const handleSpeakerChange = (name) => {
- setSpeakerName(name || '');
- isLocalNameEdit.current = true;
- updateSpeakerName(name || '');
- };
-
- const handleSelectSuggestion = (item) => {
- if (needsRole(item)) {
- askForRole(item);
- return;
- }
- setSpeakerName(item.name);
- setSelectedRole(item.role);
- // Picking someone off the agenda has to make them the *active* agenda
- // speaker, not just copy their name across. Without that link the timer has
- // no idea where in the running order it is: finishing cannot advance to the
- // next speaker, and the item is never ticked off as done.
- if (item.id && agenda.some((entry) => entry.id === item.id)) {
- loadSpeakerFromAgenda(item.id);
- return;
- }
- const rules = item.role === 'Custom' ? customRules : undefined;
- setCurrentSpeaker({ name: item.name, role: item.role, ...(rules && { rules }) });
- };
-
- /**
- * Add a typed speaker to the agenda and make them current.
- *
- * Typing a name used to change only the label above the timer — nothing was
- * recorded, and the organizer had no way to tell. Adding them puts the meeting
- * back on a running order, so finishing can advance to whoever is next.
- *
- * Shared by the stage picker and the panel field, which is the point: the two
- * places you can type a name should not disagree about what typing one means.
- */
- const handleAddSpeaker = (name) => {
- addToAgenda({ name, role: selectedRole }, { activate: true });
- setSpeakerName(name);
- showToast(`Added ${name} to the agenda`, 'success');
- (window.requestIdleCallback || setTimeout)(() => trackEvent('stage_speaker_added', {
- role: selectedRole,
- overlay_mode: overlayMode,
- }));
- };
-
- /** The stage picker's agenda list. A row that needs a role stays where it is. */
- const handleSelectAgendaSpeaker = (id) => {
- const item = agenda.find((entry) => entry.id === id);
- if (item && needsRole(item)) {
- askForRole(item);
- return;
- }
- loadSpeakerFromAgenda(id);
- };
-
- /** Fix a name on the agenda without losing the speaker's place in it. */
- const handleRenameSpeaker = (id, name) => {
- renameAgendaSpeaker(id, name);
- setSpeakerName(name);
- showToast(`Renamed to ${name}`, 'success');
- };
-
- const handleRoleChange = (role) => {
- const previousRole = selectedRole;
- setSelectedRole(role);
- // Always update current speaker, even if name is empty. Custom and Break
- // carry their rules on the speaker: Custom's are edited by hand, Break's
- // are derived from the length picked below.
- const rules =
- role === 'Custom' ? customRules : role === BREAK_ROLE ? deriveBreakRules(breakSeconds) : undefined;
- // A break called mid-agenda interrupts the running order, it does not
- // stand in for whoever was loaded: detach so finishing the break neither
- // completes that speaker's slot nor advances past them.
- if (role === BREAK_ROLE) clearActiveSpeaker();
- setCurrentSpeaker({
- name: speakerName || '',
- role,
- ...(rules && { rules }),
- });
- // Track role change
- if (previousRole !== role) {
- (window.requestIdleCallback || setTimeout)(() => trackEvent('speaker_role_changed', {
- previous_role: previousRole,
- new_role: role,
- speaker_name: speakerName || 'Unnamed'
- }));
- }
- };
-
- const handleCustomRuleChange = (field, value) => {
- const newRules = { ...customRules, [field]: value };
- setCustomRules(newRules);
-
- // If Custom is selected, update immediately (name is optional)
- if (selectedRole === 'Custom') {
- setCurrentSpeaker({
- name: speakerName || '',
- role: 'Custom',
- rules: newRules,
- });
- }
- };
-
- // Helper to format seconds as readable time
- const formatTimeReadable = (seconds) => {
- const mins = Math.floor(seconds / 60);
- const secs = seconds % 60;
- if (mins === 0) {
- return `${secs} second${secs !== 1 ? 's' : ''}`;
- }
- if (secs === 0) {
- return `${mins} minute${mins !== 1 ? 's' : ''}`;
- }
- return `${mins} min ${secs} sec`;
- };
-
- const roleExplanation = useMemo(() => {
- const rules = roleRules[selectedRole] || DEFAULT_ROLE_RULES[selectedRole] || DEFAULT_ROLE_RULES['Standard Speech'];
- return `Green: ${formatTimeReadable(rules.green)}, Yellow: ${formatTimeReadable(rules.yellow)}, Red: ${formatTimeReadable(rules.red)}`;
- }, [selectedRole, roleRules]);
-
- // Spelled in time-left terms, because that is what the countdown shows.
- const breakExplanation = useMemo(() => {
- const rules = deriveBreakRules(breakSeconds);
- return `Counts down from ${formatTimeReadable(rules.red)}. Green when ${formatTimeReadable(rules.red - rules.green)} left, yellow when ${formatTimeReadable(rules.red - rules.yellow)} left, red when time is up.`;
- }, [breakSeconds]);
-
- // Just the state: the overlay effect pushes the swatch and, when it is toggled
- // back off, takes it down again. Pushing from here as well used to fight that
- // effect, which reapplied the timer's own status a render later.
- const handlePreviewColor = (color) => {
- setPreviewColor((held) => (held === color ? null : color));
- };
-
- /** Pick a break length; the phase thresholds re-derive from it. */
- const handleBreakDurationChange = (seconds) => {
- setBreakSeconds(seconds);
- setCurrentSpeaker({
- name: speakerName || '',
- role: BREAK_ROLE,
- rules: deriveBreakRules(seconds),
- });
- (window.requestIdleCallback || setTimeout)(() => trackEvent('break_duration_changed', {
- seconds,
- }));
- };
-
- const handleStart = () => {
- // Speaker name is optional, but validate custom rules if Custom role is selected
- if (selectedRole === 'Custom') {
- // Validate custom rules
- if (customRules.green <= 0 || customRules.yellow <= customRules.green || customRules.red <= customRules.yellow) {
- showToast('Invalid timing rules. Green must be > 0, Yellow must be > Green, and Red must be > Yellow.', 'error');
- return;
- }
- }
- // Ensure current speaker is set with correct rules: hand-edited for
- // Custom, derived from the picked length for Break, the role's own
- // otherwise.
- const rules =
- selectedRole === 'Custom'
- ? customRules
- : selectedRole === BREAK_ROLE
- ? deriveBreakRules(breakSeconds)
- : roleRules[selectedRole];
- if (!rules) {
- showToast('Please set timing rules first', 'warning');
- return;
- }
-
- // Always set current speaker before starting timer
- setCurrentSpeaker({
- name: speakerName || '',
- role: selectedRole,
- ...((selectedRole === 'Custom' || selectedRole === BREAK_ROLE) && { rules }),
- });
-
- startTimer();
-
- // Track timer started event. audience_size is the meeting's head count
- // read at START (people join after the app opens); it is left out when
- // Zoom withholds the list (non-host), there's no SDK, or the read fails.
- (window.requestIdleCallback || setTimeout)(() => {
- getZoomParticipants()
- .catch(() => ({ participants: [], restricted: true }))
- .then(({ participants, restricted }) => trackEvent('timer_started', {
- speaker_name: speakerName || 'Unnamed',
- role: selectedRole,
- timing_rules: {
- green: rules.green,
- yellow: rules.yellow,
- red: rules.red
- },
- ...(!restricted && participants.length > 0 && { audience_size: participants.length }),
- }));
- });
- };
-
- const handleContinue = () => {
- // Continue is the same as start - it resumes the timer
- startTimer();
- // Track timer continued (resumed)
- (window.requestIdleCallback || setTimeout)(() => trackEvent('timer_continued', {
- elapsed_time: elapsedTime
- }));
- };
-
- const handleStop = () => {
- stopTimer();
- // Track timer stopped
- (window.requestIdleCallback || setTimeout)(() => trackEvent('timer_stopped', {
- elapsed_time: elapsedTime,
- final_status: currentStatus,
- speaker_name: currentSpeaker?.name || 'Unnamed',
- role: currentSpeaker?.role || 'Unknown'
- }));
- };
-
- /**
- * Put the timer back to zero, and put the tile back to whatever this organizer
- * has said idle should look like.
- *
- * Undoing a speech that should not have been started has to undo what the
- * meeting could see of it, so RESET has always owned the tile as well as the
- * clock. What it must not own is which of the two idle states that means. It
- * used to strip the tile outright, on the grounds that the reveal-when-idle
- * preference governs the automatic reveals between speeches and not a button
- * press — but the organizer who turned that preference off did so to keep the
- * color up for the whole meeting, and stripping their tile hands them the one
- * thing they opted out of. So both halves are asked here:
- *
- * - Reveals on: the video comes back whole, their own background included.
- * This is the eraser's path, in silence, and it costs the same confirmation
- * dialog in Timer + Camera.
- * - Reveals off: the tile returns to the blue card. Unconditionally, unlike
- * resetTimer's own handling — which is why the video is skipped there and
- * decided here. That gate exists because resetTimer also runs on every
- * speaker and role change, where pushing an overlay nothing is displaying
- * would spend a multi-MB bridge transfer for no visible effect. A button
- * press is exactly the ask it withholds it for: after the eraser, or before
- * the meeting's first speech, RESET is how the card comes back.
- */
- const handleReset = async () => {
- const previousElapsedTime = elapsedTime;
- const previousStatus = currentStatus;
- // Either branch below owns the video, so resetTimer must not also reach for
- // it: two removals in flight at once is a confirmation dialog for a
- // background that has already gone.
- resetTimer({ skipVideo: true });
- setSpeakerName('');
- // Track timer reset
- (window.requestIdleCallback || setTimeout)(() => trackEvent('timer_reset', {
- previous_elapsed_time: previousElapsedTime,
- previous_status: previousStatus,
- reveal_face_when_idle: revealFaceWhenIdle
- }));
- if (!revealFaceWhenIdle) {
- // A held swatch outranks the timer's own status everywhere the color is
- // decided, so a preview left up would paint straight back over the card.
- // RESET is a return to the starting state; a swatch does not survive it.
- setPreviewColor(null);
- if (isVideoOverlayMode(overlayMode)) {
- addDebugLog('Reset with reveal-when-idle off: returning the tile to blue', 'info');
- applyOverlay(getBackgroundUrl('blue'));
- }
- return;
- }
- // Silent when it works: the timer reading 00:00 with the tile back to normal
- // is its own confirmation, and RESET is pressed often enough that a toast per
- // press is noise. Anything that went wrong, or that the organizer has to fix
- // in Zoom themselves, is still reported.
- await clearTimerFromVideo({ announceSuccess: false });
- };
-
- // Says what RESET will do to the video, because that half of it now depends on
- // a preference set two clicks away in the mode menu — and the two outcomes are
- // opposites. Stage modes leave the camera alone entirely, so they get neither
- // promise.
- const resetTooltip = isVideoOverlayMode(overlayMode)
- ? `Reset the timer to 00:00, clear the current speaker, and ${
- revealFaceWhenIdle ? 'hand your own background back' : 'put the blue timer card back on your video'
- }`
- : 'Reset the timer to 00:00 and clear the current speaker';
-
- const handleFinish = () => {
- const currentAgendaId = activeSpeakerId;
- // Captured before finishing resets the speaker: a break ending is the
- // meeting pausing, not advancing, so the next speaker is never auto-loaded
- // off the back of one — the organizer resumes the agenda when the room is
- // back.
- const wasBreak = Boolean(currentSpeaker?.rules?.countdown);
- finishCurrentSpeech();
-
- // Auto-load next uncompleted speaker from agenda
- if (currentAgendaId && !wasBreak) {
- const currentIndex = agenda.findIndex(item => item.id === currentAgendaId);
- const nextSpeaker = agenda.slice(currentIndex + 1).find(item => !item.completed);
- // Next up has no role the organizer picked (#91): leave them unloaded
- // rather than time them under whatever role is showing, and say why the
- // running order stopped. Not skipped either: the order is the club's.
- if (nextSpeaker && needsRole(nextSpeaker)) {
- askForRole(nextSpeaker);
- } else if (nextSpeaker) {
- loadSpeakerFromAgenda(nextSpeaker.id);
- setSpeakerName(nextSpeaker.name || '');
- setSelectedRole(nextSpeaker.role);
- return;
- }
- }
- // Nobody loaded next — off the agenda entirely, the last one on it, or a
- // next speaker still waiting for a role. Clear the field rather than leaving
- // the speaker who just finished sitting there, which reads as though nothing
- // happened. On the stage this is all the organizer sees, so a stale name is
- // the whole display being wrong.
- setSpeakerName('');
- };
-
- /**
- * Strip everything the app has put on the organizer's video — the timer card
- * and any virtual background of ours — and leave the camera as Zoom found it.
- *
- * The escape hatch for the state nobody can talk their way out of: a color
- * stuck on your tile, usually because a previous session ended without
- * unwinding, or because a removal was refused. Always available, whatever the
- * mode.
- *
- * A one-shot affair. Any preview swatch is dropped, and the overlay effect is
- * told to sit out the render this causes, so the color it would otherwise push
- * straight back does not undo the clear. It stays off until the next thing that
- * legitimately wants a color on the tile — starting a speech, a status change,
- * a mode switch. Notably it does not touch the display preference: pressing
- * clear is a one-time act, not a decision to reveal after every speech.
- *
- * Shared with RESET, which owes the organizer the same thing: the two must not
- * disagree about what taking the timer off the video means, or about what to
- * say when Zoom will not do it.
- *
- * @param {{announceSuccess?: boolean}} [options] - announceSuccess false keeps
- * quiet when it worked, for a caller whose own effect is already visible.
- */
- const clearTimerFromVideo = async ({ announceSuccess = true } = {}) => {
- setIsClearingVideo(true);
- setPreviewColor(null);
- setClearGeneration((n) => n + 1);
- try {
- addDebugLog('Clearing all filters and backgrounds', 'info');
- const { ok, declined, ungranted, lostBackground } = await clearVideoPipelines();
- if (!sdkStatus?.available) {
- // Outside Zoom every SDK call is a no-op, so there is nothing to report.
- } else if (ungranted?.length) {
- // No retry will ever work: this Zoom client never granted the removal
- // API, so the only way out is Zoom's own panel. Saying "check your
- // settings" for this used to read as "you configured something wrong".
- showToast(
- `This Zoom client won't let the app remove your ${ungranted.join(' or ')}. Clear it from Zoom's Background & Effects panel.`,
- 'error',
- 8000
- );
- } else if (declined) {
- showToast('Zoom needs your confirmation to remove the background — nothing was changed.', 'info', 5000);
- } else if (!ok) {
- showToast('Zoom would not clear your video. Check Zoom\'s own background and filter settings.', 'error', 6000);
- } else if (lostBackground) {
- // The app does put a saved background back, via getVirtualBackgroundData.
- // This is the narrower case left over: the client refused those pixels,
- // or no longer knows the id. Say so rather than leaving them to notice
- // their own image is gone. Said even when the caller wanted silence:
- // losing your own background is not a success.
- showToast('Cleared the timer. Zoom wouldn\'t give your own background back — pick it again in Background & Effects.', 'info', 7000);
- } else if (announceSuccess) {
- showToast('Cleared the timer from your video.', 'success');
- }
- } finally {
- setIsClearingVideo(false);
- }
- };
-
- /** The eraser button. Tracked separately from the resets that clear as well. */
- const handleClearVideo = async () => {
- await clearTimerFromVideo();
- (window.requestIdleCallback || setTimeout)(() => trackEvent('video_cleared', {
- overlay_mode: overlayMode,
- current_timer_status: currentStatus,
- }));
- };
-
- // Switching modes can no longer be refused: opening the stage asks the client
- // for nothing, so there is no failure to roll back from. Sharing and popping
- // out can still be refused, and they say so at the point they are pressed.
- const handleModeSwitch = async (newMode) => {
- if (newMode === overlayMode) return;
- setOverlayModeLocal(newMode);
- saveOverlayMode(newMode);
- // Carry the color into the new mode only if something should be showing:
- // mid-speech, or while a preview swatch is held up. Passing it
- // unconditionally is what used to paint an idle organizer blue on arrival.
- await setOverlayMode(newMode, wantsColorNow ? getBackgroundUrl(desiredStatus) : null);
- // Leaving the stage stops its share and docks its window; reflect that.
- if (newMode !== OVERLAY_MODE_STAGE) {
- setIsSharing(false);
- setIsPoppedOut(false);
- }
- (window.requestIdleCallback || setTimeout)(() => trackEvent('overlay_mode_switched', { new_mode: newMode }));
- };
-
- /**
- * Share the stage into the meeting, or stop. The one control here the whole
- * meeting can see, so it is always a deliberate press — never a consequence of
- * choosing a mode, and never restored from a saved preference.
- */
- const handleToggleShare = async () => {
- const next = !isSharing;
- setIsSharing(next);
- const accepted = await setAppShare(next);
- if (!accepted && sdkStatus?.available) {
- setIsSharing(isAppShareActive());
- showToast(
- next
- ? 'Zoom would not start the share. Someone else may be sharing already.'
- : 'Zoom would not stop the share. Try the Stop Share button in Zoom.',
- 'error',
- 6000
- );
- return;
- }
- (window.requestIdleCallback || setTimeout)(() => trackEvent('stage_share_toggled', { sharing: next }));
- };
-
- /**
- * Undock the stage into its own window, or merge it back. Local to the
- * organizer's machine either way: the meeting sees no difference.
- */
- const handleTogglePopout = async () => {
- const next = !isPoppedOut;
- setIsPoppedOut(next);
- const accepted = await setAppPopout(next);
- if (!accepted && sdkStatus?.available) {
- setIsPoppedOut(isAppPoppedOut());
- showToast('This Zoom client cannot open the timer in its own window.', 'error');
- return;
- }
- (window.requestIdleCallback || setTimeout)(() => trackEvent('stage_popout_toggled', { poppedOut: next }));
- };
-
- const handleToggleRevealFaceWhenIdle = () => {
- const reveal = !revealFaceWhenIdle;
- setRevealFaceWhenIdle(reveal);
- saveRevealFaceWhenIdle(reveal);
- (window.requestIdleCallback || setTimeout)(() => trackEvent('reveal_face_when_idle_toggled', { reveal }));
- };
-
- const handleToggleStageClock = () => {
- const hidden = !stageClockHidden;
- setStageClockHidden(hidden);
- saveStageClockHidden(hidden);
- (window.requestIdleCallback || setTimeout)(() => trackEvent('stage_clock_toggled', { hidden }));
- };
-
- const handleTurnVideoOn = async () => {
- setIsEnablingVideo(true);
- try {
- await setVideoState(true);
- // Wait a moment for the state to update
- await new Promise(resolve => setTimeout(resolve, 500));
- // Re-check video state
- const isVideoOn = await getVideoState();
- setVideoStateLocal(isVideoOn);
- if (!isVideoOn) {
- showToast('Failed to turn video on. Please turn on video manually in Zoom.', 'error');
- }
- } catch (error) {
- console.error('Failed to turn video on:', error);
- showToast('Failed to turn video on. Please turn on video manually in Zoom.', 'error');
- } finally {
- setIsEnablingVideo(false);
- }
- };
+ speakerName,
+ selectedRole,
+ customRules,
+ breakSeconds,
+ timeInputMode,
+ setTimeInputMode,
+ showEditRulesModal,
+ setShowEditRulesModal,
+ showCardImagesModal,
+ setShowCardImagesModal,
+ showSoundsModal,
+ setShowSoundsModal,
+ soundsLockReason,
+ setCardImagesGeneration,
+ videoState,
+ isClearingVideo,
+ isSharing,
+ isPoppedOut,
+ isEnablingVideo,
+ previewColor,
+ stageClockHidden,
+ revealFaceWhenIdle,
+ overlayMode,
+ readoutPosition,
+ readoutVisible,
+ clubKit,
+ badgePlacement,
+ badgeOnCards,
+ handleBadgePlacementChange,
+ handleAdjustBadgeScale,
+ handleToggleBadgeVisible,
+ handleResetBadgePlacement,
+ handleReadoutPositionChange,
+ handleAdjustReadoutScale,
+ handleToggleReadoutVisible,
+ wantsColorNow,
+ desiredStatus,
+ debugPanelExpanded,
+ sdkStatus,
+ lastError,
+ debugLogs,
+ setDebugLogs,
+ canPopout,
+ sdkHealthy,
+ toggleDebugPanel,
+ handleSpeakerChange,
+ handleSelectSuggestion,
+ handleAddSpeaker,
+ handleSelectAgendaSpeaker,
+ handleRenameSpeaker,
+ handleRoleChange,
+ handleCustomRuleChange,
+ roleExplanation,
+ breakExplanation,
+ handlePreviewColor,
+ handleBreakDurationChange,
+ handleStart,
+ handleContinue,
+ handleStop,
+ handleReset,
+ resetTooltip,
+ handleFinish,
+ handleClearVideo,
+ handleModeSwitch,
+ handleToggleShare,
+ handleTogglePopout,
+ handleToggleRevealFaceWhenIdle,
+ handleToggleStageClock,
+ handleTurnVideoOn,
+ } = useLiveTimer();
return (
diff --git a/apps/zoom-app/src/components/SoundsModal.jsx b/apps/zoom-app/src/components/SoundsModal.jsx
index 4877ca0..622092f 100644
--- a/apps/zoom-app/src/components/SoundsModal.jsx
+++ b/apps/zoom-app/src/components/SoundsModal.jsx
@@ -1,75 +1,15 @@
-import { useEffect, useState, lazy, Suspense } from 'react';
-import { X, Play, Square, Lock } from 'lucide-react';
-import { BREAK_TRACKS, updateSoundSettings } from '@toastmaster-timer/shared';
-import { useEntitlement } from '../hooks/useEntitlement';
-import { useFlag } from '../hooks/useFlag';
-import { SOUNDS_LOCK_MESSAGES, soundEngine, useSoundSettings, useSoundsAccess } from '../hooks/useSounds';
-import { trackEvent } from '../utils/posthog';
-const UpgradeModal = lazy(() => import('./UpgradeModal'));
+import { X, Lock } from 'lucide-react';
+import { soundEngine, useSoundsAccess } from '../hooks/useSounds';
+import SoundsPanel from './SoundsPanel';
-const CUE_BUTTONS = [
- { color: 'green', label: 'Green', className: 'bg-green-500 hover:bg-green-600' },
- { color: 'yellow', label: 'Yellow', className: 'bg-yellow-400 hover:bg-yellow-500' },
- { color: 'red', label: 'Red', className: 'bg-red-500 hover:bg-red-600' },
-];
-
-function ToggleRow({ checked, onChange, title, description, locked }) {
- return (
-
- onChange(e.target.checked)}
- className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 flex-shrink-0"
- />
-
-
- {title}
- {locked && }
-
- {description}
-
-
- );
-}
-
-/**
- * Sound settings (Pro): the card-change chime, break music, and volume.
- * Opened only while the timer is stopped, so previewing music here never
- * fights a running break for the speakers.
- */
+/** The Sounds dialog: the Pro sound settings in a window of their own. */
export default function SoundsModal({ isOpen, onClose }) {
- const settings = useSoundSettings();
- const { usable, lockReason } = useSoundsAccess();
- const { known } = useEntitlement();
- // The Upgrade link opens the purchase, which stays dark until pro is
- // released. Until then the note just says sounds are part of Pro.
- const { enabled: proEnabled, known: flagsKnown } = useFlag('pro');
- const [showUpgrade, setShowUpgrade] = useState(false);
- const [previewing, setPreviewing] = useState(false);
-
- useEffect(() => () => soundEngine.stopMusic(), []);
+ const { lockReason } = useSoundsAccess();
if (!isOpen) return null;
- const change = (setting, value) => {
- updateSoundSettings({ [setting]: value });
- (window.requestIdleCallback || setTimeout)(() => trackEvent('sound_settings_changed', { setting, value }));
- };
-
- const togglePreview = () => {
- if (previewing) {
- soundEngine.stopMusic();
- setPreviewing(false);
- } else {
- soundEngine.startMusic(settings.track, settings.volume);
- setPreviewing(true);
- }
- };
-
const handleClose = () => {
soundEngine.stopMusic();
- setPreviewing(false);
onClose();
};
@@ -92,126 +32,7 @@ export default function SoundsModal({ isOpen, onClose }) {
For speakers who can't watch the card while they speak, and for a livelier break.
- {lockReason && (
-
-
-
- {lockReason === 'pro' ? 'Sounds are a Pro feature.' : SOUNDS_LOCK_MESSAGES.unreleased}
- {lockReason === 'pro' && known && flagsKnown && proEnabled && (
- <>
- {' '}
- setShowUpgrade(true)}
- className="text-blue-600 hover:text-blue-700 underline"
- >
- Upgrade to Pro
- {' '}
- to turn them on.
- >
- )}
-
-
- )}
-
- {/* The tooltip sits on a wrapper: a disabled fieldset's controls get no
- hover events of their own in most browsers. */}
-
-
-
-
change('cues', on)}
- title="Chime when the card changes"
- locked={!usable}
- description="One soft note at green, two at yellow, three at red."
- />
-
- Test:
- {CUE_BUTTONS.map(({ color, label, className }) => (
- soundEngine.playCue(color, settings.volume)}
- className={`px-2.5 py-1 rounded-md text-white text-xs font-medium transition-colors disabled:cursor-not-allowed ${className}`}
- aria-label={`Play the ${label.toLowerCase()} chime`}
- >
- {label}
-
- ))}
-
-
-
-
-
change('music', on)}
- title="Music during breaks"
- locked={!usable}
- description="Plays while Take a Break counts down, and stops at 00:00."
- />
-
- {BREAK_TRACKS.length > 1 && (
-
{
- soundEngine.stopMusic();
- setPreviewing(false);
- change('track', e.target.value);
- }}
- className="text-sm border border-gray-300 rounded-md px-2 py-1"
- aria-label="Break music"
- >
- {BREAK_TRACKS.map((track) => (
-
- {track.label}
-
- ))}
-
- )}
-
- {previewing ? : }
- {previewing ? 'Stop' : 'Preview music'}
-
-
-
-
-
- Volume
- {
- const volume = Number(e.target.value);
- updateSoundSettings({ volume });
- soundEngine.setMusicVolume(volume);
- }}
- onPointerUp={() => !previewing && soundEngine.playCue('green', settings.volume)}
- className="w-full"
- aria-label="Volume"
- />
-
-
-
-
-
- Sounds play on this computer only. For the speaker to hear them, share your computer's
- sound in Zoom (Share Screen, then Share sound), or in a hybrid meeting play them through the
- room's speakers.
-
-
- {showUpgrade && (
-
- setShowUpgrade(false)} source="sounds" />
-
- )}
+
);
diff --git a/apps/zoom-app/src/components/SoundsPanel.jsx b/apps/zoom-app/src/components/SoundsPanel.jsx
new file mode 100644
index 0000000..c979844
--- /dev/null
+++ b/apps/zoom-app/src/components/SoundsPanel.jsx
@@ -0,0 +1,196 @@
+import { useEffect, useState, lazy, Suspense } from 'react';
+import { Play, Square, Lock } from 'lucide-react';
+import { BREAK_TRACKS, updateSoundSettings } from '@toastmaster-timer/shared';
+import { useEntitlement } from '../hooks/useEntitlement';
+import { useFlag } from '../hooks/useFlag';
+import { SOUNDS_LOCK_MESSAGES, soundEngine, useSoundSettings, useSoundsAccess } from '../hooks/useSounds';
+import { trackEvent } from '../utils/posthog';
+const UpgradeModal = lazy(() => import('./UpgradeModal'));
+
+const CUE_BUTTONS = [
+ { color: 'green', label: 'Green', className: 'bg-green-500 hover:bg-green-600' },
+ { color: 'yellow', label: 'Yellow', className: 'bg-yellow-400 hover:bg-yellow-500' },
+ { color: 'red', label: 'Red', className: 'bg-red-500 hover:bg-red-600' },
+];
+
+function ToggleRow({ checked, onChange, title, description, locked }) {
+ return (
+
+ onChange(e.target.checked)}
+ className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 flex-shrink-0"
+ />
+
+
+ {title}
+ {locked && }
+
+ {description}
+
+
+ );
+}
+
+/**
+ * Sound settings (Pro): the card-change chime, break music, and volume.
+ * Opened only while the timer is stopped, so previewing music here never
+ * fights a running break for the speakers.
+ *
+ * The body of the Sounds dialog, without the dialog, so design B's Sounds
+ * sheet (plan 13) shows the same controls and the same lock. Unmounting stops
+ * a music preview.
+ */
+export default function SoundsPanel() {
+ const settings = useSoundSettings();
+ const { usable, lockReason } = useSoundsAccess();
+ const { known } = useEntitlement();
+ // The Upgrade link opens the purchase, which stays dark until pro is
+ // released. Until then the note just says sounds are part of Pro.
+ const { enabled: proEnabled, known: flagsKnown } = useFlag('pro');
+ const [showUpgrade, setShowUpgrade] = useState(false);
+ const [previewing, setPreviewing] = useState(false);
+
+ useEffect(() => () => soundEngine.stopMusic(), []);
+
+ const change = (setting, value) => {
+ updateSoundSettings({ [setting]: value });
+ (window.requestIdleCallback || setTimeout)(() => trackEvent('sound_settings_changed', { setting, value }));
+ };
+
+ const togglePreview = () => {
+ if (previewing) {
+ soundEngine.stopMusic();
+ setPreviewing(false);
+ } else {
+ soundEngine.startMusic(settings.track, settings.volume);
+ setPreviewing(true);
+ }
+ };
+
+ return (
+ <>
+ {lockReason && (
+
+
+
+ {lockReason === 'pro' ? 'Sounds are a Pro feature.' : SOUNDS_LOCK_MESSAGES.unreleased}
+ {lockReason === 'pro' && known && flagsKnown && proEnabled && (
+ <>
+ {' '}
+ setShowUpgrade(true)}
+ className="text-blue-600 hover:text-blue-700 underline"
+ >
+ Upgrade to Pro
+ {' '}
+ to turn them on.
+ >
+ )}
+
+
+ )}
+
+ {/* The tooltip sits on a wrapper: a disabled fieldset's controls get no
+ hover events of their own in most browsers. */}
+
+
+
+
change('cues', on)}
+ title="Chime when the card changes"
+ locked={!usable}
+ description="One soft note at green, two at yellow, three at red."
+ />
+
+ Test:
+ {CUE_BUTTONS.map(({ color, label, className }) => (
+ soundEngine.playCue(color, settings.volume)}
+ className={`px-2.5 py-1 rounded-md text-white text-xs font-medium transition-colors disabled:cursor-not-allowed ${className}`}
+ aria-label={`Play the ${label.toLowerCase()} chime`}
+ >
+ {label}
+
+ ))}
+
+
+
+
+
change('music', on)}
+ title="Music during breaks"
+ locked={!usable}
+ description="Plays while Take a Break counts down, and stops at 00:00."
+ />
+
+ {BREAK_TRACKS.length > 1 && (
+
{
+ soundEngine.stopMusic();
+ setPreviewing(false);
+ change('track', e.target.value);
+ }}
+ className="text-sm border border-gray-300 rounded-md px-2 py-1"
+ aria-label="Break music"
+ >
+ {BREAK_TRACKS.map((track) => (
+
+ {track.label}
+
+ ))}
+
+ )}
+
+ {previewing ? : }
+ {previewing ? 'Stop' : 'Preview music'}
+
+
+
+
+
+ Volume
+ {
+ const volume = Number(e.target.value);
+ updateSoundSettings({ volume });
+ soundEngine.setMusicVolume(volume);
+ }}
+ onPointerUp={() => !previewing && soundEngine.playCue('green', settings.volume)}
+ className="w-full"
+ aria-label="Volume"
+ />
+
+
+
+
+
+ Sounds play on this computer only. For the speaker to hear them, share your computer's
+ sound in Zoom (Share Screen, then Share sound), or in a hybrid meeting play them through the
+ room's speakers.
+
+
+ {showUpgrade && (
+
+ setShowUpgrade(false)} source="sounds" />
+
+ )}
+ >
+ );
+}
diff --git a/apps/zoom-app/src/components/TimerDisplay.jsx b/apps/zoom-app/src/components/TimerDisplay.jsx
index 5253fe3..2f07cde 100644
--- a/apps/zoom-app/src/components/TimerDisplay.jsx
+++ b/apps/zoom-app/src/components/TimerDisplay.jsx
@@ -1,7 +1,6 @@
-import { memo, useRef, useState } from 'react';
-import { Move, Plus, Minus, Eye, EyeOff, RotateCcw } from 'lucide-react';
+import { memo, useRef } from 'react';
import { formatTime, getPhaseInfo, formatPhaseTextFor, getDisplaySeconds } from '@toastmaster-timer/shared';
-import ClubBadge, { ClubBadgeLayer } from '@toastmaster-timer/ui/ClubBadge';
+import VideoPreviewLayer from './VideoPreviewLayer';
export default memo(function TimerDisplay({
elapsedTime,
@@ -27,8 +26,6 @@ export default memo(function TimerDisplay({
const displayTime = formatTime(getDisplaySeconds(elapsedTime, rules));
const containerRef = useRef(null);
- const [dragging, setDragging] = useState(false);
- const [draggingBadge, setDraggingBadge] = useState(false);
// Background color based on status
const statusColors = {
@@ -53,56 +50,6 @@ export default memo(function TimerDisplay({
textShadow: '0 1px 2px rgba(0,0,0,0.5)',
};
- // This box stands in for the organizer's video tile: dragging the badge
- // within it places the count-up on the pushed background by the same
- // fraction of the frame. Normalized coordinates make the square preview and
- // the 16:9 frame agree about what "top left" means.
- const positionFromEvent = (event) => {
- const rect = containerRef.current.getBoundingClientRect();
- return {
- x: Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)),
- y: Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)),
- };
- };
-
- const handlePointerDown = (event) => {
- event.preventDefault();
- event.currentTarget.setPointerCapture(event.pointerId);
- setDragging(true);
- };
-
- const handlePointerMove = (event) => {
- if (!dragging) return;
- onReadoutPositionChange?.(positionFromEvent(event), { commit: false });
- };
-
- // Pushing the repositioned frame to Zoom costs a multi-MB bridge transfer,
- // so it happens once, on release — the badge itself follows every move.
- const handlePointerEnd = (event) => {
- if (!dragging) return;
- setDragging(false);
- onReadoutPositionChange?.(positionFromEvent(event), { commit: true });
- };
-
- // The badge is the same gesture on the same preview, so it is the same three
- // handlers against a second piece of state rather than a second control.
- const handleBadgePointerDown = (event) => {
- event.preventDefault();
- event.currentTarget.setPointerCapture(event.pointerId);
- setDraggingBadge(true);
- };
-
- const handleBadgePointerMove = (event) => {
- if (!draggingBadge) return;
- onBadgePlacementChange?.(positionFromEvent(event), { commit: false });
- };
-
- const handleBadgePointerEnd = (event) => {
- if (!draggingBadge) return;
- setDraggingBadge(false);
- onBadgePlacementChange?.(positionFromEvent(event), { commit: true });
- };
-
return (
)}
- {/* The club's badge, where it will land on the video. Dragged and scaled
- with the same gesture as the count-up, and hidden independently of it:
- an organizer who switched the clock off has not asked to take their
- club's name off the card.
-
- The move stays on this device — the club's placement is the club's
- default, and where a badge sits relative to someone's own face is not
- something to push onto every other timer. "Reset to club position"
- puts it back. */}
- {clubKit && badgePlacement && (
-
-
-
-
-
- {!draggingBadge && (
-
- {badgePlacement.visible && (
- <>
-
onAdjustBadgeScale?.(-1)}
- className="flex items-center justify-center h-6 w-6 rounded bg-black/35 border border-white/50 text-white hover:bg-black/50"
- aria-label="Make the club badge smaller"
- >
-
-
-
onAdjustBadgeScale?.(1)}
- className="flex items-center justify-center h-6 w-6 rounded bg-black/35 border border-white/50 text-white hover:bg-black/50"
- aria-label="Make the club badge larger"
- >
-
-
- >
- )}
-
onToggleBadgeVisible?.()}
- className="flex items-center justify-center h-6 w-6 rounded bg-black/35 border border-white/50 text-white hover:bg-black/50"
- aria-label={
- badgePlacement.visible
- ? "Hide your club's badge on your video"
- : "Show your club's badge on your video"
- }
- >
- {badgePlacement.visible ? : }
-
- {badgeMoved && (
-
onResetBadgePlacement?.()}
- className="flex items-center justify-center h-6 w-6 rounded bg-black/35 border border-white/50 text-white hover:bg-black/50"
- aria-label="Reset to club position"
- >
-
-
- )}
-
- )}
-
-
- )}
-
- {/* Where the count-up sits on the video other participants see. Only the
- video modes show it: the full-screen timer pushes no frame to drag
- anything on.
-
- No data-tooltip anywhere in this cluster, deliberately: the tooltip
- pseudo-element repaints above the chip while it moves under pointer
- capture, which smeared black trails across the preview. */}
- {readoutPosition && (
-
-
-
- {displayTime}
-
- {/* Hidden while dragging: the chip is the only thing that should
- follow the pointer, and stray buttons under it catch the drop.
- Size controls sit to the chip's left and visibility to its right
- — unless the chip is near the left edge (where it starts, under
- the logo), in which case everything moves right rather than
- overflowing the card. Always out of flow, so nothing shifts the
- chip off its anchor. */}
- {!dragging && (() => {
- const nearLeftEdge = readoutPosition.x < 0.35;
- const sizeButtons = readoutVisible && (
- <>
-
onAdjustReadoutScale?.(-1)}
- className="flex items-center justify-center h-6 w-6 rounded bg-black/35 border border-white/50 text-white hover:bg-black/50"
- aria-label="Make the count-up smaller"
- >
-
-
-
onAdjustReadoutScale?.(1)}
- className="flex items-center justify-center h-6 w-6 rounded bg-black/35 border border-white/50 text-white hover:bg-black/50"
- aria-label="Make the count-up larger"
- >
-
-
- >
- );
- const eyeButton = (
-
onToggleReadoutVisible?.()}
- className="flex items-center justify-center h-6 w-6 rounded bg-black/35 border border-white/50 text-white hover:bg-black/50"
- aria-label={readoutVisible ? 'Hide the count-up from your video' : 'Show the count-up on your video'}
- >
- {readoutVisible ? : }
-
- );
- return nearLeftEdge ? (
-
- {sizeButtons}
- {eyeButton}
-
- ) : (
- <>
- {readoutVisible && (
-
- {sizeButtons}
-
- )}
-
- {eyeButton}
-
- >
- );
- })()}
-
- )}
+ {/* The count-up chip and the club badge, each dragged, sized and hidden
+ right on the preview. */}
+
);
});
diff --git a/apps/zoom-app/src/components/TimerSounds.test.jsx b/apps/zoom-app/src/components/TimerSounds.test.jsx
index b65687a..e83c174 100644
--- a/apps/zoom-app/src/components/TimerSounds.test.jsx
+++ b/apps/zoom-app/src/components/TimerSounds.test.jsx
@@ -11,7 +11,8 @@ import {
} from '@toastmaster-timer/shared';
import TimerSounds from './TimerSounds';
import SoundsModal from './SoundsModal';
-import { soundEngine } from '../hooks/useSounds';
+import SoundsPanel from './SoundsPanel';
+import { SOUNDS_LOCK_MESSAGES, soundEngine } from '../hooks/useSounds';
// Stubbed rather than imported: the real module pulls in @zoom/appssdk, which
// hangs vitest under jsdom.
@@ -236,3 +237,25 @@ describe('the Sounds modal', () => {
expect(onClose).toHaveBeenCalled();
});
});
+
+// The dialog's body on its own, as design B's Sounds sheet shows it.
+describe('the Sounds panel', () => {
+ it('shows the same lock without the dialog around it', () => {
+ setFlags({ sounds: true, pro: false });
+ setEntitlement({ plan: 'free' });
+ render( );
+ expect(screen.getByText(SOUNDS_LOCK_MESSAGES.unreleased)).toBeInTheDocument();
+ expect(screen.getByRole('checkbox', { name: /chime when the card changes/i })).toBeDisabled();
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('stops a music preview when it goes away', () => {
+ setFlags({ sounds: true, pro: true });
+ setEntitlement(PRO);
+ const view = render( );
+ fireEvent.click(screen.getByRole('button', { name: /preview music/i }));
+ soundEngine.stopMusic.mockClear();
+ view.unmount();
+ expect(soundEngine.stopMusic).toHaveBeenCalled();
+ });
+});
diff --git a/apps/zoom-app/src/components/VideoPreviewLayer.jsx b/apps/zoom-app/src/components/VideoPreviewLayer.jsx
new file mode 100644
index 0000000..18f6824
--- /dev/null
+++ b/apps/zoom-app/src/components/VideoPreviewLayer.jsx
@@ -0,0 +1,275 @@
+import { useState } from 'react';
+import { Move, Plus, Minus, Eye, EyeOff, RotateCcw } from 'lucide-react';
+import ClubBadge, { ClubBadgeLayer } from '@toastmaster-timer/ui/ClubBadge';
+
+/**
+ * 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
+ * place, sized with − / + and hidden with the eye, and the move reaches Zoom
+ * only on release.
+ *
+ * Its own component so today's square preview (TimerDisplay) and design B's
+ * 16:9 card (plan 13) share one implementation of the gesture. Positions are
+ * fractions of `containerRef`'s box, which is why the two shapes agree about
+ * where things land on the 16:9 frame.
+ *
+ * Rendered inside the positioned preview box; both pieces are absolutely
+ * placed within it.
+ */
+export default function VideoPreviewLayer({
+ containerRef,
+ displayTime,
+ readoutPosition,
+ onReadoutPositionChange,
+ readoutVisible,
+ onToggleReadoutVisible,
+ onAdjustReadoutScale,
+ clubKit,
+ badgePlacement,
+ badgeMoved,
+ onBadgePlacementChange,
+ onAdjustBadgeScale,
+ onToggleBadgeVisible,
+ onResetBadgePlacement,
+}) {
+ const [dragging, setDragging] = useState(false);
+ const [draggingBadge, setDraggingBadge] = useState(false);
+
+ // This box stands in for the organizer's video tile: dragging the badge
+ // within it places the count-up on the pushed background by the same
+ // fraction of the frame. Normalized coordinates make the square preview and
+ // the 16:9 frame agree about what "top left" means.
+ const positionFromEvent = (event) => {
+ const rect = containerRef.current.getBoundingClientRect();
+ return {
+ x: Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)),
+ y: Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)),
+ };
+ };
+
+ const handlePointerDown = (event) => {
+ event.preventDefault();
+ event.currentTarget.setPointerCapture(event.pointerId);
+ setDragging(true);
+ };
+
+ const handlePointerMove = (event) => {
+ if (!dragging) return;
+ onReadoutPositionChange?.(positionFromEvent(event), { commit: false });
+ };
+
+ // Pushing the repositioned frame to Zoom costs a multi-MB bridge transfer,
+ // so it happens once, on release — the badge itself follows every move.
+ const handlePointerEnd = (event) => {
+ if (!dragging) return;
+ setDragging(false);
+ onReadoutPositionChange?.(positionFromEvent(event), { commit: true });
+ };
+
+ // The badge is the same gesture on the same preview, so it is the same three
+ // handlers against a second piece of state rather than a second control.
+ const handleBadgePointerDown = (event) => {
+ event.preventDefault();
+ event.currentTarget.setPointerCapture(event.pointerId);
+ setDraggingBadge(true);
+ };
+
+ const handleBadgePointerMove = (event) => {
+ if (!draggingBadge) return;
+ onBadgePlacementChange?.(positionFromEvent(event), { commit: false });
+ };
+
+ const handleBadgePointerEnd = (event) => {
+ if (!draggingBadge) return;
+ setDraggingBadge(false);
+ onBadgePlacementChange?.(positionFromEvent(event), { commit: true });
+ };
+
+ return (
+ <>
+ {/* The club's badge, where it will land on the video. Dragged and scaled
+ with the same gesture as the count-up, and hidden independently of it:
+ an organizer who switched the clock off has not asked to take their
+ club's name off the card.
+
+ The move stays on this device — the club's placement is the club's
+ default, and where a badge sits relative to someone's own face is not
+ something to push onto every other timer. "Reset to club position"
+ puts it back. */}
+ {clubKit && badgePlacement && (
+
+
+
+
+
+ {!draggingBadge && (
+
+ {badgePlacement.visible && (
+ <>
+
onAdjustBadgeScale?.(-1)}
+ className="flex items-center justify-center h-6 w-6 rounded bg-black/35 border border-white/50 text-white hover:bg-black/50"
+ aria-label="Make the club badge smaller"
+ >
+
+
+
onAdjustBadgeScale?.(1)}
+ className="flex items-center justify-center h-6 w-6 rounded bg-black/35 border border-white/50 text-white hover:bg-black/50"
+ aria-label="Make the club badge larger"
+ >
+
+
+ >
+ )}
+
onToggleBadgeVisible?.()}
+ className="flex items-center justify-center h-6 w-6 rounded bg-black/35 border border-white/50 text-white hover:bg-black/50"
+ aria-label={
+ badgePlacement.visible
+ ? "Hide your club's badge on your video"
+ : "Show your club's badge on your video"
+ }
+ >
+ {badgePlacement.visible ? : }
+
+ {badgeMoved && (
+
onResetBadgePlacement?.()}
+ className="flex items-center justify-center h-6 w-6 rounded bg-black/35 border border-white/50 text-white hover:bg-black/50"
+ aria-label="Reset to club position"
+ >
+
+
+ )}
+
+ )}
+
+
+ )}
+
+ {/* Where the count-up sits on the video other participants see. Only the
+ video modes show it: the full-screen timer pushes no frame to drag
+ anything on.
+
+ No data-tooltip anywhere in this cluster, deliberately: the tooltip
+ pseudo-element repaints above the chip while it moves under pointer
+ capture, which smeared black trails across the preview. */}
+ {readoutPosition && (
+
+
+
+ {displayTime}
+
+ {/* Hidden while dragging: the chip is the only thing that should
+ follow the pointer, and stray buttons under it catch the drop.
+ Size controls sit to the chip's left and visibility to its right
+ — unless the chip is near the left edge (where it starts, under
+ the logo), in which case everything moves right rather than
+ overflowing the card. Always out of flow, so nothing shifts the
+ chip off its anchor. */}
+ {!dragging && (() => {
+ const nearLeftEdge = readoutPosition.x < 0.35;
+ const sizeButtons = readoutVisible && (
+ <>
+
onAdjustReadoutScale?.(-1)}
+ className="flex items-center justify-center h-6 w-6 rounded bg-black/35 border border-white/50 text-white hover:bg-black/50"
+ aria-label="Make the count-up smaller"
+ >
+
+
+
onAdjustReadoutScale?.(1)}
+ className="flex items-center justify-center h-6 w-6 rounded bg-black/35 border border-white/50 text-white hover:bg-black/50"
+ aria-label="Make the count-up larger"
+ >
+
+
+ >
+ );
+ const eyeButton = (
+
onToggleReadoutVisible?.()}
+ className="flex items-center justify-center h-6 w-6 rounded bg-black/35 border border-white/50 text-white hover:bg-black/50"
+ aria-label={readoutVisible ? 'Hide the count-up from your video' : 'Show the count-up on your video'}
+ >
+ {readoutVisible ? : }
+
+ );
+ return nearLeftEdge ? (
+
+ {sizeButtons}
+ {eyeButton}
+
+ ) : (
+ <>
+ {readoutVisible && (
+
+ {sizeButtons}
+
+ )}
+
+ {eyeButton}
+
+ >
+ );
+ })()}
+
+ )}
+ >
+ );
+}
diff --git a/apps/zoom-app/src/components/VideoPreviewLayer.test.jsx b/apps/zoom-app/src/components/VideoPreviewLayer.test.jsx
new file mode 100644
index 0000000..b8a584e
--- /dev/null
+++ b/apps/zoom-app/src/components/VideoPreviewLayer.test.jsx
@@ -0,0 +1,120 @@
+import '@testing-library/jest-dom';
+import { useRef } from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import VideoPreviewLayer from './VideoPreviewLayer';
+
+const KIT = { name: 'Claude Test Club', primaryColor: '#0f766e', logoUrl: null, showOnCards: true };
+
+/** A 400×225 preview box, so pointer positions map to round fractions. */
+function Harness(props) {
+ const ref = useRef(null);
+ return (
+ {
+ ref.current = node;
+ if (node) node.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 225, right: 400, bottom: 225 });
+ }}
+ style={{ position: 'relative' }}
+ >
+
+
+ );
+}
+
+const chip = () => screen.getByRole('button', { name: 'Drag to place the count-up participants see on your video' });
+const badge = () => screen.getByRole('button', { name: "Drag to place your club's badge on your video" });
+
+beforeAll(() => {
+ // jsdom has no pointer capture.
+ HTMLElement.prototype.setPointerCapture = () => {};
+});
+
+describe('VideoPreviewLayer', () => {
+ it('follows a count-up drag live and hands it to Zoom once, on release', () => {
+ const onReadoutPositionChange = vi.fn();
+ render( );
+
+ fireEvent.pointerDown(chip(), { pointerId: 1, clientX: 200, clientY: 112 });
+ fireEvent.pointerMove(chip(), { pointerId: 1, clientX: 100, clientY: 45 });
+ fireEvent.pointerMove(chip(), { pointerId: 1, clientX: 300, clientY: 180 });
+ fireEvent.pointerUp(chip(), { pointerId: 1, clientX: 300, clientY: 180 });
+
+ const commits = onReadoutPositionChange.mock.calls.filter(([, opts]) => opts.commit);
+ expect(commits).toEqual([[{ x: 0.75, y: 0.8 }, { commit: true }]]);
+ expect(onReadoutPositionChange).toHaveBeenCalledWith({ x: 0.25, y: 0.2 }, { commit: false });
+ });
+
+ it('clamps a drag that leaves the preview to its edge', () => {
+ const onReadoutPositionChange = vi.fn();
+ render( );
+
+ fireEvent.pointerDown(chip(), { pointerId: 1 });
+ fireEvent.pointerUp(chip(), { pointerId: 1, clientX: -50, clientY: 900 });
+ expect(onReadoutPositionChange).toHaveBeenLastCalledWith({ x: 0, y: 1 }, { commit: true });
+ });
+
+ it('hides the size and eye buttons while the chip is being dragged', () => {
+ render( {}} />);
+ expect(screen.getByRole('button', { name: 'Make the count-up larger' })).toBeInTheDocument();
+
+ fireEvent.pointerDown(chip(), { pointerId: 1 });
+ expect(screen.queryByRole('button', { name: 'Make the count-up larger' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Hide the count-up from your video' })).not.toBeInTheDocument();
+ });
+
+ it('sizes and hides the count-up, and offers only "show" once it is hidden', () => {
+ const onAdjustReadoutScale = vi.fn();
+ const onToggleReadoutVisible = vi.fn();
+ const { rerender } = render(
+
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Make the count-up smaller' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Make the count-up larger' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Hide the count-up from your video' }));
+ expect(onAdjustReadoutScale.mock.calls).toEqual([[-1], [1]]);
+ expect(onToggleReadoutVisible).toHaveBeenCalledTimes(1);
+
+ rerender( );
+ expect(screen.queryByRole('button', { name: 'Make the count-up larger' })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Show the count-up on your video' })).toBeInTheDocument();
+ });
+
+ it('draws no count-up where there is no frame to place it on', () => {
+ render( );
+ expect(screen.queryByRole('button', { name: /count-up/ })).not.toBeInTheDocument();
+ });
+
+ it('drags, sizes and hides the club badge, and offers the reset only once it was moved', () => {
+ const onBadgePlacementChange = vi.fn();
+ const onAdjustBadgeScale = vi.fn();
+ const onToggleBadgeVisible = vi.fn();
+ const onResetBadgePlacement = vi.fn();
+ const placement = { x: 0.8, y: 0.12, scale: 0.12, visible: true };
+ const props = { clubKit: KIT, badgePlacement: placement, onBadgePlacementChange, onAdjustBadgeScale, onToggleBadgeVisible, onResetBadgePlacement };
+ const { rerender } = render( );
+
+ expect(screen.queryByRole('button', { name: 'Reset to club position' })).not.toBeInTheDocument();
+ fireEvent.pointerDown(badge(), { pointerId: 2 });
+ fireEvent.pointerUp(badge(), { pointerId: 2, clientX: 40, clientY: 45 });
+ expect(onBadgePlacementChange).toHaveBeenLastCalledWith({ x: 0.1, y: 0.2 }, { commit: true });
+
+ fireEvent.click(screen.getByRole('button', { name: 'Make the club badge larger' }));
+ fireEvent.click(screen.getByRole('button', { name: "Hide your club's badge on your video" }));
+ expect(onAdjustBadgeScale).toHaveBeenCalledWith(1);
+ expect(onToggleBadgeVisible).toHaveBeenCalledTimes(1);
+
+ rerender( );
+ fireEvent.click(screen.getByRole('button', { name: 'Reset to club position' }));
+ expect(onResetBadgePlacement).toHaveBeenCalledTimes(1);
+ });
+
+ it('draws no badge without a club kit', () => {
+ render( );
+ expect(screen.queryByRole('button', { name: /club's badge/ })).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/zoom-app/src/hooks/useLiveTimer.js b/apps/zoom-app/src/hooks/useLiveTimer.js
new file mode 100644
index 0000000..60672bd
--- /dev/null
+++ b/apps/zoom-app/src/hooks/useLiveTimer.js
@@ -0,0 +1,1038 @@
+import { useState, useEffect, useRef, useMemo } from 'react';
+import { useTimer, useTimerTick } from '../context/TimerContext';
+import { useToast } from '../context/ToastContext';
+import { DEFAULT_ROLE_RULES, DEFAULT_CUSTOM_RULES, loadTimeInputMode, BREAK_ROLE, DEFAULT_BREAK_SECONDS, deriveBreakRules, getDisplaySeconds, initCardImages, subscribeCardImages, agendaItemNeedsRole, formatTime, saveOverlayMode, saveStageClockHidden, loadStageClockHidden, saveRevealFaceWhenIdle, loadRevealFaceWhenIdle } from '@toastmaster-timer/shared';
+import { getVideoState, setVideoState, applyOverlay, removeOverlay, clearVideoPipelines, isOverlayActive, getBackgroundUrl, getSdkStatus, setLogCallback, getOverlayMode, setOverlayMode, getOverlayTimePosition, setOverlayTimePosition, getOverlayTimeScale, setOverlayTimeScale, isOverlayTimeVisible, setOverlayTimeVisible, setOverlayTimeLabel, setPopoutChangeCallback, setShareChangeCallback, setAppShare, setAppPopout, isAppShareActive, isAppPoppedOut, isVideoOverlayMode, getClubBadgePlacement, setClubBadgePlacement, resetClubBadgePlacement, getZoomParticipants, OVERLAY_MODE_STAGE, notifyCardImagesChanged, preloadBackgroundImages } from '../utils/zoomSdk';
+import { useClub } from './useClub';
+import { useSoundsAccess } from './useSounds';
+import { trackEvent } from '../utils/posthog';
+
+/**
+ * Everything the timer tab does, apart from how it looks: the speaker and role
+ * being timed, Start / Stop / Continue / Reset / Finish, and every conversation
+ * with Zoom about the organizer's video (the overlay pushes, video on/off, the
+ * display mode, share and pop-out, where the count-up and the club badge sit).
+ *
+ * Lifted out of LiveTab unchanged so the redesigned panel (design B, plan 13)
+ * can draw the same timer without a second copy of the video logic. Whichever
+ * view calls it owns the overlay effect, so exactly one view may call it at a
+ * time: two would push to Zoom and poll the SDK twice.
+ *
+ * @returns {Object} state and handlers for a timer view
+ */
+export function useLiveTimer() {
+ const { isRunning, elapsedTime, currentStatus } = useTimerTick();
+ const {
+ currentSpeaker,
+ startTimer,
+ stopTimer,
+ resetTimer,
+ setCurrentSpeaker,
+ updateSpeakerName,
+ clearActiveSpeaker,
+ finishCurrentSpeech,
+ roleRules,
+ roleOptions,
+ agenda,
+ activeSpeakerId,
+ loadSpeakerFromAgenda,
+ addToAgenda,
+ renameAgendaSpeaker,
+ } = useTimer();
+ const { showToast } = useToast();
+
+ // The role the picker starts on: Standard Speech, unless this device or its
+ // club removed it from the list. A whose value is not among its
+ // options shows the first option while the state keeps the removed role, and
+ // START then finds no rules for it ("Please set timing rules first").
+ const defaultRole = roleOptions.includes('Standard Speech') ? 'Standard Speech' : roleOptions[0] || 'Standard Speech';
+
+ // An agenda row whose role is not one the organizer can pick (#91). The
+ // Agenda tab highlights it and asks for the role; nothing here may load it,
+ // because loading it would time the speaker under a role nobody chose.
+ const needsRole = (item) => agendaItemNeedsRole(item, roleOptions);
+ const askForRole = (item) =>
+ showToast(`Pick a role for ${item.name || 'this speaker'} on the Agenda tab first`, 'warning');
+
+ const [speakerName, setSpeakerName] = useState(currentSpeaker?.name || '');
+ const [selectedRole, setSelectedRole] = useState(currentSpeaker?.role || defaultRole);
+ const [customRules, setCustomRules] = useState({ ...DEFAULT_CUSTOM_RULES });
+ // Break Time counts down from this. Not persisted: 10 minutes is the meeting
+ // norm, and a club that broke for 5 yesterday should not silently break for
+ // 5 forever.
+ const [breakSeconds, setBreakSeconds] = useState(DEFAULT_BREAK_SECONDS);
+ const [timeInputMode, setTimeInputMode] = useState(loadTimeInputMode);
+ const [showEditRulesModal, setShowEditRulesModal] = useState(false);
+ const [showCardImagesModal, setShowCardImagesModal] = useState(false);
+ const [showSoundsModal, setShowSoundsModal] = useState(false);
+ const { lockReason: soundsLockReason } = useSoundsAccess();
+ // Bumped whenever a custom card image is uploaded or reset, so the overlay
+ // effect below re-pushes whatever is currently showing with the new artwork.
+ const [cardImagesGeneration, setCardImagesGeneration] = useState(0);
+
+ // Custom card images load from IndexedDB asynchronously at startup; until
+ // then a selected custom set resolves to the built-in fallback. Bump the
+ // generation once they are in so the overlay re-pushes the real artwork.
+ useEffect(() => {
+ let cancelled = false;
+ initCardImages().then(() => {
+ if (!cancelled) setCardImagesGeneration((generation) => generation + 1);
+ });
+ return () => { cancelled = true; };
+ }, []);
+
+ // The club's card set arrives, changes or goes away while the app is open.
+ // Drop the decoded frames and re-push, as the picker does after a change.
+ useEffect(() => subscribeCardImages(() => {
+ notifyCardImagesChanged();
+ preloadBackgroundImages();
+ setCardImagesGeneration((generation) => generation + 1);
+ }), []);
+
+ const [videoState, setVideoStateLocal] = useState(null); // null = unknown, true = on, false = off
+ // A clear is in flight. Disables both the eraser and RESET, because in Timer +
+ // Camera the wait is Zoom's confirmation dialog, and a second press behind the
+ // first would raise a second dialog for a background already on its way out.
+ const [isClearingVideo, setIsClearingVideo] = useState(false);
+ // Bumped by anything that hands the video back — the clear button, RESET.
+ // Guarantees the overlay effect re-runs exactly once afterwards, which is the
+ // run it skips — see clearTimerFromVideo.
+ const [clearGeneration, setClearGeneration] = useState(0);
+
+ // What the app itself is doing, mirrored from the SDK so Zoom's own controls
+ // move these too. Seeded from the module because it outlives this component:
+ // switching tabs unmounts the Live tab without ending a share.
+ const [isSharing, setIsSharing] = useState(isAppShareActive);
+ const [isPoppedOut, setIsPoppedOut] = useState(isAppPoppedOut);
+ const [isEnablingVideo, setIsEnablingVideo] = useState(false);
+ const [previewColor, setPreviewColor] = useState(null);
+ // Unlike the mode itself, this preference is remembered: it is a club's stance
+ // on whether a ticking clock helps or distracts their speakers.
+ const [stageClockHidden, setStageClockHidden] = useState(loadStageClockHidden);
+
+ // Whether the video modes hand the organizer their face back between speeches.
+ // On by default; off restores the older always-on behavior for clubs that want
+ // the color up for the whole meeting.
+ const [revealFaceWhenIdle, setRevealFaceWhenIdle] = useState(loadRevealFaceWhenIdle);
+
+ // Seeded from the SDK module, which resolves the organizer's saved mode
+ // itself at load time. The menu and the pipeline must never disagree about
+ // the mode: showing "Timer + Camera" while the module pushes card-mode
+ // filters is a full card over the organizer's face.
+ const [overlayMode, setOverlayModeLocal] = useState(getOverlayMode);
+
+ // Where the count-up sits on the pushed frame, mirrored from the SDK module
+ // so the drag badge follows the pointer without a bridge push per move.
+ const [readoutPosition, setReadoutPosition] = useState(getOverlayTimePosition);
+ const [readoutScale, setReadoutScale] = useState(getOverlayTimeScale);
+ const [readoutVisible, setReadoutVisible] = useState(isOverlayTimeVisible);
+
+ // The club's badge: its kit comes from the club cache, its placement from
+ // this device. Mirrored into state for the same reason the readout is — the
+ // drag handle has to follow the pointer without a bridge push per move.
+ const { kit: clubKit } = useClub();
+ const [badgePlacement, setBadgePlacement] = useState(getClubBadgePlacement);
+ const badgeOnCards = Boolean(clubKit?.showOnCards);
+
+ /** Follow the drag live; hand the move to Zoom only on release. */
+ const handleBadgePlacementChange = (position, { commit } = {}) => {
+ setBadgePlacement((current) => ({ ...current, ...position }));
+ if (commit) {
+ const applied = setClubBadgePlacement(position);
+ setBadgePlacement(applied);
+ (window.requestIdleCallback || setTimeout)(() => trackEvent('brand_badge_moved', {
+ x: Math.round(applied.x * 100) / 100,
+ y: Math.round(applied.y * 100) / 100,
+ scale: Math.round(applied.scale * 100) / 100,
+ was_reset: false,
+ overlay_mode: overlayMode,
+ }));
+ }
+ };
+
+ const handleAdjustBadgeScale = (direction) => {
+ setBadgePlacement(setClubBadgePlacement({ scale: badgePlacement.scale + direction * 0.02 }));
+ };
+
+ const handleToggleBadgeVisible = () => {
+ setBadgePlacement(setClubBadgePlacement({ visible: !badgePlacement.visible }));
+ };
+
+ /** Back to wherever the club's admin put it; this device's move is dropped. */
+ const handleResetBadgePlacement = () => {
+ setBadgePlacement(resetClubBadgePlacement());
+ (window.requestIdleCallback || setTimeout)(() => trackEvent('brand_badge_moved', {
+ was_reset: true,
+ overlay_mode: overlayMode,
+ }));
+ };
+
+ /** Follow the drag live; hand the position to Zoom only on release. */
+ const handleReadoutPositionChange = (position, { commit } = {}) => {
+ setReadoutPosition(position);
+ if (commit) {
+ setOverlayTimePosition(position);
+ (window.requestIdleCallback || setTimeout)(() => trackEvent('readout_position_moved', {
+ x: Math.round(position.x * 100) / 100,
+ y: Math.round(position.y * 100) / 100,
+ overlay_mode: overlayMode,
+ }));
+ }
+ };
+
+ /** One +/- press; the SDK clamps and reports what it actually applied. */
+ const handleAdjustReadoutScale = (direction) => {
+ const applied = setOverlayTimeScale(readoutScale + direction * 0.03);
+ setReadoutScale(applied);
+ (window.requestIdleCallback || setTimeout)(() => trackEvent('readout_scale_adjusted', {
+ scale: Math.round(applied * 100) / 100,
+ overlay_mode: overlayMode,
+ }));
+ };
+
+ const handleToggleReadoutVisible = () => {
+ const visible = !readoutVisible;
+ setReadoutVisible(visible);
+ setOverlayTimeVisible(visible);
+ (window.requestIdleCallback || setTimeout)(() => trackEvent('readout_visibility_toggled', {
+ visible,
+ overlay_mode: overlayMode,
+ }));
+ };
+
+ // A speech is under way. A pause counts as active — the speech is on hold, not
+ // over — which is why this tracks elapsedTime rather than isRunning alone.
+ const speechActive = isRunning || elapsedTime > 0;
+
+ // The selected role left the list (removed in Edit Rules, or by a club
+ // publish): move to the default rather than leave START with no rules. Never
+ // under a speech in progress, which carries its own rules on the speaker.
+ //
+ // Except for an agenda row that now needs a role: moving it to the default
+ // would time that speaker under a role nobody picked, which is the guess #91
+ // removed. Let go of the row instead, and say where its role is picked. The
+ // pickers and FINISH refuse such a row before it loads; this catches a row
+ // whose role goes away while it is up.
+ useEffect(() => {
+ if (speechActive || roleOptions.length === 0 || roleOptions.includes(selectedRole)) return;
+ const activeItem = activeSpeakerId ? agenda.find((item) => item.id === activeSpeakerId) : null;
+ if (activeItem && needsRole(activeItem)) {
+ clearActiveSpeaker();
+ setSpeakerName('');
+ setSelectedRole(defaultRole);
+ setCurrentSpeaker({ name: '', role: defaultRole });
+ askForRole(activeItem);
+ return;
+ }
+ setSelectedRole(defaultRole);
+ setCurrentSpeaker({ name: speakerName || '', role: defaultRole });
+ }, [speechActive, roleOptions, selectedRole, defaultRole, speakerName, setCurrentSpeaker, activeSpeakerId, agenda, clearActiveSpeaker, showToast]);
+
+ // Whether a color belongs on the organizer's tile right now. The video modes
+ // run only while a speech is being timed: nothing before START, nothing once it
+ // is finished or reset, so the organizer gets their normal video back between
+ // speakers rather than a card sitting on them for the whole meeting. Clubs that
+ // prefer the old always-on behavior turn the reveal-when-idle option off.
+ //
+ // A held preview swatch counts too, or the preview buttons would do nothing
+ // while the timer sits idle.
+ const wantsColorNow = speechActive || Boolean(previewColor) || !revealFaceWhenIdle;
+
+ // The color that belongs on the tile: a held preview swatch wins over the
+ // timer's own status, which is the point of previewing.
+ const desiredStatus = previewColor || currentStatus || 'blue';
+
+ // Debug panel state - collapsed by default, remember user preference in localStorage
+ const [debugPanelExpanded, setDebugPanelExpanded] = useState(() => {
+ const saved = localStorage.getItem('debugPanelExpanded');
+ return saved ? saved === 'true' : false; // Default to collapsed
+ });
+ const [sdkStatus, setSdkStatus] = useState(null);
+ const [lastError, setLastError] = useState(null);
+ const [debugLogs, setDebugLogs] = useState([]);
+ const initializedRef = useRef(false);
+ const isLocalNameEdit = useRef(false);
+ const lastClearGenerationRef = useRef(0);
+
+ // Desktop only. Outside Zoom nothing is granted, so offer it anyway rather than
+ // hiding a button the whole stage layout is being developed around.
+ const canPopout =
+ !sdkStatus?.available || !sdkStatus?.missingApis?.some((api) => api.name === 'appPopout');
+
+ // Nothing to report: connected, and this client offers every API we call.
+ const sdkHealthy =
+ sdkStatus?.initialized &&
+ sdkStatus?.available &&
+ sdkStatus?.sdkExists &&
+ sdkStatus?.missingApis?.length === 0;
+
+ // Save expanded state to localStorage
+ const toggleDebugPanel = () => {
+ const newState = !debugPanelExpanded;
+ setDebugPanelExpanded(newState);
+ localStorage.setItem('debugPanelExpanded', String(newState));
+ };
+
+ // Add log entry
+ const addDebugLog = (message, type = 'info') => {
+ const timestamp = new Date().toLocaleTimeString();
+ const logEntry = { timestamp, message, type };
+ setDebugLogs(prev => {
+ const newLogs = [...prev, logEntry];
+ // Keep only last 100 logs to prevent memory issues
+ return newLogs.slice(-100);
+ });
+ };
+
+
+ // Initialize currentSpeaker on mount if it's null
+ useEffect(() => {
+ if (!initializedRef.current && !currentSpeaker && selectedRole && roleRules && Object.keys(roleRules).length > 0) {
+ // Initialize with default role (Standard Speech)
+ // setCurrentSpeaker will automatically add rules from roleRules
+ setCurrentSpeaker({
+ name: '',
+ role: selectedRole,
+ });
+ initializedRef.current = true;
+ }
+ }, [roleRules, selectedRole, currentSpeaker, setCurrentSpeaker]); // Include all dependencies
+
+ // Update local state when currentSpeaker changes (but preserve custom rules if Custom role)
+ useEffect(() => {
+ if (currentSpeaker) {
+ if (!isLocalNameEdit.current) {
+ setSpeakerName(currentSpeaker.name || '');
+ }
+ isLocalNameEdit.current = false;
+ setSelectedRole(currentSpeaker.role);
+ // If custom role and has custom rules, update local state (merge so missing graceAfterRed is filled)
+ if (currentSpeaker.role === 'Custom' && currentSpeaker.rules) {
+ setCustomRules({ ...DEFAULT_CUSTOM_RULES, ...currentSpeaker.rules });
+ }
+ // A break loaded from the agenda carries its length in its rules; the
+ // picker has to show that length, not whatever was picked last.
+ if (currentSpeaker.role === BREAK_ROLE && currentSpeaker.rules?.red) {
+ setBreakSeconds(currentSpeaker.rules.red);
+ }
+ // If switching to Custom role but no rules yet, keep current customRules (don't reset)
+ } else {
+ setSpeakerName('');
+ setSelectedRole(defaultRole);
+ // Don't reset custom rules here - they should persist until explicitly changed
+ }
+ }, [currentSpeaker]);
+
+ // When switching to Custom role for the first time, use the default from roleRules (merge for graceAfterRed)
+ useEffect(() => {
+ if (selectedRole === 'Custom' && roleRules['Custom']) {
+ setCustomRules({ ...DEFAULT_CUSTOM_RULES, ...roleRules['Custom'] });
+ }
+ }, [selectedRole, roleRules]);
+
+ // Set up log callback for zoomSdk
+ useEffect(() => {
+ setLogCallback(addDebugLog);
+ addDebugLog('Debug panel initialized', 'info');
+ return () => setLogCallback(null);
+ }, []);
+
+ // Follow Zoom's own controls. The organizer can pop the app out from the
+ // ellipsis menu or stop a share from the sharing toolbar, neither of which goes
+ // near our buttons — so the stage reads these rather than what it last asked
+ // for. Both are just state now: docking no longer means leaving the stage, any
+ // more than stopping a share does.
+ useEffect(() => {
+ setPopoutChangeCallback(setIsPoppedOut);
+ setShareChangeCallback(setIsSharing);
+ return () => {
+ setPopoutChangeCallback(null);
+ setShareChangeCallback(null);
+ };
+ }, []);
+
+ // Check SDK status on mount and periodically
+ useEffect(() => {
+ const updateSdkStatus = () => {
+ try {
+ const status = getSdkStatus();
+ setSdkStatus(status);
+
+ // Set error if SDK is not available or key functions are missing
+ if (status.lastError) {
+ setLastError(status.lastError);
+ } else if (!status.initialized) {
+ setLastError('Zoom SDK not initialized');
+ } else if (!status.available) {
+ setLastError('Zoom SDK not available - Make sure you are running inside Zoom client');
+ } else if (!status.hasSetVideoFilter) {
+ setLastError('setVideoFilter is not available. Available methods: ' + (status.availableMethods?.join(', ') || 'none'));
+ } else {
+ setLastError(null);
+ }
+ } catch (error) {
+ console.error('Failed to get SDK status:', error);
+ setLastError('Failed to get SDK status: ' + error.message);
+ }
+ };
+
+ // Check immediately
+ updateSdkStatus();
+
+ // Update periodically every 2 seconds
+ const interval = setInterval(updateSdkStatus, 2000);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ // Check video state on mount and periodically
+ useEffect(() => {
+ const checkVideoState = async () => {
+ try {
+ const videoStateResult = await getVideoState();
+ // Only update if we got a definitive answer (true or false)
+ // null means we can't determine, so don't update the state
+ if (videoStateResult !== null) {
+ setVideoStateLocal(videoStateResult);
+ }
+ } catch (error) {
+ console.error('Failed to check video state:', error);
+ setLastError('Failed to check video state: ' + error.message);
+ // Don't update state on error - keep current state
+ }
+ };
+
+ // Check immediately
+ checkVideoState();
+
+ // Check periodically every 2-3 seconds
+ const interval = setInterval(checkVideoState, 2500);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ // Owns what the video modes have on screen. Stage modes are excluded: they push
+ // no pixels, and removeOverlay there would tear down the share or the
+ // popped-out window itself.
+ useEffect(() => {
+ // The run triggered by a clear — the eraser, or RESET: leave the video as it
+ // was just cleared to. Bumping the generation is what makes this run happen at
+ // all, so the skip is always consumed and never swallows a later push.
+ if (lastClearGenerationRef.current !== clearGeneration) {
+ lastClearGenerationRef.current = clearGeneration;
+ return;
+ }
+ if (!isVideoOverlayMode(overlayMode)) return;
+ if (wantsColorNow) {
+ // A held swatch previews everything a live speech will show, the
+ // count-up included, so the organizer can place and size it before
+ // anyone speaks. While the timer runs, TimerContext owns the label.
+ if (previewColor && !isRunning) {
+ setOverlayTimeLabel(formatTime(getDisplaySeconds(elapsedTime, currentSpeaker?.rules)));
+ } else if (!speechActive) {
+ // Idle with no swatch held: nothing to count, so no readout.
+ setOverlayTimeLabel(null);
+ }
+ const imageUrl = getBackgroundUrl(desiredStatus);
+ // A custom card is a data: URL that can run to a megabyte; never log it whole.
+ addDebugLog(`Applying overlay: ${desiredStatus} -> ${imageUrl.startsWith('data:') ? 'custom image' : imageUrl}`, 'info');
+ // Usually redundant with TimerContext's own push on status change;
+ // applyOverlay joins it onto that push while it is queued or running,
+ // so the duplicate costs no second SDK call.
+ applyOverlay(imageUrl);
+ // Idle, so the organizer gets their video back: whichever pipeline holds
+ // the card comes off, and neither is touched when nothing of ours is up.
+ // Not on mount, not on a speaker change, not on arriving in a mode with
+ // nothing showing — each of those would otherwise cost a confirmation
+ // dialog in camera mode, or delete the user's own video filter in card.
+ } else if (isOverlayActive()) {
+ addDebugLog('Removing overlay (no speech in progress)', 'info');
+ // A preview may have set the readout; the teardown must not leave it
+ // behind for the next push. Clearing never re-pushes on its own.
+ setOverlayTimeLabel(null);
+ removeOverlay();
+ }
+ // previewColor is listed on its own because desiredStatus can hide a
+ // swatch change: previewing the color the timer already shows.
+ // cardImagesGeneration re-runs the push when the artwork behind the same
+ // status changes; the already-showing guard sees a new URL and pushes it.
+ }, [overlayMode, wantsColorNow, desiredStatus, previewColor, clearGeneration, cardImagesGeneration]);
+
+ // Log when status changes
+ useEffect(() => {
+ if (currentStatus) {
+ addDebugLog(`Timer status changed to: ${currentStatus}`, 'info');
+ }
+ }, [currentStatus]);
+
+ // Clear preview when the timer starts running
+ useEffect(() => {
+ if (isRunning) setPreviewColor(null);
+ }, [isRunning]);
+
+ // Log when timer starts/stops
+ useEffect(() => {
+ if (isRunning) {
+ addDebugLog('Timer started', 'info');
+ } else if (isRunning === false && elapsedTime === 0) {
+ addDebugLog('Timer stopped/reset', 'info');
+ }
+ }, [isRunning]);
+
+ const handleSpeakerChange = (name) => {
+ setSpeakerName(name || '');
+ isLocalNameEdit.current = true;
+ updateSpeakerName(name || '');
+ };
+
+ const handleSelectSuggestion = (item) => {
+ if (needsRole(item)) {
+ askForRole(item);
+ return;
+ }
+ setSpeakerName(item.name);
+ setSelectedRole(item.role);
+ // Picking someone off the agenda has to make them the *active* agenda
+ // speaker, not just copy their name across. Without that link the timer has
+ // no idea where in the running order it is: finishing cannot advance to the
+ // next speaker, and the item is never ticked off as done.
+ if (item.id && agenda.some((entry) => entry.id === item.id)) {
+ loadSpeakerFromAgenda(item.id);
+ return;
+ }
+ const rules = item.role === 'Custom' ? customRules : undefined;
+ setCurrentSpeaker({ name: item.name, role: item.role, ...(rules && { rules }) });
+ };
+
+ /**
+ * Add a typed speaker to the agenda and make them current.
+ *
+ * Typing a name used to change only the label above the timer — nothing was
+ * recorded, and the organizer had no way to tell. Adding them puts the meeting
+ * back on a running order, so finishing can advance to whoever is next.
+ *
+ * Shared by the stage picker and the panel field, which is the point: the two
+ * places you can type a name should not disagree about what typing one means.
+ */
+ const handleAddSpeaker = (name) => {
+ addToAgenda({ name, role: selectedRole }, { activate: true });
+ setSpeakerName(name);
+ showToast(`Added ${name} to the agenda`, 'success');
+ (window.requestIdleCallback || setTimeout)(() => trackEvent('stage_speaker_added', {
+ role: selectedRole,
+ overlay_mode: overlayMode,
+ }));
+ };
+
+ /** The stage picker's agenda list. A row that needs a role stays where it is. */
+ const handleSelectAgendaSpeaker = (id) => {
+ const item = agenda.find((entry) => entry.id === id);
+ if (item && needsRole(item)) {
+ askForRole(item);
+ return;
+ }
+ loadSpeakerFromAgenda(id);
+ };
+
+ /** Fix a name on the agenda without losing the speaker's place in it. */
+ const handleRenameSpeaker = (id, name) => {
+ renameAgendaSpeaker(id, name);
+ setSpeakerName(name);
+ showToast(`Renamed to ${name}`, 'success');
+ };
+
+ const handleRoleChange = (role) => {
+ const previousRole = selectedRole;
+ setSelectedRole(role);
+ // Always update current speaker, even if name is empty. Custom and Break
+ // carry their rules on the speaker: Custom's are edited by hand, Break's
+ // are derived from the length picked below.
+ const rules =
+ role === 'Custom' ? customRules : role === BREAK_ROLE ? deriveBreakRules(breakSeconds) : undefined;
+ // A break called mid-agenda interrupts the running order, it does not
+ // stand in for whoever was loaded: detach so finishing the break neither
+ // completes that speaker's slot nor advances past them.
+ if (role === BREAK_ROLE) clearActiveSpeaker();
+ setCurrentSpeaker({
+ name: speakerName || '',
+ role,
+ ...(rules && { rules }),
+ });
+ // Track role change
+ if (previousRole !== role) {
+ (window.requestIdleCallback || setTimeout)(() => trackEvent('speaker_role_changed', {
+ previous_role: previousRole,
+ new_role: role,
+ speaker_name: speakerName || 'Unnamed'
+ }));
+ }
+ };
+
+ const handleCustomRuleChange = (field, value) => {
+ const newRules = { ...customRules, [field]: value };
+ setCustomRules(newRules);
+
+ // If Custom is selected, update immediately (name is optional)
+ if (selectedRole === 'Custom') {
+ setCurrentSpeaker({
+ name: speakerName || '',
+ role: 'Custom',
+ rules: newRules,
+ });
+ }
+ };
+
+ // Helper to format seconds as readable time
+ const formatTimeReadable = (seconds) => {
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ if (mins === 0) {
+ return `${secs} second${secs !== 1 ? 's' : ''}`;
+ }
+ if (secs === 0) {
+ return `${mins} minute${mins !== 1 ? 's' : ''}`;
+ }
+ return `${mins} min ${secs} sec`;
+ };
+
+ const roleExplanation = useMemo(() => {
+ const rules = roleRules[selectedRole] || DEFAULT_ROLE_RULES[selectedRole] || DEFAULT_ROLE_RULES['Standard Speech'];
+ return `Green: ${formatTimeReadable(rules.green)}, Yellow: ${formatTimeReadable(rules.yellow)}, Red: ${formatTimeReadable(rules.red)}`;
+ }, [selectedRole, roleRules]);
+
+ // Spelled in time-left terms, because that is what the countdown shows.
+ const breakExplanation = useMemo(() => {
+ const rules = deriveBreakRules(breakSeconds);
+ return `Counts down from ${formatTimeReadable(rules.red)}. Green when ${formatTimeReadable(rules.red - rules.green)} left, yellow when ${formatTimeReadable(rules.red - rules.yellow)} left, red when time is up.`;
+ }, [breakSeconds]);
+
+ // Just the state: the overlay effect pushes the swatch and, when it is toggled
+ // back off, takes it down again. Pushing from here as well used to fight that
+ // effect, which reapplied the timer's own status a render later.
+ const handlePreviewColor = (color) => {
+ setPreviewColor((held) => (held === color ? null : color));
+ };
+
+ /** Pick a break length; the phase thresholds re-derive from it. */
+ const handleBreakDurationChange = (seconds) => {
+ setBreakSeconds(seconds);
+ setCurrentSpeaker({
+ name: speakerName || '',
+ role: BREAK_ROLE,
+ rules: deriveBreakRules(seconds),
+ });
+ (window.requestIdleCallback || setTimeout)(() => trackEvent('break_duration_changed', {
+ seconds,
+ }));
+ };
+
+ const handleStart = () => {
+ // Speaker name is optional, but validate custom rules if Custom role is selected
+ if (selectedRole === 'Custom') {
+ // Validate custom rules
+ if (customRules.green <= 0 || customRules.yellow <= customRules.green || customRules.red <= customRules.yellow) {
+ showToast('Invalid timing rules. Green must be > 0, Yellow must be > Green, and Red must be > Yellow.', 'error');
+ return;
+ }
+ }
+ // Ensure current speaker is set with correct rules: hand-edited for
+ // Custom, derived from the picked length for Break, the role's own
+ // otherwise.
+ const rules =
+ selectedRole === 'Custom'
+ ? customRules
+ : selectedRole === BREAK_ROLE
+ ? deriveBreakRules(breakSeconds)
+ : roleRules[selectedRole];
+ if (!rules) {
+ showToast('Please set timing rules first', 'warning');
+ return;
+ }
+
+ // Always set current speaker before starting timer
+ setCurrentSpeaker({
+ name: speakerName || '',
+ role: selectedRole,
+ ...((selectedRole === 'Custom' || selectedRole === BREAK_ROLE) && { rules }),
+ });
+
+ startTimer();
+
+ // Track timer started event. audience_size is the meeting's head count
+ // read at START (people join after the app opens); it is left out when
+ // Zoom withholds the list (non-host), there's no SDK, or the read fails.
+ (window.requestIdleCallback || setTimeout)(() => {
+ getZoomParticipants()
+ .catch(() => ({ participants: [], restricted: true }))
+ .then(({ participants, restricted }) => trackEvent('timer_started', {
+ speaker_name: speakerName || 'Unnamed',
+ role: selectedRole,
+ timing_rules: {
+ green: rules.green,
+ yellow: rules.yellow,
+ red: rules.red
+ },
+ ...(!restricted && participants.length > 0 && { audience_size: participants.length }),
+ }));
+ });
+ };
+
+ const handleContinue = () => {
+ // Continue is the same as start - it resumes the timer
+ startTimer();
+ // Track timer continued (resumed)
+ (window.requestIdleCallback || setTimeout)(() => trackEvent('timer_continued', {
+ elapsed_time: elapsedTime
+ }));
+ };
+
+ const handleStop = () => {
+ stopTimer();
+ // Track timer stopped
+ (window.requestIdleCallback || setTimeout)(() => trackEvent('timer_stopped', {
+ elapsed_time: elapsedTime,
+ final_status: currentStatus,
+ speaker_name: currentSpeaker?.name || 'Unnamed',
+ role: currentSpeaker?.role || 'Unknown'
+ }));
+ };
+
+ /**
+ * Put the timer back to zero, and put the tile back to whatever this organizer
+ * has said idle should look like.
+ *
+ * Undoing a speech that should not have been started has to undo what the
+ * meeting could see of it, so RESET has always owned the tile as well as the
+ * clock. What it must not own is which of the two idle states that means. It
+ * used to strip the tile outright, on the grounds that the reveal-when-idle
+ * preference governs the automatic reveals between speeches and not a button
+ * press — but the organizer who turned that preference off did so to keep the
+ * color up for the whole meeting, and stripping their tile hands them the one
+ * thing they opted out of. So both halves are asked here:
+ *
+ * - Reveals on: the video comes back whole, their own background included.
+ * This is the eraser's path, in silence, and it costs the same confirmation
+ * dialog in Timer + Camera.
+ * - Reveals off: the tile returns to the blue card. Unconditionally, unlike
+ * resetTimer's own handling — which is why the video is skipped there and
+ * decided here. That gate exists because resetTimer also runs on every
+ * speaker and role change, where pushing an overlay nothing is displaying
+ * would spend a multi-MB bridge transfer for no visible effect. A button
+ * press is exactly the ask it withholds it for: after the eraser, or before
+ * the meeting's first speech, RESET is how the card comes back.
+ */
+ const handleReset = async () => {
+ const previousElapsedTime = elapsedTime;
+ const previousStatus = currentStatus;
+ // Either branch below owns the video, so resetTimer must not also reach for
+ // it: two removals in flight at once is a confirmation dialog for a
+ // background that has already gone.
+ resetTimer({ skipVideo: true });
+ setSpeakerName('');
+ // Track timer reset
+ (window.requestIdleCallback || setTimeout)(() => trackEvent('timer_reset', {
+ previous_elapsed_time: previousElapsedTime,
+ previous_status: previousStatus,
+ reveal_face_when_idle: revealFaceWhenIdle
+ }));
+ if (!revealFaceWhenIdle) {
+ // A held swatch outranks the timer's own status everywhere the color is
+ // decided, so a preview left up would paint straight back over the card.
+ // RESET is a return to the starting state; a swatch does not survive it.
+ setPreviewColor(null);
+ if (isVideoOverlayMode(overlayMode)) {
+ addDebugLog('Reset with reveal-when-idle off: returning the tile to blue', 'info');
+ applyOverlay(getBackgroundUrl('blue'));
+ }
+ return;
+ }
+ // Silent when it works: the timer reading 00:00 with the tile back to normal
+ // is its own confirmation, and RESET is pressed often enough that a toast per
+ // press is noise. Anything that went wrong, or that the organizer has to fix
+ // in Zoom themselves, is still reported.
+ await clearTimerFromVideo({ announceSuccess: false });
+ };
+
+ // Says what RESET will do to the video, because that half of it now depends on
+ // a preference set two clicks away in the mode menu — and the two outcomes are
+ // opposites. Stage modes leave the camera alone entirely, so they get neither
+ // promise.
+ const resetTooltip = isVideoOverlayMode(overlayMode)
+ ? `Reset the timer to 00:00, clear the current speaker, and ${
+ revealFaceWhenIdle ? 'hand your own background back' : 'put the blue timer card back on your video'
+ }`
+ : 'Reset the timer to 00:00 and clear the current speaker';
+
+ const handleFinish = () => {
+ const currentAgendaId = activeSpeakerId;
+ // Captured before finishing resets the speaker: a break ending is the
+ // meeting pausing, not advancing, so the next speaker is never auto-loaded
+ // off the back of one — the organizer resumes the agenda when the room is
+ // back.
+ const wasBreak = Boolean(currentSpeaker?.rules?.countdown);
+ finishCurrentSpeech();
+
+ // Auto-load next uncompleted speaker from agenda
+ if (currentAgendaId && !wasBreak) {
+ const currentIndex = agenda.findIndex(item => item.id === currentAgendaId);
+ const nextSpeaker = agenda.slice(currentIndex + 1).find(item => !item.completed);
+ // Next up has no role the organizer picked (#91): leave them unloaded
+ // rather than time them under whatever role is showing, and say why the
+ // running order stopped. Not skipped either: the order is the club's.
+ if (nextSpeaker && needsRole(nextSpeaker)) {
+ askForRole(nextSpeaker);
+ } else if (nextSpeaker) {
+ loadSpeakerFromAgenda(nextSpeaker.id);
+ setSpeakerName(nextSpeaker.name || '');
+ setSelectedRole(nextSpeaker.role);
+ return;
+ }
+ }
+ // Nobody loaded next — off the agenda entirely, the last one on it, or a
+ // next speaker still waiting for a role. Clear the field rather than leaving
+ // the speaker who just finished sitting there, which reads as though nothing
+ // happened. On the stage this is all the organizer sees, so a stale name is
+ // the whole display being wrong.
+ setSpeakerName('');
+ };
+
+ /**
+ * Strip everything the app has put on the organizer's video — the timer card
+ * and any virtual background of ours — and leave the camera as Zoom found it.
+ *
+ * The escape hatch for the state nobody can talk their way out of: a color
+ * stuck on your tile, usually because a previous session ended without
+ * unwinding, or because a removal was refused. Always available, whatever the
+ * mode.
+ *
+ * A one-shot affair. Any preview swatch is dropped, and the overlay effect is
+ * told to sit out the render this causes, so the color it would otherwise push
+ * straight back does not undo the clear. It stays off until the next thing that
+ * legitimately wants a color on the tile — starting a speech, a status change,
+ * a mode switch. Notably it does not touch the display preference: pressing
+ * clear is a one-time act, not a decision to reveal after every speech.
+ *
+ * Shared with RESET, which owes the organizer the same thing: the two must not
+ * disagree about what taking the timer off the video means, or about what to
+ * say when Zoom will not do it.
+ *
+ * @param {{announceSuccess?: boolean}} [options] - announceSuccess false keeps
+ * quiet when it worked, for a caller whose own effect is already visible.
+ */
+ const clearTimerFromVideo = async ({ announceSuccess = true } = {}) => {
+ setIsClearingVideo(true);
+ setPreviewColor(null);
+ setClearGeneration((n) => n + 1);
+ try {
+ addDebugLog('Clearing all filters and backgrounds', 'info');
+ const { ok, declined, ungranted, lostBackground } = await clearVideoPipelines();
+ if (!sdkStatus?.available) {
+ // Outside Zoom every SDK call is a no-op, so there is nothing to report.
+ } else if (ungranted?.length) {
+ // No retry will ever work: this Zoom client never granted the removal
+ // API, so the only way out is Zoom's own panel. Saying "check your
+ // settings" for this used to read as "you configured something wrong".
+ showToast(
+ `This Zoom client won't let the app remove your ${ungranted.join(' or ')}. Clear it from Zoom's Background & Effects panel.`,
+ 'error',
+ 8000
+ );
+ } else if (declined) {
+ showToast('Zoom needs your confirmation to remove the background — nothing was changed.', 'info', 5000);
+ } else if (!ok) {
+ showToast('Zoom would not clear your video. Check Zoom\'s own background and filter settings.', 'error', 6000);
+ } else if (lostBackground) {
+ // The app does put a saved background back, via getVirtualBackgroundData.
+ // This is the narrower case left over: the client refused those pixels,
+ // or no longer knows the id. Say so rather than leaving them to notice
+ // their own image is gone. Said even when the caller wanted silence:
+ // losing your own background is not a success.
+ showToast('Cleared the timer. Zoom wouldn\'t give your own background back — pick it again in Background & Effects.', 'info', 7000);
+ } else if (announceSuccess) {
+ showToast('Cleared the timer from your video.', 'success');
+ }
+ } finally {
+ setIsClearingVideo(false);
+ }
+ };
+
+ /** The eraser button. Tracked separately from the resets that clear as well. */
+ const handleClearVideo = async () => {
+ await clearTimerFromVideo();
+ (window.requestIdleCallback || setTimeout)(() => trackEvent('video_cleared', {
+ overlay_mode: overlayMode,
+ current_timer_status: currentStatus,
+ }));
+ };
+
+ // Switching modes can no longer be refused: opening the stage asks the client
+ // for nothing, so there is no failure to roll back from. Sharing and popping
+ // out can still be refused, and they say so at the point they are pressed.
+ const handleModeSwitch = async (newMode) => {
+ if (newMode === overlayMode) return;
+ setOverlayModeLocal(newMode);
+ saveOverlayMode(newMode);
+ // Carry the color into the new mode only if something should be showing:
+ // mid-speech, or while a preview swatch is held up. Passing it
+ // unconditionally is what used to paint an idle organizer blue on arrival.
+ await setOverlayMode(newMode, wantsColorNow ? getBackgroundUrl(desiredStatus) : null);
+ // Leaving the stage stops its share and docks its window; reflect that.
+ if (newMode !== OVERLAY_MODE_STAGE) {
+ setIsSharing(false);
+ setIsPoppedOut(false);
+ }
+ (window.requestIdleCallback || setTimeout)(() => trackEvent('overlay_mode_switched', { new_mode: newMode }));
+ };
+
+ /**
+ * Share the stage into the meeting, or stop. The one control here the whole
+ * meeting can see, so it is always a deliberate press — never a consequence of
+ * choosing a mode, and never restored from a saved preference.
+ */
+ const handleToggleShare = async () => {
+ const next = !isSharing;
+ setIsSharing(next);
+ const accepted = await setAppShare(next);
+ if (!accepted && sdkStatus?.available) {
+ setIsSharing(isAppShareActive());
+ showToast(
+ next
+ ? 'Zoom would not start the share. Someone else may be sharing already.'
+ : 'Zoom would not stop the share. Try the Stop Share button in Zoom.',
+ 'error',
+ 6000
+ );
+ return;
+ }
+ (window.requestIdleCallback || setTimeout)(() => trackEvent('stage_share_toggled', { sharing: next }));
+ };
+
+ /**
+ * Undock the stage into its own window, or merge it back. Local to the
+ * organizer's machine either way: the meeting sees no difference.
+ */
+ const handleTogglePopout = async () => {
+ const next = !isPoppedOut;
+ setIsPoppedOut(next);
+ const accepted = await setAppPopout(next);
+ if (!accepted && sdkStatus?.available) {
+ setIsPoppedOut(isAppPoppedOut());
+ showToast('This Zoom client cannot open the timer in its own window.', 'error');
+ return;
+ }
+ (window.requestIdleCallback || setTimeout)(() => trackEvent('stage_popout_toggled', { poppedOut: next }));
+ };
+
+ const handleToggleRevealFaceWhenIdle = () => {
+ const reveal = !revealFaceWhenIdle;
+ setRevealFaceWhenIdle(reveal);
+ saveRevealFaceWhenIdle(reveal);
+ (window.requestIdleCallback || setTimeout)(() => trackEvent('reveal_face_when_idle_toggled', { reveal }));
+ };
+
+ const handleToggleStageClock = () => {
+ const hidden = !stageClockHidden;
+ setStageClockHidden(hidden);
+ saveStageClockHidden(hidden);
+ (window.requestIdleCallback || setTimeout)(() => trackEvent('stage_clock_toggled', { hidden }));
+ };
+
+ const handleTurnVideoOn = async () => {
+ setIsEnablingVideo(true);
+ try {
+ await setVideoState(true);
+ // Wait a moment for the state to update
+ await new Promise(resolve => setTimeout(resolve, 500));
+ // Re-check video state
+ const isVideoOn = await getVideoState();
+ setVideoStateLocal(isVideoOn);
+ if (!isVideoOn) {
+ showToast('Failed to turn video on. Please turn on video manually in Zoom.', 'error');
+ }
+ } catch (error) {
+ console.error('Failed to turn video on:', error);
+ showToast('Failed to turn video on. Please turn on video manually in Zoom.', 'error');
+ } finally {
+ setIsEnablingVideo(false);
+ }
+ };
+
+ return {
+ isRunning,
+ elapsedTime,
+ currentStatus,
+ currentSpeaker,
+ roleOptions,
+ agenda,
+ activeSpeakerId,
+ speakerName,
+ selectedRole,
+ customRules,
+ breakSeconds,
+ timeInputMode,
+ setTimeInputMode,
+ showEditRulesModal,
+ setShowEditRulesModal,
+ showCardImagesModal,
+ setShowCardImagesModal,
+ showSoundsModal,
+ setShowSoundsModal,
+ soundsLockReason,
+ setCardImagesGeneration,
+ videoState,
+ isClearingVideo,
+ isSharing,
+ isPoppedOut,
+ isEnablingVideo,
+ previewColor,
+ stageClockHidden,
+ revealFaceWhenIdle,
+ overlayMode,
+ readoutPosition,
+ readoutVisible,
+ clubKit,
+ badgePlacement,
+ badgeOnCards,
+ handleBadgePlacementChange,
+ handleAdjustBadgeScale,
+ handleToggleBadgeVisible,
+ handleResetBadgePlacement,
+ handleReadoutPositionChange,
+ handleAdjustReadoutScale,
+ handleToggleReadoutVisible,
+ wantsColorNow,
+ desiredStatus,
+ debugPanelExpanded,
+ sdkStatus,
+ lastError,
+ debugLogs,
+ setDebugLogs,
+ canPopout,
+ sdkHealthy,
+ toggleDebugPanel,
+ handleSpeakerChange,
+ handleSelectSuggestion,
+ handleAddSpeaker,
+ handleSelectAgendaSpeaker,
+ handleRenameSpeaker,
+ handleRoleChange,
+ handleCustomRuleChange,
+ roleExplanation,
+ breakExplanation,
+ handlePreviewColor,
+ handleBreakDurationChange,
+ handleStart,
+ handleContinue,
+ handleStop,
+ handleReset,
+ resetTooltip,
+ handleFinish,
+ handleClearVideo,
+ handleModeSwitch,
+ handleToggleShare,
+ handleTogglePopout,
+ handleToggleRevealFaceWhenIdle,
+ handleToggleStageClock,
+ handleTurnVideoOn,
+ };
+}
diff --git a/apps/zoom-app/src/hooks/useLiveTimer.test.jsx b/apps/zoom-app/src/hooks/useLiveTimer.test.jsx
new file mode 100644
index 0000000..25cc5c7
--- /dev/null
+++ b/apps/zoom-app/src/hooks/useLiveTimer.test.jsx
@@ -0,0 +1,113 @@
+import { renderHook, act } from '@testing-library/react';
+import { ToastProvider } from '../context/ToastContext';
+import { TimerProvider } from '../context/TimerContext';
+import { useLiveTimer } from './useLiveTimer';
+import { applyOverlay, clearVideoPipelines, isOverlayActive } from '../utils/zoomSdk';
+
+// 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(() => true),
+ 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 }),
+ OVERLAY_MODE_CARD: 'card',
+ OVERLAY_MODE_CAMERA: 'camera',
+ OVERLAY_MODE_STAGE: 'stage',
+}));
+vi.mock('../utils/posthog', () => ({ trackEvent: vi.fn() }));
+
+const wrapper = ({ children }) => (
+
+ {children}
+
+);
+
+/**
+ * LiveTab.test.jsx proves the hook through today's panel. This proves the part
+ * the redesigned panel relies on: a view with no markup of LiveTab's gets the
+ * same timer and the same video behaviour from the hook alone.
+ */
+describe('useLiveTimer', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ vi.clearAllMocks();
+ // Reveal between speeches, so idle means "nothing of ours on the video".
+ localStorage.setItem('toastmaster_reveal_face_when_idle', 'true');
+ });
+
+ it('runs the timer for any view that calls it', () => {
+ vi.useFakeTimers();
+ try {
+ const { result } = renderHook(() => useLiveTimer(), { wrapper });
+ expect(result.current.isRunning).toBe(false);
+
+ act(() => result.current.handleStart());
+ expect(result.current.isRunning).toBe(true);
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
+ expect(result.current.elapsedTime).toBeGreaterThanOrEqual(2);
+
+ act(() => result.current.handleStop());
+ expect(result.current.isRunning).toBe(false);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('owns the video: the colour goes on at Start, and comes off when a reset ends the speech', async () => {
+ const { result } = renderHook(() => useLiveTimer(), { wrapper });
+ expect(applyOverlay).not.toHaveBeenCalled();
+
+ act(() => result.current.handleStart());
+ expect(applyOverlay).toHaveBeenCalledWith('/backgrounds/blue.png');
+
+ act(() => result.current.handleStop());
+ isOverlayActive.mockReturnValue(true);
+ await act(async () => {
+ await result.current.handleReset();
+ });
+ expect(result.current.elapsedTime).toBe(0);
+ // Reveals on: Reset hands the video back through the same path as the eraser.
+ expect(clearVideoPipelines).toHaveBeenCalledTimes(1);
+ });
+
+ it('hands both looks the same set of controls', () => {
+ const { result } = renderHook(() => useLiveTimer(), { wrapper });
+ for (const handler of [
+ 'handleStart', 'handleStop', 'handleContinue', 'handleReset', 'handleFinish',
+ 'handleSpeakerChange', 'handleRoleChange', 'handleSelectSuggestion', 'handleAddSpeaker',
+ 'handleModeSwitch', 'handleClearVideo', 'handlePreviewColor', 'handleTurnVideoOn',
+ 'handleReadoutPositionChange', 'handleBadgePlacementChange', 'handleToggleRevealFaceWhenIdle',
+ ]) {
+ expect(typeof result.current[handler], handler).toBe('function');
+ }
+ });
+});
diff --git a/apps/zoom-app/src/hooks/useNewLook.js b/apps/zoom-app/src/hooks/useNewLook.js
new file mode 100644
index 0000000..2960247
--- /dev/null
+++ b/apps/zoom-app/src/hooks/useNewLook.js
@@ -0,0 +1,43 @@
+import { useEffect, useState } from 'react';
+import { useFlag } from './useFlag';
+
+/**
+ * Device-local memory of the last `new_look` answer. Plain localStorage, not
+ * the shared storage helpers: this is a rendering hint for this device, never
+ * something to sync.
+ */
+export const NEW_LOOK_LAST_KEY = 'toastmaster_new_look_last';
+
+function readLastAnswer() {
+ try {
+ return localStorage.getItem(NEW_LOOK_LAST_KEY) === '1';
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Whether to draw the redesigned panel (design B).
+ *
+ * Flags arrive with the session response, a moment after the first paint. Until
+ * then this answers what the server said last time on this device, so someone
+ * on the new look does not see the old panel flash up at every open. A device
+ * that has never had an answer starts on today's panel.
+ *
+ * @returns {boolean}
+ */
+export function useNewLook() {
+ const { enabled, known } = useFlag('new_look');
+ const [lastAnswer] = useState(readLastAnswer);
+
+ useEffect(() => {
+ if (!known) return;
+ try {
+ localStorage.setItem(NEW_LOOK_LAST_KEY, enabled ? '1' : '0');
+ } catch {
+ // Storage refused: the next open starts on today's panel until flags land.
+ }
+ }, [known, enabled]);
+
+ return known ? enabled : lastAnswer;
+}
diff --git a/apps/zoom-app/src/hooks/useNewLook.test.js b/apps/zoom-app/src/hooks/useNewLook.test.js
new file mode 100644
index 0000000..f9487c7
--- /dev/null
+++ b/apps/zoom-app/src/hooks/useNewLook.test.js
@@ -0,0 +1,46 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { resetFlagsForTests, setFlags } from '@toastmaster-timer/shared';
+import { useNewLook, NEW_LOOK_LAST_KEY } from './useNewLook';
+
+describe('useNewLook', () => {
+ beforeEach(() => {
+ resetFlagsForTests();
+ localStorage.clear();
+ });
+
+ it('starts on today\'s panel on a device that has never had an answer', () => {
+ const { result } = renderHook(() => useNewLook());
+ expect(result.current).toBe(false);
+ });
+
+ it('follows the server once flags land, and remembers the answer', () => {
+ const { result } = renderHook(() => useNewLook());
+ act(() => {
+ setFlags({ new_look: true });
+ });
+ expect(result.current).toBe(true);
+ expect(localStorage.getItem(NEW_LOOK_LAST_KEY)).toBe('1');
+
+ act(() => {
+ setFlags({ new_look: false });
+ });
+ expect(result.current).toBe(false);
+ expect(localStorage.getItem(NEW_LOOK_LAST_KEY)).toBe('0');
+ });
+
+ it('answers the remembered value while flags are unknown, so the new look does not flash the old one', () => {
+ localStorage.setItem(NEW_LOOK_LAST_KEY, '1');
+ const { result } = renderHook(() => useNewLook());
+ expect(result.current).toBe(true);
+ });
+
+ it('lets the server overrule a remembered answer', () => {
+ localStorage.setItem(NEW_LOOK_LAST_KEY, '1');
+ const { result } = renderHook(() => useNewLook());
+ act(() => {
+ setFlags({});
+ });
+ expect(result.current).toBe(false);
+ });
+});
diff --git a/docs/FEATURE_FLAGS.md b/docs/FEATURE_FLAGS.md
index 88c2d9d..f678072 100644
--- a/docs/FEATURE_FLAGS.md
+++ b/docs/FEATURE_FLAGS.md
@@ -164,11 +164,19 @@ declared in both blocks, because `vars` are not inherited.
|---|---|
| `"1"` | Every declared flag on. No PostHog request. |
| `"0"` | Every declared flag off. No PostHog request. |
+| `"=1"` / `"=0"` | That one flag on / off; every other flag asked of PostHog as usual. |
+| `"1,new_look=0"` | A base and per-key entries, comma-separated, read left to right: here every flag on except `new_look`. No PostHog request. |
| `""` or unset | Ask PostHog (or the fallbacks, if `POSTHOG_API_KEY` is missing). |
+An unknown key or a malformed entry forces nothing. Per-key entries let a test run
+pick the mix production has, for example `"1,pro=0"` (Pro still dark, everything
+else on), or keep the shipped panel while `new_look` is on elsewhere
+(`"1,new_look=0"`). `worker/flags.test.js` fails if production's value forces any
+flag on.
+
| Where | Value | Why |
|---|---|---|
-| Production (top-level `vars`) | `""` | Always asks PostHog. `worker/flags.test.js` fails if this is ever `"1"`, which would light up every dark feature at once. |
+| Production (top-level `vars`) | `""` | Always asks PostHog. `worker/flags.test.js` fails if this ever forces a flag on (`"1"`, or any `=1`), which would light dark features up for everyone. |
| Dev (`env.dev.vars`) | `"1"` | Dev works with everything on and spends none of the free quota. Set `"0"` to see the dark path, or `""` to test the real PostHog round trip. Restore `"1"` and redeploy afterwards. |
| Local (`npm run cf:dev`) | set in `.dev.vars` | `wrangler dev` runs **without** `--env dev`, so it reads the production block (`""`). With no `POSTHOG_API_KEY` in `.dev.vars` every flag is at its fallback, so every gated feature is hidden. Put `FLAGS_FORCE=1` in `.dev.vars` to see them, or `FLAGS_FORCE=0` to check the dark path. `.dev.vars` overrides `vars`. |
diff --git a/e2e/zoom-client/INDEX.md b/e2e/zoom-client/INDEX.md
index 113ba4d..db43e97 100644
--- a/e2e/zoom-client/INDEX.md
+++ b/e2e/zoom-client/INDEX.md
@@ -4,17 +4,17 @@ Read this first. Update it in the same PR as any spec change.
| Spec | Status | Verified | Covers |
|---|---|---|---|
-| [timer-core](timer-core.md) | current | 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 |
+| [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 |
| [agenda-import](agenda-import.md) | current | b029a29, 2026-11-13 | Simple Format import takes exact role names from the bracket text and highlights the rest for a manual pick; imported and picked roles' thresholds on real video (#77, #91) |
| [zoom-deeplink](zoom-deeplink.md) | draft | never | "Open in Zoom" deeplink resolves and launches this build's app (dev vs production client id) |
| auth-identity | **not migrated** | — | source: `docs/ZOOM_TEST_PLAN.md` Steps 1, 1b |
| [connection-notice](connection-notice.md) | draft | never | guest-mode banner and "Approve Toastmusters Timer in Zoom" modal at open; raised mid-session when Zoom drops the grant, modal held until the timer stops (#85) |
| [reauth-landing](reauth-landing.md) | stale | never | install landing: `tt_approved` mark, `?welcome=new` / `?welcome=back`, "You're reconnected" and Open in Zoom; a marked browser's bare `/` redirects to `/reconnect` (`/?home` and the `zoom.` host do not); where Zoom lands a guest after approving (#96) |
| [contact-capture](contact-capture.md) | draft | never | Zoom email/name capture: authorize, "Stay in touch" card, browser and web sign-in doors, purge on uninstall (#83) |
-| [timer-sounds](timer-sounds.md) | stale | never | Pro sounds: card-change chime (1/2/3 notes), break music during Take a Break, locked for free plans ("Hold tight! Sounds are coming soon." while `pro` is off), audible through Share sound (#105). TS-09 ran green on 60e1b6e (2026-11-14); TS-01 needs a free device (the test device is on club Pro), TS-08 needs per-uid PostHog flags; audio steps never run |
+| [timer-sounds](timer-sounds.md) | stale | never | Pro sounds: card-change chime (1/2/3 notes), break music during Take a Break, locked for free plans ("Hold tight! Sounds are coming soon." while `pro` is off), audible through Share sound (#105). TS-09 ran green on 60e1b6e (2026-11-14); TS-01 needs a free device (the test device is on club Pro), TS-08 needs per-uid PostHog flags; audio steps never run. Plan 13 Phase 0 moved the modal's body into `SoundsPanel` (rerun pending) |
| [pro-launch](pro-launch.md) | stale | eeaa840, 2026-11-13 | one-time "New: Pro for your club" window after FINISH for free devices once `pro` is on; hand-off to the Upgrade dialog; red dot on the footer's Upgrade button until Pro has been looked at. Stale: the Sounds line (ffc3a37) and the merge with dev's locked Pro button (pro-coming-soon) came after the last run; PL-01's free-device half and PL-04 (Free account) not run yet |
-| [announcement-banner](announcement-banner.md) | current | cb49176, 2026-11-14 | one-line announcement above the tabs, picked from `ANNOUNCEMENTS` (packages/shared/announcements.js); today the pre-launch notice ("Coming soon: a fresh new look… and Pro features") while `pro` is off; a close is kept per device per announcement |
-| [club-card-sets](club-card-sets.md) | stale | 905d8d1, 2026-11-13 | an admin or editor shares a custom card set with the club; another club device downloads it and shows it on real video; own choice kept; members can't publish; console removal; lapse; republish prunes the old image (plan 10 Phase 1). CC-07 and CC-08 not run on 905d8d1, and CC-06's member 403 never yet (a guest got 401). Stale since 2026-11-14: covered files changed for the 60-day reminder (#147) and the pro gate (club-pro-gate); rerun before the next release |
+| [announcement-banner](announcement-banner.md) | stale | cb49176, 2026-11-14 | one-line announcement above the tabs, picked from `ANNOUNCEMENTS` (packages/shared/announcements.js); today the pre-launch notice ("Coming soon: a fresh new look… and Pro features") while `pro` is off; a close is kept per device per announcement. Stale since 2026-11-14: App.jsx now switches shells on `new_look` (plan 13 Phase 0); rerun blocked while the test device is held for #145's CC-10 |
+| [club-card-sets](club-card-sets.md) | stale | 905d8d1, 2026-11-13 | an admin or editor shares a custom card set with the club; another club device downloads it and shows it on real video; own choice kept; members can't publish; console removal; lapse; republish prunes the old image (plan 10 Phase 1). CC-07 and CC-08 not run on 905d8d1, and CC-06's member 403 never yet (a guest got 401). Stale since 2026-11-14: covered files changed for the 60-day reminder (#147) and the pro gate (club-pro-gate), and plan 13 Phase 0 moved the picker into `CardSetsPanel`; rerun before the next release |
| [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 |
diff --git a/e2e/zoom-client/announcement-banner.md b/e2e/zoom-client/announcement-banner.md
index 327f635..271067f 100644
--- a/e2e/zoom-client/announcement-banner.md
+++ b/e2e/zoom-client/announcement-banner.md
@@ -1,7 +1,7 @@
---
id: announcement-banner
title: Announcement banner (currently the pre-launch notice)
-status: current
+status: stale
covers:
- packages/shared/announcements.js
- packages/ui/AnnouncementBanner.jsx
@@ -71,3 +71,7 @@ Evidence: `localStorage.toastmaster_announcements_dismissed` holds its id; PostH
yet; unit and e2e tests cover them. Run AB-02 against the first entry that does.
- What replaces the pre-launch notice at launch: the one-time Pro announcement, on its own
branch (`feat/pro-launch-announcement`) with its own spec.
+- Design B's panel (`new_look` on, plan 13). App.jsx now picks between today's panel and
+ NewLookShell; until design B's own shell lands, NewLookShell is today's panel, so these
+ steps hold either way. Run them with `new_look` off (`FLAGS_FORCE "1,new_look=0"` or
+ `"0"`); design B's banner placement gets its own steps with its shell.
diff --git a/e2e/zoom-client/club-card-sets.md b/e2e/zoom-client/club-card-sets.md
index a622ac6..a035414 100644
--- a/e2e/zoom-client/club-card-sets.md
+++ b/e2e/zoom-client/club-card-sets.md
@@ -8,7 +8,9 @@ covers:
- packages/shared/cardImages.js
- packages/shared/club.js
- apps/zoom-app/src/components/CardImagesModal.jsx
+ - apps/zoom-app/src/components/CardSetsPanel.jsx
- apps/zoom-app/src/components/LiveTab.jsx
+ - apps/zoom-app/src/hooks/useLiveTimer.js
- apps/zoom-app/src/main.jsx
automated:
- worker/club-cards.test.js
diff --git a/e2e/zoom-client/timer-core.md b/e2e/zoom-client/timer-core.md
index 63a3ac8..de7c5f1 100644
--- a/e2e/zoom-client/timer-core.md
+++ b/e2e/zoom-client/timer-core.md
@@ -1,16 +1,18 @@
---
id: timer-core
title: Core timer in the Zoom client
-status: current
+status: stale
covers:
- apps/zoom-app/src/context/TimerContext.jsx
- apps/zoom-app/src/components/LiveTab.jsx
+ - apps/zoom-app/src/hooks/useLiveTimer.js
- apps/zoom-app/src/utils/zoomSdk.js
- packages/shared/clubBadge.js
automated:
- e2e/zoom.timer-flow.spec.js
- apps/zoom-app/src/utils/zoomSdk.test.js
- apps/zoom-app/src/components/LiveTab.test.jsx
+ - apps/zoom-app/src/hooks/useLiveTimer.test.jsx
needs:
- dev deployed
- camera on, in a meeting
diff --git a/e2e/zoom-client/timer-sounds.md b/e2e/zoom-client/timer-sounds.md
index a5b3bfc..d11f5ba 100644
--- a/e2e/zoom-client/timer-sounds.md
+++ b/e2e/zoom-client/timer-sounds.md
@@ -6,9 +6,11 @@ covers:
- packages/shared/timerSounds.js
- apps/zoom-app/src/components/TimerSounds.jsx
- apps/zoom-app/src/components/SoundsModal.jsx
+ - apps/zoom-app/src/components/SoundsPanel.jsx
- apps/zoom-app/src/hooks/useSounds.js
- apps/zoom-app/src/hooks/useEntitlement.js
- apps/zoom-app/src/components/LiveTab.jsx
+ - apps/zoom-app/src/hooks/useLiveTimer.js
automated:
- packages/shared/__tests__/timerSounds.test.js
- apps/zoom-app/src/components/TimerSounds.test.jsx
diff --git a/worker/flags.js b/worker/flags.js
index c9e0f30..6eb1eec 100644
--- a/worker/flags.js
+++ b/worker/flags.js
@@ -39,6 +39,11 @@ export const FLAG_FALLBACKS = Object.freeze({
// (packages/shared/timerSounds.js).
// removeBy: 2027-03-31
sounds: false,
+ // Design B, the redesigned Zoom side panel (apps/zoom-app/src/b/): one timer
+ // card, a "Look & sound" section of sheets, the ⋯ More menu, no footer. Off,
+ // the panel is the one shipped today.
+ // removeBy: 2027-03-31
+ new_look: false,
});
// The ingestion host, called directly rather than through the browser's
@@ -71,15 +76,41 @@ export function flagCacheKey(distinctId) {
/**
* The per-environment short circuit, set in wrangler.jsonc like
- * ENTITLEMENT_ENFORCE: '1' → every flag on, '0' → every flag off, anything
- * else (including unset) → ask PostHog.
+ * ENTITLEMENT_ENFORCE. A comma-separated list, read left to right:
+ *
+ * "1" / "0" every declared flag on / off, PostHog not asked
+ * "=1|0" that one flag on / off
+ * "1,new_look=0" everything on except new_look
+ * "pro=0" pro off; every other flag asked of PostHog as usual
+ *
+ * Anything else, including unset, an unknown key or a malformed entry, forces
+ * nothing. Per-key entries exist so a test run can pick the mix production has
+ * (one feature on, another still dark) instead of everything at once.
+ *
+ * @returns {{all: boolean|null, keys: Object}}
+ */
+export function parseFlagsForce(env) {
+ const parsed = { all: null, keys: {} };
+ const raw = env?.FLAGS_FORCE;
+ if (typeof raw !== 'string') return parsed;
+ for (const entry of raw.split(',').map((part) => part.trim()).filter(Boolean)) {
+ if (entry === '1' || entry === '0') {
+ parsed.all = entry === '1';
+ continue;
+ }
+ const match = /^([a-z0-9_]+)=([01])$/.exec(entry);
+ if (match && Object.hasOwn(FLAG_FALLBACKS, match[1])) parsed.keys[match[1]] = match[2] === '1';
+ }
+ return parsed;
+}
+
+/**
+ * The all-flags part of FLAGS_FORCE: true, false, or null for "ask PostHog".
*
* @returns {boolean|null}
*/
export function flagOverride(env) {
- if (env?.FLAGS_FORCE === '1') return true;
- if (env?.FLAGS_FORCE === '0') return false;
- return null;
+ return parseFlagsForce(env).all;
}
const allFlags = (value) => Object.fromEntries(Object.keys(FLAG_FALLBACKS).map((key) => [key, value]));
@@ -154,8 +185,19 @@ async function askPostHog(env, distinctId) {
* @returns {Promise>}
*/
export async function resolveFlags(env, { uid } = {}, ctx) {
- const override = flagOverride(env);
- if (override !== null) return allFlags(override);
+ const forced = parseFlagsForce(env);
+ if (forced.all !== null) return withForcedKeys(allFlags(forced.all), forced.keys);
+ const resolved = await resolveUnforced(env, { uid }, ctx);
+ return withForcedKeys(resolved, forced.keys);
+}
+
+/** The flags as resolved, with any FLAGS_FORCE per-key entries laid over them. */
+function withForcedKeys(flags, keys) {
+ return Object.keys(keys).length === 0 ? flags : { ...flags, ...keys };
+}
+
+/** PostHog's answer (cached per distinct id), or the fallbacks. */
+async function resolveUnforced(env, { uid }, ctx) {
if (!env?.POSTHOG_API_KEY) return FLAG_FALLBACKS;
const distinctId = uid ? `zoom:${uid}` : ANONYMOUS_DISTINCT_ID;
diff --git a/worker/flags.test.js b/worker/flags.test.js
index 3031f50..2d4dd0d 100644
--- a/worker/flags.test.js
+++ b/worker/flags.test.js
@@ -8,6 +8,7 @@ import {
ANONYMOUS_DISTINCT_ID,
flagCacheKey,
flagOverride,
+ parseFlagsForce,
resolveFlags,
flagEnabled,
} from './flags.js';
@@ -70,6 +71,47 @@ describe('flagOverride', () => {
});
});
+describe('parseFlagsForce', () => {
+ it('reads a base and per-key entries, left to right', () => {
+ expect(parseFlagsForce({ FLAGS_FORCE: '1,new_look=0' })).toEqual({ all: true, keys: { new_look: false } });
+ expect(parseFlagsForce({ FLAGS_FORCE: ' 0 , pro=1 ' })).toEqual({ all: false, keys: { pro: true } });
+ expect(parseFlagsForce({ FLAGS_FORCE: 'pro=0,sounds=1' })).toEqual({ all: null, keys: { pro: false, sounds: true } });
+ expect(parseFlagsForce({ FLAGS_FORCE: 'pro=1,pro=0' })).toEqual({ all: null, keys: { pro: false } });
+ });
+
+ it('forces nothing for an unknown key or a malformed entry', () => {
+ for (const value of ['nope=1', 'pro=true', 'pro', 'PRO=1', '=1', ',', 'pro = 1']) {
+ expect(parseFlagsForce({ FLAGS_FORCE: value }), value).toEqual({ all: null, keys: {} });
+ }
+ });
+});
+
+describe('FLAGS_FORCE per-key entries', () => {
+ it('lay one flag over an all-on or all-off base, without asking PostHog', async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+
+ expect(await resolveFlags({ ...env, FLAGS_FORCE: '1,new_look=0' }, { uid: 'abc' }, ctx))
+ .toEqual({ ...allOn, new_look: false });
+ expect(await resolveFlags({ ...env, FLAGS_FORCE: '0,sounds=1' }, { uid: 'abc' }, ctx))
+ .toEqual({ ...allOff, sounds: true });
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('without a base, still ask PostHog for every other flag', async () => {
+ vi.stubGlobal('fetch', respondWith(flagsAnswer(allOn)));
+
+ expect(await resolveFlags({ ...env, FLAGS_FORCE: 'pro=0' }, { uid: 'abc' }, ctx))
+ .toEqual({ ...allOn, pro: false });
+ });
+
+ it('apply over the fallbacks when PostHog cannot be asked', async () => {
+ expect(await resolveFlags({ FLAGS_FORCE: 'new_look=1' }, { uid: 'abc' }, ctx))
+ .toEqual({ ...FLAG_FALLBACKS, new_look: true });
+ expect(await flagEnabled({ FLAGS_FORCE: '1,pro=0' }, 'pro')).toBe(false);
+ });
+});
+
describe('resolveFlags', () => {
it('asks PostHog for this user, and returns what it says for declared keys', async () => {
const fetchMock = respondWith(flagsAnswer(allOn));
@@ -851,6 +893,8 @@ describe('the declared flags', () => {
// App-only: the Worker endpoint it feeds is not gated, because the
// browser doors save the same record unflagged.
['contact_capture', [join('apps', 'zoom-app')]],
+ // UI only: design B redraws the Zoom panel and calls no endpoint of its own.
+ ['new_look', [join('apps', 'zoom-app')]],
])('sees the server gate and the UI gates for %s', (key, places) => {
const files = [...(referencedFlags().get(key) ?? [])];
for (const place of places) {
@@ -878,7 +922,10 @@ describe('FLAGS_FORCE in wrangler.jsonc', () => {
expect(config.env.dev.vars).toHaveProperty('FLAGS_FORCE');
});
- it('is never "1" in production, which would light up every dark feature at once', () => {
+ it('never forces a flag on in production, which would light up dark features for everyone', () => {
expect(config.vars.FLAGS_FORCE).not.toBe('1');
+ const forced = parseFlagsForce(config.vars);
+ expect(forced.all).not.toBe(true);
+ expect(Object.values(forced.keys)).not.toContain(true);
});
});