From c36148d497ae96d81c98b3335316900c870694eb Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 17:21:24 -0500 Subject: [PATCH 1/9] feat(flags): declare new_look, and let FLAGS_FORCE force single flags new_look (fallback false) gates design B, the redesigned Zoom panel (plan 13). FLAGS_FORCE now takes per-key entries after an optional base, e.g. "1,new_look=0" or "pro=0", so a dev test run can pick the mix production has instead of every flag at once. The production guard now fails on any flag forced on, not only "1". --- docs/FEATURE_FLAGS.md | 10 +++++++- worker/flags.js | 56 +++++++++++++++++++++++++++++++++++++------ worker/flags.test.js | 49 ++++++++++++++++++++++++++++++++++++- 3 files changed, 106 insertions(+), 9 deletions(-) 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/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); }); }); From 032c4228152e7091675cb8c444e12dc777a69823 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 17:21:24 -0500 Subject: [PATCH 2/9] feat(zoom-app): switch between today's panel and design B on new_look App picks ClassicShell (unchanged) or the lazily loaded NewLookShell. Until flags land, useNewLook answers this device's last answer, so the new look does not flash the old one at every open. NewLookShell is today's panel until Phase 1 fills it in; the flag is off everywhere. --- apps/zoom-app/src/App.jsx | 73 ++++++++++++++-------- apps/zoom-app/src/b/NewLookShell.jsx | 13 ++++ apps/zoom-app/src/hooks/useNewLook.js | 43 +++++++++++++ apps/zoom-app/src/hooks/useNewLook.test.js | 46 ++++++++++++++ 4 files changed, 148 insertions(+), 27 deletions(-) create mode 100644 apps/zoom-app/src/b/NewLookShell.jsx create mode 100644 apps/zoom-app/src/hooks/useNewLook.js create mode 100644 apps/zoom-app/src/hooks/useNewLook.test.js 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 ( +
+ + {/* Above the tabs, ahead of everything: the week of warning a club + gets before it loses Pro has to be seen by whoever is timing, who + is rarely whoever pays. */} + + + + + +
+ +
+ }> +
+ {activeTab === 'live' && } + {activeTab === 'agenda' && setActiveTab('live')} />} + {activeTab === 'report' && } +
+
+ +
+ + + +
+ ); +} + +function App() { + // Behind the `new_look` release flag until design B is finished and launched. + const newLook = useNewLook(); + return ( -
- - {/* Above the tabs, ahead of everything: the week of warning a club - gets before it loses Pro has to be seen by whoever is timing, who - is rarely whoever pays. */} - - - - - -
- -
- }> -
- {activeTab === 'live' && } - {activeTab === 'agenda' && setActiveTab('live')} />} - {activeTab === 'report' && } -
+ {newLook ? ( + + - -
- - - -
+ ) : ( + + )}
); diff --git a/apps/zoom-app/src/b/NewLookShell.jsx b/apps/zoom-app/src/b/NewLookShell.jsx new file mode 100644 index 0000000..36f17d9 --- /dev/null +++ b/apps/zoom-app/src/b/NewLookShell.jsx @@ -0,0 +1,13 @@ +import { ClassicShell } from '../App'; + +/** + * Design B's panel: the top bar with the Agenda / Timer / Report switch, the + * Pro chip and the ⋯ More menu, the timer card and the "Look & sound" sheets. + * + * Phase 0 of plan 13 only puts the `new_look` switch in place, so until the + * Phase 1 shell lands this is today's panel unchanged. The flag is off + * everywhere, so nobody sees the difference either way. + */ +export default function NewLookShell() { + return ; +} 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); + }); +}); From 68ffbcc16867d2b2458083821361ed080b614b2a Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 17:21:24 -0500 Subject: [PATCH 3/9] refactor(zoom-app): move LiveTab's state, effects and handlers into useLiveTimer A straight move: the logic and the JSX are byte-for-byte what they were, and LiveTab.test.jsx passes unchanged. Design B's timer card will call the same hook, so the overlay pushes and video handling exist once. --- apps/zoom-app/src/components/LiveTab.jsx | 1022 ++-------------- apps/zoom-app/src/hooks/useLiveTimer.js | 1038 +++++++++++++++++ apps/zoom-app/src/hooks/useLiveTimer.test.jsx | 113 ++ 3 files changed, 1231 insertions(+), 942 deletions(-) create mode 100644 apps/zoom-app/src/hooks/useLiveTimer.js create mode 100644 apps/zoom-app/src/hooks/useLiveTimer.test.jsx 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); + } + }; + + 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'); + } + }); +}); From e992c6df25f25da3909b78c25f7d20ea8cbd1722 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 17:21:24 -0500 Subject: [PATCH 4/9] refactor(zoom-app): VideoPreviewLayer, the count-up chip and club badge layer Out of TimerDisplay unchanged, so design B's 16:9 preview shares the drag, size and hide gesture. New tests pin commit-on-release, clamping, and which controls show when. --- apps/zoom-app/src/components/TimerDisplay.jsx | 258 ++-------------- .../src/components/VideoPreviewLayer.jsx | 275 ++++++++++++++++++ .../src/components/VideoPreviewLayer.test.jsx | 120 ++++++++ 3 files changed, 415 insertions(+), 238 deletions(-) create mode 100644 apps/zoom-app/src/components/VideoPreviewLayer.jsx create mode 100644 apps/zoom-app/src/components/VideoPreviewLayer.test.jsx 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 && ( - <> - - - - )} - - {badgeMoved && ( - - )} -
- )} -
-
- )} - - {/* 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 && ( -
- - {/* 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 && ( - <> - - - - ); - const eyeButton = ( - - ); - 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/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 && ( + <> + + + + )} + + {badgeMoved && ( + + )} +
+ )} +
+
+ )} + + {/* 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 && ( +
+ + {/* 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 && ( + <> + + + + ); + const eyeButton = ( + + ); + 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(); + }); +}); From b62d4040c4daeec325beb57286c45c7eef2bdcde Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 17:21:25 -0500 Subject: [PATCH 5/9] refactor(zoom-app): CardSetsPanel and SoundsPanel out of their dialogs The dialogs render exactly as before around the extracted bodies. Design B's Cards and Sounds sheets will show the same panels, with the own-background picker left to its own sheet. --- .../src/components/CardImagesModal.jsx | 390 +----------------- .../src/components/CardImagesModal.test.jsx | 18 + .../zoom-app/src/components/CardSetsPanel.jsx | 390 ++++++++++++++++++ apps/zoom-app/src/components/SoundsModal.jsx | 191 +-------- apps/zoom-app/src/components/SoundsPanel.jsx | 196 +++++++++ .../src/components/TimerSounds.test.jsx | 25 +- 6 files changed, 644 insertions(+), 566 deletions(-) create mode 100644 apps/zoom-app/src/components/CardSetsPanel.jsx create mode 100644 apps/zoom-app/src/components/SoundsPanel.jsx 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 && ( - - )} - {onDelete && ( - - )} - -
-
- {CARD_COLORS.map((color) => ( - {`${COLOR_LABELS[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 && ( - - )} -
-
- {CARD_COLORS.map((color) => - draft[color] ? ( - - ) : ( - - ) - )} -
-

- 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.{' '} - {' '} - 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 && ( + + )} + {onDelete && ( + + )} + +
+
+ {CARD_COLORS.map((color) => ( + {`${COLOR_LABELS[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 && ( + + )} +
+
+ {CARD_COLORS.map((color) => + draft[color] ? ( + + ) : ( + + ) + )} +
+

+ 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.{' '} + {' '} + 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/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 ( - - ); -} - -/** - * 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 && ( -

-

- )} - - {/* 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 }) => ( - - ))} -
-
- -
- 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 && ( - - )} - -
-
- - -
-
- -

- 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 ( + + ); +} + +/** + * 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 && ( +

+

+ )} + + {/* 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 }) => ( + + ))} +
+
+ +
+ 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 && ( + + )} + +
+
+ + +
+
+ +

+ 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/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(); + }); +}); From 3e6ed36bdff8175167ddaa11f001feca14d928c2 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 17:21:25 -0500 Subject: [PATCH 6/9] feat(zoom-app): Sheet, design B's bottom sheet A modal dialog that rises from the bottom: focus in on open and back to the opener on close, Tab trapped, Escape and the dimmed panel close. Unused until Phase 1. --- apps/zoom-app/src/b/Sheet.jsx | 111 +++++++++++++++++++++++++++++ apps/zoom-app/src/b/Sheet.test.jsx | 97 +++++++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 apps/zoom-app/src/b/Sheet.jsx create mode 100644 apps/zoom-app/src/b/Sheet.test.jsx diff --git a/apps/zoom-app/src/b/Sheet.jsx b/apps/zoom-app/src/b/Sheet.jsx new file mode 100644 index 0000000..a96f299 --- /dev/null +++ b/apps/zoom-app/src/b/Sheet.jsx @@ -0,0 +1,111 @@ +import { useEffect, useId, useRef } from 'react'; +import { X } from 'lucide-react'; + +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +/** + * A bottom sheet: design B's one way of opening a setting (plan 13). The panel + * dims, the sheet rises from the bottom edge with a title and a close button, + * and only that one setting is inside it. + * + * A modal dialog to assistive tech and the keyboard: focus moves in when it + * opens and goes back to whatever opened it when it closes, Tab stays inside, + * Escape and a press on the dimmed panel close it. + * + * @param {{ + * open: boolean, + * onClose: () => void, + * title: string, + * description?: import('react').ReactNode, + * badge?: import('react').ReactNode, + * children: import('react').ReactNode, + * }} props - `badge` sits beside the title (the PRO tag on Sounds) + */ +export default function Sheet({ open, onClose, title, description, badge, children }) { + const titleId = useId(); + const sheetRef = useRef(null); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + useEffect(() => { + if (!open) return undefined; + const opener = document.activeElement; + const sheet = sheetRef.current; + const first = sheet?.querySelector(FOCUSABLE); + (first || sheet)?.focus(); + + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + event.stopPropagation(); + onCloseRef.current(); + return; + } + if (event.key !== 'Tab' || !sheet) return; + const focusable = [...sheet.querySelectorAll(FOCUSABLE)]; + if (focusable.length === 0) { + event.preventDefault(); + return; + } + const firstEl = focusable[0]; + const lastEl = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === firstEl) { + event.preventDefault(); + lastEl.focus(); + } else if (!event.shiftKey && document.activeElement === lastEl) { + event.preventDefault(); + firstEl.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + if (opener && typeof opener.focus === 'function' && document.contains(opener)) opener.focus(); + }; + }, [open]); + + if (!open) return null; + + 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. */} + + ); +} 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 ( + <> + + { + onClose(); + setOpen(false); + }} + title="Cards" + description="The card the timer shows at each colour." + > + {children ?? ( + <> + + + + )} + + + ); +} + +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(); + }); +}); From 571ee5b82cdc23a20fc0b3d72be3aa3bdbbb50c6 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 17:21:25 -0500 Subject: [PATCH 7/9] test(zoom-client): cover the code moved out of LiveTab, CardImagesModal and SoundsModal Steps unchanged: the behaviour did not move, only the files it lives in. --- e2e/zoom-client/club-card-sets.md | 2 ++ e2e/zoom-client/timer-core.md | 2 ++ e2e/zoom-client/timer-sounds.md | 2 ++ 3 files changed, 6 insertions(+) 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..5fbc30b 100644 --- a/e2e/zoom-client/timer-core.md +++ b/e2e/zoom-client/timer-core.md @@ -5,12 +5,14 @@ status: current 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 From 1bf93fee8c896bcde770896c9e8a0c978c6012a7 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 17:21:43 -0500 Subject: [PATCH 8/9] test(zoom-client): announcement-banner notes the new_look shell switch in App.jsx --- e2e/zoom-client/announcement-banner.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/e2e/zoom-client/announcement-banner.md b/e2e/zoom-client/announcement-banner.md index 327f635..e37e708 100644 --- a/e2e/zoom-client/announcement-banner.md +++ b/e2e/zoom-client/announcement-banner.md @@ -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. From 411f0d67c160b4eae7ba38bf4ffa1f2c6ab4560f Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 17:22:20 -0500 Subject: [PATCH 9/9] test(zoom-client): mark timer-core and announcement-banner stale until Phase 0 runs in Zoom The test device is held for #145's CC-10 tonight, so the real-client run for plan 13 Phase 0 waits. Steps unchanged. --- e2e/zoom-client/INDEX.md | 8 ++++---- e2e/zoom-client/announcement-banner.md | 2 +- e2e/zoom-client/timer-core.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) 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 e37e708..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 diff --git a/e2e/zoom-client/timer-core.md b/e2e/zoom-client/timer-core.md index 5fbc30b..de7c5f1 100644 --- a/e2e/zoom-client/timer-core.md +++ b/e2e/zoom-client/timer-core.md @@ -1,7 +1,7 @@ --- 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