diff --git a/apps/docs/docs/widgets/timer/index.mdx b/apps/docs/docs/widgets/timer/index.mdx index e1dca36ae..9b3a416cf 100644 --- a/apps/docs/docs/widgets/timer/index.mdx +++ b/apps/docs/docs/widgets/timer/index.mdx @@ -35,7 +35,8 @@ supplement the visible timer state rather than replacing it. When automatic start is disabled, a completed phase labels the next step as ready and flashes the widget edges with the board's secondary color. The border glow and completion chime run together as a five-beat alarm burst at 300-millisecond intervals, then stop. Reduced-motion and forced-colors modes replace the flash with a static border. -Compact controls also step down in size when the widget preview is narrow or short. +Compact labels, timer text, and controls use the dashboard theme's responsive size scale, with secondary details hidden +when the widget preview is narrow or short. Alerts can fire while Homarr is open. Browser and operating-system notification settings may still suppress them. The timer's mode, durations, session count, and automatic-start behavior are permanent board settings and are edited diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index d8505a0c7..15d64af7a 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -227,7 +227,9 @@ const LoadedBoardItemContent = ({ (host: HTMLDivElement | null) => { if (!host || !surfacePortalTarget || isAdvanced) return; cancelPreviewEntrance(); - host.append(surfacePortalTarget); + if (host.childNodes.length !== 1 || host.firstChild !== surfacePortalTarget) { + host.replaceChildren(surfacePortalTarget); + } }, [cancelPreviewEntrance, isAdvanced, surfacePortalTarget], ); diff --git a/packages/widgets/src/timer/component.module.css b/packages/widgets/src/timer/component.module.css deleted file mode 100644 index 0f1d68fea..000000000 --- a/packages/widgets/src/timer/component.module.css +++ /dev/null @@ -1,73 +0,0 @@ -.root { - container-type: size; - overflow: hidden; -} - -.hero { - background: linear-gradient(145deg, var(--mantine-primary-color-light), var(--mantine-color-secondaryColor-light)); -} - -.time { - font-variant-numeric: tabular-nums; - letter-spacing: -0.045em; -} - -.root[data-needs-attention="true"], -.hero[data-needs-attention="true"] { - animation: timer-border-alert 300ms ease-out 5; -} - -@keyframes timer-border-alert { - 0% { - box-shadow: - inset 0 0 0 0 alpha(var(--mantine-color-secondaryColor-filled), 0), - 0 0 0 0 alpha(var(--mantine-color-secondaryColor-filled), 0); - } - - 35% { - box-shadow: - inset 0 0 0 2px alpha(var(--mantine-color-secondaryColor-filled), 0.9), - 0 0 22px 5px alpha(var(--mantine-color-secondaryColor-filled), 0.65); - } - - 100% { - box-shadow: - inset 0 0 0 1px alpha(var(--mantine-color-secondaryColor-filled), 0), - 0 0 28px 7px alpha(var(--mantine-color-secondaryColor-filled), 0); - } -} - -@media (prefers-reduced-motion: reduce) { - .root[data-needs-attention="true"], - .hero[data-needs-attention="true"] { - animation: none; - box-shadow: inset 0 0 0 2px var(--mantine-color-secondaryColor-filled); - } -} - -@media (forced-colors: active) { - .root[data-needs-attention="true"], - .hero[data-needs-attention="true"] { - animation: none; - outline: 2px solid Highlight; - outline-offset: 2px; - } -} - -@container (max-height: 125px) { - .root [role="progressbar"] { - display: none; - } -} - -@container (max-width: 210px) { - .completedFocus { - display: none; - } -} - -@container (max-height: 110px) { - .phaseBadge { - display: none; - } -} diff --git a/packages/widgets/src/timer/component.tsx b/packages/widgets/src/timer/component.tsx index 482519fab..e1da71a69 100644 --- a/packages/widgets/src/timer/component.tsx +++ b/packages/widgets/src/timer/component.tsx @@ -1,5 +1,6 @@ "use client"; +import type { CSSProperties } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActionIcon, @@ -15,6 +16,7 @@ import { Text, Tooltip, } from "@mantine/core"; +import { useMediaQuery, useReducedMotion } from "@mantine/hooks"; import { IconBell, IconPlayerPause, IconPlayerPlay, IconRefresh, IconSettings, IconVolume } from "@tabler/icons-react"; import { useTiks } from "@rexa-developer/tiks/react"; @@ -29,7 +31,6 @@ import { import { useLocalWidgetState } from "../common/use-local-widget-state"; import { useWidgetNow } from "../common/use-widget-now"; import type { WidgetComponentProps } from "../definition"; -import classes from "./component.module.css"; import { createTimerRuntime, getRemainingMs, @@ -58,6 +59,8 @@ export default function TimerWidget({ }: WidgetComponentProps<"timer">) { const now = useWidgetNow("second"); const t = useScopedI18n("widget.timer"); + const reducedMotion = useReducedMotion(); + const forcedColors = useMediaQuery("(forced-colors: active)") ?? false; const { click: playResetSound, toggle: playRunningSound } = useTiks({ theme: "soft", volume: 0.12, @@ -97,13 +100,31 @@ export default function TimerWidget({ migrate: migrateTimerRuntimeState, }); const [notificationPermission, setNotificationPermission] = useState(() => getBrowserNotificationPermission()); + const [attentionFlash, setAttentionFlash] = useState(false); const historyInitializedRef = useRef(false); const handledHistoryIdRef = useRef(null); const completionSoundTimeoutRef = useRef(null); + const attentionFlashTimeoutRef = useRef(null); const stopCompletionSoundSequence = useCallback(() => { - if (completionSoundTimeoutRef.current === null) return; - window.clearTimeout(completionSoundTimeoutRef.current); - completionSoundTimeoutRef.current = null; + if (completionSoundTimeoutRef.current !== null) { + window.clearTimeout(completionSoundTimeoutRef.current); + completionSoundTimeoutRef.current = null; + } + if (attentionFlashTimeoutRef.current !== null) { + window.clearTimeout(attentionFlashTimeoutRef.current); + attentionFlashTimeoutRef.current = null; + } + setAttentionFlash(false); + }, []); + const flashAttention = useCallback(() => { + if (attentionFlashTimeoutRef.current !== null) { + window.clearTimeout(attentionFlashTimeoutRef.current); + } + setAttentionFlash(true); + attentionFlashTimeoutRef.current = window.setTimeout(() => { + attentionFlashTimeoutRef.current = null; + setAttentionFlash(false); + }, 180); }, []); useEffect(() => { @@ -138,12 +159,14 @@ export default function TimerWidget({ stopCompletionSoundSequence(); playCompletionSound(); if (!runtime.awaitingManualStart) return; + flashAttention(); let remainingSounds = completionSoundCount - 1; const playNextSound = () => { completionSoundTimeoutRef.current = window.setTimeout(() => { completionSoundTimeoutRef.current = null; playCompletionSound(); + flashAttention(); remainingSounds -= 1; if (remainingSounds > 0) playNextSound(); }, completionSoundIntervalMs); @@ -151,6 +174,7 @@ export default function TimerWidget({ playNextSound(); }, [ playCompletionSound, + flashAttention, runtime.alerts.notifications, runtime.alerts.sound, runtime.awaitingManualStart, @@ -180,6 +204,12 @@ export default function TimerWidget({ const phaseDurationMs = runtime.totalDurationMs; const progress = Math.min(100, Math.max(0, ((phaseDurationMs - remainingMs) / phaseDurationMs) * 100)); const controlSize = getTimerControlSize(width, height, displayMode); + const attentionStyle = getTimerAttentionStyle({ + awaitingManualStart: runtime.awaitingManualStart, + flash: attentionFlash, + forcedColors, + reducedMotion, + }); const toggleRunning = () => { if (runtime.alerts.sound) playRunningSound(runtime.status !== "running"); if (runtime.status === "running") { @@ -210,12 +240,15 @@ export default function TimerWidget({ const shared = { disabled: isEditMode, + attentionStyle, controlSize, + height, onReset: reset, onToggleRunning: toggleRunning, progress, remainingMs, runtime, + width, }; if (displayMode !== "advanced") return ; @@ -234,51 +267,63 @@ export default function TimerWidget({ type TimerControlSize = "sm" | "md" | "lg"; interface TimerDisplayProps { + attentionStyle: CSSProperties; controlSize: TimerControlSize; disabled: boolean; + height: number; onReset: () => void; onToggleRunning: () => void; progress: number; remainingMs: number; runtime: TimerRuntimeState; + width: number; } const CompactTimer = ({ + attentionStyle, controlSize, disabled, + height, onReset, onToggleRunning, progress, remainingMs, runtime, + width, }: TimerDisplayProps) => { const t = useScopedI18n("widget.timer"); const phaseLabel = getTimerPhaseLabel(runtime, t); + const showPhaseBadge = height >= 110; + const showCompletedFocus = runtime.mode === "pomodoro" && width >= 210; + const showProgress = height >= 125; return ( - - {phaseLabel} - - {runtime.mode === "pomodoro" && ( - + {showPhaseBadge && ( + + {phaseLabel} + + )} + {showCompletedFocus && ( + {t("compact.completedFocus", { count: runtime.completedFocusSessions })} )} - + {formatTimerDuration(remainingMs)} - + {showProgress && ( + + )} - + - + {phaseLabel} - + {formatTimerDuration(remainingMs)} @@ -442,6 +502,42 @@ const getTimerControlSize = ( return "lg"; }; +const getTimerAttentionStyle = ({ + awaitingManualStart, + flash, + forcedColors, + reducedMotion, +}: { + awaitingManualStart: boolean; + flash: boolean; + forcedColors: boolean; + reducedMotion: boolean; +}): CSSProperties => { + if (forcedColors && awaitingManualStart) { + return { + outline: "0.125rem solid Highlight", + outlineOffset: "0.125rem", + }; + } + if (reducedMotion && awaitingManualStart) { + return { + boxShadow: + "inset 0 0 1rem 0.125rem color-mix(in srgb, var(--mantine-color-secondaryColor-filled) 45%, transparent)", + }; + } + if (!flash) { + return { + boxShadow: "none", + transition: "box-shadow 0.12s ease-out", + }; + } + return { + boxShadow: + "inset 0 0 1rem 0.125rem color-mix(in srgb, var(--mantine-color-secondaryColor-filled) 45%, transparent), 0 0 3rem 0.875rem color-mix(in srgb, var(--mantine-color-secondaryColor-filled) 40%, transparent)", + transition: "box-shadow 0.12s ease-out", + }; +}; + const PomodoroSequence = ({ runtime, durations }: { runtime: TimerRuntimeState; durations: TimerDurations }) => { const t = useScopedI18n("widget.timer"); return (