@wandelbots/wandelbots-js-react-components 2.34.0 → 2.34.1-pr.feature-robot-precondition-list.372.a71f99a

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 (65) hide show
  1. package/dist/components/AppHeader.d.ts +34 -0
  2. package/dist/components/AppHeader.d.ts.map +1 -0
  3. package/dist/components/CycleTimer.d.ts +21 -16
  4. package/dist/components/CycleTimer.d.ts.map +1 -1
  5. package/dist/components/DataGrid.d.ts +66 -0
  6. package/dist/components/DataGrid.d.ts.map +1 -0
  7. package/dist/components/LogPanel.d.ts +38 -0
  8. package/dist/components/LogPanel.d.ts.map +1 -0
  9. package/dist/components/LogStore.d.ts +12 -0
  10. package/dist/components/LogStore.d.ts.map +1 -0
  11. package/dist/components/LogViewer.d.ts +46 -0
  12. package/dist/components/LogViewer.d.ts.map +1 -0
  13. package/dist/components/ProgramControl.d.ts +8 -2
  14. package/dist/components/ProgramControl.d.ts.map +1 -1
  15. package/dist/components/ProgramStateIndicator.d.ts +1 -1
  16. package/dist/components/ProgramStateIndicator.d.ts.map +1 -1
  17. package/dist/components/RobotCard.d.ts +100 -0
  18. package/dist/components/RobotCard.d.ts.map +1 -0
  19. package/dist/components/RobotListItem.d.ts +34 -0
  20. package/dist/components/RobotListItem.d.ts.map +1 -0
  21. package/dist/components/RobotSetupReadinessIndicator.d.ts +31 -0
  22. package/dist/components/RobotSetupReadinessIndicator.d.ts.map +1 -0
  23. package/dist/components/RobotSetupReadinessIndicator.test.d.ts +2 -0
  24. package/dist/components/RobotSetupReadinessIndicator.test.d.ts.map +1 -0
  25. package/dist/components/TabBar.d.ts +30 -0
  26. package/dist/components/TabBar.d.ts.map +1 -0
  27. package/dist/components/robots/Robot.d.ts +3 -2
  28. package/dist/components/robots/Robot.d.ts.map +1 -1
  29. package/dist/components/robots/manufacturerHomePositions.d.ts +21 -0
  30. package/dist/components/robots/manufacturerHomePositions.d.ts.map +1 -0
  31. package/dist/icons/DropdownArrowIcon.d.ts +3 -0
  32. package/dist/icons/DropdownArrowIcon.d.ts.map +1 -0
  33. package/dist/icons/index.d.ts +1 -0
  34. package/dist/icons/index.d.ts.map +1 -1
  35. package/dist/index.cjs +49 -49
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.d.ts +10 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +11166 -9377
  40. package/dist/index.js.map +1 -1
  41. package/dist/themes/createDarkTheme.d.ts.map +1 -1
  42. package/package.json +2 -1
  43. package/src/components/AppHeader.md +84 -0
  44. package/src/components/AppHeader.tsx +199 -0
  45. package/src/components/CycleTimer.tsx +229 -148
  46. package/src/components/DataGrid.tsx +659 -0
  47. package/src/components/LogPanel.tsx +69 -0
  48. package/src/components/LogStore.ts +44 -0
  49. package/src/components/LogViewer.tsx +370 -0
  50. package/src/components/ProgramControl.tsx +27 -12
  51. package/src/components/ProgramStateIndicator.tsx +25 -8
  52. package/src/components/RobotCard.tsx +559 -0
  53. package/src/components/RobotListItem.tsx +150 -0
  54. package/src/components/RobotSetupReadinessIndicator.test.tsx +60 -0
  55. package/src/components/RobotSetupReadinessIndicator.tsx +124 -0
  56. package/src/components/TabBar.tsx +144 -0
  57. package/src/components/robots/Robot.tsx +5 -2
  58. package/src/components/robots/manufacturerHomePositions.ts +76 -0
  59. package/src/i18n/locales/de/translations.json +7 -1
  60. package/src/i18n/locales/en/translations.json +7 -1
  61. package/src/icons/DropdownArrowIcon.tsx +13 -0
  62. package/src/icons/chevronDown.svg +3 -0
  63. package/src/icons/index.ts +1 -0
  64. package/src/index.ts +14 -0
  65. 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
@@ -33,13 +33,15 @@ export interface CycleTimerProps {
33
33
  }
34
34
 
35
35
  /**
36
- * A circular gauge timer component that shows the remaining time of a cycle
36
+ * A circular gauge timer component that shows the remaining time of a cycle or counts up
37
37
  *
38
38
  * Features:
39
39
  * - 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
40
+ * - Two modes: count-down (with max time) or count-up (without max time)
41
+ * - Count-down mode: shows remaining time prominently, counts down to zero
42
+ * - Count-up mode: shows elapsed time, gauge progresses in minute steps
43
+ * - Displays appropriate labels based on mode
44
+ * - Automatically counts down/up and triggers callback when reaching zero (count-down only)
43
45
  * - Full timer control: start, pause, resume functionality
44
46
  * - Support for starting with elapsed time (resume mid-cycle)
45
47
  * - Smooth spring-based progress animations for all state transitions
@@ -56,29 +58,32 @@ export interface CycleTimerProps {
56
58
  *
57
59
  * Usage:
58
60
  * ```tsx
61
+ * // Count-down timer (with max time)
59
62
  * <CycleTimer
60
63
  * onCycleComplete={(controls) => {
61
- * // Start a 5-minute cycle
64
+ * // Start a 5-minute countdown cycle
62
65
  * controls.startNewCycle(300)
63
66
  *
64
67
  * // Or start a 5-minute cycle with 2 minutes already elapsed
65
68
  * controls.startNewCycle(300, 120)
69
+ * }}
70
+ * onCycleEnd={() => console.log('Cycle completed!')}
71
+ * />
66
72
  *
67
- * // Pause the timer
68
- * controls.pause()
69
- *
70
- * // Resume the timer
71
- * controls.resume()
73
+ * // Count-up timer (no max time)
74
+ * <CycleTimer
75
+ * onCycleComplete={(controls) => {
76
+ * // Start count-up timer
77
+ * controls.startNewCycle()
72
78
  *
73
- * // Check if paused
74
- * const paused = controls.isPaused()
79
+ * // Or start count-up timer with some elapsed time
80
+ * controls.startNewCycle(undefined, 120)
75
81
  * }}
76
- * onCycleEnd={() => console.log('Cycle completed!')}
77
82
  * />
78
83
  * ```
79
84
  *
80
85
  * Control Functions:
81
- * - `startNewCycle(maxTimeSeconds, elapsedSeconds?)` - Start a new timer cycle
86
+ * - `startNewCycle(maxTimeSeconds?, elapsedSeconds?)` - Start a new timer cycle (omit maxTimeSeconds for count-up mode)
82
87
  * - `pause()` - Pause the countdown while preserving remaining time
83
88
  * - `resume()` - Resume countdown from where it was paused
84
89
  * - `isPaused()` - Check current pause state
@@ -96,7 +101,7 @@ export const CycleTimer = externalizeComponent(
96
101
  const theme = useTheme()
97
102
  const { t } = useTranslation()
98
103
  const [remainingTime, setRemainingTime] = useState(0)
99
- const [maxTime, setMaxTime] = useState(0)
104
+ const [maxTime, setMaxTime] = useState<number | null>(null)
100
105
  const [isRunning, setIsRunning] = useState(false)
101
106
  const [isPausedState, setIsPausedState] = useState(false)
102
107
  const [currentProgress, setCurrentProgress] = useState(0)
@@ -104,6 +109,10 @@ export const CycleTimer = externalizeComponent(
104
109
  const startTimeRef = useRef<number | null>(null)
105
110
  const pausedTimeRef = useRef<number>(0)
106
111
 
112
+ // Track mode changes for fade transitions
113
+ const [showLabels, setShowLabels] = useState(true)
114
+ const prevMaxTimeRef = useRef<number | null | undefined>(undefined)
115
+
107
116
  // Spring-based interpolator for smooth gauge progress animations
108
117
  // Uses physics simulation to create natural, smooth transitions between progress values
109
118
  const [progressInterpolator] = useInterpolation([0], {
@@ -114,37 +123,78 @@ export const CycleTimer = externalizeComponent(
114
123
  },
115
124
  })
116
125
 
126
+ // Handle mode changes with fade transitions for labels only
127
+ useEffect(() => {
128
+ const currentIsCountUp = maxTime === null
129
+ const prevMaxTime = prevMaxTimeRef.current
130
+ const prevIsCountUp = prevMaxTime === null
131
+
132
+ // Check if mode actually changed (not just first render)
133
+ if (
134
+ prevMaxTimeRef.current !== undefined &&
135
+ prevIsCountUp !== currentIsCountUp
136
+ ) {
137
+ // Mode changed - labels will fade based on the Fade component conditions
138
+ // We just need to ensure showLabels is true so Fade can control visibility
139
+ setShowLabels(true)
140
+ } else {
141
+ // No mode change or first time - set initial state
142
+ setShowLabels(true)
143
+ }
144
+
145
+ prevMaxTimeRef.current = maxTime
146
+ }, [maxTime])
147
+
117
148
  const startNewCycle = useCallback(
118
- (maxTimeSeconds: number, elapsedSeconds: number = 0) => {
119
- setMaxTime(maxTimeSeconds)
120
- const remainingSeconds = Math.max(0, maxTimeSeconds - elapsedSeconds)
121
- setRemainingTime(remainingSeconds)
149
+ (maxTimeSeconds?: number, elapsedSeconds: number = 0) => {
150
+ setMaxTime(maxTimeSeconds ?? null)
122
151
  setIsPausedState(false)
123
152
  pausedTimeRef.current = 0
124
153
 
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])
154
+ if (maxTimeSeconds !== undefined) {
155
+ // Count-down mode: set remaining time
156
+ const remainingSeconds = Math.max(
157
+ 0,
158
+ maxTimeSeconds - elapsedSeconds,
159
+ )
160
+ setRemainingTime(remainingSeconds)
161
+
162
+ // Animate progress smoothly to starting position
163
+ const initialProgress =
164
+ elapsedSeconds > 0 ? (elapsedSeconds / maxTimeSeconds) * 100 : 0
165
+ if (elapsedSeconds === 0) {
166
+ progressInterpolator.setTarget([0])
167
+ } else {
168
+ progressInterpolator.setTarget([initialProgress])
169
+ }
170
+
171
+ if (remainingSeconds === 0) {
172
+ setIsRunning(false)
173
+ startTimeRef.current = null
174
+ // Trigger completion callback immediately if time is already up
175
+ if (onCycleEnd) {
176
+ setTimeout(() => onCycleEnd(), 0)
177
+ }
178
+ } else if (autoStart) {
179
+ startTimeRef.current = Date.now() - elapsedSeconds * 1000
180
+ setIsRunning(true)
181
+ } else {
182
+ startTimeRef.current = null
183
+ }
132
184
  } else {
185
+ // Count-up mode: start from elapsed time
186
+ setRemainingTime(elapsedSeconds)
187
+
188
+ // For count-up mode, progress is based on minute steps
189
+ const initialProgress = ((elapsedSeconds / 60) % 1) * 100
133
190
  progressInterpolator.setTarget([initialProgress])
134
- }
135
191
 
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)
192
+ if (autoStart) {
193
+ startTimeRef.current = Date.now() - elapsedSeconds * 1000
194
+ setIsRunning(true)
195
+ } else {
196
+ startTimeRef.current = null
142
197
  }
143
- } else if (autoStart) {
144
- startTimeRef.current = Date.now() - elapsedSeconds * 1000
145
- setIsRunning(true)
146
- } else {
147
- startTimeRef.current = null
148
198
  }
149
199
  },
150
200
  [autoStart, onCycleEnd, progressInterpolator],
@@ -159,8 +209,16 @@ export const CycleTimer = externalizeComponent(
159
209
  // Calculate exact progress position and smoothly animate to it when pausing
160
210
  // This ensures the visual progress matches the actual elapsed time
161
211
  const totalElapsed = pausedTimeRef.current / 1000
162
- const exactProgress = Math.min(100, (totalElapsed / maxTime) * 100)
163
- progressInterpolator.setTarget([exactProgress])
212
+
213
+ if (maxTime !== null) {
214
+ // Count-down mode
215
+ const exactProgress = Math.min(100, (totalElapsed / maxTime) * 100)
216
+ progressInterpolator.setTarget([exactProgress])
217
+ } else {
218
+ // Count-up mode: progress based on minute steps
219
+ const exactProgress = ((totalElapsed / 60) % 1) * 100
220
+ progressInterpolator.setTarget([exactProgress])
221
+ }
164
222
  }
165
223
  setIsRunning(false)
166
224
  setIsPausedState(true)
@@ -202,30 +260,39 @@ export const CycleTimer = externalizeComponent(
202
260
  if (isRunning) {
203
261
  // Single animation frame loop that handles both time updates and progress
204
262
  const updateTimer = () => {
205
- if (startTimeRef.current && maxTime > 0) {
263
+ if (startTimeRef.current) {
206
264
  const now = Date.now()
207
265
  const elapsed =
208
266
  (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)
267
+
268
+ if (maxTime !== null) {
269
+ // Count-down mode
270
+ const remaining = Math.max(0, maxTime - elapsed)
271
+ setRemainingTime(Math.ceil(remaining))
272
+
273
+ // Smoothly animate progress based on elapsed time for fluid visual feedback
274
+ const progress = Math.min(100, (elapsed / maxTime) * 100)
275
+ progressInterpolator.setTarget([progress])
276
+
277
+ if (remaining <= 0) {
278
+ setIsRunning(false)
279
+ startTimeRef.current = null
280
+ setRemainingTime(0)
281
+ // Animate to 100% completion with smooth spring transition
282
+ progressInterpolator.setTarget([100])
283
+ // Call onCycleEnd when timer reaches zero to notify about completion
284
+ if (onCycleEnd) {
285
+ setTimeout(() => onCycleEnd(), 0)
286
+ }
287
+ return
227
288
  }
228
- return
289
+ } else {
290
+ // Count-up mode
291
+ setRemainingTime(Math.floor(elapsed))
292
+
293
+ // For count-up mode, progress completes every minute (0-100% per minute)
294
+ const progress = ((elapsed / 60) % 1) * 100
295
+ progressInterpolator.setTarget([progress])
229
296
  }
230
297
 
231
298
  // Continue animation loop while running
@@ -272,9 +339,16 @@ export const CycleTimer = externalizeComponent(
272
339
  // Keep interpolator synchronized with static progress when timer is stopped
273
340
  // Ensures correct visual state when component initializes or timer stops
274
341
  useEffect(() => {
275
- if (!isRunning && !isPausedState && maxTime > 0) {
276
- const staticProgress = ((maxTime - remainingTime) / maxTime) * 100
277
- progressInterpolator.setTarget([staticProgress])
342
+ if (!isRunning && !isPausedState) {
343
+ if (maxTime !== null && maxTime > 0) {
344
+ // Count-down mode
345
+ const staticProgress = ((maxTime - remainingTime) / maxTime) * 100
346
+ progressInterpolator.setTarget([staticProgress])
347
+ } else if (maxTime === null) {
348
+ // Count-up mode
349
+ const staticProgress = ((remainingTime / 60) % 1) * 100
350
+ progressInterpolator.setTarget([staticProgress])
351
+ }
278
352
  }
279
353
  }, [
280
354
  isRunning,
@@ -301,77 +375,56 @@ export const CycleTimer = externalizeComponent(
301
375
  sx={{
302
376
  display: "flex",
303
377
  alignItems: "center",
304
- gap: 0.125, // Minimal gap - 1px
378
+ m: 0,
379
+ gap: 1, // 8px gap between circle and text
305
380
  }}
306
381
  >
307
- {/* Animated progress gauge icon */}
382
+ {/* Animated progress ring icon */}
308
383
  <Box
309
384
  sx={{
310
- position: "relative",
311
- width: 40,
312
- height: 40,
385
+ width: 20,
386
+ height: 20,
313
387
  display: "flex",
314
388
  alignItems: "center",
315
389
  justifyContent: "center",
316
- borderRadius: "50%",
317
- overflow: "visible",
390
+ opacity: isPausedState ? 0.6 : 1,
391
+ transition: "opacity 0.2s ease",
318
392
  }}
319
393
  >
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
- />
394
+ <svg
395
+ width="20"
396
+ height="20"
397
+ viewBox="0 0 20 20"
398
+ style={{ transform: "rotate(-90deg)" }}
399
+ role="img"
400
+ aria-label="Timer progress"
401
+ >
402
+ {/* Background ring */}
403
+ <circle
404
+ cx="10"
405
+ cy="10"
406
+ r="8"
407
+ fill="none"
408
+ stroke={theme.palette.success.main}
409
+ strokeWidth="2"
410
+ opacity={0.3}
411
+ />
412
+ {/* Progress ring */}
413
+ <circle
414
+ cx="10"
415
+ cy="10"
416
+ r="8"
417
+ fill="none"
418
+ stroke={theme.palette.success.main}
419
+ strokeWidth="2"
420
+ strokeLinecap="round"
421
+ strokeDasharray={`${2 * Math.PI * 8}`}
422
+ strokeDashoffset={`${2 * Math.PI * 8 * (1 - progressValue / 100)}`}
423
+ style={{
424
+ transition: "stroke-dashoffset 0.1s ease-out",
425
+ }}
426
+ />
427
+ </svg>
375
428
  </Box>
376
429
 
377
430
  {/* Timer text display */}
@@ -382,11 +435,15 @@ export const CycleTimer = externalizeComponent(
382
435
  fontSize: "14px",
383
436
  }}
384
437
  >
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) })}`}
438
+ {maxTime !== null
439
+ ? // Count-down mode: show remaining time
440
+ compact
441
+ ? // Compact mode: show remaining time with "min." suffix
442
+ `${formatTime(remainingTime)} ${t("CycleTimer.Time.lb", { time: "" }).replace(/\s*$/, "")}`
443
+ : // Full mode: show "remaining / of total min." format
444
+ `${formatTime(remainingTime)} / ${t("CycleTimer.Time.lb", { time: formatTime(maxTime) })}`
445
+ : // Count-up mode: show elapsed time only
446
+ formatTime(remainingTime)}
390
447
  </Typography>
391
448
  </Box>
392
449
  )
@@ -446,19 +503,30 @@ export const CycleTimer = externalizeComponent(
446
503
  gap: 1,
447
504
  }}
448
505
  >
449
- {/* "remaining time" label */}
450
- <Typography
451
- variant="body2"
506
+ {/* "remaining time" label - always reserves space to prevent layout shift */}
507
+ <Box
452
508
  sx={{
453
- fontSize: "12px",
454
- color: theme.palette.text.secondary,
509
+ height: "16px", // Fixed height to prevent layout shift
510
+ display: "flex",
511
+ alignItems: "center",
512
+ justifyContent: "center",
455
513
  marginBottom: 0.5,
456
514
  }}
457
515
  >
458
- {t("CycleTimer.RemainingTime.lb")}
459
- </Typography>
516
+ <Fade in={showLabels && maxTime !== null} timeout={300}>
517
+ <Typography
518
+ variant="body2"
519
+ sx={{
520
+ fontSize: "12px",
521
+ color: theme.palette.text.secondary,
522
+ }}
523
+ >
524
+ {t("CycleTimer.RemainingTime.lb")}
525
+ </Typography>
526
+ </Fade>
527
+ </Box>
460
528
 
461
- {/* Main timer display */}
529
+ {/* Main timer display - never fades, always visible */}
462
530
  <Typography
463
531
  variant="h1"
464
532
  sx={{
@@ -472,16 +540,29 @@ export const CycleTimer = externalizeComponent(
472
540
  {formatTime(remainingTime)}
473
541
  </Typography>
474
542
 
475
- {/* Total time display */}
476
- <Typography
477
- variant="body2"
543
+ {/* Total time display - always reserves space to prevent layout shift */}
544
+ <Box
478
545
  sx={{
479
- fontSize: "12px",
480
- color: theme.palette.text.secondary,
546
+ height: "16px", // Fixed height to prevent layout shift
547
+ display: "flex",
548
+ alignItems: "center",
549
+ justifyContent: "center",
481
550
  }}
482
551
  >
483
- {t("CycleTimer.OfTime.lb", { time: formatTime(maxTime) })}
484
- </Typography>
552
+ <Fade in={showLabels && maxTime !== null} timeout={300}>
553
+ <Typography
554
+ variant="body2"
555
+ sx={{
556
+ fontSize: "12px",
557
+ color: theme.palette.text.secondary,
558
+ }}
559
+ >
560
+ {maxTime !== null
561
+ ? t("CycleTimer.OfTime.lb", { time: formatTime(maxTime) })
562
+ : ""}
563
+ </Typography>
564
+ </Fade>
565
+ </Box>
485
566
  </Box>
486
567
  </Box>
487
568
  )