@lostgradient/cinder 0.9.0 → 0.11.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 (138) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/components.json +82 -4
  3. package/dist/components/access-gate/index.js +381 -239
  4. package/dist/components/chat/builders.d.ts +17 -8
  5. package/dist/components/chat/conversation-model.d.ts +1 -1
  6. package/dist/components/chat/index.d.ts +1 -1
  7. package/dist/components/chat/index.js +300 -17
  8. package/dist/components/chat/utilities/conversation.d.ts +6 -6
  9. package/dist/components/chat-conversation-header/index.js +19 -6
  10. package/dist/components/chat-conversation-list/index.js +19 -6
  11. package/dist/components/connection-indicator/connection-indicator.css +136 -0
  12. package/dist/components/connection-indicator/connection-indicator.css.d.ts +1 -0
  13. package/dist/components/connection-indicator/connection-indicator.schema.d.ts +3 -0
  14. package/dist/components/connection-indicator/connection-indicator.schema.js +89 -0
  15. package/dist/components/connection-indicator/connection-indicator.svelte.d.ts +21 -0
  16. package/dist/components/connection-indicator/connection-indicator.types.d.ts +46 -0
  17. package/dist/components/connection-indicator/connection-indicator.variables.d.ts +2 -0
  18. package/dist/components/connection-indicator/connection-indicator.variables.js +61 -0
  19. package/dist/components/connection-indicator/index.d.ts +5 -0
  20. package/dist/components/connection-indicator/index.js +409 -0
  21. package/dist/components/container/container.schema.js +4 -4
  22. package/dist/components/invocation-rule-builder/index.d.ts +1 -1
  23. package/dist/components/invocation-rule-builder/index.js +305 -150
  24. package/dist/components/invocation-rule-builder/invocation-rule-builder.css +28 -0
  25. package/dist/components/invocation-rule-builder/invocation-rule-builder.schema.js +89 -5
  26. package/dist/components/invocation-rule-builder/invocation-rule-builder.svelte.d.ts +2 -1
  27. package/dist/components/invocation-rule-builder/invocation-rule-builder.types.d.ts +114 -16
  28. package/dist/components/run-step-timeline/index.d.ts +1 -1
  29. package/dist/components/run-step-timeline/index.js +992 -559
  30. package/dist/components/run-step-timeline/run-step-branch-disclosure.svelte.d.ts +17 -0
  31. package/dist/components/run-step-timeline/run-step-timeline.css +124 -0
  32. package/dist/components/run-step-timeline/run-step-timeline.schema.js +873 -350
  33. package/dist/components/run-step-timeline/run-step-timeline.svelte.d.ts +1 -15
  34. package/dist/components/run-step-timeline/run-step-timeline.types.d.ts +182 -5
  35. package/dist/components/run-step-timeline/run-step-timeline.utilities.d.ts +52 -0
  36. package/dist/components/schedule-builder/index.d.ts +5 -0
  37. package/dist/components/schedule-builder/index.js +3176 -0
  38. package/dist/components/schedule-builder/schedule-builder.css +121 -0
  39. package/dist/components/schedule-builder/schedule-builder.css.d.ts +1 -0
  40. package/dist/components/schedule-builder/schedule-builder.schema.d.ts +3 -0
  41. package/dist/components/schedule-builder/schedule-builder.schema.js +141 -0
  42. package/dist/components/schedule-builder/schedule-builder.svelte.d.ts +22 -0
  43. package/dist/components/schedule-builder/schedule-builder.types.d.ts +130 -0
  44. package/dist/components/schedule-builder/schedule-builder.utilities.d.ts +78 -0
  45. package/dist/components/schedule-builder/schedule-builder.variables.d.ts +2 -0
  46. package/dist/components/schedule-builder/schedule-builder.variables.js +61 -0
  47. package/dist/components/tag-input/index.js +22 -4
  48. package/dist/components/tag-input/tag-input.schema.js +5 -1
  49. package/dist/components/tag-input/tag-input.types.d.ts +4 -0
  50. package/dist/index.d.ts +6 -2
  51. package/dist/index.js +22704 -20574
  52. package/dist/server/components/access-gate/index.js +2 -1
  53. package/dist/server/components/chat/index.js +4 -4
  54. package/dist/server/components/chat-conversation-header/index.js +5 -5
  55. package/dist/server/components/chat-conversation-list/index.js +3 -3
  56. package/dist/server/components/connection-indicator/index.js +16 -0
  57. package/dist/server/components/data-table/index.js +3 -3
  58. package/dist/server/components/diff-viewer/index.js +4 -3
  59. package/dist/server/components/invocation-rule-builder/index.js +1 -1
  60. package/dist/server/components/json-schema-editor/index.js +4 -3
  61. package/dist/server/components/review-editor/index.js +4 -3
  62. package/dist/server/components/run-step-timeline/index.js +1 -1
  63. package/dist/server/components/schedule-builder/index.js +30 -0
  64. package/dist/server/components/side-navigation/index.js +2 -2
  65. package/dist/server/components/table/index.js +3 -3
  66. package/dist/server/components/tag-input/index.js +1 -1
  67. package/dist/server/components/time-field/index.js +5 -3
  68. package/dist/server/index.js +100 -87
  69. package/dist/server/index.server-0sx92swg.js +9 -0
  70. package/dist/server/{index.server-mzzb7dnm.js → index.server-2bbwajb2.js} +1 -1
  71. package/dist/server/{index.server-q7w4jw89.js → index.server-2s5h1d05.js} +2 -2
  72. package/dist/server/{index.server-dg83ke3k.js → index.server-2zdbz3rj.js} +1 -1
  73. package/dist/server/{index.server-0jhra85x.js → index.server-3w72dxz7.js} +4 -4
  74. package/dist/server/{index.server-qh9qzd5t.js → index.server-4x4hgvqe.js} +283 -13
  75. package/dist/server/{index.server-c6pe4kaf.js → index.server-5gzyqv78.js} +1 -1
  76. package/dist/server/{index.server-mqt1ngqw.js → index.server-6hnfzc7f.js} +19 -6
  77. package/dist/server/index.server-be9bpqeh.js +93 -0
  78. package/dist/server/{index.server-f3z7tpxr.js → index.server-c42b462p.js} +2 -4
  79. package/dist/server/{index.server-7vyzen5x.js → index.server-c5qv3p61.js} +171 -63
  80. package/dist/server/index.server-cch0eed5.js +144 -0
  81. package/dist/server/index.server-gmh5ar16.js +2 -0
  82. package/dist/server/{index.server-0atyznp7.js → index.server-h89zk5ev.js} +7 -7
  83. package/dist/server/{index.server-jxg42jpz.js → index.server-jdcq5zza.js} +7 -7
  84. package/dist/server/{index.server-2dga5yzr.js → index.server-jfrssta6.js} +1 -1
  85. package/dist/server/{index.server-fy068yne.js → index.server-jj76374p.js} +1 -1
  86. package/dist/server/index.server-qbkxz7r2.js +750 -0
  87. package/dist/server/{index.server-marjz8ps.js → index.server-tcay903b.js} +3 -2
  88. package/dist/server/index.server-z38bxms8.js +734 -0
  89. package/dist/server/{index.server-yz4k36cw.js → index.server-z7hxtqs1.js} +1 -1
  90. package/package.json +66 -3
  91. package/src/components/chat/builders.ts +445 -14
  92. package/src/components/chat/conversation-model.ts +1 -0
  93. package/src/components/chat/index.ts +1 -0
  94. package/src/components/chat/utilities/conversation.ts +33 -15
  95. package/src/components/connection-indicator/connection-indicator.css +136 -0
  96. package/src/components/connection-indicator/connection-indicator.examples.json +31 -0
  97. package/src/components/connection-indicator/connection-indicator.schema.json +29 -0
  98. package/src/components/connection-indicator/connection-indicator.schema.ts +36 -0
  99. package/src/components/connection-indicator/connection-indicator.svelte +63 -0
  100. package/src/components/connection-indicator/connection-indicator.types.ts +58 -0
  101. package/src/components/connection-indicator/connection-indicator.variables.json +1 -0
  102. package/src/components/connection-indicator/connection-indicator.variables.ts +3 -0
  103. package/src/components/connection-indicator/index.ts +10 -0
  104. package/src/components/container/container.schema.json +3 -3
  105. package/src/components/container/container.schema.ts +3 -3
  106. package/src/components/invocation-rule-builder/index.ts +4 -0
  107. package/src/components/invocation-rule-builder/invocation-rule-builder.css +28 -0
  108. package/src/components/invocation-rule-builder/invocation-rule-builder.examples.json +6 -0
  109. package/src/components/invocation-rule-builder/invocation-rule-builder.schema.json +76 -3
  110. package/src/components/invocation-rule-builder/invocation-rule-builder.schema.ts +83 -3
  111. package/src/components/invocation-rule-builder/invocation-rule-builder.svelte +326 -113
  112. package/src/components/invocation-rule-builder/invocation-rule-builder.types.ts +157 -43
  113. package/src/components/run-step-timeline/index.ts +8 -0
  114. package/src/components/run-step-timeline/run-step-branch-disclosure.svelte +16 -0
  115. package/src/components/run-step-timeline/run-step-timeline.css +124 -0
  116. package/src/components/run-step-timeline/run-step-timeline.examples.json +18 -0
  117. package/src/components/run-step-timeline/run-step-timeline.schema.json +872 -349
  118. package/src/components/run-step-timeline/run-step-timeline.schema.ts +931 -349
  119. package/src/components/run-step-timeline/run-step-timeline.svelte +387 -268
  120. package/src/components/run-step-timeline/run-step-timeline.types.ts +192 -5
  121. package/src/components/run-step-timeline/run-step-timeline.utilities.ts +242 -0
  122. package/src/components/schedule-builder/index.ts +14 -0
  123. package/src/components/schedule-builder/schedule-builder.css +121 -0
  124. package/src/components/schedule-builder/schedule-builder.examples.json +31 -0
  125. package/src/components/schedule-builder/schedule-builder.schema.json +78 -0
  126. package/src/components/schedule-builder/schedule-builder.schema.ts +86 -0
  127. package/src/components/schedule-builder/schedule-builder.svelte +629 -0
  128. package/src/components/schedule-builder/schedule-builder.types.ts +137 -0
  129. package/src/components/schedule-builder/schedule-builder.utilities.ts +344 -0
  130. package/src/components/schedule-builder/schedule-builder.variables.json +1 -0
  131. package/src/components/schedule-builder/schedule-builder.variables.ts +3 -0
  132. package/src/components/tag-input/tag-input.schema.json +4 -0
  133. package/src/components/tag-input/tag-input.schema.ts +4 -0
  134. package/src/components/tag-input/tag-input.svelte +20 -3
  135. package/src/components/tag-input/tag-input.types.ts +4 -0
  136. package/src/index.ts +23 -0
  137. package/src/styles/components.css +2 -0
  138. package/dist/server/index.server-qk1h0n02.js +0 -393
@@ -0,0 +1,629 @@
1
+ <script lang="ts" module>
2
+ export {};
3
+ </script>
4
+
5
+ <script lang="ts">
6
+ import { classNames } from '../../utilities/class-names.ts';
7
+ import Chip from '../chip/chip.svelte';
8
+ import Input from '../input/input.svelte';
9
+ import NumberInput from '../number-input/number-input.svelte';
10
+ import Segment from '../segment/segment.svelte';
11
+ import SegmentedControl from '../segmented-control/segmented-control.svelte';
12
+ import Select from '../select/select.svelte';
13
+ import TimeField from '../time-field/time-field.svelte';
14
+ import { CRON_FIELDS, WEEKDAYS, INTERVAL_UNITS, cronFieldsValid, defaultScheduleValue, describeValue, joinCron, lowerDailyAt, lowerEveryN, lowerMonthlyOnDay, lowerWeeklyAt, validateCronField, valueToCronFields, valueToInterval, } from './schedule-builder.utilities.ts';
15
+ const PRESET_EVERY_UNIT_OPTIONS = [
16
+ { value: 'minutes', label: 'Minutes' },
17
+ { value: 'hours', label: 'Hours' },
18
+ ];
19
+ const INTERVAL_UNIT_LABELS = {
20
+ minutes: 'Minutes',
21
+ hours: 'Hours',
22
+ days: 'Days',
23
+ weeks: 'Weeks',
24
+ };
25
+ const INTERVAL_UNIT_OPTIONS = INTERVAL_UNITS.map((unit) => ({
26
+ value: unit,
27
+ label: INTERVAL_UNIT_LABELS[unit],
28
+ }));
29
+ let { value, onchange, computeNextFires, previewCount = 5, timezoneLabel, timezone, label = 'Schedule', class: className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, ...rest } = $props();
30
+ const baseId = $props.id();
31
+ // Normalize ARIA label props: an empty string `aria-label=""` (or
32
+ // `aria-labelledby=""`) suppresses the accessible-name fallback (ARIA spec
33
+ // §4.3) without actually providing a name. Convert empty strings to
34
+ // `undefined` so the DOM attribute is omitted and the `label` default
35
+ // remains the accessible name. Matches button.svelte's normalization.
36
+ const normalizedAriaLabel = $derived(typeof ariaLabel === 'string' && ariaLabel.trim().length > 0 ? ariaLabel : undefined);
37
+ const normalizedAriaLabelledby = $derived(typeof ariaLabelledby === 'string' && ariaLabelledby.trim().length > 0
38
+ ? ariaLabelledby
39
+ : undefined);
40
+ const resolvedAriaLabel = $derived(normalizedAriaLabelledby === undefined && normalizedAriaLabel === undefined
41
+ ? label
42
+ : normalizedAriaLabel);
43
+ // ---------------------------------------------------------------------------
44
+ // Seed local field state from the `value` prop (or the shared default), so
45
+ // mid-edit intermediate text (e.g. a half-typed cron range) has somewhere to
46
+ // live without fighting a controlled `value` prop on every keystroke. This
47
+ // seeding runs at construction AND — guarded against re-seeding on a mere
48
+ // re-render or an echoed `onchange` — again whenever the CONSUMER hands in a
49
+ // genuinely different `value` (see the resync `$effect` below `lastKnownValue`).
50
+ // ---------------------------------------------------------------------------
51
+ const seedValue = value ?? defaultScheduleValue();
52
+ /** Structural equality for the small `ScheduleValue` discriminated union. */
53
+ function scheduleValuesEqual(a, b) {
54
+ if (a.mode !== b.mode)
55
+ return false;
56
+ if (a.mode === 'cron' && b.mode === 'cron')
57
+ return a.expression === b.expression;
58
+ if (a.mode === 'interval' && b.mode === 'interval') {
59
+ return a.every === b.every && a.unit === b.unit;
60
+ }
61
+ return false;
62
+ }
63
+ /**
64
+ * The authoring mode to open for a given value. A minutes/hours interval
65
+ * (or no value at all) cleanly matches the "every N" preset — and is
66
+ * already seeded correctly there by `seedFieldsFromValue` below — so those
67
+ * open `presets`. Any other `interval` (days/weeks, which presets cannot
68
+ * represent) opens directly in `interval` mode; any `cron` value opens
69
+ * directly in `cron` mode. Either way the destination mode's fields are
70
+ * seeded from the value, so the summary and preview are correct immediately
71
+ * instead of showing the presets default.
72
+ */
73
+ function initialAuthoringMode(candidateValue) {
74
+ if (candidateValue === undefined)
75
+ return 'presets';
76
+ if (candidateValue.mode === 'cron')
77
+ return 'cron';
78
+ return candidateValue.unit === 'minutes' || candidateValue.unit === 'hours'
79
+ ? 'presets'
80
+ : 'interval';
81
+ }
82
+ /**
83
+ * Computes every field this component seeds from a `ScheduleValue` — shared
84
+ * by the initial seed below and the resync `$effect` (see `lastKnownValue`),
85
+ * so a later controlled `value` change is seeded exactly like the first
86
+ * render. `presetDailyTime`/`presetWeeklyDays`/`presetWeeklyTime`/
87
+ * `presetMonthlyDay`/`presetMonthlyTime` have no general inverse from an
88
+ * arbitrary cron expression (only `valueToInterval` exists), so both the
89
+ * initial seed and a resync reset them to the same neutral defaults.
90
+ */
91
+ function seedFieldsFromValue(seededValue) {
92
+ const interval = valueToInterval(seededValue);
93
+ const isMinutesOrHours = interval !== undefined && (interval.unit === 'minutes' || interval.unit === 'hours');
94
+ return {
95
+ authoringMode: initialAuthoringMode(seededValue),
96
+ cronFields: valueToCronFields(seededValue),
97
+ intervalEvery: interval?.every ?? 15,
98
+ intervalUnit: interval?.unit ?? 'minutes',
99
+ presetKind: 'every',
100
+ presetEveryValue: isMinutesOrHours ? interval.every : 15,
101
+ presetEveryUnit: (isMinutesOrHours ? interval.unit : 'minutes'),
102
+ presetDailyTime: '09:00',
103
+ presetWeeklyDays: [],
104
+ presetWeeklyTime: '09:00',
105
+ presetMonthlyDay: 1,
106
+ presetMonthlyTime: '09:00',
107
+ };
108
+ }
109
+ const initialSeed = seedFieldsFromValue(seedValue);
110
+ let authoringMode = $state(initialSeed.authoringMode);
111
+ let presetKind = $state(initialSeed.presetKind);
112
+ let cronFields = $state(initialSeed.cronFields);
113
+ let intervalEvery = $state(initialSeed.intervalEvery);
114
+ let intervalUnit = $state(initialSeed.intervalUnit);
115
+ let presetEveryValue = $state(initialSeed.presetEveryValue);
116
+ let presetEveryUnit = $state(initialSeed.presetEveryUnit);
117
+ let presetDailyTime = $state(initialSeed.presetDailyTime);
118
+ let presetWeeklyDays = $state(initialSeed.presetWeeklyDays);
119
+ let presetWeeklyTime = $state(initialSeed.presetWeeklyTime);
120
+ let presetMonthlyDay = $state(initialSeed.presetMonthlyDay);
121
+ let presetMonthlyTime = $state(initialSeed.presetMonthlyTime);
122
+ function valueForPresets() {
123
+ switch (presetKind) {
124
+ case 'every':
125
+ return lowerEveryN(presetEveryValue, presetEveryUnit);
126
+ case 'daily':
127
+ return lowerDailyAt(presetDailyTime);
128
+ case 'weekly':
129
+ return lowerWeeklyAt(presetWeeklyDays, presetWeeklyTime);
130
+ case 'monthly':
131
+ return lowerMonthlyOnDay(presetMonthlyDay, presetMonthlyTime);
132
+ }
133
+ }
134
+ /** The value the given authoring mode currently represents, from its own field state. */
135
+ function valueForMode(mode) {
136
+ if (mode === 'cron')
137
+ return { mode: 'cron', expression: joinCron(cronFields) };
138
+ if (mode === 'interval')
139
+ return { mode: 'interval', every: intervalEvery, unit: intervalUnit };
140
+ return valueForPresets();
141
+ }
142
+ /**
143
+ * The value the active authoring mode currently represents. This is ONLY
144
+ * ever `cron` or `interval` — presets are sugar that lower to one of the
145
+ * two via the matching `lower*` utility, so `mode: 'preset'` never appears.
146
+ */
147
+ const currentValue = $derived(valueForMode(authoringMode));
148
+ /**
149
+ * Whether `currentValue` is safe to hand to the consumer's `computeNextFires`.
150
+ * In cron mode a mid-edit field can make the joined expression invalid (e.g.
151
+ * an out-of-range hour); a real cron-parsing callback would likely throw on
152
+ * that. Presets and interval mode always produce a structurally valid value.
153
+ */
154
+ const currentValueIsValid = $derived(authoringMode !== 'cron' || cronFieldsValid(cronFields));
155
+ // `previewCount` is documented and schema-constrained as a positive
156
+ // integer, but it's still a public prop a consumer can hand a stray 0,
157
+ // negative, NaN, or fractional value to — normalize before it ever reaches
158
+ // `computeNextFires`, the same way `toPositiveInteger` guards field edits.
159
+ const resolvedPreviewCount = $derived(Number.isInteger(previewCount) && previewCount > 0 ? previewCount : 5);
160
+ /**
161
+ * Guards the next-fires preview two ways: (1) `computeNextFires` is only
162
+ * ever called with a value that has passed `cronFieldsValid` — never an
163
+ * in-progress, invalid cron edit (`status: 'invalid'`) — and (2) the call is
164
+ * wrapped in a try/catch so a consumer callback that throws for any other
165
+ * reason degrades to a generic unavailable state (`status: 'error'`)
166
+ * instead of crashing the component.
167
+ */
168
+ const previewResult = $derived.by(() => {
169
+ if (!computeNextFires)
170
+ return { status: 'hidden' };
171
+ if (!currentValueIsValid)
172
+ return { status: 'invalid' };
173
+ try {
174
+ return { status: 'ok', fires: computeNextFires(currentValue, resolvedPreviewCount) };
175
+ }
176
+ catch {
177
+ return { status: 'error' };
178
+ }
179
+ });
180
+ /**
181
+ * The last value this component knows the consumer has (starting from the
182
+ * initial `value`/default seed, then advancing to whatever the user last
183
+ * committed via `onchange`, then advancing again whenever the resync
184
+ * `$effect` below adopts a genuinely new controlled `value`). This is
185
+ * intentionally NOT `currentValue`: `currentValue` reflects whatever the
186
+ * active mode's fields would produce right now, even before the user has
187
+ * touched them (e.g. presets defaults to "every 15 minutes" until edited).
188
+ * Mode-switch seeding and the resync effect must compare against the value
189
+ * actually committed/known, not the untouched default of whichever mode
190
+ * happens to be active — otherwise switching straight from the initial
191
+ * `presets` default into cron mode would clobber a real initial cron
192
+ * `value` with "every 15 minutes" the moment you looked at it.
193
+ *
194
+ * Deliberately a plain (non-`$state`) variable: it is read only from
195
+ * plain functions and the effect below, never from `$derived` or the
196
+ * template, so it needs no reactivity of its own — and keeping it
197
+ * non-reactive means the resync effect's only real dependency is the
198
+ * `value` prop, not a self-write of its own tracked state.
199
+ */
200
+ let lastKnownValue = seedValue;
201
+ /**
202
+ * The raw `value` prop as observed on the resync effect's most recent run.
203
+ * This is deliberately separate from `lastKnownValue`: it exists only to
204
+ * answer "did the *prop itself* actually change since I last looked?",
205
+ * independent of whatever this component's own local edits have done to
206
+ * `lastKnownValue`. See the effect below for why that distinction matters.
207
+ */
208
+ let previousValueProp = value;
209
+ /**
210
+ * Two-way-controlled sync: when the CONSUMER hands in a `value` that is
211
+ * genuinely different from what this component last knew about, adopt it —
212
+ * re-seeding every field exactly like the initial seed (see
213
+ * `seedFieldsFromValue`). This is what makes `value` actually controlled:
214
+ * loading a saved schedule, a form reset, or a parent that normalizes or
215
+ * rejects an `onchange` all flow back in.
216
+ *
217
+ * The explicit `scheduleValuesEqual` comparison against `lastKnownValue` —
218
+ * not a bare `$effect(() => { ...reseed from value...})` — is load-bearing:
219
+ * without it, the routine controlled-component echo (parent stores
220
+ * whatever `onchange` just emitted and passes it straight back down,
221
+ * frequently as a freshly-constructed object with the same content) would
222
+ * look like a "new" value on every edit and wipe mid-edit state — e.g. an
223
+ * in-progress, not-yet-valid cron field — on every keystroke.
224
+ *
225
+ * `value === undefined` is treated as "reset to default", not "skip": a
226
+ * controlled parent that clears `value` back to `undefined` is a
227
+ * documented reset to the default/omitted state (e.g. a form reset) and
228
+ * must reseed to `defaultScheduleValue()`, exactly like a transition to
229
+ * any other genuinely new value.
230
+ *
231
+ * The `previousValueProp` reference check runs *before* any of that: this
232
+ * effect is only allowed to reseed on a genuine transition of the raw
233
+ * `value` prop, never merely because its resolved content (after the
234
+ * `?? defaultScheduleValue()` fallback) currently disagrees with
235
+ * `lastKnownValue`. That distinction is load-bearing for the uncontrolled
236
+ * case (no `value` prop, ever): `lastKnownValue` still advances on every
237
+ * local edit (via `emitChange`), so if this effect ever re-ran for a
238
+ * reason unrelated to `value` itself changing — e.g. a host test harness
239
+ * or framework integration that re-invokes effects on unrelated re-renders
240
+ * — a guard keyed only on `scheduleValuesEqual(default, lastKnownValue)`
241
+ * would misread "I have local edits the default doesn't have" as "the
242
+ * parent reset me to default" and wipe an in-progress, uncontrolled edit.
243
+ * Comparing the raw prop to what this effect saw last time is immune to
244
+ * that: for an uncontrolled consumer `value` is always `undefined`, so
245
+ * `previousValueProp` never disagrees with it, and the block below never
246
+ * runs at all — regardless of how many times the effect itself re-fires.
247
+ */
248
+ $effect(() => {
249
+ const incoming = value;
250
+ const propChanged = incoming !== previousValueProp;
251
+ previousValueProp = incoming;
252
+ if (!propChanged)
253
+ return;
254
+ const resolved = incoming ?? defaultScheduleValue();
255
+ // A genuine reset (`value` transitioning to `undefined`) always reseeds back
256
+ // to the default, even when `lastKnownValue` already equals the default. A
257
+ // non-committing mode switch (browsing Cron/Interval without emitting) leaves
258
+ // `lastKnownValue` at the default while the visible mode/fields have moved on,
259
+ // so a content-equality short-circuit here would swallow the reset and strand
260
+ // the UI in the browsed mode. Content equality still guards the echo case for
261
+ // defined values (parent handing back exactly what we just emitted is a no-op).
262
+ if (incoming !== undefined && scheduleValuesEqual(resolved, lastKnownValue))
263
+ return;
264
+ const seed = seedFieldsFromValue(resolved);
265
+ authoringMode = seed.authoringMode;
266
+ cronFields = seed.cronFields;
267
+ intervalEvery = seed.intervalEvery;
268
+ intervalUnit = seed.intervalUnit;
269
+ presetKind = seed.presetKind;
270
+ presetEveryValue = seed.presetEveryValue;
271
+ presetEveryUnit = seed.presetEveryUnit;
272
+ presetDailyTime = seed.presetDailyTime;
273
+ presetWeeklyDays = seed.presetWeeklyDays;
274
+ presetWeeklyTime = seed.presetWeeklyTime;
275
+ presetMonthlyDay = seed.presetMonthlyDay;
276
+ presetMonthlyTime = seed.presetMonthlyTime;
277
+ lastKnownValue = resolved;
278
+ });
279
+ /**
280
+ * `lastKnownValue` is updated optimistically, *before* the consumer's
281
+ * `onchange` handler runs — this is what lets the resync effect above tell
282
+ * "the parent echoed back exactly what I just emitted" (no-op, keep
283
+ * mid-edit state) apart from "the parent handed me something genuinely
284
+ * different" (reseed). A validating/authorizing parent that rejects an
285
+ * edit is expected to express that by re-passing a `value` whose *content*
286
+ * differs from the emitted edit (e.g. its own prior value) — that flows
287
+ * through the same `scheduleValuesEqual` gate and correctly reverts the
288
+ * fields (see the "controlled parent that rejects an edit" test).
289
+ *
290
+ * The one case this does not cover: a parent that rejects by leaving
291
+ * `value` at the exact same object reference it already held. Svelte's
292
+ * prop reactivity never marks an unchanged reference dirty, so no effect
293
+ * re-runs at all — there is no signal for the child to observe. Detecting
294
+ * that would require polling `value` on every local edit instead of only
295
+ * when the prop itself changes, which reintroduces the race this whole
296
+ * mechanism was built to avoid: the child's own optimistic write to
297
+ * `lastKnownValue` happens synchronously in the same tick as the edit,
298
+ * strictly before the parent's (asynchronous, next-render) echo, so a
299
+ * same-tick comparison would misread "parent hasn't responded yet" as
300
+ * "parent rejected this" and revert the edit the user just made. Left
301
+ * unhandled by design — see PR discussion for the fuller tradeoff.
302
+ */
303
+ function emitChange() {
304
+ lastKnownValue = currentValue;
305
+ onchange?.(currentValue);
306
+ }
307
+ /**
308
+ * Switching modes never emits a change by itself (browsing modes is
309
+ * exploratory, not a commitment) — it only re-seeds the destination mode's
310
+ * fields from `lastKnownValue`, losslessly where representable
311
+ * (`valueToCronFields` always succeeds; `valueToInterval` returns
312
+ * `undefined` for non-interval cron patterns, in which case the interval
313
+ * fields are left as they were).
314
+ */
315
+ function handleAuthoringModeChange(nextMode) {
316
+ if (nextMode === authoringMode)
317
+ return;
318
+ authoringMode = nextMode;
319
+ if (nextMode === 'cron') {
320
+ cronFields = valueToCronFields(lastKnownValue);
321
+ }
322
+ else if (nextMode === 'interval') {
323
+ const interval = valueToInterval(lastKnownValue);
324
+ if (interval) {
325
+ intervalEvery = interval.every;
326
+ intervalUnit = interval.unit;
327
+ }
328
+ }
329
+ else if (nextMode === 'presets') {
330
+ // Same "lossless where representable" seeding as the initial mount and
331
+ // the resync effect: the only preset kind with a general inverse is
332
+ // "every N" at a minutes/hours cadence (`seedFieldsFromValue` already
333
+ // gates that through `valueToInterval` + the minutes/hours check).
334
+ // Daily/weekly/monthly and non-divisor/day/week intervals have no
335
+ // general inverse, so they fall back to the same neutral defaults used
336
+ // everywhere else a value gets seeded into the preset fields.
337
+ const seed = seedFieldsFromValue(lastKnownValue);
338
+ presetKind = seed.presetKind;
339
+ presetEveryValue = seed.presetEveryValue;
340
+ presetEveryUnit = seed.presetEveryUnit;
341
+ presetDailyTime = seed.presetDailyTime;
342
+ presetWeeklyDays = seed.presetWeeklyDays;
343
+ presetWeeklyTime = seed.presetWeeklyTime;
344
+ presetMonthlyDay = seed.presetMonthlyDay;
345
+ presetMonthlyTime = seed.presetMonthlyTime;
346
+ }
347
+ }
348
+ /**
349
+ * Unlike the top-level authoring-mode tabs, switching the preset KIND
350
+ * (Every N / Daily / Weekly / Monthly) changes what `currentValue` computes
351
+ * to immediately — each kind lowers its own already-filled-in fields to a
352
+ * value right away, there is no "browsing, not yet committed" state the way
353
+ * there is for an empty cron/interval panel. So this commits like any other
354
+ * field edit: it emits the newly derived value to a controlled parent.
355
+ */
356
+ function handlePresetKindChange(nextKind) {
357
+ if (nextKind === presetKind)
358
+ return;
359
+ presetKind = nextKind;
360
+ emitChange();
361
+ }
362
+ function handleCronFieldChange(index, raw) {
363
+ const next = [...cronFields];
364
+ next[index] = raw;
365
+ cronFields = next;
366
+ if (cronFieldsValid(next))
367
+ emitChange();
368
+ }
369
+ // `ScheduleValue.every` is contractually a positive integer; NumberInput can
370
+ // yield decimals or nulls, so coerce every edit to `>= 1` whole units.
371
+ function toPositiveInteger(next) {
372
+ if (next === null || !Number.isFinite(next) || next < 1)
373
+ return 1;
374
+ return Math.trunc(next);
375
+ }
376
+ function handleIntervalEveryChange(next) {
377
+ intervalEvery = toPositiveInteger(next);
378
+ emitChange();
379
+ }
380
+ function handleIntervalUnitChange(event) {
381
+ intervalUnit = event.currentTarget.value;
382
+ emitChange();
383
+ }
384
+ function handlePresetEveryValueChange(next) {
385
+ presetEveryValue = toPositiveInteger(next);
386
+ emitChange();
387
+ }
388
+ function handlePresetEveryUnitChange(event) {
389
+ presetEveryUnit = event.currentTarget.value;
390
+ emitChange();
391
+ }
392
+ /**
393
+ * TimeField emits an empty string (via its native `change` event) when the
394
+ * user clears it, and `parseTime('')` silently defaults to midnight —
395
+ * accepting that here would let a user unintentionally submit a `00:00`
396
+ * cron just by clearing the field, with the field itself showing blank
397
+ * rather than "00:00". Reject the empty edit instead: don't touch the
398
+ * backing state at all, and don't emit. Because the preset time fields use
399
+ * a real two-way `bind:value` (not a one-way `value` prop), TimeField's own
400
+ * next read of `value` pulls the unchanged, last-committed time back
401
+ * through this setter's getter — so the field visually re-asserts the last
402
+ * valid time instead of drifting from the emitted cron.
403
+ */
404
+ function acceptPresetTime(next, commit) {
405
+ if (!next)
406
+ return;
407
+ commit(next);
408
+ emitChange();
409
+ }
410
+ function handlePresetDailyTimeChange(next) {
411
+ acceptPresetTime(next, (time) => (presetDailyTime = time));
412
+ }
413
+ function toggleWeeklyDay(day) {
414
+ presetWeeklyDays = presetWeeklyDays.includes(day)
415
+ ? presetWeeklyDays.filter((existing) => existing !== day)
416
+ : [...presetWeeklyDays, day];
417
+ emitChange();
418
+ }
419
+ function handlePresetWeeklyTimeChange(next) {
420
+ acceptPresetTime(next, (time) => (presetWeeklyTime = time));
421
+ }
422
+ function handlePresetMonthlyDayChange(next) {
423
+ presetMonthlyDay = next && next >= 1 ? Math.min(31, Math.trunc(next)) : 1;
424
+ emitChange();
425
+ }
426
+ function handlePresetMonthlyTimeChange(next) {
427
+ acceptPresetTime(next, (time) => (presetMonthlyTime = time));
428
+ }
429
+ const modeTabId = (mode) => `${baseId}-mode-${mode}-tab`;
430
+ const modePanelId = (mode) => `${baseId}-mode-${mode}-panel`;
431
+ </script>
432
+
433
+ <div
434
+ {...rest}
435
+ class={classNames('cinder-schedule-builder', className)}
436
+ role="group"
437
+ aria-label={resolvedAriaLabel}
438
+ aria-labelledby={normalizedAriaLabelledby}
439
+ data-sb-mode={authoringMode}
440
+ >
441
+ <SegmentedControl
442
+ id={`${baseId}-mode`}
443
+ label="Schedule authoring mode"
444
+ variant="tablist"
445
+ value={authoringMode}
446
+ onchange={handleAuthoringModeChange}
447
+ class="cinder-schedule-builder__mode-switch"
448
+ >
449
+ <Segment id={modeTabId('presets')} value="presets" controls={modePanelId('presets')}>
450
+ Presets
451
+ </Segment>
452
+ <Segment id={modeTabId('cron')} value="cron" controls={modePanelId('cron')}>Cron</Segment>
453
+ <Segment id={modeTabId('interval')} value="interval" controls={modePanelId('interval')}>
454
+ Interval
455
+ </Segment>
456
+ </SegmentedControl>
457
+
458
+ {#if authoringMode === 'presets'}
459
+ <div
460
+ id={modePanelId('presets')}
461
+ role="tabpanel"
462
+ aria-labelledby={modeTabId('presets')}
463
+ class="cinder-schedule-builder__panel"
464
+ data-sb-panel="presets"
465
+ >
466
+ <SegmentedControl
467
+ id={`${baseId}-preset-kind`}
468
+ label="Preset kind"
469
+ value={presetKind}
470
+ onchange={handlePresetKindChange}
471
+ class="cinder-schedule-builder__preset-kind"
472
+ >
473
+ <Segment id={`${baseId}-preset-kind-every`} value="every">Every N</Segment>
474
+ <Segment id={`${baseId}-preset-kind-daily`} value="daily">Daily</Segment>
475
+ <Segment id={`${baseId}-preset-kind-weekly`} value="weekly">Weekly</Segment>
476
+ <Segment id={`${baseId}-preset-kind-monthly`} value="monthly">Monthly</Segment>
477
+ </SegmentedControl>
478
+
479
+ {#if presetKind === 'every'}
480
+ <div class="cinder-schedule-builder__field-row">
481
+ <NumberInput
482
+ id={`${baseId}-preset-every-value`}
483
+ label="Every"
484
+ min={1}
485
+ step={1}
486
+ bind:value={() => presetEveryValue, handlePresetEveryValueChange}
487
+ />
488
+ <Select
489
+ id={`${baseId}-preset-every-unit`}
490
+ label="Unit"
491
+ options={PRESET_EVERY_UNIT_OPTIONS}
492
+ value={presetEveryUnit}
493
+ onchange={handlePresetEveryUnitChange}
494
+ />
495
+ </div>
496
+ {:else if presetKind === 'daily'}
497
+ <TimeField
498
+ id={`${baseId}-preset-daily-time`}
499
+ label="At"
500
+ bind:value={() => presetDailyTime, handlePresetDailyTimeChange}
501
+ />
502
+ {:else if presetKind === 'weekly'}
503
+ <div class="cinder-schedule-builder__weekday-group" role="group" aria-label="Days of week">
504
+ {#each WEEKDAYS as day (day.value)}
505
+ <Chip
506
+ mode="toggle"
507
+ label={day.short}
508
+ aria-label={day.long}
509
+ pressed={presetWeeklyDays.includes(day.value)}
510
+ onpressedchange={() => toggleWeeklyDay(day.value)}
511
+ />
512
+ {/each}
513
+ </div>
514
+ <TimeField
515
+ id={`${baseId}-preset-weekly-time`}
516
+ label="At"
517
+ bind:value={() => presetWeeklyTime, handlePresetWeeklyTimeChange}
518
+ />
519
+ {:else}
520
+ <div class="cinder-schedule-builder__field-row">
521
+ <NumberInput
522
+ id={`${baseId}-preset-monthly-day`}
523
+ label="Day of month"
524
+ min={1}
525
+ max={31}
526
+ bind:value={() => presetMonthlyDay, handlePresetMonthlyDayChange}
527
+ />
528
+ <TimeField
529
+ id={`${baseId}-preset-monthly-time`}
530
+ label="At"
531
+ bind:value={() => presetMonthlyTime, handlePresetMonthlyTimeChange}
532
+ />
533
+ </div>
534
+ {/if}
535
+ </div>
536
+ {:else if authoringMode === 'cron'}
537
+ <div
538
+ id={modePanelId('cron')}
539
+ role="tabpanel"
540
+ aria-labelledby={modeTabId('cron')}
541
+ class="cinder-schedule-builder__panel"
542
+ data-sb-panel="cron"
543
+ >
544
+ <div class="cinder-schedule-builder__cron-fields">
545
+ {#each CRON_FIELDS as field, index (field.name)}
546
+ {@const fieldError = validateCronField(cronFields[index] ?? '*', index)}
547
+ <Input
548
+ id={`${baseId}-cron-field-${index}`}
549
+ label={field.name}
550
+ description={field.hint}
551
+ bind:value={
552
+ () => cronFields[index] ?? '*', (next) => handleCronFieldChange(index, next)
553
+ }
554
+ {...fieldError ? { error: fieldError } : {}}
555
+ />
556
+ {/each}
557
+ </div>
558
+ </div>
559
+ {:else}
560
+ <div
561
+ id={modePanelId('interval')}
562
+ role="tabpanel"
563
+ aria-labelledby={modeTabId('interval')}
564
+ class="cinder-schedule-builder__panel"
565
+ data-sb-panel="interval"
566
+ >
567
+ <div class="cinder-schedule-builder__field-row">
568
+ <NumberInput
569
+ id={`${baseId}-interval-every`}
570
+ label="Every"
571
+ min={1}
572
+ step={1}
573
+ bind:value={() => intervalEvery, handleIntervalEveryChange}
574
+ />
575
+ <Select
576
+ id={`${baseId}-interval-unit`}
577
+ label="Unit"
578
+ options={INTERVAL_UNIT_OPTIONS}
579
+ value={intervalUnit}
580
+ onchange={handleIntervalUnitChange}
581
+ />
582
+ </div>
583
+ </div>
584
+ {/if}
585
+
586
+ <dl class="cinder-schedule-builder__summary">
587
+ <dt class="cinder-schedule-builder__section-label">Summary</dt>
588
+ <dd class="cinder-schedule-builder__summary-text">{describeValue(currentValue)}</dd>
589
+ </dl>
590
+
591
+ {#if previewResult.status !== 'hidden'}
592
+ <div class="cinder-schedule-builder__preview">
593
+ <span class="cinder-schedule-builder__section-label" id={`${baseId}-preview-label`}>
594
+ Upcoming fires
595
+ </span>
596
+ {#if previewResult.status === 'invalid'}
597
+ <p class="cinder-schedule-builder__empty">
598
+ Preview unavailable — fix the cron expression above.
599
+ </p>
600
+ {:else if previewResult.status === 'error'}
601
+ <p class="cinder-schedule-builder__empty">Preview unavailable.</p>
602
+ {:else if previewResult.fires.length > 0}
603
+ <ul
604
+ class="cinder-schedule-builder__preview-list"
605
+ aria-labelledby={`${baseId}-preview-label`}
606
+ >
607
+ {#each previewResult.fires as fire (fire.id)}
608
+ <li class="cinder-schedule-builder__preview-item">{fire.label}</li>
609
+ {/each}
610
+ </ul>
611
+ {:else}
612
+ <p class="cinder-schedule-builder__empty">No upcoming fires.</p>
613
+ {/if}
614
+ </div>
615
+ {/if}
616
+
617
+ <dl class="cinder-schedule-builder__timezone">
618
+ <dt class="cinder-schedule-builder__section-label">Timezone</dt>
619
+ <dd class="cinder-schedule-builder__timezone-value">
620
+ {#if timezone}
621
+ {@render timezone()}
622
+ {:else if timezoneLabel}
623
+ {timezoneLabel}
624
+ {:else}
625
+ <span class="cinder-schedule-builder__empty">Not set</span>
626
+ {/if}
627
+ </dd>
628
+ </dl>
629
+ </div>