@astryxdesign/core 0.1.4-canary.39ffd21 → 0.1.4-canary.3d64ef2

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 (234) hide show
  1. package/dist/Banner/Banner.d.ts.map +1 -1
  2. package/dist/Banner/Banner.js +8 -1
  3. package/dist/Calendar/Calendar.d.ts.map +1 -1
  4. package/dist/Calendar/Calendar.js +31 -11
  5. package/dist/Calendar/styles.d.ts +2 -2
  6. package/dist/Calendar/styles.d.ts.map +1 -1
  7. package/dist/Calendar/styles.js +2 -2
  8. package/dist/Card/Card.d.ts +4 -2
  9. package/dist/Card/Card.d.ts.map +1 -1
  10. package/dist/Card/Card.js +15 -7
  11. package/dist/Carousel/Carousel.d.ts.map +1 -1
  12. package/dist/Carousel/Carousel.js +1 -0
  13. package/dist/Chat/ChatToolCalls.d.ts.map +1 -1
  14. package/dist/Chat/ChatToolCalls.js +8 -1
  15. package/dist/CheckboxInput/CheckboxInput.d.ts +1 -1
  16. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  17. package/dist/CheckboxInput/CheckboxInput.js +3 -1
  18. package/dist/ClickableCard/ClickableCard.d.ts.map +1 -1
  19. package/dist/ClickableCard/ClickableCard.js +26 -3
  20. package/dist/CodeBlock/CodeBlock.d.ts +10 -1
  21. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  22. package/dist/CodeBlock/CodeBlock.js +23 -5
  23. package/dist/Collapsible/Collapsible.d.ts +9 -3
  24. package/dist/Collapsible/Collapsible.d.ts.map +1 -1
  25. package/dist/Collapsible/Collapsible.js +101 -13
  26. package/dist/Collapsible/CollapsibleGroup.d.ts +39 -7
  27. package/dist/Collapsible/CollapsibleGroup.d.ts.map +1 -1
  28. package/dist/Collapsible/CollapsibleGroup.js +70 -9
  29. package/dist/Collapsible/CollapsibleGroupContext.d.ts +31 -0
  30. package/dist/Collapsible/CollapsibleGroupContext.d.ts.map +1 -1
  31. package/dist/Collapsible/CollapsibleGroupContext.js +33 -3
  32. package/dist/Collapsible/index.d.ts +1 -0
  33. package/dist/Collapsible/index.d.ts.map +1 -1
  34. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  35. package/dist/ContextMenu/ContextMenu.js +2 -0
  36. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  37. package/dist/DateTimeInput/DateTimeInput.js +2 -0
  38. package/dist/Grid/Grid.d.ts +4 -3
  39. package/dist/Grid/Grid.d.ts.map +1 -1
  40. package/dist/Grid/Grid.js +26 -9
  41. package/dist/Icon/Icon.d.ts.map +1 -1
  42. package/dist/Icon/Icon.js +5 -1
  43. package/dist/InputGroup/InputGroup.js +2 -2
  44. package/dist/Item/Item.js +1 -1
  45. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  46. package/dist/Lightbox/Lightbox.js +26 -2
  47. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  48. package/dist/NumberInput/NumberInput.js +32 -16
  49. package/dist/Pagination/Pagination.d.ts.map +1 -1
  50. package/dist/Pagination/Pagination.js +87 -36
  51. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  52. package/dist/PowerSearch/PowerSearch.js +39 -21
  53. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  54. package/dist/ProgressBar/ProgressBar.js +2 -1
  55. package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
  56. package/dist/Resizable/ResizeHandle.js +10 -2
  57. package/dist/SideNav/SideNav.d.ts.map +1 -1
  58. package/dist/SideNav/SideNav.js +6 -2
  59. package/dist/TabList/TabMenu.d.ts.map +1 -1
  60. package/dist/TabList/TabMenu.js +34 -7
  61. package/dist/Table/BaseTable.d.ts.map +1 -1
  62. package/dist/Table/BaseTable.js +14 -3
  63. package/dist/Table/plugins/columnResize/useTableColumnResize.d.ts.map +1 -1
  64. package/dist/Table/plugins/columnResize/useTableColumnResize.js +5 -2
  65. package/dist/Table/plugins/pagination/paginateData.d.ts.map +1 -1
  66. package/dist/Table/plugins/pagination/paginateData.js +5 -1
  67. package/dist/Table/plugins/selection/useTableSelectionState.d.ts.map +1 -1
  68. package/dist/Table/plugins/selection/useTableSelectionState.js +11 -1
  69. package/dist/Table/plugins/sortable/useTableSortableState.d.ts.map +1 -1
  70. package/dist/Table/plugins/sortable/useTableSortableState.js +8 -4
  71. package/dist/Thumbnail/Thumbnail.d.ts +3 -2
  72. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  73. package/dist/Thumbnail/Thumbnail.js +1 -0
  74. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  75. package/dist/TimeInput/TimeInput.js +6 -1
  76. package/dist/Toast/Toast.d.ts.map +1 -1
  77. package/dist/Toast/Toast.js +11 -3
  78. package/dist/Toast/ToastViewport.d.ts.map +1 -1
  79. package/dist/Toast/ToastViewport.js +12 -0
  80. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  81. package/dist/Typeahead/BaseTypeahead.js +5 -2
  82. package/dist/astryx.css +7 -0
  83. package/dist/astryx.umd.js +48 -48
  84. package/dist/astryx.umd.js.map +4 -4
  85. package/dist/hooks/focusableSelector.d.ts +1 -1
  86. package/dist/hooks/focusableSelector.d.ts.map +1 -1
  87. package/dist/hooks/focusableSelector.js +1 -1
  88. package/dist/hooks/useFocusTrap.d.ts +3 -0
  89. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  90. package/dist/hooks/useFocusTrap.js +38 -1
  91. package/dist/theme/defineTheme.d.ts +1 -1
  92. package/dist/theme/defineTheme.d.ts.map +1 -1
  93. package/dist/theme/expandColorScale.d.ts.map +1 -1
  94. package/dist/theme/expandColorScale.js +10 -3
  95. package/dist/theme/hct.d.ts +0 -15
  96. package/dist/theme/hct.d.ts.map +1 -1
  97. package/dist/theme/hct.js +9 -9
  98. package/dist/theme/tokens.d.ts +10 -2
  99. package/dist/theme/tokens.d.ts.map +1 -1
  100. package/dist/theme/tokens.js +220 -4
  101. package/dist/utils/color.d.ts +42 -0
  102. package/dist/utils/color.d.ts.map +1 -0
  103. package/dist/utils/color.js +150 -0
  104. package/dist/utils/index.d.ts +2 -0
  105. package/dist/utils/index.d.ts.map +1 -1
  106. package/dist/utils/index.js +2 -1
  107. package/dist/utils/timeParser.d.ts.map +1 -1
  108. package/dist/utils/timeParser.js +8 -5
  109. package/package.json +1 -1
  110. package/src/Avatar/Avatar.doc.mjs +11 -0
  111. package/src/Banner/Banner.doc.mjs +8 -0
  112. package/src/Banner/Banner.test.tsx +38 -0
  113. package/src/Banner/Banner.tsx +8 -1
  114. package/src/Calendar/Calendar.doc.mjs +1 -1
  115. package/src/Calendar/Calendar.test.tsx +112 -4
  116. package/src/Calendar/Calendar.tsx +26 -5
  117. package/src/Calendar/styles.ts +12 -2
  118. package/src/Card/Card.tsx +16 -6
  119. package/src/Carousel/Carousel.doc.mjs +12 -12
  120. package/src/Carousel/Carousel.test.tsx +13 -0
  121. package/src/Carousel/Carousel.tsx +1 -0
  122. package/src/Chat/Chat.doc.mjs +1 -1
  123. package/src/Chat/ChatToolCalls.test.tsx +55 -0
  124. package/src/Chat/ChatToolCalls.tsx +10 -12
  125. package/src/CheckboxInput/CheckboxInput.doc.mjs +3 -3
  126. package/src/CheckboxInput/CheckboxInput.test.tsx +71 -4
  127. package/src/CheckboxInput/CheckboxInput.tsx +2 -0
  128. package/src/Citation/Citation.doc.mjs +5 -0
  129. package/src/ClickableCard/ClickableCard.tsx +45 -2
  130. package/src/CodeBlock/CodeBlock.doc.mjs +7 -2
  131. package/src/CodeBlock/CodeBlock.test.tsx +87 -3
  132. package/src/CodeBlock/CodeBlock.tsx +43 -3
  133. package/src/Collapsible/Collapsible.doc.mjs +8 -4
  134. package/src/Collapsible/Collapsible.tsx +86 -10
  135. package/src/Collapsible/CollapsibleGroup.doc.mjs +27 -3
  136. package/src/Collapsible/CollapsibleGroup.test.tsx +288 -0
  137. package/src/Collapsible/CollapsibleGroup.tsx +107 -9
  138. package/src/Collapsible/CollapsibleGroupContext.tsx +42 -2
  139. package/src/Collapsible/index.ts +4 -0
  140. package/src/CommandPalette/CommandPaletteEmpty.doc.mjs +5 -0
  141. package/src/CommandPalette/CommandPaletteGroup.doc.mjs +10 -0
  142. package/src/CommandPalette/CommandPaletteInput.doc.mjs +6 -0
  143. package/src/CommandPalette/CommandPaletteItem.doc.mjs +6 -0
  144. package/src/CommandPalette/CommandPaletteList.doc.mjs +9 -0
  145. package/src/ContextMenu/ContextMenu.doc.mjs +15 -0
  146. package/src/ContextMenu/ContextMenu.test.tsx +15 -0
  147. package/src/ContextMenu/ContextMenu.tsx +2 -0
  148. package/src/ContextMenu/ContextMenuItem.doc.mjs +6 -0
  149. package/src/DateTimeInput/DateTimeInput.test.tsx +70 -0
  150. package/src/DateTimeInput/DateTimeInput.tsx +2 -0
  151. package/src/Dialog/DialogHeader.doc.mjs +45 -0
  152. package/src/DropdownMenu/DropdownMenu.doc.mjs +1 -1
  153. package/src/Field/FieldLabel.test.tsx +2 -2
  154. package/src/FormLayout/__snapshots__/FormLayout.test.tsx.snap +0 -3
  155. package/src/Grid/Grid.test.tsx +43 -18
  156. package/src/Grid/Grid.tsx +26 -9
  157. package/src/Icon/Icon.test.tsx +41 -29
  158. package/src/Icon/Icon.tsx +6 -2
  159. package/src/InputGroup/InputGroup.tsx +2 -2
  160. package/src/Item/Item.tsx +1 -1
  161. package/src/Layout/LayoutContent.doc.mjs +12 -0
  162. package/src/Layout/LayoutPanel.doc.mjs +40 -0
  163. package/src/Lightbox/Lightbox.doc.mjs +5 -0
  164. package/src/Lightbox/Lightbox.test.tsx +123 -2
  165. package/src/Lightbox/Lightbox.tsx +29 -1
  166. package/src/NavMenu/NavMenu.doc.mjs +15 -0
  167. package/src/NumberInput/NumberInput.test.tsx +48 -3
  168. package/src/NumberInput/NumberInput.tsx +32 -15
  169. package/src/Overlay/Overlay.doc.mjs +6 -0
  170. package/src/Pagination/Pagination.test.tsx +143 -0
  171. package/src/Pagination/Pagination.tsx +75 -23
  172. package/src/Popover/Popover.test.tsx +65 -0
  173. package/src/PowerSearch/PowerSearch.doc.mjs +2 -2
  174. package/src/PowerSearch/PowerSearch.test.tsx +109 -1
  175. package/src/PowerSearch/PowerSearch.tsx +37 -16
  176. package/src/ProgressBar/ProgressBar.test.tsx +10 -0
  177. package/src/ProgressBar/ProgressBar.tsx +2 -1
  178. package/src/Resizable/ResizeHandle.test.tsx +180 -0
  179. package/src/Resizable/ResizeHandle.tsx +10 -2
  180. package/src/SegmentedControl/SegmentedControl.doc.mjs +1 -1
  181. package/src/SelectableCard/SelectableCard.doc.mjs +1 -1
  182. package/src/SideNav/SideNav.doc.mjs +7 -1
  183. package/src/SideNav/SideNav.test.tsx +20 -0
  184. package/src/SideNav/SideNav.tsx +6 -2
  185. package/src/TabList/TabList.test.tsx +185 -1
  186. package/src/TabList/TabMenu.tsx +45 -9
  187. package/src/Table/BaseTable.tsx +13 -2
  188. package/src/Table/Table.test.tsx +51 -0
  189. package/src/Table/plugins/columnResize/useTableColumnResize.test.tsx +13 -0
  190. package/src/Table/plugins/columnResize/useTableColumnResize.tsx +3 -1
  191. package/src/Table/plugins/pagination/paginateData.ts +5 -1
  192. package/src/Table/plugins/pagination/useTablePagination.test.tsx +13 -0
  193. package/src/Table/plugins/selection/useTableSelectionState.test.tsx +23 -0
  194. package/src/Table/plugins/selection/useTableSelectionState.tsx +11 -6
  195. package/src/Table/plugins/sortable/useTableSortableState.test.tsx +35 -1
  196. package/src/Table/plugins/sortable/useTableSortableState.tsx +8 -4
  197. package/src/TextArea/TextArea.test.tsx +3 -3
  198. package/src/TextInput/TextInput.test.tsx +3 -3
  199. package/src/Thumbnail/Thumbnail.test.tsx +36 -2
  200. package/src/Thumbnail/Thumbnail.tsx +4 -2
  201. package/src/TimeInput/TimeInput.test.tsx +37 -0
  202. package/src/TimeInput/TimeInput.tsx +11 -1
  203. package/src/Timestamp/Timestamp.doc.mjs +1 -1
  204. package/src/Toast/Toast.tsx +11 -3
  205. package/src/Toast/ToastViewport.test.tsx +64 -0
  206. package/src/Toast/ToastViewport.tsx +12 -0
  207. package/src/ToggleButton/ToggleButton.doc.mjs +1 -0
  208. package/src/Tokenizer/Tokenizer.doc.mjs +2 -2
  209. package/src/TopNav/TopNav.doc.mjs +1 -1
  210. package/src/TreeList/TreeList.doc.mjs +2 -2
  211. package/src/Typeahead/BaseTypeahead.doc.mjs +19 -0
  212. package/src/Typeahead/BaseTypeahead.tsx +5 -2
  213. package/src/Typeahead/Typeahead.doc.mjs +1 -1
  214. package/src/Typeahead/Typeahead.test.tsx +45 -1
  215. package/src/__tests__/TestIcon.tsx +26 -0
  216. package/src/docPropReferences.test.ts +178 -0
  217. package/src/hooks/focusableSelector.ts +1 -1
  218. package/src/hooks/useFocusTrap.doc.mjs +4 -2
  219. package/src/hooks/useFocusTrap.test.tsx +149 -1
  220. package/src/hooks/useFocusTrap.ts +50 -1
  221. package/src/theme/SyntaxTheme.doc.mjs +4 -4
  222. package/src/theme/defineTheme.ts +4 -13
  223. package/src/theme/derivedVarRegistry.test.ts +2 -2
  224. package/src/theme/expandColorScale.test.ts +29 -0
  225. package/src/theme/expandColorScale.ts +11 -6
  226. package/src/theme/hct.ts +9 -17
  227. package/src/theme/tokens.test.ts +182 -9
  228. package/src/theme/tokens.ts +239 -4
  229. package/src/theme/useTheme.test.tsx +20 -0
  230. package/src/utils/color.test.ts +133 -0
  231. package/src/utils/color.ts +148 -0
  232. package/src/utils/index.ts +3 -0
  233. package/src/utils/timeParser.test.ts +13 -0
  234. package/src/utils/timeParser.ts +8 -5
@@ -0,0 +1,148 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file color.ts
5
+ * @input CSS color strings (hex, rgb()/rgba(), a small set of named colors)
6
+ * @output Shared color parsing/formatting primitives used across the design system
7
+ * @position Package utility; backs theme token resolution, HCT color math, and chart rendering
8
+ *
9
+ * A single home for the color parsers that were previously duplicated across
10
+ * the theme layer, charts, and lab. Consumers get one well-tested definition of
11
+ * "parse this color" rather than several subtly different regexes.
12
+ */
13
+
14
+ /** A color decomposed into 0-255 RGB channels and a 0-1 alpha. */
15
+ export interface RGBA {
16
+ r: number;
17
+ g: number;
18
+ b: number;
19
+ a: number;
20
+ }
21
+
22
+ /** The named colors the design system relies on in token expressions. */
23
+ const NAMED_COLORS: Record<string, RGBA> = {
24
+ transparent: {r: 0, g: 0, b: 0, a: 0},
25
+ black: {r: 0, g: 0, b: 0, a: 1},
26
+ white: {r: 255, g: 255, b: 255, a: 1},
27
+ };
28
+
29
+ const clamp = (value: number, min: number, max: number): number =>
30
+ Math.max(min, Math.min(max, value));
31
+
32
+ /** Expand a shorthand hex body (`rgb`/`rgba`) to its full form (`rrggbb`/`rrggbbaa`). */
33
+ function expandShorthand(body: string): string {
34
+ return body
35
+ .split('')
36
+ .map(c => c + c)
37
+ .join('');
38
+ }
39
+
40
+ /**
41
+ * Parse a hex color into {@link RGBA}. Accepts `#rgb`, `#rgba`, `#rrggbb`, and
42
+ * `#rrggbbaa`, with or without the leading `#`. Returns null for anything else.
43
+ */
44
+ export function parseHex(hex: string): RGBA | null {
45
+ if (typeof hex !== 'string') {
46
+ return null;
47
+ }
48
+ const body = hex.trim().replace(/^#/, '');
49
+ const normalized =
50
+ body.length === 3 || body.length === 4 ? expandShorthand(body) : body;
51
+
52
+ if (
53
+ (normalized.length !== 6 && normalized.length !== 8) ||
54
+ !/^[0-9a-fA-F]+$/.test(normalized)
55
+ ) {
56
+ return null;
57
+ }
58
+
59
+ return {
60
+ r: parseInt(normalized.slice(0, 2), 16),
61
+ g: parseInt(normalized.slice(2, 4), 16),
62
+ b: parseInt(normalized.slice(4, 6), 16),
63
+ a: normalized.length === 8 ? parseInt(normalized.slice(6, 8), 16) / 255 : 1,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Parse an `rgb()`/`rgba()` color into {@link RGBA}. Accepts comma- or
69
+ * space-separated channels, an optional `/ alpha`, and percentage channels.
70
+ */
71
+ export function parseRgb(value: string): RGBA | null {
72
+ const open = value.indexOf('(');
73
+ if (open === -1 || !value.trim().endsWith(')')) {
74
+ return null;
75
+ }
76
+ const body = value
77
+ .slice(open + 1, value.lastIndexOf(')'))
78
+ .replace(/\//g, ' ');
79
+ const parts = body
80
+ .split(/[\s,]+/)
81
+ .map(p => p.trim())
82
+ .filter(Boolean);
83
+ if (parts.length < 3) {
84
+ return null;
85
+ }
86
+
87
+ const channel = (p: string): number => {
88
+ const n = p.endsWith('%') ? (parseFloat(p) / 100) * 255 : parseFloat(p);
89
+ return clamp(n, 0, 255);
90
+ };
91
+ const r = channel(parts[0]);
92
+ const g = channel(parts[1]);
93
+ const b = channel(parts[2]);
94
+ if ([r, g, b].some(Number.isNaN)) {
95
+ return null;
96
+ }
97
+
98
+ let a = 1;
99
+ if (parts.length >= 4) {
100
+ const raw = parts[3];
101
+ a = raw.endsWith('%') ? parseFloat(raw) / 100 : parseFloat(raw);
102
+ if (Number.isNaN(a)) {
103
+ return null;
104
+ }
105
+ a = clamp(a, 0, 1);
106
+ }
107
+ return {r, g, b, a};
108
+ }
109
+
110
+ /**
111
+ * Parse a concrete CSS color string into {@link RGBA}. Supports hex,
112
+ * `rgb()`/`rgba()`, and the named colors used in token expressions. Returns
113
+ * null for anything it can't evaluate (e.g. `var()`, `oklch()`, unknown names)
114
+ * so callers can preserve the original expression rather than guessing.
115
+ */
116
+ export function parseColor(value: string): RGBA | null {
117
+ const trimmed = value.trim();
118
+ const named = NAMED_COLORS[trimmed.toLowerCase()];
119
+ if (named) {
120
+ return {...named};
121
+ }
122
+ if (trimmed.startsWith('#')) {
123
+ return parseHex(trimmed);
124
+ }
125
+ if (/^rgba?\(/i.test(trimmed)) {
126
+ return parseRgb(trimmed);
127
+ }
128
+ return null;
129
+ }
130
+
131
+ /** Format RGB channels (0-255) as an uppercase `#RRGGBB` string. */
132
+ export function formatHex(r: number, g: number, b: number): string {
133
+ const channel = (c: number): string =>
134
+ clamp(Math.round(c), 0, 255).toString(16).padStart(2, '0').toUpperCase();
135
+ return `#${channel(r)}${channel(g)}${channel(b)}`;
136
+ }
137
+
138
+ /**
139
+ * Serialize {@link RGBA} to a compact, JS-usable CSS color: `#RRGGBB` when
140
+ * fully opaque, otherwise `rgba(r, g, b, a)`.
141
+ */
142
+ export function formatColor({r, g, b, a}: RGBA): string {
143
+ if (a >= 1) {
144
+ return formatHex(r, g, b);
145
+ }
146
+ const round = (n: number): number => clamp(Math.round(n), 0, 255);
147
+ return `rgba(${round(r)}, ${round(g)}, ${round(b)}, ${parseFloat(a.toFixed(4))})`;
148
+ }
@@ -81,3 +81,6 @@ export {observeResize, unobserveResize} from './sharedResizeObserver';
81
81
  export {isRenderable} from './isRenderable';
82
82
  export {getInputARIA} from './inputAria';
83
83
  export type {InputARIA, InputARIAInputGroup} from './inputAria';
84
+
85
+ export {parseHex, parseRgb, parseColor, formatHex, formatColor} from './color';
86
+ export type {RGBA} from './color';
@@ -249,6 +249,19 @@ describe('isTimeInRange', () => {
249
249
  });
250
250
 
251
251
  describe('adjustTime', () => {
252
+ it('returns the input unchanged for non-finite deltas', () => {
253
+ // -Infinity previously spun the wrap-around loop forever;
254
+ // NaN previously produced the corrupt string "NaN:NaN".
255
+ expect(adjustTime('10:00' as ISOTimeString, -Infinity)).toBe('10:00');
256
+ expect(adjustTime('10:00' as ISOTimeString, Infinity)).toBe('10:00');
257
+ expect(adjustTime('10:00' as ISOTimeString, NaN)).toBe('10:00');
258
+ });
259
+
260
+ it('wraps large negative deltas in constant time', () => {
261
+ expect(adjustTime('10:00' as ISOTimeString, -1440 * 1e7)).toBe('10:00');
262
+ expect(adjustTime('10:00' as ISOTimeString, -1440 * 1e7 - 30)).toBe('09:30');
263
+ });
264
+
252
265
  it('adds minutes', () => {
253
266
  expect(adjustTime('14:30' as ISOTimeString, 15)).toBe('14:45');
254
267
  expect(adjustTime('14:30' as ISOTimeString, 30)).toBe('15:00');
@@ -361,13 +361,16 @@ export function adjustTime(
361
361
  return time;
362
362
  }
363
363
 
364
+ // A non-finite delta would spin the wrap-around forever (-Infinity) or
365
+ // produce "NaN:NaN" (NaN) — return the input unchanged instead.
366
+ if (!Number.isFinite(deltaMinutes)) {
367
+ return time;
368
+ }
369
+
364
370
  let totalMinutes = parsed.hour * 60 + parsed.minute + deltaMinutes;
365
371
 
366
- // Wrap around midnight
367
- while (totalMinutes < 0) {
368
- totalMinutes += 24 * 60;
369
- }
370
- totalMinutes = totalMinutes % (24 * 60);
372
+ // Wrap around midnight (double-modulo handles negatives in O(1))
373
+ totalMinutes = ((totalMinutes % (24 * 60)) + 24 * 60) % (24 * 60);
371
374
 
372
375
  const newHour = Math.floor(totalMinutes / 60);
373
376
  const newMinute = totalMinutes % 60;