@charcuterie/ui 3.32.0 → 3.34.0

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 (76) hide show
  1. package/README.md +30 -0
  2. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts +16 -0
  3. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts.map +1 -1
  4. package/dist/AdaptiveGrid/useAdaptiveColumns.js +1 -0
  5. package/dist/AdaptiveGrid/useAdaptiveColumns.js.map +1 -1
  6. package/dist/Board/Board.d.ts.map +1 -1
  7. package/dist/Board/Board.js +2 -2
  8. package/dist/Board/Board.js.map +1 -1
  9. package/dist/RangeSlider/RangeSlider.d.ts +117 -0
  10. package/dist/RangeSlider/RangeSlider.d.ts.map +1 -0
  11. package/dist/RangeSlider/RangeSlider.js +242 -0
  12. package/dist/RangeSlider/RangeSlider.js.map +1 -0
  13. package/dist/RangeSlider/rangeSliderValue.d.ts +102 -0
  14. package/dist/RangeSlider/rangeSliderValue.d.ts.map +1 -0
  15. package/dist/RangeSlider/rangeSliderValue.js +116 -0
  16. package/dist/RangeSlider/rangeSliderValue.js.map +1 -0
  17. package/dist/SegmentedControl/SegmentedControl.d.ts +12 -1
  18. package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -1
  19. package/dist/SegmentedControl/SegmentedControl.js +3 -3
  20. package/dist/SegmentedControl/SegmentedControl.js.map +1 -1
  21. package/dist/SegmentedControl/SegmentedOption.d.ts +2 -1
  22. package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -1
  23. package/dist/SegmentedControl/SegmentedOption.js +7 -2
  24. package/dist/SegmentedControl/SegmentedOption.js.map +1 -1
  25. package/dist/Slider/Slider.d.ts +6 -1
  26. package/dist/Slider/Slider.d.ts.map +1 -1
  27. package/dist/Slider/Slider.js +8 -22
  28. package/dist/Slider/Slider.js.map +1 -1
  29. package/dist/TimecodeInput/TimecodeInput.d.ts +88 -0
  30. package/dist/TimecodeInput/TimecodeInput.d.ts.map +1 -0
  31. package/dist/TimecodeInput/TimecodeInput.js +258 -0
  32. package/dist/TimecodeInput/TimecodeInput.js.map +1 -0
  33. package/dist/TimecodeInput/timecode.d.ts +132 -0
  34. package/dist/TimecodeInput/timecode.d.ts.map +1 -0
  35. package/dist/TimecodeInput/timecode.js +246 -0
  36. package/dist/TimecodeInput/timecode.js.map +1 -0
  37. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts +35 -0
  38. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts.map +1 -1
  39. package/dist/VirtualizedGrid/VirtualizedGrid.js +37 -1
  40. package/dist/VirtualizedGrid/VirtualizedGrid.js.map +1 -1
  41. package/dist/index.d.ts +7 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +3 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/sliderStyles.d.ts +39 -0
  46. package/dist/sliderStyles.d.ts.map +1 -0
  47. package/dist/sliderStyles.js +50 -0
  48. package/dist/sliderStyles.js.map +1 -0
  49. package/package.json +1 -1
  50. package/src/AdaptiveGrid/useAdaptiveColumns.ts +17 -0
  51. package/src/Board/Board.test.tsx +8 -0
  52. package/src/Board/Board.tsx +7 -1
  53. package/src/RangeSlider/RangeSlider.mdx +85 -0
  54. package/src/RangeSlider/RangeSlider.stories.tsx +346 -0
  55. package/src/RangeSlider/RangeSlider.test.tsx +452 -0
  56. package/src/RangeSlider/RangeSlider.tsx +595 -0
  57. package/src/RangeSlider/rangeSliderValue.test.ts +217 -0
  58. package/src/RangeSlider/rangeSliderValue.ts +185 -0
  59. package/src/SegmentedControl/SegmentedControl.stories.tsx +50 -0
  60. package/src/SegmentedControl/SegmentedControl.test.tsx +36 -1
  61. package/src/SegmentedControl/SegmentedControl.tsx +14 -0
  62. package/src/SegmentedControl/SegmentedOption.tsx +7 -0
  63. package/src/Slider/Slider.tsx +23 -29
  64. package/src/TimecodeInput/TimecodeInput.mdx +155 -0
  65. package/src/TimecodeInput/TimecodeInput.stories.tsx +304 -0
  66. package/src/TimecodeInput/TimecodeInput.test.tsx +279 -0
  67. package/src/TimecodeInput/TimecodeInput.tsx +493 -0
  68. package/src/TimecodeInput/timecode.test.ts +236 -0
  69. package/src/TimecodeInput/timecode.ts +391 -0
  70. package/src/VirtualizedGrid/VirtualizedGrid.mdx +21 -0
  71. package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +64 -0
  72. package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +122 -3
  73. package/src/VirtualizedGrid/VirtualizedGrid.tsx +37 -1
  74. package/src/index.ts +18 -0
  75. package/src/sliderStyles.ts +69 -0
  76. package/src/sourceRules.test.ts +25 -1
@@ -0,0 +1,258 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createElement as _createElement } from "react";
3
+ import { useUniqueId } from "@charcuterie/logic";
4
+ import { useState } from "react";
5
+ import { CONTROL_SIZE_CLASS } from "../controlStyles.js";
6
+ import { DISABLED_CLASS, FOCUS_RING_CLASS, } from "../intentStyles.js";
7
+ import { mergeSlotProps } from "../slotProps.js";
8
+ import { toClassName } from "../toClassName.js";
9
+ import { clampTimecode, formatTimecode, parseTimecodeInput, } from "./timecode.js";
10
+ const INPUT_CLASS = "w-full min-w-0 rounded-md border border-border-default bg-surface-raised text-content-primary transition-colors duration-(--duration-fast) ease-standard placeholder:text-content-muted hover:border-border-strong aria-invalid:border-intent-danger-border";
11
+ const toSeededRange = (valueMs) => valueMs === undefined
12
+ ? { end: null, start: null }
13
+ : typeof valueMs === "number"
14
+ ? { end: null, start: valueMs }
15
+ : valueMs;
16
+ /**
17
+ * A timecode field: a text input a person can actually type a media
18
+ * position into, with an `isRange` mode for a section of a file.
19
+ *
20
+ * ### Why this is a text field and not `<input type="time">`
21
+ *
22
+ * Two reasons and neither is taste.
23
+ *
24
+ * A `time` input's smallest step is a **second** unless `step` is
25
+ * fractional, its value is a wall-clock `HH:MM` string capped at 24
26
+ * hours, and it has no way to express "1.5 hours into a file" as a
27
+ * duration rather than as half past one. A clip boundary in a video
28
+ * editor is milliseconds; the platform control cannot carry them.
29
+ *
30
+ * And it is painted by the operating system, from
31
+ * `::-webkit-datetime-edit-*` pseudo-elements no token reaches
32
+ * inside — the same mechanism that
33
+ * [deprecated the native `Select`](../../../../docs/decisions/2026-08-20-native-select-is-deprecated-and-the-platform-hatch-is-closed.md)
34
+ * and that kept `Slider` off
35
+ * [`<input type="range">`](../../../../docs/decisions/2026-08-22-the-slider-is-a-div-with-role-slider-not-an-input-range.md).
36
+ * A `<input type="text">` this component owns every pixel of is the
37
+ * settled answer to that shape of question.
38
+ *
39
+ * ### Typing is the whole interface, so nothing guesses in silence
40
+ *
41
+ * `90`, `1:30`, `1:02:03` and `1:02:03.500` all resolve, and the
42
+ * resolution is echoed underneath the field, in a live region, before
43
+ * anything commits. Commit is Enter or blur; a keystroke never
44
+ * commits, a refused value leaves the text exactly as typed, and
45
+ * Escape puts back the last committed value. `timecode.ts` is the
46
+ * grammar and the reasoning.
47
+ *
48
+ * ### Both ends of a section are independently optional
49
+ *
50
+ * `{ end, start }` has four real states, not three plus an error: no
51
+ * window, from here to the end of the media, from the beginning up to
52
+ * here, and the window between two points. So there is no
53
+ * `isEndOpen` prop — `null` says it — and clearing one end never
54
+ * touches the other.
55
+ *
56
+ * An **inverted** section swaps, because throwing the pair away would
57
+ * discard the boundary just typed. A **zero-length** section is
58
+ * refused by name: it plays nothing, so swapping would hide a typo
59
+ * behind a window that silently does not exist.
60
+ */
61
+ export const TimecodeInput = ({ className, durationMs, isDisabled = false, isRange = false, label, maxValueMs, minValueMs, onChange, placeholder = "e.g. 1:02:03.500", size = "md", stepMs = 1_000, valueMs, ...receivedSlotProps }) => {
62
+ const baseId = useUniqueId();
63
+ const minimum = minValueMs ?? 0;
64
+ const maximum = maxValueMs ?? durationMs;
65
+ const [seededRange] = useState(() => toSeededRange(valueMs));
66
+ const [range, setRange] = useState(seededRange);
67
+ const toDisplayText = (milliseconds) => milliseconds === null
68
+ ? ""
69
+ : formatTimecode(milliseconds);
70
+ const [texts, setTexts] = useState({
71
+ end: toDisplayText(seededRange.end),
72
+ start: toDisplayText(seededRange.start),
73
+ });
74
+ const [activeEndpoint, setActiveEndpoint] = useState("start");
75
+ /**
76
+ * A refusal the grammar cannot see, because it is about the
77
+ * **pair** rather than about either text: a zero-length section.
78
+ * Cleared by the next keystroke, so an edit always gets a fresh
79
+ * reading rather than an old complaint.
80
+ */
81
+ const [rangeRefusal, setRangeRefusal] = useState(null);
82
+ const parsed = parseTimecodeInput(texts[activeEndpoint]);
83
+ const echoId = `${baseId}-echo`;
84
+ const report = (nextRange) => {
85
+ setRange(nextRange);
86
+ if (isRange) {
87
+ onChange?.(nextRange.end === null && nextRange.start === null
88
+ ? null
89
+ : nextRange);
90
+ return;
91
+ }
92
+ onChange?.(nextRange.start);
93
+ };
94
+ const writeEndpoint = (endpoint, milliseconds) => {
95
+ if (!isRange) {
96
+ setTexts({
97
+ end: "",
98
+ start: toDisplayText(milliseconds),
99
+ });
100
+ setRangeRefusal(null);
101
+ report({ end: null, start: milliseconds });
102
+ return;
103
+ }
104
+ const nextRange = {
105
+ ...range,
106
+ [endpoint]: milliseconds,
107
+ };
108
+ // Only a pair can be compared. An open start is the absence of a
109
+ // choice rather than zero, so a section that only says where to
110
+ // stop is clamped and accepted — and stays able to complain the
111
+ // moment a start arrives that really does sit after it.
112
+ const isPair = nextRange.end !== null && nextRange.start !== null;
113
+ if (isPair && nextRange.end === nextRange.start) {
114
+ setRangeRefusal(`A section needs a length, and this one starts and ends at ${toDisplayText(milliseconds)}.`);
115
+ return;
116
+ }
117
+ const isBackwards = isPair &&
118
+ (nextRange.end ?? 0) < (nextRange.start ?? 0);
119
+ const settled = isBackwards
120
+ ? { end: nextRange.start, start: nextRange.end }
121
+ : nextRange;
122
+ setTexts(isBackwards
123
+ ? {
124
+ end: toDisplayText(settled.end),
125
+ start: toDisplayText(settled.start),
126
+ }
127
+ : (previous) => ({
128
+ ...previous,
129
+ [endpoint]: toDisplayText(milliseconds),
130
+ }));
131
+ setRangeRefusal(null);
132
+ report(settled);
133
+ };
134
+ const commitText = (endpoint) => {
135
+ const result = parseTimecodeInput(texts[endpoint]);
136
+ if (result.kind === "empty") {
137
+ writeEndpoint(endpoint, null);
138
+ return;
139
+ }
140
+ if (result.kind === "unparsed") {
141
+ // The text stays exactly as typed. Clearing it throws away
142
+ // what the person has to edit; rewriting it to the last good
143
+ // value discards what they meant.
144
+ return;
145
+ }
146
+ writeEndpoint(endpoint, clampTimecode(result.milliseconds, minimum, maximum));
147
+ };
148
+ /**
149
+ * ArrowUp and ArrowDown, which **do** commit.
150
+ *
151
+ * That is not an exception to "a keystroke never commits" — it is
152
+ * the other side of it. Typing is a draft the echo reports on;
153
+ * a step is a whole gesture on a value that already exists, the
154
+ * same act as clicking a day in `DatePicker`'s grid, and a
155
+ * stepper that needed Enter afterwards would be a stepper nobody
156
+ * pressed twice.
157
+ */
158
+ const stepEndpoint = (endpoint, direction, isCoarse) => {
159
+ const result = parseTimecodeInput(texts[endpoint]);
160
+ const base = result.kind === "timecode"
161
+ ? result.milliseconds
162
+ : (range[endpoint] ?? 0);
163
+ writeEndpoint(endpoint, clampTimecode(base + stepMs * (isCoarse ? 10 : 1) * direction, minimum, maximum));
164
+ };
165
+ const handleKeyDown = (keyEvent, endpoint) => {
166
+ if (keyEvent.key === "Enter") {
167
+ keyEvent.preventDefault();
168
+ commitText(endpoint);
169
+ return;
170
+ }
171
+ if (keyEvent.key === "Escape") {
172
+ keyEvent.preventDefault();
173
+ setRangeRefusal(null);
174
+ setTexts((previous) => ({
175
+ ...previous,
176
+ [endpoint]: toDisplayText(range[endpoint]),
177
+ }));
178
+ return;
179
+ }
180
+ if (keyEvent.key === "ArrowUp" ||
181
+ keyEvent.key === "ArrowDown") {
182
+ keyEvent.preventDefault();
183
+ stepEndpoint(endpoint, keyEvent.key === "ArrowUp" ? 1 : -1, keyEvent.shiftKey);
184
+ }
185
+ };
186
+ const clampedPreview = parsed.kind === "timecode"
187
+ ? clampTimecode(parsed.milliseconds, minimum, maximum)
188
+ : null;
189
+ const parsedEcho = parsed.kind === "unparsed"
190
+ ? parsed.reason
191
+ : parsed.kind === "empty" || clampedPreview === null
192
+ ? ""
193
+ : clampedPreview === parsed.milliseconds
194
+ ? formatTimecode(parsed.milliseconds)
195
+ : `${formatTimecode(parsed.milliseconds)} is outside the media, so it commits as ${formatTimecode(clampedPreview)}.`;
196
+ const echoText = rangeRefusal ?? parsedEcho;
197
+ const isInvalid = rangeRefusal !== null || parsed.kind === "unparsed";
198
+ const renderInput = (endpoint) => {
199
+ const ownProps = {
200
+ "aria-describedby": echoId,
201
+ "aria-invalid": activeEndpoint === endpoint && isInvalid
202
+ ? true
203
+ : undefined,
204
+ "aria-label": isRange
205
+ ? `${label} ${endpoint}`
206
+ : label,
207
+ autoComplete: "off",
208
+ className: toClassName(INPUT_CLASS, CONTROL_SIZE_CLASS[size], FOCUS_RING_CLASS, DISABLED_CLASS),
209
+ disabled: isDisabled,
210
+ // A `Field` above mints an `id` for its `<label htmlFor>` and
211
+ // clones it down. Preferring it over the generated one is what
212
+ // keeps that label pointing at something — the generated id is
213
+ // referenced by nothing here, so there is nothing to lose by
214
+ // standing aside for a caller's.
215
+ id: endpoint === "start"
216
+ ? (receivedSlotProps.id ?? `${baseId}-start`)
217
+ : `${baseId}-end`,
218
+ // A timecode is digits and two separators, so the numeric
219
+ // keypad is the right soft keyboard — and a bare seconds count
220
+ // is a first-class form of the grammar precisely because iOS
221
+ // gives that keypad no colon. `90` and `5400` are complete
222
+ // timecodes, so the phone case is answered by the grammar
223
+ // rather than by a keyboard the field cannot choose.
224
+ inputMode: "numeric",
225
+ onBlur: () => {
226
+ commitText(endpoint);
227
+ },
228
+ onChange: (changeEvent) => {
229
+ // Read out of the event NOW. React nulls `currentTarget`
230
+ // once the handler returns, and a `setTexts` updater runs
231
+ // after that — so reaching for it inside the updater is a
232
+ // `Cannot read properties of null` at the next render.
233
+ const nextText = changeEvent.currentTarget.value;
234
+ setRangeRefusal(null);
235
+ setTexts((previous) => ({
236
+ ...previous,
237
+ [endpoint]: nextText,
238
+ }));
239
+ },
240
+ onFocus: () => {
241
+ setActiveEndpoint(endpoint);
242
+ },
243
+ onKeyDown: (keyEvent) => {
244
+ handleKeyDown(keyEvent, endpoint);
245
+ },
246
+ placeholder,
247
+ type: "text",
248
+ value: texts[endpoint],
249
+ };
250
+ return (_createElement("input", { ...(endpoint === "start"
251
+ ? mergeSlotProps(receivedSlotProps, ownProps)
252
+ : ownProps), key: endpoint }));
253
+ };
254
+ return (_jsxs("div", { className: toClassName("flex flex-col gap-1", className), children: [_jsxs("div", { className: "flex items-center gap-2", children: [renderInput("start"), isRange ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: "shrink-0 text-content-secondary text-sm", children: "to" }), renderInput("end")] })) : null] }), _jsx("p", { "aria-live": "polite", className: toClassName("min-h-5 text-sm", isInvalid
255
+ ? "text-intent-danger-content"
256
+ : "text-content-secondary"), id: echoId, role: "status", children: echoText })] }));
257
+ };
258
+ //# sourceMappingURL=TimecodeInput.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TimecodeInput.js","sourceRoot":"","sources":["../../src/TimecodeInput/TimecodeInput.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,132 @@
1
+ /**
2
+ * What a human types into a timecode field, turned into a position
3
+ * in a piece of media — or into a stated reason it was not one.
4
+ *
5
+ * ### Milliseconds, and only milliseconds
6
+ *
7
+ * A media position is a **duration from the start of the file**, so
8
+ * the value is an integer count of milliseconds and nothing else. It
9
+ * is not a `Date`, it is not a clock time, and it is not a string:
10
+ * the only arithmetic anybody does with it is subtraction, and a
11
+ * number is the type that supports it. The props that carry one keep
12
+ * the `Ms` suffix, because a duration's unit is genuinely ambiguous
13
+ * and the fleet has both seconds and milliseconds in use
14
+ * ([decision](../../../../docs/decisions/2026-08-25-an-outbound-http-cache-names-its-fields-fetchedat-and-expiresat.md)).
15
+ *
16
+ * ### The fleet writes the printing half five times and the reading
17
+ * half never
18
+ *
19
+ * `formatTimecode` replaces five hand-rolled `h:mm:ss` printers —
20
+ * queuepilot's `clock` and `toClock`, castkit's `formatTime`, and
21
+ * mux-magic's two modal formatters — each with its own answer to
22
+ * whether the hour is shown and whether the minute is padded.
23
+ * `parseTimecodeInput` has no counterpart at all: nothing in the
24
+ * fleet *reads* a timecode a person typed, which is why the grammar
25
+ * gets the care here rather than being rediscovered per app.
26
+ *
27
+ * ### The rule that governs the grammar: never guess in silence
28
+ *
29
+ * The same three mechanisms `parseDateInput.ts` states, for the same
30
+ * reason:
31
+ *
32
+ * 1. **The grammar is ordered and total.** Each accepting rule is
33
+ * anchored on the *whole* input, the first match wins, and if
34
+ * none matches the answer is a named failure. `1:` is not one
35
+ * minute, and `1:90` is not `2:30`.
36
+ * 2. **An overflow is a failure with a name, not a carry.** `1:90`
37
+ * is refused, and the message offers both legal spellings —
38
+ * `2:30` and `90` — without committing to either. Silently
39
+ * carrying 90 seconds into the minute is the `1:60` trap
40
+ * mux-magic wrote down in
41
+ * `docs/audits/2026-06-29-pre-rename-domain-decisions.md`.
42
+ * 3. **Nothing commits without an echo.** `TimecodeInput` renders
43
+ * the resolved position in full underneath the field, in a live
44
+ * region, before anything commits.
45
+ *
46
+ * ### The largest field present is unbounded; every smaller one is
47
+ * 0–59
48
+ *
49
+ * One sentence covers every bound in the grammar. `90` is ninety
50
+ * **seconds**, `90:00` is ninety **minutes**, and `1:90` is refused
51
+ * because the minute beside it makes 90 a seconds field. That is why
52
+ * a bare number is seconds rather than minutes: seconds is the
53
+ * smallest unit the field prints, so the reading that needs no
54
+ * explanation is the one where the typist appended nothing.
55
+ */
56
+ /**
57
+ * A clip window, in milliseconds. Keys alphabetised, matching
58
+ * `DateRange`.
59
+ *
60
+ * **Both ends are independently optional**, and the four
61
+ * combinations are four real states rather than three plus an error:
62
+ * no window at all, from here to the end of the media, from the
63
+ * beginning up to here, and the window between two points. An open
64
+ * start is not zero — it is the absence of a choice — which is what
65
+ * lets a half-open window be clamped without being compared.
66
+ */
67
+ export type TimecodeRange = {
68
+ end: null | number;
69
+ start: null | number;
70
+ };
71
+ export type TimecodeInputResult = {
72
+ kind: "empty";
73
+ } | {
74
+ kind: "timecode";
75
+ milliseconds: number;
76
+ } | {
77
+ kind: "unparsed";
78
+ reason: string;
79
+ };
80
+ export type FormatTimecodeOptions = {
81
+ /**
82
+ * `true` — the default — prints the hours field even when it is
83
+ * `00`, so every value the field writes has the same shape and the
84
+ * same width. `false` rolls the hours into the minutes
85
+ * (`5400000` → `90:00.000`), which the grammar reads back
86
+ * unchanged because the largest field present is unbounded.
87
+ */
88
+ isHoursShown?: boolean;
89
+ /** `0` through `3`. `0` drops the fraction and its separator. */
90
+ millisecondDigits?: number;
91
+ };
92
+ /**
93
+ * A position in a piece of media, printed.
94
+ *
95
+ * Zero-padded, and **truncating** rather than rounding: at
96
+ * `millisecondDigits: 0` a position 1999 ms into a file is second 1,
97
+ * because it has not reached second 2 yet. Rounding would print a
98
+ * seek point the media has not played.
99
+ *
100
+ * The default is the canonical `hh:mm:ss.mmm`, and that is what the
101
+ * field writes back on commit. One spelling means a re-read of the
102
+ * field parses to the number that produced it, and a screenshot of
103
+ * the same value is the same picture — neither of which is true of
104
+ * the fleet's five printers, three of which hide the hour and two of
105
+ * which do not pad the minute.
106
+ */
107
+ export declare const formatTimecode: (milliseconds: number, { isHoursShown, millisecondDigits, }?: FormatTimecodeOptions) => string;
108
+ /**
109
+ * Hold a position inside the media.
110
+ *
111
+ * The floor is `0` when no `minimum` is given, because a media
112
+ * position before the start of the file does not exist — the same
113
+ * reason the grammar refuses a negative rather than clamping one
114
+ * silently. An inverted pair of bounds resolves to the floor: a
115
+ * caller whose `maximum` sits below its `minimum` has a bug, and
116
+ * quietly widening the window would hide it somewhere nobody looks.
117
+ */
118
+ export declare const clampTimecode: (milliseconds: number, minimum?: number, maximum?: number) => number;
119
+ /**
120
+ * Read a typed timecode, or say why it is not one.
121
+ *
122
+ * Takes no options, deliberately. Every knob considered belongs
123
+ * somewhere else: a maximum is the field's clamp at commit rather
124
+ * than a parse failure (two sources for one fact is how a control
125
+ * ends up invalid with nothing saying why), the decimal comma is
126
+ * accepted unconditionally instead of being a locale switch, and a
127
+ * frame rate makes this a different widget. An options bag that
128
+ * means nothing is API a consumer has to read before learning there
129
+ * is nothing in it.
130
+ */
131
+ export declare const parseTimecodeInput: (rawText: string) => TimecodeInputResult;
132
+ //# sourceMappingURL=timecode.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timecode.d.ts","sourceRoot":"","sources":["../../src/TimecodeInput/timecode.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AAQH;;;;;;;;;;GAUG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,GAAG,EAAE,IAAI,GAAG,MAAM,CAAA;IAClB,KAAK,EAAE,IAAI,GAAG,MAAM,CAAA;CACrB,CAAA;AAED,MAAM,MAAM,mBAAmB,GAC3B;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,GACjB;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAA;AAExC,MAAM,MAAM,qBAAqB,GAAG;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,iEAAiE;IACjE,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B,CAAA;AAKD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,cAAc,GACzB,cAAc,MAAM,EACpB,uCAGG,qBAA0B,KAC5B,MAgCF,CAAA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,aAAa,GACxB,cAAc,MAAM,EACpB,UAAU,MAAM,EAChB,UAAU,MAAM,KACf,MAaF,CAAA;AA6HD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,kBAAkB,GAC7B,SAAS,MAAM,KACd,mBAsEF,CAAA"}
@@ -0,0 +1,246 @@
1
+ /**
2
+ * What a human types into a timecode field, turned into a position
3
+ * in a piece of media — or into a stated reason it was not one.
4
+ *
5
+ * ### Milliseconds, and only milliseconds
6
+ *
7
+ * A media position is a **duration from the start of the file**, so
8
+ * the value is an integer count of milliseconds and nothing else. It
9
+ * is not a `Date`, it is not a clock time, and it is not a string:
10
+ * the only arithmetic anybody does with it is subtraction, and a
11
+ * number is the type that supports it. The props that carry one keep
12
+ * the `Ms` suffix, because a duration's unit is genuinely ambiguous
13
+ * and the fleet has both seconds and milliseconds in use
14
+ * ([decision](../../../../docs/decisions/2026-08-25-an-outbound-http-cache-names-its-fields-fetchedat-and-expiresat.md)).
15
+ *
16
+ * ### The fleet writes the printing half five times and the reading
17
+ * half never
18
+ *
19
+ * `formatTimecode` replaces five hand-rolled `h:mm:ss` printers —
20
+ * queuepilot's `clock` and `toClock`, castkit's `formatTime`, and
21
+ * mux-magic's two modal formatters — each with its own answer to
22
+ * whether the hour is shown and whether the minute is padded.
23
+ * `parseTimecodeInput` has no counterpart at all: nothing in the
24
+ * fleet *reads* a timecode a person typed, which is why the grammar
25
+ * gets the care here rather than being rediscovered per app.
26
+ *
27
+ * ### The rule that governs the grammar: never guess in silence
28
+ *
29
+ * The same three mechanisms `parseDateInput.ts` states, for the same
30
+ * reason:
31
+ *
32
+ * 1. **The grammar is ordered and total.** Each accepting rule is
33
+ * anchored on the *whole* input, the first match wins, and if
34
+ * none matches the answer is a named failure. `1:` is not one
35
+ * minute, and `1:90` is not `2:30`.
36
+ * 2. **An overflow is a failure with a name, not a carry.** `1:90`
37
+ * is refused, and the message offers both legal spellings —
38
+ * `2:30` and `90` — without committing to either. Silently
39
+ * carrying 90 seconds into the minute is the `1:60` trap
40
+ * mux-magic wrote down in
41
+ * `docs/audits/2026-06-29-pre-rename-domain-decisions.md`.
42
+ * 3. **Nothing commits without an echo.** `TimecodeInput` renders
43
+ * the resolved position in full underneath the field, in a live
44
+ * region, before anything commits.
45
+ *
46
+ * ### The largest field present is unbounded; every smaller one is
47
+ * 0–59
48
+ *
49
+ * One sentence covers every bound in the grammar. `90` is ninety
50
+ * **seconds**, `90:00` is ninety **minutes**, and `1:90` is refused
51
+ * because the minute beside it makes 90 a seconds field. That is why
52
+ * a bare number is seconds rather than minutes: seconds is the
53
+ * smallest unit the field prints, so the reading that needs no
54
+ * explanation is the one where the typist appended nothing.
55
+ */
56
+ const MILLISECONDS_PER_SECOND = 1_000;
57
+ const MILLISECONDS_PER_MINUTE = 60_000;
58
+ const MILLISECONDS_PER_HOUR = 3_600_000;
59
+ const padTwo = (value) => String(value).padStart(2, "0");
60
+ /**
61
+ * A position in a piece of media, printed.
62
+ *
63
+ * Zero-padded, and **truncating** rather than rounding: at
64
+ * `millisecondDigits: 0` a position 1999 ms into a file is second 1,
65
+ * because it has not reached second 2 yet. Rounding would print a
66
+ * seek point the media has not played.
67
+ *
68
+ * The default is the canonical `hh:mm:ss.mmm`, and that is what the
69
+ * field writes back on commit. One spelling means a re-read of the
70
+ * field parses to the number that produced it, and a screenshot of
71
+ * the same value is the same picture — neither of which is true of
72
+ * the fleet's five printers, three of which hide the hour and two of
73
+ * which do not pad the minute.
74
+ */
75
+ export const formatTimecode = (milliseconds, { isHoursShown = true, millisecondDigits = 3, } = {}) => {
76
+ const total = Number.isFinite(milliseconds)
77
+ ? Math.max(0, Math.floor(milliseconds))
78
+ : 0;
79
+ const digits = Math.min(3, Math.max(0, Math.floor(millisecondDigits)));
80
+ const hours = isHoursShown
81
+ ? Math.floor(total / MILLISECONDS_PER_HOUR)
82
+ : 0;
83
+ const minutes = Math.floor((total - hours * MILLISECONDS_PER_HOUR) /
84
+ MILLISECONDS_PER_MINUTE);
85
+ const seconds = Math.floor((total % MILLISECONDS_PER_MINUTE) /
86
+ MILLISECONDS_PER_SECOND);
87
+ const fraction = digits === 0
88
+ ? ""
89
+ : `.${String(total % MILLISECONDS_PER_SECOND)
90
+ .padStart(3, "0")
91
+ .slice(0, digits)}`;
92
+ return `${isHoursShown ? `${padTwo(hours)}:` : ""}${padTwo(minutes)}:${padTwo(seconds)}${fraction}`;
93
+ };
94
+ /**
95
+ * Hold a position inside the media.
96
+ *
97
+ * The floor is `0` when no `minimum` is given, because a media
98
+ * position before the start of the file does not exist — the same
99
+ * reason the grammar refuses a negative rather than clamping one
100
+ * silently. An inverted pair of bounds resolves to the floor: a
101
+ * caller whose `maximum` sits below its `minimum` has a bug, and
102
+ * quietly widening the window would hide it somewhere nobody looks.
103
+ */
104
+ export const clampTimecode = (milliseconds, minimum, maximum) => {
105
+ const floor = Math.max(0, minimum ?? 0);
106
+ if (!Number.isFinite(milliseconds)) {
107
+ return floor;
108
+ }
109
+ const ceiling = maximum === undefined || !Number.isFinite(maximum)
110
+ ? Number.POSITIVE_INFINITY
111
+ : Math.max(floor, maximum);
112
+ return Math.min(Math.max(milliseconds, floor), ceiling);
113
+ };
114
+ /**
115
+ * The three accepting rules, in the order they are tried.
116
+ *
117
+ * They are disjoint by colon count, so the order is a statement of
118
+ * discipline rather than a tie-breaker — but the discipline is what
119
+ * keeps a fourth rule from being appended as a nearest guess.
120
+ *
121
+ * The fraction separator may be a `.` or a `,`: half of Europe types
122
+ * the second one, both are unambiguous here because a timecode has
123
+ * no thousands separator, and the echo prints the canonical `.` back
124
+ * either way.
125
+ */
126
+ const HOURS_MINUTES_SECONDS = /^\+?(\d{1,5}):(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?$/;
127
+ const MINUTES_SECONDS = /^\+?(\d{1,7}):(\d{1,2})(?:[.,](\d{1,3}))?$/;
128
+ const SECONDS = /^\+?(\d{1,9})(?:[.,](\d{1,3}))?$/;
129
+ /** Anchored diagnostics, tried only after every accepting rule has failed. */
130
+ const NEGATIVE = /^-/;
131
+ const TOO_MANY_PARTS = /^\+?\d+(?::\d+){3,}(?:[.,]\d+)?$/;
132
+ const LONG_FRACTION = /^\+?\d+(?::\d+){0,2}[.,]\d{4,}$/;
133
+ const unparsed = (reason) => ({
134
+ kind: "unparsed",
135
+ reason,
136
+ });
137
+ /**
138
+ * A field that overflowed, named rather than carried.
139
+ *
140
+ * The message OFFERS both legal spellings and commits to neither,
141
+ * which is the whole difference between this and a parser that
142
+ * "just works": carrying the overflow moves a clip start by a whole
143
+ * minute and nothing ever says so. Offering the carried form is not
144
+ * the same as taking it — the typist reads two options and picks,
145
+ * and either pick is one keystroke away.
146
+ */
147
+ const describeOverflow = ({ carriedMilliseconds, dropped, isHoursShown, larger, typed, unit, }) => `${larger} has 60 ${unit}s, so "${typed}" is not a ${unit}s field. Type ${formatTimecode(carriedMilliseconds, { isHoursShown, millisecondDigits: 0 })} to carry it, or ${dropped} to mean ${typed} ${unit}s.`;
148
+ const toMilliseconds = ({ fraction, hours, minutes, seconds, }) => {
149
+ const total = Number(hours ?? 0) * MILLISECONDS_PER_HOUR +
150
+ Number(minutes ?? 0) * MILLISECONDS_PER_MINUTE +
151
+ Number(seconds) * MILLISECONDS_PER_SECOND;
152
+ // The largest field present is unbounded; every smaller one is
153
+ // 0–59. Checked outermost-first, so `1:90:90` reports the minute —
154
+ // the field a reader fixes first.
155
+ if (hours !== undefined &&
156
+ minutes !== undefined &&
157
+ Number(minutes) > 59) {
158
+ return unparsed(describeOverflow({
159
+ carriedMilliseconds: total,
160
+ dropped: `${minutes}:${seconds}`,
161
+ isHoursShown: true,
162
+ larger: "An hour",
163
+ typed: minutes,
164
+ unit: "minute",
165
+ }));
166
+ }
167
+ if (minutes !== undefined && Number(seconds) > 59) {
168
+ return unparsed(describeOverflow({
169
+ carriedMilliseconds: total,
170
+ dropped: seconds,
171
+ isHoursShown: hours !== undefined,
172
+ larger: "A minute",
173
+ typed: seconds,
174
+ unit: "second",
175
+ }));
176
+ }
177
+ // `.5` is half a second, not five milliseconds — a fraction is a
178
+ // decimal fraction of a second, so it pads on the RIGHT.
179
+ return {
180
+ kind: "timecode",
181
+ milliseconds: total +
182
+ (fraction === undefined
183
+ ? 0
184
+ : Number(fraction.padEnd(3, "0"))),
185
+ };
186
+ };
187
+ /**
188
+ * Read a typed timecode, or say why it is not one.
189
+ *
190
+ * Takes no options, deliberately. Every knob considered belongs
191
+ * somewhere else: a maximum is the field's clamp at commit rather
192
+ * than a parse failure (two sources for one fact is how a control
193
+ * ends up invalid with nothing saying why), the decimal comma is
194
+ * accepted unconditionally instead of being a locale switch, and a
195
+ * frame rate makes this a different widget. An options bag that
196
+ * means nothing is API a consumer has to read before learning there
197
+ * is nothing in it.
198
+ */
199
+ export const parseTimecodeInput = (rawText) => {
200
+ const text = rawText.replace(/\s/g, "");
201
+ if (text === "") {
202
+ return { kind: "empty" };
203
+ }
204
+ // A leading `-` is refused before anything else. Every accepting
205
+ // rule below would otherwise fail on it and report the generic
206
+ // message, which tells a typist that `-5` is unreadable rather
207
+ // than that it is out of bounds.
208
+ if (NEGATIVE.test(text)) {
209
+ return unparsed("A timecode is never negative. The start of the media is 0.");
210
+ }
211
+ const hoursMinutesSeconds = HOURS_MINUTES_SECONDS.exec(text);
212
+ if (hoursMinutesSeconds?.[1] !== undefined &&
213
+ hoursMinutesSeconds[2] !== undefined &&
214
+ hoursMinutesSeconds[3] !== undefined) {
215
+ return toMilliseconds({
216
+ fraction: hoursMinutesSeconds[4],
217
+ hours: hoursMinutesSeconds[1],
218
+ minutes: hoursMinutesSeconds[2],
219
+ seconds: hoursMinutesSeconds[3],
220
+ });
221
+ }
222
+ const minutesSeconds = MINUTES_SECONDS.exec(text);
223
+ if (minutesSeconds?.[1] !== undefined &&
224
+ minutesSeconds[2] !== undefined) {
225
+ return toMilliseconds({
226
+ fraction: minutesSeconds[3],
227
+ minutes: minutesSeconds[1],
228
+ seconds: minutesSeconds[2],
229
+ });
230
+ }
231
+ const seconds = SECONDS.exec(text);
232
+ if (seconds?.[1] !== undefined) {
233
+ return toMilliseconds({
234
+ fraction: seconds[2],
235
+ seconds: seconds[1],
236
+ });
237
+ }
238
+ if (TOO_MANY_PARTS.test(text)) {
239
+ return unparsed("A timecode has three parts at most — hours, minutes, seconds. Try 1:02:03.");
240
+ }
241
+ if (LONG_FRACTION.test(text)) {
242
+ return unparsed("The fraction is milliseconds — three digits at most. Try 1:02.500.");
243
+ }
244
+ return unparsed("Not a timecode. Try 90, 1:30, 1:02:03, or 1:02:03.500.");
245
+ };
246
+ //# sourceMappingURL=timecode.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timecode.js","sourceRoot":"","sources":["../../src/TimecodeInput/timecode.ts"],"names":[],"mappings":"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"}