From 93575a546e1f181b30709ab5780ca1a6ff225231 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 20:08:23 -0500 Subject: [PATCH 1/6] feat(zoom-app): hover reveal and a one-time hint for the preview chip; TUTORIALS_URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VideoPreviewLayer takes revealControls='hover' (design B) to keep − / + / eye / reset out of sight until the chip or badge is pointed at or focused, and a chipHint under the chip. Today's panel keeps 'always'. useLiveTimer also returns cardImagesGeneration, so a view can follow the artwork. --- .../src/components/VideoPreviewLayer.jsx | 61 +++++++++++++++++-- apps/zoom-app/src/hooks/useLiveTimer.js | 1 + packages/shared/appLinks.js | 7 +++ 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/apps/zoom-app/src/components/VideoPreviewLayer.jsx b/apps/zoom-app/src/components/VideoPreviewLayer.jsx index 18f6824..16b7a22 100644 --- a/apps/zoom-app/src/components/VideoPreviewLayer.jsx +++ b/apps/zoom-app/src/components/VideoPreviewLayer.jsx @@ -2,6 +2,10 @@ import { useState } from 'react'; import { Move, Plus, Minus, Eye, EyeOff, RotateCcw } from 'lucide-react'; import ClubBadge, { ClubBadgeLayer } from '@toastmaster-timer/ui/ClubBadge'; +// Written out in full, so Tailwind sees every class it has to generate. +const CHIP_REVEAL = 'opacity-0 transition-opacity group-hover/chip:opacity-100 group-focus-within/chip:opacity-100'; +const BADGE_REVEAL = 'opacity-0 transition-opacity group-hover/badge:opacity-100 group-focus-within/badge:opacity-100'; + /** * What the organizer moves around on the timer preview: the count-up chip * participants see on the video, and the club's badge. Each is dragged into @@ -15,6 +19,12 @@ import ClubBadge, { ClubBadgeLayer } from '@toastmaster-timer/ui/ClubBadge'; * * Rendered inside the positioned preview box; both pieces are absolutely * placed within it. + * + * `revealControls` 'hover' (design B) keeps the − / + / eye / reset buttons + * out of sight until the pointer is on the chip or badge, or keyboard focus is + * inside it; 'always' (today's panel) shows them all the time. `chipHint` is a + * one-line note under the chip; `onChipEngaged` fires the first time the + * pointer reaches the chip, so a caller can retire that note. */ export default function VideoPreviewLayer({ containerRef, @@ -31,7 +41,11 @@ export default function VideoPreviewLayer({ onAdjustBadgeScale, onToggleBadgeVisible, onResetBadgePlacement, + revealControls = 'always', + chipHint = null, + onChipEngaged, }) { + const hover = revealControls === 'hover'; const [dragging, setDragging] = useState(false); const [draggingBadge, setDraggingBadge] = useState(false); @@ -51,6 +65,7 @@ export default function VideoPreviewLayer({ event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); setDragging(true); + onChipEngaged?.(); }; const handlePointerMove = (event) => { @@ -106,7 +121,7 @@ export default function VideoPreviewLayer({ transform: 'translate(-50%, -50%)', maxWidth: '44%', }} - className="pointer-events-auto" + className={hover ? 'group/badge pointer-events-auto' : 'pointer-events-auto'} > {!draggingBadge && ( -
+
{badgePlacement.visible && ( <> + + {open && ( +
moveFocusWithArrows(event, menuRef.current, '[role="menuitem"]')} + className="absolute right-0 top-full z-40 mt-1.5 w-[262px] overflow-hidden rounded-xl border border-gray-200 bg-white py-1 shadow-[0_12px_28px_rgba(15,23,42,0.22)]" + > + {proOn && ( +
+ + Your plan + {isPro ? 'Pro' : 'Free'} + + +
+ )} + {proOn && !club && ( + + )} + + +
+ + + +
+ )} + + {upgradeSource && ( + + setUpgradeSource(null)} source={upgradeSource} /> + + )} + {showFeedback && ( + + setShowFeedback(false)} /> + + )} +
+ ); +} diff --git a/apps/zoom-app/src/b/NewLookShell.jsx b/apps/zoom-app/src/b/NewLookShell.jsx index 36f17d9..799a005 100644 --- a/apps/zoom-app/src/b/NewLookShell.jsx +++ b/apps/zoom-app/src/b/NewLookShell.jsx @@ -1,13 +1,80 @@ -import { ClassicShell } from '../App'; +import { lazy, Suspense, useState } from 'react'; +import ZoomConnectionNotice from '../components/ZoomConnectionNotice'; +import ClubStatusBanner from '../components/ClubStatusBanner'; +import AnnouncementBanner from '../components/AnnouncementBanner'; +import PeriodicPrompts from '../components/PeriodicPrompts'; +import TimerSounds from '../components/TimerSounds'; +import ContactCapture from '../components/ContactCapture'; +import TopBar from './TopBar'; + +const TimerTab = lazy(() => import('./TimerTab')); +const AgendaTab = lazy(() => import('../components/AgendaTab')); +const ReportTab = lazy(() => import('../components/ReportTab')); +const EditRulesModal = lazy(() => import('../components/EditRulesModal')); + +function TabFallback() { + return ( +
+
+
+ ); +} /** - * Design B's panel: the top bar with the Agenda / Timer / Report switch, the - * Pro chip and the ⋯ More menu, the timer card and the "Look & sound" sheets. + * Design B's panel (plan 13): notices on top, then the Agenda / Timer / Report + * switch with the Pro chip and the ⋯ menu, then the tab. No footer: what it + * held moved into the chip and the menu, which is what keeps the Timer tab's + * Start button high in a 400px panel. * - * Phase 0 of plan 13 only puts the `new_look` switch in place, so until the - * Phase 1 shell lands this is today's panel unchanged. The flag is off - * everywhere, so nobody sees the difference either way. + * The Agenda and Report tabs are today's until Phase 2 redraws them. */ export default function NewLookShell() { - return ; + const [activeTab, setActiveTab] = useState('live'); + const [showRules, setShowRules] = useState(false); + // "Clear the timer from my video" from the ⋯ menu. The clear belongs to the + // Timer tab, which owns the video, so the menu switches there and the tab + // runs it once for every bump of this counter. + const [clearRequest, setClearRequest] = useState(0); + + const handleClearVideo = () => { + setActiveTab('live'); + setClearRequest((count) => count + 1); + }; + + return ( +
+ + + + setShowRules(true)} + /> + + }> +
+ {activeTab === 'live' && ( + setActiveTab('agenda')} + onOpenRules={() => setShowRules(true)} + /> + )} + {activeTab === 'agenda' && setActiveTab('live')} />} + {activeTab === 'report' && } +
+
+ + {showRules && ( + + setShowRules(false)} /> + + )} + + + +
+ ); } diff --git a/apps/zoom-app/src/b/ProChip.jsx b/apps/zoom-app/src/b/ProChip.jsx new file mode 100644 index 0000000..724d3f0 --- /dev/null +++ b/apps/zoom-app/src/b/ProChip.jsx @@ -0,0 +1,141 @@ +import { lazy, Suspense, useEffect, useId, useRef, useState } from 'react'; +import { Check, Lock, Sparkles } from 'lucide-react'; +import { isProLaunchBadgeDue, loadPromptState, subscribeToPromptState } from '@toastmaster-timer/shared'; +import { useEntitlement } from '../hooks/useEntitlement'; +import { useFlag } from '../hooks/useFlag'; +import { trackEvent } from '../utils/posthog'; +import { PRO_COMING_SOON_ITEMS, PRO_COMING_SOON_MORE } from '../components/ProComingSoon'; +import { usePopover } from './usePopover'; + +const UpgradeModal = lazy(() => import('../components/UpgradeModal')); + +const CHIP = 'relative flex h-8 flex-shrink-0 items-center gap-1 rounded-lg px-2.5 text-xs font-bold transition-colors'; + +/** + * Before Pro is released: the chip greyed with a lock. Pointing at it or + * pressing it lists what is coming, the same list the old footer button shows. + * Nothing can be bought yet, so nothing here sells. + */ +function ProSoonChip() { + const [hovered, setHovered] = useState(false); + const [pinned, setPinned] = useState(false); + const open = hovered || pinned; + const wrapperRef = useRef(null); + const trackedHover = useRef(false); + const panelId = useId(); + + usePopover(pinned, () => { + setPinned(false); + setHovered(false); + }, [wrapperRef]); + + const handleEnter = () => { + setHovered(true); + if (!trackedHover.current) { + trackedHover.current = true; + trackEvent('pro_coming_soon_opened', { trigger: 'hover', surface: 'pro_chip' }); + } + }; + + const handleClick = () => { + if (!pinned) trackEvent('pro_coming_soon_opened', { trigger: 'click', surface: 'pro_chip' }); + setPinned(!pinned); + }; + + return ( +
setHovered(false)}> + + {open && ( + + )} +
+ ); +} + +/** + * The Pro chip beside the tabs, where the old footer's Upgrade / Pro button + * went. + * + * - Flags not in yet: nothing, so no chip flashes and changes. + * - `pro` off: locked, "coming soon". + * - `pro` on, free plan: blue "Pro", with the red dot until the launch has + * been looked at. Opens the Upgrade dialog. + * - `pro` on, Pro plan: amber "Pro". Opens the same dialog, which then + * manages the plan. + */ +export default function ProChip() { + const { isPro, known } = useEntitlement(); + const { enabled: proEnabled, known: flagsKnown } = useFlag('pro'); + const [promptState, setPromptState] = useState(loadPromptState); + const [showUpgrade, setShowUpgrade] = useState(false); + useEffect(() => subscribeToPromptState(setPromptState), []); + + if (!flagsKnown) return null; + if (!proEnabled) return ; + if (!known) return null; + + const showDot = !isPro && isProLaunchBadgeDue(promptState); + + return ( + <> + + {showUpgrade && ( + + setShowUpgrade(false)} source="pro_chip" /> + + )} + + ); +} diff --git a/apps/zoom-app/src/b/TopBar.jsx b/apps/zoom-app/src/b/TopBar.jsx new file mode 100644 index 0000000..f186416 --- /dev/null +++ b/apps/zoom-app/src/b/TopBar.jsx @@ -0,0 +1,59 @@ +import { memo } from 'react'; +import { trackEvent } from '../utils/posthog'; +import ProChip from './ProChip'; +import MoreMenu from './MoreMenu'; + +// Agenda, Timer, Report: the meeting's own order. The ids stay what NavTabs +// sends to PostHog ('live' for the timer), so tab_viewed compares across looks. +export const TABS = Object.freeze([ + { id: 'agenda', label: 'Agenda' }, + { id: 'live', label: 'Timer' }, + { id: 'report', label: 'Report' }, +]); + +/** + * Design B's top row (the BTop board): the Agenda / Timer / Report switch, the + * Pro chip and the ⋯ menu, on one line at 360px. The switch takes whatever the + * chip and the menu leave, so nothing wraps. + * + * @param {{ + * activeTab: string, + * onTabChange: (id: string) => void, + * onClearVideo: () => void, + * onOpenRules: () => void, + * }} props + */ +export default memo(function TopBar({ activeTab, onTabChange, onClearVideo, onOpenRules }) { + const select = (id) => { + if (id === activeTab) return; + (window.requestIdleCallback || setTimeout)(() => + trackEvent('tab_viewed', { tab_name: id, previous_tab: activeTab, look: 'b' }) + ); + onTabChange(id); + }; + + return ( +
+ + + +
+ ); +}); diff --git a/apps/zoom-app/src/b/TopBar.test.jsx b/apps/zoom-app/src/b/TopBar.test.jsx new file mode 100644 index 0000000..f7f5793 --- /dev/null +++ b/apps/zoom-app/src/b/TopBar.test.jsx @@ -0,0 +1,147 @@ +import '@testing-library/jest-dom'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { + TUTORIALS_URL, + resetClubForTests, + resetEntitlementForTests, + resetFlagsForTests, + setEntitlement, + setFlags, +} from '@toastmaster-timer/shared'; +import { ToastProvider } from '../context/ToastContext'; +import { openExternalUrl } from '../utils/zoomSdk'; +import { trackEvent } from '../utils/posthog'; +import TopBar from './TopBar'; + +vi.mock('../utils/zoomSdk', () => ({ openExternalUrl: vi.fn(async () => true) })); +vi.mock('../utils/posthog', () => ({ trackEvent: vi.fn() })); + +const PRO = { plan: 'pro', status: 'active', entitled: true, source: 'subscription' }; + +function renderBar(props = {}) { + const handlers = { onTabChange: vi.fn(), onClearVideo: vi.fn(), onOpenRules: vi.fn() }; + render( + + + + ); + return handlers; +} + +const openMore = () => fireEvent.click(screen.getByRole('button', { name: 'More' })); +const menuItems = () => screen.getAllByRole('menuitem').map((item) => item.textContent.trim()); + +beforeEach(() => { + localStorage.clear(); + resetFlagsForTests(); + resetEntitlementForTests(); + resetClubForTests(); + vi.clearAllMocks(); +}); + +describe('the section switch', () => { + it('names the timer "Timer" and keeps its PostHog id', async () => { + vi.useFakeTimers(); + try { + const { onTabChange } = renderBar(); + expect(screen.getByRole('button', { name: 'Timer' })).toHaveAttribute('aria-current', 'page'); + fireEvent.click(screen.getByRole('button', { name: 'Agenda' })); + expect(onTabChange).toHaveBeenCalledWith('agenda'); + act(() => vi.runAllTimers()); + expect(trackEvent).toHaveBeenCalledWith('tab_viewed', { tab_name: 'agenda', previous_tab: 'live', look: 'b' }); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('the Pro chip', () => { + it('shows nothing until the flags are in', () => { + renderBar(); + expect(screen.queryByRole('button', { name: /pro/i })).toBeNull(); + }); + + it('is locked and says what is coming while pro is off', () => { + setFlags({ pro: false }); + renderBar(); + const chip = screen.getByRole('button', { name: 'Pro (coming soon)' }); + fireEvent.click(chip); + expect(screen.getByRole('dialog', { name: 'Pro is coming soon' })).toBeInTheDocument(); + expect(trackEvent).toHaveBeenCalledWith('pro_coming_soon_opened', { trigger: 'click', surface: 'pro_chip' }); + }); + + it('offers the upgrade with the launch dot to a free plan once pro is on', () => { + setFlags({ pro: true }); + setEntitlement({ plan: 'free' }); + renderBar(); + expect(screen.getByRole('button', { name: 'Upgrade to Pro' })).toBeInTheDocument(); + expect(screen.getByTestId('pro-new-dot')).toBeInTheDocument(); + }); + + it('manages the plan for Pro, without the dot', () => { + setFlags({ pro: true }); + setEntitlement(PRO); + renderBar(); + expect(screen.getByRole('button', { name: 'Manage your Pro plan' })).toBeInTheDocument(); + expect(screen.queryByTestId('pro-new-dot')).toBeNull(); + }); +}); + +describe('the ⋯ menu', () => { + it('holds the timer tools and the help while pro is off', () => { + setFlags({ pro: false }); + renderBar(); + openMore(); + expect(menuItems()).toEqual([ + 'Roles & timing rules', + 'Clear the timer from my video', + 'How-to videos', + 'Send feedback', + 'Rate the app', + ]); + }); + + it('adds your plan and the club code once pro is on', () => { + setFlags({ pro: true }); + setEntitlement({ plan: 'free' }); + renderBar(); + openMore(); + expect(screen.getByText('Your plan')).toBeInTheDocument(); + expect(menuItems()).toEqual(expect.arrayContaining(['See Pro', 'Join my club with a code'])); + }); + + it('hands Clear and the rules to the shell, and closes', () => { + const { onClearVideo, onOpenRules } = renderBar(); + openMore(); + fireEvent.click(screen.getByRole('menuitem', { name: 'Clear the timer from my video' })); + expect(onClearVideo).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('menu')).toBeNull(); + openMore(); + fireEvent.click(screen.getByRole('menuitem', { name: 'Roles & timing rules' })); + expect(onOpenRules).toHaveBeenCalledTimes(1); + }); + + it('opens the tutorials and the Marketplace review in the browser', async () => { + renderBar(); + openMore(); + fireEvent.click(screen.getByRole('menuitem', { name: 'How-to videos' })); + expect(openExternalUrl).toHaveBeenCalledWith(TUTORIALS_URL); + + openMore(); + await act(async () => { + fireEvent.click(screen.getByRole('menuitem', { name: 'Rate the app' })); + }); + expect(openExternalUrl).toHaveBeenLastCalledWith(expect.stringContaining('marketplace.zoom.us')); + }); + + it('moves between items with the arrow keys and closes on Escape', () => { + renderBar(); + openMore(); + const first = screen.getAllByRole('menuitem')[0]; + first.focus(); + fireEvent.keyDown(first, { key: 'ArrowDown' }); + expect(screen.getAllByRole('menuitem')[1]).toHaveFocus(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('menu')).toBeNull(); + }); +}); diff --git a/apps/zoom-app/src/b/usePopover.js b/apps/zoom-app/src/b/usePopover.js new file mode 100644 index 0000000..f501a24 --- /dev/null +++ b/apps/zoom-app/src/b/usePopover.js @@ -0,0 +1,54 @@ +import { useEffect, useRef } from 'react'; + +/** + * Close an open popover on Escape or on a press outside the elements it owns + * (the button that opened it and the popover itself). + * + * @param {boolean} open + * @param {() => void} onClose + * @param {Array>} refs + */ +export function usePopover(open, onClose, refs) { + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + const refsRef = useRef(refs); + refsRef.current = refs; + + useEffect(() => { + if (!open) return undefined; + const inside = (target) => refsRef.current.some((ref) => ref.current?.contains(target)); + const onPointer = (event) => { + if (!inside(event.target)) onCloseRef.current(); + }; + const onKey = (event) => { + if (event.key === 'Escape') onCloseRef.current(); + }; + document.addEventListener('mousedown', onPointer); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onPointer); + document.removeEventListener('keydown', onKey); + }; + }, [open]); +} + +/** + * Up / Down / Home / End between the items of an open menu or listbox. + * + * @param {KeyboardEvent} event + * @param {HTMLElement|null} container + * @param {string} selector - the focusable items, in order + */ +export function moveFocusWithArrows(event, container, selector) { + if (!container || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + const items = [...container.querySelectorAll(selector)].filter((el) => !el.disabled); + if (items.length === 0) return; + event.preventDefault(); + const index = items.indexOf(document.activeElement); + let next; + if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = items.length - 1; + else if (event.key === 'ArrowDown') next = index < 0 ? 0 : (index + 1) % items.length; + else next = index < 0 ? items.length - 1 : (index - 1 + items.length) % items.length; + items[next].focus(); +} From 59290eb8e5a91f4d4bbd9613020ccea10f70d357 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 20:08:23 -0500 Subject: [PATCH 3/6] feat(zoom-app): design B's timer card, pickers and the panels around it The card takes the meeting's colour: speaker and role pickers, the rules line, a 16:9 preview with the count-up chip and club badge (controls on hover), the clock and one status line, preview swatches, and the buttons for ready, running, paused and video off. Up next, the after-Finish note for a row that needs a role, break length with Stop music, and custom times in one row. --- apps/zoom-app/src/b/RolePicker.jsx | 110 +++++++++++ apps/zoom-app/src/b/SpeakerPicker.jsx | 229 +++++++++++++++++++++++ apps/zoom-app/src/b/TimerCard.jsx | 242 +++++++++++++++++++++++++ apps/zoom-app/src/b/TimerCard.test.jsx | 239 ++++++++++++++++++++++++ apps/zoom-app/src/b/TimerPanels.jsx | 226 +++++++++++++++++++++++ apps/zoom-app/src/b/format.js | 93 ++++++++++ apps/zoom-app/src/b/format.test.js | 62 +++++++ 7 files changed, 1201 insertions(+) create mode 100644 apps/zoom-app/src/b/RolePicker.jsx create mode 100644 apps/zoom-app/src/b/SpeakerPicker.jsx create mode 100644 apps/zoom-app/src/b/TimerCard.jsx create mode 100644 apps/zoom-app/src/b/TimerCard.test.jsx create mode 100644 apps/zoom-app/src/b/TimerPanels.jsx create mode 100644 apps/zoom-app/src/b/format.js create mode 100644 apps/zoom-app/src/b/format.test.js diff --git a/apps/zoom-app/src/b/RolePicker.jsx b/apps/zoom-app/src/b/RolePicker.jsx new file mode 100644 index 0000000..cfca7bf --- /dev/null +++ b/apps/zoom-app/src/b/RolePicker.jsx @@ -0,0 +1,110 @@ +import { useRef, useState } from 'react'; +import { Check, ChevronDown, Pencil } from 'lucide-react'; +import { BREAK_ROLE } from '@toastmaster-timer/shared'; +import { thresholdsLine } from './format'; +import { moveFocusWithArrows, usePopover } from './usePopover'; + +const OPTION = 'flex w-full items-center gap-1.5 px-3 py-2 text-left text-sm hover:bg-slate-50 focus:bg-slate-50 focus:outline-none'; + +/** + * The role button on design B's timer card, and the list it opens (board + * B-T04): every role with its three thresholds, then Custom and Take a Break, + * then the way to the rules themselves. + * + * @param {{ + * role: string, + * roleOptions: string[], + * roleRules: Object, + * onChange: (role: string) => void, + * onEditRules: () => void, + * palette: {soft: string, fg: string}, + * }} props + */ +export default function RolePicker({ role, roleOptions, roleRules, onChange, onEditRules, palette }) { + const [open, setOpen] = useState(false); + const buttonRef = useRef(null); + const listRef = useRef(null); + usePopover(open, () => setOpen(false), [buttonRef, listRef]); + + const timed = roleOptions.filter((name) => name !== 'Custom' && name !== BREAK_ROLE); + const special = [ + roleOptions.includes('Custom') && { name: 'Custom', note: 'set your own times' }, + roleOptions.includes(BREAK_ROLE) && { name: BREAK_ROLE, note: 'counts down' }, + ].filter(Boolean); + + const openList = () => { + setOpen(true); + setTimeout(() => { + const selected = listRef.current?.querySelector('[aria-selected="true"]'); + (selected || listRef.current?.querySelector('[role="option"]'))?.focus(); + }, 0); + }; + + const choose = (name) => { + setOpen(false); + buttonRef.current?.focus(); + if (name !== role) onChange(name); + }; + + const renderOption = (name, note) => { + const selected = name === role; + return ( + + ); + }; + + return ( +
+ + + {open && ( +
moveFocusWithArrows(event, listRef.current, '[role="option"], [data-edit-rules]')} + className="absolute right-0 top-full z-30 mt-1.5 w-[300px] max-w-[calc(100vw-24px)] overflow-hidden rounded-xl border border-gray-200 bg-white text-gray-900 shadow-[0_12px_28px_rgba(15,23,42,0.22)]" + > +
+ {timed.map((name) => renderOption(name, thresholdsLine(roleRules[name])))} + {special.length > 0 &&
} + {special.map(({ name, note }) => renderOption(name, note))} +
+ +
+ )} +
+ ); +} diff --git a/apps/zoom-app/src/b/SpeakerPicker.jsx b/apps/zoom-app/src/b/SpeakerPicker.jsx new file mode 100644 index 0000000..5c9a34b --- /dev/null +++ b/apps/zoom-app/src/b/SpeakerPicker.jsx @@ -0,0 +1,229 @@ +import { useMemo, useRef, useState } from 'react'; +import { Check, ChevronDown, CornerDownLeft, Search, Users } from 'lucide-react'; +import useZoomParticipants, { participantsNotOnAgenda } from '../hooks/useZoomParticipants'; +import { moveFocusWithArrows, usePopover } from './usePopover'; + +const OPTION = 'flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-slate-50 focus:bg-slate-50 focus:outline-none'; +const GROUP = 'px-3 pb-1 pt-2 text-[11px] font-bold tracking-wide text-slate-500'; + +/** + * "Who's speaking?": the speaker button on design B's timer card, and the + * list it opens (board B-T03). + * + * The same two sources the old name field and the stage picker offer: the + * agenda, and everyone in the meeting who is not on it. Typing filters both; + * a name that matches nobody is added to the agenda under the current role, + * which is what Enter has always done with a typed name. A row that needs a + * role says so and is left to the Agenda tab (onSelectSpeaker refuses it). + * + * @param {{ + * value: string, + * role: string, + * agendaItems: Array, + * activeSpeakerId: string|null, + * needsRole: (item: Object) => boolean, + * onSelectSpeaker: (id: string) => void, + * onAddSpeaker: (name: string) => void, + * onRenameSpeaker: (id: string, name: string) => void, + * palette: {soft: string, fg: string}, + * }} props + */ +export default function SpeakerPicker({ + value, + role, + agendaItems, + activeSpeakerId, + needsRole, + onSelectSpeaker, + onAddSpeaker, + onRenameSpeaker, + palette, +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const buttonRef = useRef(null); + const popRef = useRef(null); + const inputRef = useRef(null); + const { participants, restricted } = useZoomParticipants(); + + const close = () => { + setOpen(false); + setQuery(''); + }; + usePopover(open, close, [buttonRef, popRef]); + + const typed = query.trim(); + const needle = typed.toLowerCase(); + const upNext = useMemo( + () => (agendaItems || []).filter((item) => (!item.completed || item.id === activeSpeakerId) && item.name), + [agendaItems, activeSpeakerId] + ); + const activeItem = upNext.find((item) => item.id === activeSpeakerId) || null; + const agendaMatches = needle ? upNext.filter((item) => item.name.toLowerCase().includes(needle)) : upNext; + const guests = participantsNotOnAgenda(participants, upNext).filter( + (person) => !needle || person.name.toLowerCase().includes(needle) + ); + const exact = upNext.find((item) => item.name.trim().toLowerCase() === needle); + const canAdd = Boolean(typed) && !exact; + const canRename = Boolean(typed) && activeItem && typed !== activeItem.name.trim(); + + const openList = () => { + setOpen(true); + setTimeout(() => inputRef.current?.focus(), 0); + }; + + const finish = () => { + close(); + buttonRef.current?.focus(); + }; + + const pick = (item) => { + finish(); + onSelectSpeaker(item.id); + }; + + const add = (name) => { + finish(); + onAddSpeaker(name); + }; + + const handleInputKey = (event) => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + popRef.current?.querySelector('[role="option"]')?.focus(); + return; + } + if (event.key !== 'Enter') return; + event.preventDefault(); + if (exact) pick(exact); + else if (canAdd) add(typed); + }; + + return ( +
+ + + {open && ( +
event.target !== inputRef.current && moveFocusWithArrows(event, popRef.current, '[role="option"]')} + className="absolute left-0 top-full z-30 mt-1.5 w-[300px] max-w-[calc(100vw-24px)] overflow-hidden rounded-xl border border-gray-200 bg-white text-gray-900 shadow-[0_12px_28px_rgba(15,23,42,0.22)]" + > + + +
+ {canAdd && ( + + )} + {canRename && ( + + )} + + {agendaMatches.length > 0 && ( +
+ AGENDA +
+ )} + {agendaMatches.map((item) => { + const active = item.id === activeSpeakerId; + const blocked = needsRole(item); + return ( + + ); + })} + + {guests.length > 0 && ( +
+ IN THIS MEETING +
+ )} + {guests.map((person) => ( + + ))} + + {!canAdd && agendaMatches.length === 0 && guests.length === 0 && ( +

No speakers yet. Type a name to add one.

+ )} + {restricted && ( +

+

+ )} +
+ +

+

+
+ )} +
+ ); +} diff --git a/apps/zoom-app/src/b/TimerCard.jsx b/apps/zoom-app/src/b/TimerCard.jsx new file mode 100644 index 0000000..30ae55c --- /dev/null +++ b/apps/zoom-app/src/b/TimerCard.jsx @@ -0,0 +1,242 @@ +import { memo, useRef } from 'react'; +import { Check, Pause, Play, RotateCcw } from 'lucide-react'; +import { BREAK_ROLE, formatTime, getDisplaySeconds } from '@toastmaster-timer/shared'; +import VideoPreviewLayer from '../components/VideoPreviewLayer'; +import SpeakerPicker from './SpeakerPicker'; +import RolePicker from './RolePicker'; +import { CARD_PALETTE, PREVIEW_SWATCHES, cardStatusLine, rulesLine } from './format'; + +const BUTTON = 'flex items-center justify-center gap-2 rounded-xl transition-opacity disabled:cursor-not-allowed'; + +/** + * Design B's timer card (the BCard board): the whole card takes the colour the + * meeting sees. Speaker and role along the top, the rules under them, a 16:9 + * preview of the video with the count-up chip and club badge to drag, the big + * clock and one status line, the preview swatches while nothing runs, and the + * buttons for the state the timer is in. + * + * Draws only. Every action is a prop, wired to useLiveTimer by TimerTab. + */ +export default memo(function TimerCard({ + status, + previewColor, + elapsedTime, + isRunning, + rules, + speakerName, + selectedRole, + roleOptions, + roleRules, + agenda, + activeSpeakerId, + needsRole, + onSelectSpeaker, + onAddSpeaker, + onRenameSpeaker, + onRoleChange, + onEditRules, + readoutPosition, + readoutVisible, + onReadoutPositionChange, + onToggleReadoutVisible, + onAdjustReadoutScale, + clubKit, + badgePlacement, + badgeMoved, + onBadgePlacementChange, + onAdjustBadgeScale, + onToggleBadgeVisible, + onResetBadgePlacement, + coachHint, + onCoachSeen, + videoOff, + isClearingVideo, + onPreviewColor, + onStart, + onStop, + onContinue, + onReset, + onFinish, +}) { + const insetRef = useRef(null); + const colour = status || 'blue'; + const p = CARD_PALETTE[colour] || CARD_PALETTE.blue; + const idle = !isRunning && elapsedTime === 0; + const paused = !isRunning && elapsedTime > 0; + const shownRules = rules || roleRules[selectedRole] || null; + const showRulesLine = selectedRole !== 'Custom' && selectedRole !== BREAK_ROLE && shownRules; + const displayTime = formatTime(getDisplaySeconds(elapsedTime, shownRules)); + const statusLine = cardStatusLine({ status: colour, rules: shownRules, isRunning, elapsedTime, previewColor, videoOff }); + + const white = { background: '#ffffff', color: '#111827' }; + const ghost = { background: p.ghost, color: p.fg }; + const playColour = '#16a34a'; + + return ( +
+
+ + +
+ + {showRulesLine && ( +

+ {rulesLine(shownRules)} + +

+ )} + +
+ +
+ {displayTime} +
+ {statusLine && ( +
+ {statusLine} +
+ )} +
+ + {!isRunning && ( +
+ Preview on video + {PREVIEW_SWATCHES.map(({ color, label, bg }) => ( +
+ )} + + {idle && ( +
+ + +
+ )} + + {isRunning && ( +
+ + +
+ )} + + {paused && ( +
+ +
+ + +
+
+ )} +
+ ); +}); diff --git a/apps/zoom-app/src/b/TimerCard.test.jsx b/apps/zoom-app/src/b/TimerCard.test.jsx new file mode 100644 index 0000000..bb77415 --- /dev/null +++ b/apps/zoom-app/src/b/TimerCard.test.jsx @@ -0,0 +1,239 @@ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { BREAK_ROLE, DEFAULT_ROLE_RULES, deriveBreakRules } from '@toastmaster-timer/shared'; +import TimerCard from './TimerCard'; +import RolePicker from './RolePicker'; +import SpeakerPicker from './SpeakerPicker'; +import LookRows from './LookRows'; +import { BreakPanel, CustomTimesPanel, UpNext, parseTimeText } from './TimerPanels'; + +vi.mock('../utils/zoomSdk', () => ({ + getZoomParticipants: vi.fn(async () => ({ participants: [{ id: 'p1', name: 'Maria Chen' }], restricted: false })), +})); + +const ROLE_RULES = { ...DEFAULT_ROLE_RULES, [BREAK_ROLE]: deriveBreakRules(600) }; +const ROLE_OPTIONS = [...Object.keys(DEFAULT_ROLE_RULES), BREAK_ROLE]; +const PALETTE = { soft: 'rgba(255,255,255,0.22)', fg: '#fff' }; +const AGENDA = [ + { id: 'a1', name: 'Bob', role: 'Ice Breaker', completed: false }, + { id: 'a2', name: 'Alex', role: 'Table Topics Master', completed: false }, + { id: 'a3', name: 'Alice', role: 'Standard Speech', completed: false }, +]; +const needsRole = (item) => !ROLE_OPTIONS.includes(item.role); + +function renderCard(over = {}) { + const handlers = { + onStart: vi.fn(), onStop: vi.fn(), onContinue: vi.fn(), onReset: vi.fn(), onFinish: vi.fn(), + onPreviewColor: vi.fn(), onEditRules: vi.fn(), onRoleChange: vi.fn(), + onSelectSpeaker: vi.fn(), onAddSpeaker: vi.fn(), onRenameSpeaker: vi.fn(), + }; + const props = { + status: 'blue', previewColor: null, elapsedTime: 0, isRunning: false, + rules: DEFAULT_ROLE_RULES['Ice Breaker'], speakerName: 'Bob', selectedRole: 'Ice Breaker', + roleOptions: ROLE_OPTIONS, roleRules: ROLE_RULES, agenda: AGENDA, activeSpeakerId: 'a1', needsRole, + readoutPosition: { x: 0.18, y: 0.3 }, readoutVisible: true, clubKit: null, badgePlacement: null, + videoOff: false, isClearingVideo: false, coachHint: null, + ...handlers, ...over, + }; + const view = render(); + return { ...handlers, rerender: (next) => view.rerender() }; +} + +const button = (name) => screen.getByRole('button', { name }); + +describe('TimerCard', () => { + it('ready: speaker, role, rules, preview swatches, Start and Reset', () => { + const { onStart, onReset, onEditRules } = renderCard(); + expect(button('Speaker: Bob. Change speaker')).toBeInTheDocument(); + expect(button('Timing role: Ice Breaker. Change role')).toBeInTheDocument(); + expect(screen.getByText('Green 4:00 · Yellow 5:00 · Red 6:00')).toBeInTheDocument(); + expect(screen.getByTestId('timer-status')).toHaveTextContent('Ready · green at 4:00'); + expect(screen.getAllByRole('button', { name: /^Preview .* on your video$/ })).toHaveLength(4); + fireEvent.click(button('Start')); + fireEvent.click(button('Reset')); + fireEvent.click(button('Edit rules')); + expect(onStart).toHaveBeenCalled(); + expect(onReset).toHaveBeenCalled(); + expect(onEditRules).toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: 'Finish' })).toBeNull(); + }); + + it('says who is speaking when nobody is', () => { + renderCard({ speakerName: '' }); + expect(button("Who's speaking? Pick a speaker")).toHaveTextContent('Who’s speaking?'); + }); + + it('running: the card takes the status colour, Stop and Finish only, no swatches', () => { + const { onStop, onFinish } = renderCard({ status: 'green', isRunning: true, elapsedTime: 267 }); + expect(screen.getByTestId('timer-card')).toHaveAttribute('data-status', 'green'); + expect(screen.getByTestId('timer-display')).toHaveTextContent('04:27'); + expect(screen.getByTestId('timer-status')).toHaveTextContent('Green · yellow at 5:00'); + expect(screen.queryByRole('button', { name: /^Preview/ })).toBeNull(); + fireEvent.click(button('Stop')); + fireEvent.click(button('Finish')); + expect(onStop).toHaveBeenCalled(); + expect(onFinish).toHaveBeenCalled(); + }); + + it('paused: Continue, Reset and Finish', () => { + const { onContinue } = renderCard({ status: 'yellow', elapsedTime: 331 }); + expect(screen.getByTestId('timer-status')).toHaveTextContent('Paused · red at 6:00'); + fireEvent.click(button('Continue')); + expect(onContinue).toHaveBeenCalled(); + expect(button('Reset')).toBeInTheDocument(); + expect(button('Finish')).toBeInTheDocument(); + }); + + it('video off: Start and the swatches wait for the video', () => { + renderCard({ videoOff: true }); + expect(button('Start')).toBeDisabled(); + expect(button('Preview green on your video')).toBeDisabled(); + expect(screen.getByTestId('timer-status')).toHaveTextContent('Start works once your video is on'); + }); + + it('holds a preview swatch and says so', () => { + const { onPreviewColor, rerender } = renderCard(); + fireEvent.click(button('Preview green on your video')); + expect(onPreviewColor).toHaveBeenCalledWith('green'); + rerender({ previewColor: 'green', status: 'green' }); + expect(button('Preview green on your video')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('timer-status')).toHaveTextContent('Previewing green on your video'); + }); + + it('hides the rules line for Custom and Take a Break, whose times show below the card', () => { + renderCard({ selectedRole: BREAK_ROLE, rules: deriveBreakRules(300) }); + expect(screen.queryByRole('button', { name: 'Edit rules' })).toBeNull(); + expect(screen.getByTestId('timer-display')).toHaveTextContent('05:00'); + }); + + it('keeps the count-up controls hidden until pointed at, and shows the one-time hint', () => { + const onCoachSeen = vi.fn(); + renderCard({ coachHint: 'Drag me to move. Point at me to resize or hide.', onCoachSeen }); + expect(screen.getByRole('note')).toHaveTextContent('Drag me to move'); + const controls = button('Make the count-up larger').parentElement; + expect(controls.className).toContain('opacity-0'); + expect(controls.className).toContain('group-hover/chip:opacity-100'); + fireEvent.mouseEnter(button('Drag to place the count-up participants see on your video').parentElement); + expect(onCoachSeen).toHaveBeenCalled(); + }); + + it('draws no count-up chip in the full-screen mode', () => { + renderCard({ readoutPosition: null }); + expect(screen.queryByRole('button', { name: /count-up/ })).toBeNull(); + }); +}); + +describe('SpeakerPicker', () => { + function renderPicker(over = {}) { + const props = { + value: 'Bob', role: 'Ice Breaker', agendaItems: AGENDA, activeSpeakerId: 'a1', needsRole, + onSelectSpeaker: vi.fn(), onAddSpeaker: vi.fn(), onRenameSpeaker: vi.fn(), palette: PALETTE, ...over, + }; + render(); + fireEvent.click(screen.getByRole('button', { name: /speaker/i })); + return props; + } + + it('lists the agenda and the meeting, and marks a row that needs a role', async () => { + renderPicker(); + expect(screen.getByRole('listbox', { name: 'Speakers' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /Bob/ })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('option', { name: /Alex/ })).toHaveTextContent('Pick a role first'); + expect(await screen.findByRole('option', { name: /Maria Chen/ })).toHaveTextContent('Add as Ice Breaker'); + }); + + it('picks an agenda row, and adds a meeting participant to the agenda', async () => { + const props = renderPicker(); + fireEvent.click(screen.getByRole('option', { name: /Alice/ })); + expect(props.onSelectSpeaker).toHaveBeenCalledWith('a3'); + + fireEvent.click(screen.getByRole('button', { name: /speaker/i })); + fireEvent.click(await screen.findByRole('option', { name: /Maria Chen/ })); + expect(props.onAddSpeaker).toHaveBeenCalledWith('Maria Chen'); + }); + + it('adds a new typed name on Enter, and offers to rename the current speaker', () => { + const props = renderPicker(); + const input = screen.getByRole('textbox', { name: 'Type a name, or pick one' }); + fireEvent.change(input, { target: { value: 'Priya' } }); + expect(screen.getByRole('option', { name: /Rename Bob to Priya/ })).toBeInTheDocument(); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(props.onAddSpeaker).toHaveBeenCalledWith('Priya'); + }); + + it('picks an existing name on Enter rather than adding it twice', () => { + const props = renderPicker(); + const input = screen.getByRole('textbox', { name: 'Type a name, or pick one' }); + fireEvent.change(input, { target: { value: 'alice' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(props.onSelectSpeaker).toHaveBeenCalledWith('a3'); + expect(props.onAddSpeaker).not.toHaveBeenCalled(); + }); +}); + +describe('RolePicker', () => { + it('lists the roles with their thresholds, then Custom and Take a Break, then the rules', () => { + const onChange = vi.fn(); + const onEditRules = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /Timing role/ })); + const options = screen.getAllByRole('option').map((option) => option.textContent); + expect(options[0]).toBe('Short Roles0:30 · 0:45 · 1:00'); + expect(options.slice(-2)).toEqual(['Customset your own times', `${BREAK_ROLE}counts down`]); + expect(screen.getByRole('option', { name: /Ice Breaker/ })).toHaveAttribute('aria-selected', 'true'); + + fireEvent.click(screen.getByRole('option', { name: /Speech Evaluation/ })); + expect(onChange).toHaveBeenCalledWith('Speech Evaluation'); + + fireEvent.click(screen.getByRole('button', { name: /Timing role/ })); + fireEvent.click(screen.getByRole('button', { name: 'Edit roles & timing rules' })); + expect(onEditRules).toHaveBeenCalled(); + }); +}); + +describe('LookRows', () => { + it('shows each setting with its value, and opens only its own sheet', () => { + const onOpen = vi.fn(); + render(); + expect(screen.getByRole('button', { name: 'On your video: Camera + timer' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sounds, Pro' })).toHaveTextContent('PRO'); + fireEvent.click(screen.getByRole('button', { name: 'Cards: Classic' })); + fireEvent.click(screen.getByRole('button', { name: 'Between speeches: Timer card' })); + expect(onOpen.mock.calls).toEqual([['cards'], ['between']]); + }); +}); + +describe('the panels', () => { + it('reads typed times the way they are written', () => { + expect(parseTimeText('1:30', 'minsec')).toBe(90); + expect(parseTimeText('2', 'minsec')).toBe(120); + expect(parseTimeText('90', 'seconds')).toBe(90); + expect(parseTimeText('1:75', 'minsec')).toBeNull(); + expect(parseTimeText('', 'minsec')).toBeNull(); + }); + + it('custom times commit on blur and warn about a wrong order', () => { + const onChange = vi.fn(); + render( {}} onChange={onChange} />); + expect(screen.getByText('Yellow must be greater than Green')).toBeInTheDocument(); + const green = screen.getByLabelText('Green'); + fireEvent.change(green, { target: { value: '0:45' } }); + fireEvent.blur(green); + expect(onChange).toHaveBeenCalledWith('green', 45); + }); + + it('break length: one tap per quick pick, and a custom length', () => { + const onChange = vi.fn(); + render( {}} onChange={onChange} />); + expect(screen.getByRole('button', { name: '10 min' })).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(screen.getByRole('button', { name: '5 min' })); + expect(onChange).toHaveBeenCalledWith(300); + fireEvent.click(screen.getByRole('button', { name: 'Custom' })); + expect(screen.getByLabelText('Length')).toBeInTheDocument(); + }); + + it('up next says when that row still needs a role', () => { + render(); + expect(screen.getByTestId('up-next')).toHaveTextContent('Up nextAlexPick a role'); + }); +}); diff --git a/apps/zoom-app/src/b/TimerPanels.jsx b/apps/zoom-app/src/b/TimerPanels.jsx new file mode 100644 index 0000000..e6f9197 --- /dev/null +++ b/apps/zoom-app/src/b/TimerPanels.jsx @@ -0,0 +1,226 @@ +import { useEffect, useState } from 'react'; +import { Music, VideoOff, X } from 'lucide-react'; +import { BREAK_QUICK_PICKS, BREAK_TRACKS, BREAK_ROLE } from '@toastmaster-timer/shared'; +import { soundEngine, useSoundSettings, useSoundsAccess } from '../hooks/useSounds'; +import { formatThreshold } from './format'; + +/** + * The pieces design B puts around the timer card (boards B-T02, T11, T12–T15): + * who is up next, what happened after Finish, video off, the break length and + * the custom times. Each draws only; TimerTab wires them to useLiveTimer. + */ + +/** "Up next: Alex · Table Topics", with a tag when that row still needs a role. */ +export function UpNext({ item, needsRole }) { + if (!item) return null; + return ( +
+ Up next + + {item.name} + {!needsRole && item.role ? ` · ${item.role}` : ''} + + {needsRole && Pick a role} +
+ ); +} + +/** After Finish, when the next row cannot be loaded until it has a role. */ +export function FinishNotice({ finishedName, nextName, onPickRole, onDismiss }) { + return ( +
+ + {finishedName ? `${finishedName}'s speech is saved to the report. ` : 'Saved to the report. '} + Next on the agenda: {nextName || 'the next speaker'}, who needs a role. + + + +
+ ); +} + +/** Video off: the colour has nowhere to go, so say so and offer the fix. */ +export function VideoOffNotice({ onTurnOn, busy }) { + return ( +
+
+ ); +} + +/** + * Parse what someone typed into a time field. In min:sec mode "1:30" is 90 and + * a bare "2" is two minutes; in seconds mode it is a count of seconds. + * + * @returns {number|null} seconds, or null for something unreadable + */ +export function parseTimeText(text, mode) { + const value = String(text ?? '').trim(); + if (!value) return null; + if (mode === 'seconds') return /^\d+$/.test(value) ? Number(value) : null; + const match = /^(\d{1,3})(?::([0-5]?\d))?$/.exec(value); + if (!match) return null; + return Number(match[1]) * 60 + Number(match[2] ?? 0); +} + +const showTime = (seconds, mode) => (mode === 'seconds' ? String(seconds) : formatThreshold(seconds)); + +/** One compact time field: typed as text, committed on blur or Enter. */ +export function TimeField({ label, value, mode, onChange, tone = 'text-slate-600' }) { + const [text, setText] = useState(() => showTime(value, mode)); + useEffect(() => setText(showTime(value, mode)), [value, mode]); + + const commit = () => { + const seconds = parseTimeText(text, mode); + if (seconds === null) setText(showTime(value, mode)); + else if (seconds !== value) onChange(seconds); + }; + + return ( + + ); +} + +/** min:sec | seconds, as a small segmented switch. */ +export function TimeModeSwitch({ mode, onModeChange }) { + return ( + + {[ + ['minsec', 'min:sec'], + ['seconds', 'seconds'], + ].map(([id, label]) => ( + + ))} + + ); +} + +/** Custom role: four thresholds in one row (board B-T14). */ +export function CustomTimesPanel({ rules, mode, onModeChange, onChange }) { + const yellowLow = rules.yellow <= rules.green; + const redLow = rules.red <= rules.yellow; + return ( +
+
+ Custom times + +
+
+ onChange('green', v)} /> + onChange('yellow', v)} /> + onChange('red', v)} /> + onChange('graceAfterRed', v)} /> +
+ {yellowLow &&

Yellow must be greater than Green

} + {redLow &&

Red must be greater than Yellow

} +

Grace: how long after red before the report marks the speech over time.

+
+ ); +} + +/** Take a Break, before it starts: how long (board B-T12). */ +export function BreakPanel({ seconds, explanation, mode, onModeChange, onChange }) { + const [custom, setCustom] = useState(!BREAK_QUICK_PICKS.includes(seconds)); + const pick = 'h-[34px] rounded-lg text-[13px]'; + const on = `${pick} border-2 border-blue-600 bg-blue-50 font-bold text-blue-900`; + const off = `${pick} border border-gray-200 bg-white hover:bg-slate-50`; + return ( +
+
+ Break length + {custom && } +
+
+ {BREAK_QUICK_PICKS.map((value) => ( + + ))} + +
+ {custom && ( +
+ +
+ )} + {explanation &&

{explanation}

} +
+ ); +} + +/** + * While a break counts down with music on: say what is playing, and let the + * organizer stop it for this stretch (board B-T13). Pausing and continuing the + * break starts it again, as it always has. + */ +export function BreakMusicRow({ isRunning, role }) { + const settings = useSoundSettings(); + const { usable } = useSoundsAccess(); + const [stopped, setStopped] = useState(false); + useEffect(() => setStopped(false), [isRunning]); + + if (!usable || !settings.music || !isRunning || role !== BREAK_ROLE) return null; + const track = BREAK_TRACKS.find((t) => t.id === settings.track) || BREAK_TRACKS[0]; + + return ( +
+
+ ); +} + diff --git a/apps/zoom-app/src/b/format.js b/apps/zoom-app/src/b/format.js new file mode 100644 index 0000000..ae09dc3 --- /dev/null +++ b/apps/zoom-app/src/b/format.js @@ -0,0 +1,93 @@ +import { formatPhaseTextFor, getPhaseInfo } from '@toastmaster-timer/shared'; + +/** + * Words and colours for design B's timer card (plan 13). Kept apart from the + * components so the card's sentences have one definition and one test. + */ + +/** The card's colours per status, as drawn on the BCard board. */ +export const CARD_PALETTE = Object.freeze({ + blue: { bg: '#4f86f7', fg: '#ffffff', shadow: 'rgba(37,99,235,0.28)', soft: 'rgba(255,255,255,0.22)', ghost: 'rgba(0,0,0,0.18)', inset: 'rgba(0,0,0,0.12)', ring: 'rgba(255,255,255,0.7)' }, + green: { bg: '#16a34a', fg: '#ffffff', shadow: 'rgba(22,163,74,0.3)', soft: 'rgba(255,255,255,0.22)', ghost: 'rgba(0,0,0,0.2)', inset: 'rgba(0,0,0,0.12)', ring: 'rgba(255,255,255,0.7)' }, + yellow: { bg: '#facc15', fg: '#422006', shadow: 'rgba(202,138,4,0.35)', soft: 'rgba(255,255,255,0.55)', ghost: 'rgba(66,32,6,0.12)', inset: 'rgba(66,32,6,0.08)', ring: 'rgba(66,32,6,0.35)' }, + red: { bg: '#dc2626', fg: '#ffffff', shadow: 'rgba(220,38,38,0.3)', soft: 'rgba(255,255,255,0.22)', ghost: 'rgba(0,0,0,0.22)', inset: 'rgba(0,0,0,0.14)', ring: 'rgba(255,255,255,0.7)' }, +}); + +/** The four preview swatches, in the order the meeting sees the colours. */ +export const PREVIEW_SWATCHES = Object.freeze([ + { color: 'blue', label: 'Blue', bg: '#4f86f7' }, + { color: 'green', label: 'Green', bg: '#22c55e' }, + { color: 'yellow', label: 'Yellow', bg: '#eab308' }, + { color: 'red', label: 'Red', bg: '#ef4444' }, +]); + +/** + * A threshold the way people say it: 4:00, 0:30, 12:05. Shorter than the + * clock's 04:00, which is what lets three of them share a line at 400px. + * + * @param {number} seconds + */ +export function formatThreshold(seconds) { + const safe = Math.max(0, Math.round(Number(seconds) || 0)); + const mins = Math.floor(safe / 60); + const secs = safe % 60; + return `${mins}:${String(secs).padStart(2, '0')}`; +} + +/** "Green 4:00 · Yellow 5:00 · Red 6:00", or '' without rules. */ +export function rulesLine(rules) { + if (!rules) return ''; + return `Green ${formatThreshold(rules.green)} · Yellow ${formatThreshold(rules.yellow)} · Red ${formatThreshold(rules.red)}`; +} + +/** "4:00 · 5:00 · 6:00", for the role picker's rows. */ +export function thresholdsLine(rules) { + if (!rules) return ''; + return `${formatThreshold(rules.green)} · ${formatThreshold(rules.yellow)} · ${formatThreshold(rules.red)}`; +} + +const lowerFirst = (text) => (text ? text.charAt(0).toLowerCase() + text.slice(1) : text); +const NEXT_COLOUR = { blue: 'green', green: 'yellow', yellow: 'red' }; +const COLOUR_NAME = { blue: 'Blue', green: 'Green', yellow: 'Yellow', red: 'Red' }; +const THRESHOLD_FOR = { green: 'green', yellow: 'yellow', red: 'red' }; + +/** + * The one line under the big clock: what is happening now and what comes + * next, in the card's own words. + * + * Ready · green at 4:00 idle speech + * Blue · green at 4:00 running + * Red phase running, past red + * Paused · red at 6:00 stopped mid-speech + * Counts down · green when 02:00 left idle break + * Start works once your video is on video off, nothing started + * + * @param {{ + * status: 'blue'|'green'|'yellow'|'red'|null, + * rules: Object|null, + * isRunning: boolean, + * elapsedTime: number, + * previewColor?: string|null, + * videoOff?: boolean, + * }} state + * @returns {string} + */ +export function cardStatusLine({ status, rules, isRunning, elapsedTime, previewColor = null, videoOff = false }) { + const idle = !isRunning && elapsedTime === 0; + if (idle && videoOff) return 'Start works once your video is on'; + if (idle && previewColor) return `Previewing ${previewColor} on your video`; + if (!rules) return ''; + + const current = status || 'blue'; + if (rules.countdown) { + const phase = formatPhaseTextFor(getPhaseInfo(elapsedTime, rules, current), rules); + if (idle) return `Counts down · ${lowerFirst(phase)}`; + return isRunning ? phase : `Paused · ${lowerFirst(phase)}`; + } + + if (idle) return `Ready · green at ${formatThreshold(rules.green)}`; + const next = NEXT_COLOUR[current]; + const upcoming = next ? `${next} at ${formatThreshold(rules[THRESHOLD_FOR[next]])}` : null; + if (!isRunning) return upcoming ? `Paused · ${upcoming}` : 'Paused · red'; + return upcoming ? `${COLOUR_NAME[current]} · ${upcoming}` : 'Red phase'; +} diff --git a/apps/zoom-app/src/b/format.test.js b/apps/zoom-app/src/b/format.test.js new file mode 100644 index 0000000..6b72057 --- /dev/null +++ b/apps/zoom-app/src/b/format.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { deriveBreakRules } from '@toastmaster-timer/shared'; +import { cardStatusLine, formatThreshold, rulesLine, thresholdsLine } from './format'; + +const ICE_BREAKER = { green: 240, yellow: 300, red: 360, graceAfterRed: 30 }; + +describe('formatThreshold', () => { + it('says thresholds the short way', () => { + expect(formatThreshold(240)).toBe('4:00'); + expect(formatThreshold(30)).toBe('0:30'); + expect(formatThreshold(725)).toBe('12:05'); + expect(formatThreshold(-5)).toBe('0:00'); + }); +}); + +describe('rulesLine / thresholdsLine', () => { + it('reads the three colours', () => { + expect(rulesLine(ICE_BREAKER)).toBe('Green 4:00 · Yellow 5:00 · Red 6:00'); + expect(thresholdsLine(ICE_BREAKER)).toBe('4:00 · 5:00 · 6:00'); + expect(rulesLine(null)).toBe(''); + }); +}); + +describe('cardStatusLine', () => { + const speech = (over) => cardStatusLine({ status: 'blue', rules: ICE_BREAKER, isRunning: false, elapsedTime: 0, ...over }); + + it('says when the first colour comes before anything starts', () => { + expect(speech()).toBe('Ready · green at 4:00'); + }); + + it('names the colour on and the next one while running', () => { + expect(speech({ isRunning: true, elapsedTime: 72 })).toBe('Blue · green at 4:00'); + expect(speech({ status: 'green', isRunning: true, elapsedTime: 267 })).toBe('Green · yellow at 5:00'); + expect(speech({ status: 'yellow', isRunning: true, elapsedTime: 318 })).toBe('Yellow · red at 6:00'); + expect(speech({ status: 'red', isRunning: true, elapsedTime: 369 })).toBe('Red phase'); + }); + + it('says paused, with what is next', () => { + expect(speech({ status: 'yellow', elapsedTime: 331 })).toBe('Paused · red at 6:00'); + expect(speech({ status: 'red', elapsedTime: 400 })).toBe('Paused · red'); + }); + + it('tells a video-off organizer why Start will not work, before anything starts', () => { + expect(speech({ videoOff: true })).toBe('Start works once your video is on'); + expect(speech({ videoOff: true, isRunning: true, elapsedTime: 10 })).toBe('Blue · green at 4:00'); + }); + + it('says a preview is a preview', () => { + expect(speech({ previewColor: 'green' })).toBe('Previewing green on your video'); + }); + + it('counts a break down', () => { + const rules = deriveBreakRules(300); + expect(cardStatusLine({ status: 'blue', rules, isRunning: false, elapsedTime: 0 })).toMatch(/^Counts down · green when \d\d:\d\d left$/); + expect(cardStatusLine({ status: 'green', rules, isRunning: true, elapsedTime: 200 })).toMatch(/^Yellow when \d\d:\d\d left$/); + expect(cardStatusLine({ status: 'green', rules, isRunning: false, elapsedTime: 200 })).toMatch(/^Paused · yellow when/); + }); + + it('says nothing without rules', () => { + expect(cardStatusLine({ status: 'blue', rules: null, isRunning: false, elapsedTime: 0 })).toBe(''); + }); +}); From bf4ef52691fe654cfe26bd2170bd2f5afb75cc27 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 20:08:24 -0500 Subject: [PATCH 4/6] feat(zoom-app): design B's Timer tab and the Look & sound sheets TimerTab draws the card from useLiveTimer, so every push to Zoom is the old tab's code. Look & sound rows show their current values and each opens only its own sheet: On your video (modes, clear), Cards (sets only), Sounds (the same lock), Between speeches (timer card, or my video with an optional background). --- apps/zoom-app/src/b/LookRows.jsx | 58 +++++ apps/zoom-app/src/b/LookSheets.jsx | 178 +++++++++++++ apps/zoom-app/src/b/NewLookShell.test.jsx | 188 ++++++++++++++ apps/zoom-app/src/b/TimerTab.jsx | 300 ++++++++++++++++++++++ 4 files changed, 724 insertions(+) create mode 100644 apps/zoom-app/src/b/LookRows.jsx create mode 100644 apps/zoom-app/src/b/LookSheets.jsx create mode 100644 apps/zoom-app/src/b/NewLookShell.test.jsx create mode 100644 apps/zoom-app/src/b/TimerTab.jsx diff --git a/apps/zoom-app/src/b/LookRows.jsx b/apps/zoom-app/src/b/LookRows.jsx new file mode 100644 index 0000000..716f2a9 --- /dev/null +++ b/apps/zoom-app/src/b/LookRows.jsx @@ -0,0 +1,58 @@ +import { ChevronRight, Image, Layers, Video, Volume2 } from 'lucide-react'; + +const ROW = + 'flex w-full items-center gap-2.5 rounded-[10px] border border-gray-200 bg-white px-3 py-2.5 text-left text-sm text-gray-900 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-blue-500'; + +/** Design B's names for the three display modes. */ +export const VIDEO_MODE_NOTES = Object.freeze({ + camera: 'Camera + timer', + card: 'Timer only', + stage: 'Full-screen timer', +}); + +/** + * "Look & sound" (the BLook board): one wide row per setting, each showing + * what it is set to now and opening only its own sheet. + * + * @param {{ + * video: string, + * cards: string, + * sounds: string, + * soundsLocked: boolean, + * between: string, + * onOpen: (sheet: 'video'|'cards'|'sounds'|'between') => void, + * }} props + */ +export default function LookRows({ video, cards, sounds, soundsLocked, between, onOpen }) { + const rows = [ + { id: 'video', label: 'On your video', note: video, Icon: Video }, + { id: 'cards', label: 'Cards', note: cards, Icon: Image }, + { id: 'sounds', label: 'Sounds', note: soundsLocked ? null : sounds, Icon: Volume2, pro: soundsLocked }, + { id: 'between', label: 'Between speeches', note: between, Icon: Layers }, + ]; + + return ( +
+

+ Look & sound +

+
+ {rows.map(({ id, label, note, Icon, pro }) => ( + + ))} +
+
+ ); +} diff --git a/apps/zoom-app/src/b/LookSheets.jsx b/apps/zoom-app/src/b/LookSheets.jsx new file mode 100644 index 0000000..89ecce4 --- /dev/null +++ b/apps/zoom-app/src/b/LookSheets.jsx @@ -0,0 +1,178 @@ +import { Check, Info } from 'lucide-react'; +import CardSetsPanel from '../components/CardSetsPanel'; +import SoundsPanel from '../components/SoundsPanel'; +import OwnBackgroundPicker from '../components/OwnBackgroundPicker'; +import { soundEngine } from '../hooks/useSounds'; +import Sheet from './Sheet'; + +const CHOICE = 'flex w-full items-center gap-3 rounded-xl px-3 text-left'; + +/** A 72×42 sketch of what the meeting sees, for the mode and background choices. */ +function Sketch({ kind }) { + const person = ( + <> + + + + ); + const base = 'relative h-[42px] w-[72px] flex-shrink-0 overflow-hidden rounded-md'; + if (kind === 'camera') return ; + if (kind === 'card') + return ( + + ); + if (kind === 'stage') + return ( + + ); + if (kind === 'card-idle') return ; + return ( +