@wandelbots/wandelbots-js-react-components 2.34.2 → 2.35.0-pr.feature-replace-forwardref.376.d3a302b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +1 -1
  2. package/dist/components/AppHeader.d.ts +34 -0
  3. package/dist/components/AppHeader.d.ts.map +1 -0
  4. package/dist/components/CopyableText.d.ts +3 -2
  5. package/dist/components/CopyableText.d.ts.map +1 -1
  6. package/dist/components/CycleTimer.d.ts +33 -16
  7. package/dist/components/CycleTimer.d.ts.map +1 -1
  8. package/dist/components/DataGrid.d.ts +66 -0
  9. package/dist/components/DataGrid.d.ts.map +1 -0
  10. package/dist/components/LogPanel.d.ts +38 -0
  11. package/dist/components/LogPanel.d.ts.map +1 -0
  12. package/dist/components/LogStore.d.ts +12 -0
  13. package/dist/components/LogStore.d.ts.map +1 -0
  14. package/dist/components/LogViewer.d.ts +46 -0
  15. package/dist/components/LogViewer.d.ts.map +1 -0
  16. package/dist/components/ProgramControl.d.ts +8 -2
  17. package/dist/components/ProgramControl.d.ts.map +1 -1
  18. package/dist/components/ProgramStateIndicator.d.ts +1 -1
  19. package/dist/components/ProgramStateIndicator.d.ts.map +1 -1
  20. package/dist/components/RobotCard.d.ts +103 -0
  21. package/dist/components/RobotCard.d.ts.map +1 -0
  22. package/dist/components/RobotListItem.d.ts +34 -0
  23. package/dist/components/RobotListItem.d.ts.map +1 -0
  24. package/dist/components/RobotSetupReadinessIndicator.d.ts +31 -0
  25. package/dist/components/RobotSetupReadinessIndicator.d.ts.map +1 -0
  26. package/dist/components/RobotSetupReadinessIndicator.test.d.ts +2 -0
  27. package/dist/components/RobotSetupReadinessIndicator.test.d.ts.map +1 -0
  28. package/dist/components/SelectableFab.d.ts +2 -1
  29. package/dist/components/SelectableFab.d.ts.map +1 -1
  30. package/dist/components/TabBar.d.ts +32 -0
  31. package/dist/components/TabBar.d.ts.map +1 -0
  32. package/dist/components/robots/Robot.d.ts +3 -2
  33. package/dist/components/robots/Robot.d.ts.map +1 -1
  34. package/dist/components/robots/manufacturerHomePositions.d.ts +21 -0
  35. package/dist/components/robots/manufacturerHomePositions.d.ts.map +1 -0
  36. package/dist/icons/DropdownArrowIcon.d.ts +3 -0
  37. package/dist/icons/DropdownArrowIcon.d.ts.map +1 -0
  38. package/dist/icons/index.d.ts +1 -0
  39. package/dist/icons/index.d.ts.map +1 -1
  40. package/dist/index.cjs +50 -50
  41. package/dist/index.cjs.map +1 -1
  42. package/dist/index.d.ts +10 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +11381 -9522
  45. package/dist/index.js.map +1 -1
  46. package/dist/themes/createDarkTheme.d.ts.map +1 -1
  47. package/package.json +6 -5
  48. package/src/components/AppHeader.md +84 -0
  49. package/src/components/AppHeader.tsx +199 -0
  50. package/src/components/CopyableText.tsx +70 -73
  51. package/src/components/CycleTimer.tsx +384 -159
  52. package/src/components/DataGrid.tsx +659 -0
  53. package/src/components/LogPanel.tsx +69 -0
  54. package/src/components/LogStore.ts +44 -0
  55. package/src/components/LogViewer.tsx +370 -0
  56. package/src/components/ProgramControl.tsx +27 -12
  57. package/src/components/ProgramStateIndicator.tsx +25 -8
  58. package/src/components/RobotCard.tsx +568 -0
  59. package/src/components/RobotListItem.tsx +150 -0
  60. package/src/components/RobotSetupReadinessIndicator.test.tsx +60 -0
  61. package/src/components/RobotSetupReadinessIndicator.tsx +124 -0
  62. package/src/components/SelectableFab.tsx +14 -15
  63. package/src/components/TabBar.tsx +147 -0
  64. package/src/components/robots/Robot.tsx +5 -2
  65. package/src/components/robots/manufacturerHomePositions.ts +76 -0
  66. package/src/i18n/locales/de/translations.json +8 -1
  67. package/src/i18n/locales/en/translations.json +8 -1
  68. package/src/icons/DropdownArrowIcon.tsx +13 -0
  69. package/src/icons/chevronDown.svg +3 -0
  70. package/src/icons/index.ts +1 -0
  71. package/src/index.ts +14 -0
  72. package/src/themes/createDarkTheme.ts +75 -1
@@ -1,4 +1,4 @@
1
- import { Box, Typography, useTheme } from "@mui/material"
1
+ import { Box, Fade, Typography, useTheme } from "@mui/material"
2
2
  import { Gauge } from "@mui/x-charts/Gauge"
3
3
  import { observer } from "mobx-react-lite"
4
4
  import { useCallback, useEffect, useRef, useState } from "react"
@@ -9,13 +9,13 @@ import { useInterpolation } from "./utils/interpolation"
9
9
  export interface CycleTimerProps {
10
10
  /**
11
11
  * Callback that receives the timer control functions:
12
- * - `startNewCycle(maxTimeSeconds, elapsedSeconds?)` - Start a new timer cycle
12
+ * - `startNewCycle(maxTimeSeconds?, elapsedSeconds?)` - Start a new timer cycle (if maxTimeSeconds is omitted, runs as count-up timer)
13
13
  * - `pause()` - Pause the countdown while preserving remaining time
14
14
  * - `resume()` - Resume countdown from where it was paused
15
15
  * - `isPaused()` - Check current pause state
16
16
  */
17
17
  onCycleComplete: (controls: {
18
- startNewCycle: (maxTimeSeconds: number, elapsedSeconds?: number) => void
18
+ startNewCycle: (maxTimeSeconds?: number, elapsedSeconds?: number) => void
19
19
  pause: () => void
20
20
  resume: () => void
21
21
  isPaused: () => boolean
@@ -30,18 +30,23 @@ export interface CycleTimerProps {
30
30
  compact?: boolean
31
31
  /** Additional CSS classes */
32
32
  className?: string
33
+ /** Whether the timer is in an error state (pauses timer and shows error styling) */
34
+ hasError?: boolean
33
35
  }
34
36
 
35
37
  /**
36
- * A circular gauge timer component that shows the remaining time of a cycle
38
+ * A circular gauge timer component that shows the remaining time of a cycle or counts up
37
39
  *
38
40
  * Features:
39
41
  * - Circular gauge with 264px diameter and 40px thickness
40
- * - Shows remaining time prominently in the center (60px font)
41
- * - Displays "remaining time" label at top and total time at bottom
42
- * - Automatically counts down and triggers callback when reaching zero
42
+ * - Two modes: count-down (with max time) or count-up (without max time)
43
+ * - Count-down mode: shows remaining time prominently, counts down to zero
44
+ * - Count-up mode: shows elapsed time, gauge progresses in minute steps
45
+ * - Displays appropriate labels based on mode
46
+ * - Automatically counts down/up and triggers callback when reaching zero (count-down only)
43
47
  * - Full timer control: start, pause, resume functionality
44
48
  * - Support for starting with elapsed time (resume mid-cycle)
49
+ * - Error state support: pauses timer and shows error styling (red color)
45
50
  * - Smooth spring-based progress animations for all state transitions
46
51
  * - Fully localized with i18next
47
52
  * - Material-UI theming integration
@@ -53,32 +58,44 @@ export interface CycleTimerProps {
53
58
  * @param variant - Visual variant: "default" (large gauge) or "small" (animated icon with text)
54
59
  * @param compact - For small variant: whether to hide remaining time details
55
60
  * @param className - Additional CSS classes
61
+ * @param hasError - Whether the timer is in an error state (pauses timer and shows error styling)
56
62
  *
57
63
  * Usage:
58
64
  * ```tsx
65
+ * // Count-down timer (with max time)
59
66
  * <CycleTimer
60
67
  * onCycleComplete={(controls) => {
61
- * // Start a 5-minute cycle
68
+ * // Start a 5-minute countdown cycle
62
69
  * controls.startNewCycle(300)
63
70
  *
64
71
  * // Or start a 5-minute cycle with 2 minutes already elapsed
65
72
  * controls.startNewCycle(300, 120)
73
+ * }}
74
+ * onCycleEnd={() => console.log('Cycle completed!')}
75
+ * />
66
76
  *
67
- * // Pause the timer
68
- * controls.pause()
77
+ * // Count-up timer (no max time)
78
+ * <CycleTimer
79
+ * onCycleComplete={(controls) => {
80
+ * // Start count-up timer
81
+ * controls.startNewCycle()
69
82
  *
70
- * // Resume the timer
71
- * controls.resume()
83
+ * // Or start count-up timer with some elapsed time
84
+ * controls.startNewCycle(undefined, 120)
85
+ * }}
86
+ * />
72
87
  *
73
- * // Check if paused
74
- * const paused = controls.isPaused()
88
+ * // Timer with error state
89
+ * <CycleTimer
90
+ * hasError={errorCondition}
91
+ * onCycleComplete={(controls) => {
92
+ * controls.startNewCycle(300)
75
93
  * }}
76
- * onCycleEnd={() => console.log('Cycle completed!')}
77
94
  * />
78
95
  * ```
79
96
  *
80
97
  * Control Functions:
81
- * - `startNewCycle(maxTimeSeconds, elapsedSeconds?)` - Start a new timer cycle
98
+ * - `startNewCycle(maxTimeSeconds?, elapsedSeconds?)` - Start a new timer cycle (omit maxTimeSeconds for count-up mode)
82
99
  * - `pause()` - Pause the countdown while preserving remaining time
83
100
  * - `resume()` - Resume countdown from where it was paused
84
101
  * - `isPaused()` - Check current pause state
@@ -92,17 +109,29 @@ export const CycleTimer = externalizeComponent(
92
109
  variant = "default",
93
110
  compact = false,
94
111
  className,
112
+ hasError = false,
95
113
  }: CycleTimerProps) => {
96
114
  const theme = useTheme()
97
115
  const { t } = useTranslation()
98
116
  const [remainingTime, setRemainingTime] = useState(0)
99
- const [maxTime, setMaxTime] = useState(0)
117
+ const [maxTime, setMaxTime] = useState<number | null>(null)
100
118
  const [isRunning, setIsRunning] = useState(false)
101
119
  const [isPausedState, setIsPausedState] = useState(false)
102
120
  const [currentProgress, setCurrentProgress] = useState(0)
103
121
  const animationRef = useRef<number | null>(null)
104
122
  const startTimeRef = useRef<number | null>(null)
105
123
  const pausedTimeRef = useRef<number>(0)
124
+ const [wasRunningBeforeError, setWasRunningBeforeError] = useState(false)
125
+
126
+ // Brief animation states for pause and error visual feedback
127
+ const [showPauseAnimation, setShowPauseAnimation] = useState(false)
128
+ const [showErrorAnimation, setShowErrorAnimation] = useState(false)
129
+ const pauseAnimationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
130
+ const errorAnimationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
131
+
132
+ // Track mode changes for fade transitions
133
+ const [showLabels, setShowLabels] = useState(true)
134
+ const prevMaxTimeRef = useRef<number | null | undefined>(undefined)
106
135
 
107
136
  // Spring-based interpolator for smooth gauge progress animations
108
137
  // Uses physics simulation to create natural, smooth transitions between progress values
@@ -114,37 +143,78 @@ export const CycleTimer = externalizeComponent(
114
143
  },
115
144
  })
116
145
 
146
+ // Handle mode changes with fade transitions for labels only
147
+ useEffect(() => {
148
+ const currentIsCountUp = maxTime === null
149
+ const prevMaxTime = prevMaxTimeRef.current
150
+ const prevIsCountUp = prevMaxTime === null
151
+
152
+ // Check if mode actually changed (not just first render)
153
+ if (
154
+ prevMaxTimeRef.current !== undefined &&
155
+ prevIsCountUp !== currentIsCountUp
156
+ ) {
157
+ // Mode changed - labels will fade based on the Fade component conditions
158
+ // We just need to ensure showLabels is true so Fade can control visibility
159
+ setShowLabels(true)
160
+ } else {
161
+ // No mode change or first time - set initial state
162
+ setShowLabels(true)
163
+ }
164
+
165
+ prevMaxTimeRef.current = maxTime
166
+ }, [maxTime])
167
+
117
168
  const startNewCycle = useCallback(
118
- (maxTimeSeconds: number, elapsedSeconds: number = 0) => {
119
- setMaxTime(maxTimeSeconds)
120
- const remainingSeconds = Math.max(0, maxTimeSeconds - elapsedSeconds)
121
- setRemainingTime(remainingSeconds)
169
+ (maxTimeSeconds?: number, elapsedSeconds: number = 0) => {
170
+ setMaxTime(maxTimeSeconds ?? null)
122
171
  setIsPausedState(false)
123
172
  pausedTimeRef.current = 0
124
173
 
125
- // Animate progress smoothly to starting position
126
- // For new cycles (no elapsed time), animate from current position to 0%
127
- // For resumed cycles, animate to the appropriate progress percentage
128
- const initialProgress =
129
- elapsedSeconds > 0 ? (elapsedSeconds / maxTimeSeconds) * 100 : 0
130
- if (elapsedSeconds === 0) {
131
- progressInterpolator.setTarget([0])
174
+ if (maxTimeSeconds !== undefined) {
175
+ // Count-down mode: set remaining time
176
+ const remainingSeconds = Math.max(
177
+ 0,
178
+ maxTimeSeconds - elapsedSeconds,
179
+ )
180
+ setRemainingTime(remainingSeconds)
181
+
182
+ // Animate progress smoothly to starting position
183
+ const initialProgress =
184
+ elapsedSeconds > 0 ? (elapsedSeconds / maxTimeSeconds) * 100 : 0
185
+ if (elapsedSeconds === 0) {
186
+ progressInterpolator.setTarget([0])
187
+ } else {
188
+ progressInterpolator.setTarget([initialProgress])
189
+ }
190
+
191
+ if (remainingSeconds === 0) {
192
+ setIsRunning(false)
193
+ startTimeRef.current = null
194
+ // Trigger completion callback immediately if time is already up
195
+ if (onCycleEnd) {
196
+ setTimeout(() => onCycleEnd(), 0)
197
+ }
198
+ } else if (autoStart) {
199
+ startTimeRef.current = Date.now() - elapsedSeconds * 1000
200
+ setIsRunning(true)
201
+ } else {
202
+ startTimeRef.current = null
203
+ }
132
204
  } else {
205
+ // Count-up mode: start from elapsed time
206
+ setRemainingTime(elapsedSeconds)
207
+
208
+ // For count-up mode, progress is based on minute steps
209
+ const initialProgress = ((elapsedSeconds / 60) % 1) * 100
133
210
  progressInterpolator.setTarget([initialProgress])
134
- }
135
211
 
136
- if (remainingSeconds === 0) {
137
- setIsRunning(false)
138
- startTimeRef.current = null
139
- // Trigger completion callback immediately if time is already up
140
- if (onCycleEnd) {
141
- setTimeout(() => onCycleEnd(), 0)
212
+ if (autoStart) {
213
+ startTimeRef.current = Date.now() - elapsedSeconds * 1000
214
+ setIsRunning(true)
215
+ } else {
216
+ startTimeRef.current = null
142
217
  }
143
- } else if (autoStart) {
144
- startTimeRef.current = Date.now() - elapsedSeconds * 1000
145
- setIsRunning(true)
146
- } else {
147
- startTimeRef.current = null
148
218
  }
149
219
  },
150
220
  [autoStart, onCycleEnd, progressInterpolator],
@@ -159,11 +229,32 @@ export const CycleTimer = externalizeComponent(
159
229
  // Calculate exact progress position and smoothly animate to it when pausing
160
230
  // This ensures the visual progress matches the actual elapsed time
161
231
  const totalElapsed = pausedTimeRef.current / 1000
162
- const exactProgress = Math.min(100, (totalElapsed / maxTime) * 100)
163
- progressInterpolator.setTarget([exactProgress])
232
+
233
+ if (maxTime !== null) {
234
+ // Count-down mode
235
+ const exactProgress = Math.min(100, (totalElapsed / maxTime) * 100)
236
+ progressInterpolator.setTarget([exactProgress])
237
+ } else {
238
+ // Count-up mode: progress based on minute steps
239
+ const exactProgress = ((totalElapsed / 60) % 1) * 100
240
+ progressInterpolator.setTarget([exactProgress])
241
+ }
164
242
  }
165
243
  setIsRunning(false)
166
244
  setIsPausedState(true)
245
+
246
+ // Trigger brief pause animation
247
+ setShowPauseAnimation(true)
248
+
249
+ // Clear any existing timeout
250
+ if (pauseAnimationTimeoutRef.current) {
251
+ clearTimeout(pauseAnimationTimeoutRef.current)
252
+ }
253
+
254
+ // Reset animation after longer duration
255
+ pauseAnimationTimeoutRef.current = setTimeout(() => {
256
+ setShowPauseAnimation(false)
257
+ }, 800) // 800ms smooth animation
167
258
  }, [isRunning, maxTime, progressInterpolator])
168
259
 
169
260
  const resume = useCallback(() => {
@@ -178,6 +269,49 @@ export const CycleTimer = externalizeComponent(
178
269
  return isPausedState
179
270
  }, [isPausedState])
180
271
 
272
+ // Handle error state changes
273
+ useEffect(() => {
274
+ if (hasError) {
275
+ // Error occurred - pause timer if running and remember state
276
+ if (isRunning && !isPausedState) {
277
+ setWasRunningBeforeError(true)
278
+ pause()
279
+ }
280
+
281
+ // Trigger brief error animation
282
+ setShowErrorAnimation(true)
283
+
284
+ // Clear any existing timeout
285
+ if (errorAnimationTimeoutRef.current) {
286
+ clearTimeout(errorAnimationTimeoutRef.current)
287
+ }
288
+
289
+ // Reset animation after longer duration
290
+ errorAnimationTimeoutRef.current = setTimeout(() => {
291
+ setShowErrorAnimation(false)
292
+ }, 600) // 600ms smooth animation
293
+ } else {
294
+ // Error resolved - resume if was running before error
295
+ if (wasRunningBeforeError && isPausedState) {
296
+ setWasRunningBeforeError(false)
297
+ resume()
298
+ }
299
+
300
+ // Clear error animation if error is resolved
301
+ setShowErrorAnimation(false)
302
+ if (errorAnimationTimeoutRef.current) {
303
+ clearTimeout(errorAnimationTimeoutRef.current)
304
+ }
305
+ }
306
+ }, [
307
+ hasError,
308
+ isRunning,
309
+ isPausedState,
310
+ wasRunningBeforeError,
311
+ pause,
312
+ resume,
313
+ ])
314
+
181
315
  // Call onCycleComplete immediately to provide the timer control functions
182
316
  useEffect(() => {
183
317
  let isMounted = true
@@ -202,30 +336,39 @@ export const CycleTimer = externalizeComponent(
202
336
  if (isRunning) {
203
337
  // Single animation frame loop that handles both time updates and progress
204
338
  const updateTimer = () => {
205
- if (startTimeRef.current && maxTime > 0) {
339
+ if (startTimeRef.current) {
206
340
  const now = Date.now()
207
341
  const elapsed =
208
342
  (now - startTimeRef.current + pausedTimeRef.current) / 1000
209
- const remaining = Math.max(0, maxTime - elapsed)
210
-
211
- // Update remaining time based on timestamp calculation
212
- setRemainingTime(Math.ceil(remaining))
213
-
214
- // Smoothly animate progress based on elapsed time for fluid visual feedback
215
- const progress = Math.min(100, (elapsed / maxTime) * 100)
216
- progressInterpolator.setTarget([progress])
217
-
218
- if (remaining <= 0) {
219
- setIsRunning(false)
220
- startTimeRef.current = null
221
- setRemainingTime(0)
222
- // Animate to 100% completion with smooth spring transition
223
- progressInterpolator.setTarget([100])
224
- // Call onCycleEnd when timer reaches zero to notify about completion
225
- if (onCycleEnd) {
226
- setTimeout(() => onCycleEnd(), 0)
343
+
344
+ if (maxTime !== null) {
345
+ // Count-down mode
346
+ const remaining = Math.max(0, maxTime - elapsed)
347
+ setRemainingTime(Math.ceil(remaining))
348
+
349
+ // Smoothly animate progress based on elapsed time for fluid visual feedback
350
+ const progress = Math.min(100, (elapsed / maxTime) * 100)
351
+ progressInterpolator.setTarget([progress])
352
+
353
+ if (remaining <= 0) {
354
+ setIsRunning(false)
355
+ startTimeRef.current = null
356
+ setRemainingTime(0)
357
+ // Animate to 100% completion with smooth spring transition
358
+ progressInterpolator.setTarget([100])
359
+ // Call onCycleEnd when timer reaches zero to notify about completion
360
+ if (onCycleEnd) {
361
+ setTimeout(() => onCycleEnd(), 0)
362
+ }
363
+ return
227
364
  }
228
- return
365
+ } else {
366
+ // Count-up mode
367
+ setRemainingTime(Math.floor(elapsed))
368
+
369
+ // For count-up mode, progress completes every minute (0-100% per minute)
370
+ const progress = ((elapsed / 60) % 1) * 100
371
+ progressInterpolator.setTarget([progress])
229
372
  }
230
373
 
231
374
  // Continue animation loop while running
@@ -269,12 +412,31 @@ export const CycleTimer = externalizeComponent(
269
412
  }
270
413
  }, [progressInterpolator])
271
414
 
415
+ // Cleanup animation timeouts on unmount
416
+ useEffect(() => {
417
+ return () => {
418
+ if (pauseAnimationTimeoutRef.current) {
419
+ clearTimeout(pauseAnimationTimeoutRef.current)
420
+ }
421
+ if (errorAnimationTimeoutRef.current) {
422
+ clearTimeout(errorAnimationTimeoutRef.current)
423
+ }
424
+ }
425
+ }, [])
426
+
272
427
  // Keep interpolator synchronized with static progress when timer is stopped
273
428
  // Ensures correct visual state when component initializes or timer stops
274
429
  useEffect(() => {
275
- if (!isRunning && !isPausedState && maxTime > 0) {
276
- const staticProgress = ((maxTime - remainingTime) / maxTime) * 100
277
- progressInterpolator.setTarget([staticProgress])
430
+ if (!isRunning && !isPausedState) {
431
+ if (maxTime !== null && maxTime > 0) {
432
+ // Count-down mode
433
+ const staticProgress = ((maxTime - remainingTime) / maxTime) * 100
434
+ progressInterpolator.setTarget([staticProgress])
435
+ } else if (maxTime === null) {
436
+ // Count-up mode
437
+ const staticProgress = ((remainingTime / 60) % 1) * 100
438
+ progressInterpolator.setTarget([staticProgress])
439
+ }
278
440
  }
279
441
  }, [
280
442
  isRunning,
@@ -301,92 +463,92 @@ export const CycleTimer = externalizeComponent(
301
463
  sx={{
302
464
  display: "flex",
303
465
  alignItems: "center",
304
- gap: 0.125, // Minimal gap - 1px
466
+ m: 0,
467
+ gap: 1, // 8px gap between circle and text
305
468
  }}
306
469
  >
307
- {/* Animated progress gauge icon */}
470
+ {/* Animated progress ring icon */}
308
471
  <Box
309
472
  sx={{
310
- position: "relative",
311
- width: 40,
312
- height: 40,
473
+ width: 20,
474
+ height: 20,
313
475
  display: "flex",
314
476
  alignItems: "center",
315
477
  justifyContent: "center",
316
- borderRadius: "50%",
317
- overflow: "visible",
478
+ opacity: showPauseAnimation || showErrorAnimation ? 0.6 : 1,
479
+ transition: "opacity 0.5s ease-out",
318
480
  }}
319
481
  >
320
- <Gauge
321
- width={40}
322
- height={40}
323
- value={progressValue}
324
- valueMin={0}
325
- valueMax={100}
326
- innerRadius="70%"
327
- outerRadius="95%"
328
- skipAnimation={true}
329
- sx={{
330
- opacity: isPausedState ? 0.6 : 1,
331
- transition: "opacity 0.2s ease",
332
- [`& .MuiGauge-valueArc`]: {
333
- fill: theme.palette.success.main,
334
- },
335
- [`& .MuiGauge-referenceArc`]: {
336
- fill: theme.palette.success.main,
337
- opacity: 0.3,
338
- },
339
- [`& .MuiGauge-valueText`]: {
340
- display: "none",
341
- },
342
- [`& .MuiGauge-text`]: {
343
- display: "none",
344
- },
345
- [`& text`]: {
346
- display: "none",
347
- },
348
- // Hide any inner circle elements that might flash
349
- [`& .MuiGauge-referenceArcBackground`]: {
350
- display: "none",
351
- },
352
- [`& .MuiGauge-valueArcBackground`]: {
353
- display: "none",
354
- },
355
- [`& circle`]: {
356
- display: "none",
357
- },
358
- }}
359
- />
360
-
361
- {/* Inner circle overlay to prevent flashing */}
362
- <Box
363
- sx={{
364
- position: "absolute",
365
- top: "50%",
366
- left: "50%",
367
- transform: "translate(-50%, -50%)",
368
- width: 13,
369
- height: 13,
370
- borderRadius: "50%",
371
- backgroundColor: theme.palette.background?.paper || "white",
372
- pointerEvents: "none",
373
- }}
374
- />
482
+ <svg
483
+ width="20"
484
+ height="20"
485
+ viewBox="0 0 20 20"
486
+ style={{ transform: "rotate(-90deg)" }}
487
+ role="img"
488
+ aria-label="Timer progress"
489
+ >
490
+ {/* Background ring */}
491
+ <circle
492
+ cx="10"
493
+ cy="10"
494
+ r="8"
495
+ fill="none"
496
+ stroke={
497
+ hasError
498
+ ? theme.palette.error.light
499
+ : theme.palette.success.main
500
+ }
501
+ strokeWidth="2"
502
+ opacity={0.3}
503
+ style={{
504
+ transition: "stroke 0.5s ease-out",
505
+ }}
506
+ />
507
+ {/* Progress ring */}
508
+ <circle
509
+ cx="10"
510
+ cy="10"
511
+ r="8"
512
+ fill="none"
513
+ stroke={
514
+ hasError
515
+ ? theme.palette.error.light
516
+ : theme.palette.success.main
517
+ }
518
+ strokeWidth="2"
519
+ strokeLinecap="round"
520
+ strokeDasharray={`${2 * Math.PI * 8}`}
521
+ strokeDashoffset={`${2 * Math.PI * 8 * (1 - progressValue / 100)}`}
522
+ style={{
523
+ transition:
524
+ "stroke-dashoffset 0.1s ease-out, stroke 0.5s ease-out",
525
+ }}
526
+ />
527
+ </svg>
375
528
  </Box>
376
529
 
377
530
  {/* Timer text display */}
378
531
  <Typography
379
532
  variant="body2"
380
533
  sx={{
381
- color: theme.palette.text.primary,
534
+ color: hasError
535
+ ? theme.palette.error.light
536
+ : theme.palette.text.primary,
382
537
  fontSize: "14px",
538
+ transition: "color 0.5s ease-out",
383
539
  }}
384
540
  >
385
- {compact
386
- ? // Compact mode: show only remaining time
387
- formatTime(remainingTime)
388
- : // Full mode: show "remaining / of total min." format
389
- `${formatTime(remainingTime)} / ${t("CycleTimer.Time.lb", { time: formatTime(maxTime) })}`}
541
+ {hasError
542
+ ? t("CycleTimer.Error.lb", "Error")
543
+ : maxTime !== null
544
+ ? // Count-down mode: show remaining time
545
+ compact
546
+ ? // Compact mode: show remaining time with "min." suffix
547
+ `${formatTime(remainingTime)} ${t("CycleTimer.Time.lb", { time: "" }).replace(/\s*$/, "")}`
548
+ : // Full mode: show "remaining / of total min." format
549
+ `${formatTime(remainingTime)} / ${t("CycleTimer.Time.lb", { time: formatTime(maxTime) })}`
550
+ : // Count-up mode: show elapsed time only
551
+ formatTime(remainingTime)}
390
552
  </Typography>
391
553
  </Box>
392
554
  )
@@ -415,10 +577,13 @@ export const CycleTimer = externalizeComponent(
415
577
  outerRadius="90%"
416
578
  skipAnimation={true}
417
579
  sx={{
418
- opacity: isPausedState ? 0.6 : 1,
419
- transition: "opacity 0.2s ease",
580
+ opacity: showPauseAnimation || showErrorAnimation ? 0.6 : 1,
581
+ transition: "opacity 0.5s ease-out",
420
582
  [`& .MuiGauge-valueArc`]: {
421
- fill: theme.palette.success.main,
583
+ fill: hasError
584
+ ? theme.palette.error.light
585
+ : theme.palette.success.main,
586
+ transition: "fill 0.5s ease-out",
422
587
  },
423
588
  [`& .MuiGauge-referenceArc`]: {
424
589
  fill: "white",
@@ -446,42 +611,102 @@ export const CycleTimer = externalizeComponent(
446
611
  gap: 1,
447
612
  }}
448
613
  >
449
- {/* "remaining time" label */}
450
- <Typography
451
- variant="body2"
614
+ {/* "remaining time" label - always reserves space to prevent layout shift */}
615
+ <Box
452
616
  sx={{
453
- fontSize: "12px",
454
- color: theme.palette.text.secondary,
617
+ height: "16px", // Fixed height to prevent layout shift
618
+ display: "flex",
619
+ alignItems: "center",
620
+ justifyContent: "center",
455
621
  marginBottom: 0.5,
456
622
  }}
457
623
  >
458
- {t("CycleTimer.RemainingTime.lb")}
459
- </Typography>
624
+ <Fade
625
+ in={showLabels && maxTime !== null && !hasError}
626
+ timeout={300}
627
+ >
628
+ <Typography
629
+ variant="body2"
630
+ sx={{
631
+ fontSize: "12px",
632
+ color: theme.palette.text.secondary,
633
+ }}
634
+ >
635
+ {t("CycleTimer.RemainingTime.lb")}
636
+ </Typography>
637
+ </Fade>
638
+ </Box>
460
639
 
461
- {/* Main timer display */}
462
- <Typography
463
- variant="h1"
640
+ {/* Main timer display with error state transition */}
641
+ <Box
464
642
  sx={{
465
- fontSize: "48px",
466
- fontWeight: 500,
467
- color: theme.palette.text.primary,
468
- lineHeight: 1,
643
+ position: "relative",
644
+ height: "48px", // Fixed height to prevent layout shift
645
+ display: "flex",
646
+ alignItems: "center",
647
+ justifyContent: "center",
469
648
  marginBottom: 0.5,
470
649
  }}
471
650
  >
472
- {formatTime(remainingTime)}
473
- </Typography>
651
+ {/* Error text */}
652
+ <Fade in={hasError} timeout={500}>
653
+ <Typography
654
+ variant="h3"
655
+ sx={{
656
+ position: "absolute",
657
+ fontSize: "40px",
658
+ fontWeight: 400,
659
+ color: "#FFFFFF",
660
+ lineHeight: "116.7%",
661
+ }}
662
+ >
663
+ {t("CycleTimer.Error.lb", "Error")}
664
+ </Typography>
665
+ </Fade>
666
+
667
+ {/* Normal timer text */}
668
+ <Fade in={!hasError} timeout={500}>
669
+ <Typography
670
+ variant="h1"
671
+ sx={{
672
+ position: "absolute",
673
+ fontSize: "48px",
674
+ fontWeight: 500,
675
+ color: theme.palette.text.primary,
676
+ lineHeight: 1,
677
+ }}
678
+ >
679
+ {formatTime(remainingTime)}
680
+ </Typography>
681
+ </Fade>
682
+ </Box>
474
683
 
475
- {/* Total time display */}
476
- <Typography
477
- variant="body2"
684
+ {/* Total time display - always reserves space to prevent layout shift */}
685
+ <Box
478
686
  sx={{
479
- fontSize: "12px",
480
- color: theme.palette.text.secondary,
687
+ height: "16px", // Fixed height to prevent layout shift
688
+ display: "flex",
689
+ alignItems: "center",
690
+ justifyContent: "center",
481
691
  }}
482
692
  >
483
- {t("CycleTimer.OfTime.lb", { time: formatTime(maxTime) })}
484
- </Typography>
693
+ <Fade
694
+ in={showLabels && maxTime !== null && !hasError}
695
+ timeout={300}
696
+ >
697
+ <Typography
698
+ variant="body2"
699
+ sx={{
700
+ fontSize: "12px",
701
+ color: theme.palette.text.secondary,
702
+ }}
703
+ >
704
+ {maxTime !== null
705
+ ? t("CycleTimer.OfTime.lb", { time: formatTime(maxTime) })
706
+ : ""}
707
+ </Typography>
708
+ </Fade>
709
+ </Box>
485
710
  </Box>
486
711
  </Box>
487
712
  )