@urbicon-ui/blocks 6.40.4 → 6.43.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 (72) hide show
  1. package/dist/components/Chat/A2UIView/A2UINode.svelte +20 -39
  2. package/dist/components/Chat/A2UIView/A2UIView.svelte +47 -50
  3. package/dist/components/Chat/A2UIView/a2ui-basic-catalog.d.ts +17 -0
  4. package/dist/components/Chat/A2UIView/a2ui-basic-catalog.js +69 -0
  5. package/dist/components/Chat/A2UIView/a2ui-catalog.d.ts +95 -0
  6. package/dist/components/Chat/A2UIView/a2ui-catalog.js +87 -0
  7. package/dist/components/Chat/A2UIView/a2ui-node-common.d.ts +40 -0
  8. package/dist/components/Chat/A2UIView/a2ui-node-common.js +72 -0
  9. package/dist/components/Chat/A2UIView/a2ui-prompt.d.ts +21 -10
  10. package/dist/components/Chat/A2UIView/a2ui-prompt.js +170 -24
  11. package/dist/components/Chat/A2UIView/a2ui-registry.d.ts +1 -1
  12. package/dist/components/Chat/A2UIView/a2ui-registry.js +2 -1
  13. package/dist/components/Chat/A2UIView/a2ui-render.d.ts +30 -0
  14. package/dist/components/Chat/A2UIView/a2ui-render.js +61 -23
  15. package/dist/components/Chat/A2UIView/a2ui-router.d.ts +136 -0
  16. package/dist/components/Chat/A2UIView/a2ui-router.js +264 -0
  17. package/dist/components/Chat/A2UIView/a2ui-schema.d.ts +45 -0
  18. package/dist/components/Chat/A2UIView/a2ui-schema.js +133 -0
  19. package/dist/components/Chat/A2UIView/a2ui-stream.d.ts +120 -0
  20. package/dist/components/Chat/A2UIView/a2ui-stream.js +406 -0
  21. package/dist/components/Chat/A2UIView/a2ui-transcript.d.ts +65 -0
  22. package/dist/components/Chat/A2UIView/a2ui-transcript.js +101 -0
  23. package/dist/components/Chat/A2UIView/a2ui-validate.d.ts +33 -1
  24. package/dist/components/Chat/A2UIView/a2ui-validate.js +167 -63
  25. package/dist/components/Chat/A2UIView/a2ui.types.d.ts +50 -0
  26. package/dist/components/Chat/A2UIView/a2ui.types.js +38 -1
  27. package/dist/components/Chat/A2UIView/index.d.ts +35 -2
  28. package/dist/components/Chat/A2UIView/index.js +13 -0
  29. package/dist/components/Chat/A2UIView/urbicon/UrbiconA2UINode.svelte +831 -0
  30. package/dist/components/Chat/A2UIView/urbicon/UrbiconA2UINode.svelte.d.ts +4 -0
  31. package/dist/components/Chat/A2UIView/urbicon/a2ui-urbicon-catalog.d.ts +14 -0
  32. package/dist/components/Chat/A2UIView/urbicon/a2ui-urbicon-catalog.js +87 -0
  33. package/dist/components/Chat/A2UIView/urbicon/a2ui-urbicon-registry.d.ts +78 -0
  34. package/dist/components/Chat/A2UIView/urbicon/a2ui-urbicon-registry.js +783 -0
  35. package/dist/components/Chat/A2UIView/urbicon/a2ui-urbicon.variants.d.ts +43 -0
  36. package/dist/components/Chat/A2UIView/urbicon/a2ui-urbicon.variants.js +33 -0
  37. package/dist/components/Chat/A2UIView/urbicon/urbicon-axes.gen.d.ts +7 -0
  38. package/dist/components/Chat/A2UIView/urbicon/urbicon-axes.gen.js +86 -0
  39. package/dist/components/Chat/ChatMessage/chat-message.variants.d.ts +19 -19
  40. package/dist/components/FileUpload/fileUpload.variants.js +15 -4
  41. package/dist/components/PinInput/PinInput.svelte +1 -0
  42. package/dist/components/PinInput/pin-input.variants.js +14 -1
  43. package/dist/components/TimeInput/time-input.variants.js +16 -1
  44. package/dist/internal/field-chrome.d.ts +18 -1
  45. package/dist/internal/field-chrome.js +18 -1
  46. package/dist/primitives/Avatar/avatar.variants.js +4 -1
  47. package/dist/primitives/Badge/badge.variants.js +11 -2
  48. package/dist/primitives/Button/button.variants.js +4 -1
  49. package/dist/primitives/Card/card.variants.js +4 -1
  50. package/dist/primitives/Checkbox/checkbox.variants.js +7 -1
  51. package/dist/primitives/Combobox/Combobox.svelte +9 -1
  52. package/dist/primitives/Combobox/combobox.variants.d.ts +25 -0
  53. package/dist/primitives/Combobox/combobox.variants.js +32 -3
  54. package/dist/primitives/Combobox/index.d.ts +8 -2
  55. package/dist/primitives/Dialog/dialog.variants.js +4 -1
  56. package/dist/primitives/Drawer/drawer.variants.js +4 -1
  57. package/dist/primitives/Input/input.variants.js +17 -1
  58. package/dist/primitives/Menu/menu.variants.js +5 -1
  59. package/dist/primitives/Progress/progress.variants.js +5 -2
  60. package/dist/primitives/RadioGroup/radioGroup.variants.js +68 -9
  61. package/dist/primitives/Select/select.variants.js +13 -4
  62. package/dist/primitives/Slider/Slider.svelte +4 -1
  63. package/dist/primitives/Slider/slider.variants.js +9 -2
  64. package/dist/primitives/Textarea/textarea.variants.js +15 -1
  65. package/dist/primitives/Toast/toast.variants.js +5 -1
  66. package/dist/primitives/Toggle/toggle.variants.js +92 -18
  67. package/dist/primitives/Tooltip/tooltip.variants.d.ts +3 -3
  68. package/dist/primitives/Tooltip/tooltip.variants.js +5 -1
  69. package/dist/utils/persistent-state.svelte.d.ts +25 -1
  70. package/dist/utils/persistent-state.svelte.js +98 -10
  71. package/docs/MIGRATION-v5.md +29 -0
  72. package/package.json +3 -3
@@ -1,4 +1,7 @@
1
+ import { FIELD_MESSAGE_TONES, fieldErrorFrame } from '../../internal/field-chrome.js';
1
2
  import { tv } from '../../utils/variants.js';
3
+ // The trigger is the focusable element, so the error ring lives on it directly.
4
+ const focus = 'focus-visible';
2
5
  export const selectVariants = tv({
3
6
  slots: {
4
7
  wrapper: ['flex flex-col w-full gap-1.5'],
@@ -146,13 +149,19 @@ export const selectVariants = tv({
146
149
  // Declared BEFORE `error` so the error tone wins the message-color
147
150
  // bucket in every call shape — `{ error: true }` alone must read red.
148
151
  messageType: {
149
- error: { message: 'text-danger' },
150
- helper: { message: 'text-text-tertiary' }
152
+ error: { message: FIELD_MESSAGE_TONES.error },
153
+ helper: { message: FIELD_MESSAGE_TONES.helper }
151
154
  },
155
+ // Same error frame as every other field (shared fragment, so a token fix
156
+ // can't miss Select again). Unlike Input/Textarea/PinInput/TimeInput,
157
+ // Select has no `intent` axis, so nothing competes for the border/ring
158
+ // buckets and the frame can stay on the axis. If an `intent` axis is ever
159
+ // added here, move this to the compound stage — see the precedence note in
160
+ // input.variants.ts.
152
161
  error: {
153
162
  true: {
154
- trigger: 'border-danger focus-visible:border-danger focus-visible:ring-danger/20',
155
- message: 'text-danger'
163
+ trigger: fieldErrorFrame(focus),
164
+ message: FIELD_MESSAGE_TONES.error
156
165
  }
157
166
  },
158
167
  required: {
@@ -160,7 +160,7 @@
160
160
  if (status === 'insideValidOnly') return 'var(--color-warning-subtle)';
161
161
  if (status === 'outsideValidDanger') return 'var(--color-danger-subtle)';
162
162
  if (status === 'outsideValidWarning') return 'var(--color-warning-subtle)';
163
- return 'var(--color-surface-subtle)';
163
+ return 'var(--color-surface-interactive)';
164
164
  };
165
165
 
166
166
  const points = [min, ...zoneBoundaries, max];
@@ -460,6 +460,7 @@
460
460
  ? `${label} minimum`
461
461
  : bt('accessibility.minimum') || 'Minimum'}
462
462
  aria-describedby={describedBy}
463
+ aria-invalid={error ? 'true' : undefined}
463
464
  aria-disabled={disabled || undefined}
464
465
  class={unstyled ? (slotClasses?.thumb ?? '') : styles.thumb({ class: slotClasses?.thumb })}
465
466
  style="left: {thumbStartPosition}%"
@@ -479,6 +480,7 @@
479
480
  ? `${label} maximum`
480
481
  : bt('accessibility.maximum') || 'Maximum'}
481
482
  aria-describedby={describedBy}
483
+ aria-invalid={error ? 'true' : undefined}
482
484
  aria-disabled={disabled || undefined}
483
485
  class={unstyled ? (slotClasses?.thumb ?? '') : styles.thumb({ class: slotClasses?.thumb })}
484
486
  style="left: {thumbEndPosition}%"
@@ -495,6 +497,7 @@
495
497
  aria-labelledby={ariaLabelledby}
496
498
  aria-label={ariaLabelledby ? undefined : label || bt('accessibility.slider') || 'Slider'}
497
499
  aria-describedby={describedBy}
500
+ aria-invalid={error ? 'true' : undefined}
498
501
  aria-disabled={disabled || undefined}
499
502
  class={unstyled ? (slotClasses?.thumb ?? '') : styles.thumb({ class: slotClasses?.thumb })}
500
503
  style="left: {thumbPosition}%"
@@ -7,8 +7,11 @@ export const sliderVariants = tv({
7
7
  valueText: ['text-sm tabular-nums text-text-tertiary'],
8
8
  base: ['relative flex items-center w-full touch-none select-none cursor-pointer'],
9
9
  // tier: commit — slider is a continuous-value pill (track + range + thumb).
10
+ // Track fill is `surface-interactive`, matching Toggle's unchecked track:
11
+ // `surface-subtle` resolves to the SAME neutral step as `surface-elevated`
12
+ // in both modes, so the groove vanished on any elevated card/popover.
10
13
  track: [
11
- 'relative w-full overflow-hidden rounded-commit bg-surface-subtle',
14
+ 'relative w-full overflow-hidden rounded-commit bg-surface-interactive',
12
15
  'transition-[background-color] duration-[var(--blocks-duration-fast)]'
13
16
  ],
14
17
  range: [
@@ -20,7 +23,11 @@ export const sliderVariants = tv({
20
23
  'absolute rounded-commit bg-surface-base border-2',
21
24
  'shadow-[var(--blocks-shadow-sm)]',
22
25
  '-translate-x-1/2 -translate-y-1/2 top-1/2',
23
- 'transition-[box-shadow,transform] duration-[var(--blocks-duration-fast)]',
26
+ // `scale`, NOT `transform`: Tailwind 4 emits `scale-*` as the discrete
27
+ // `scale:` property — `hover:scale-110` is the only animated transform
28
+ // here, so it is the only one listed. The centring `-translate-*` above
29
+ // is static (the thumb travels via `left`), so `translate` stays out.
30
+ 'transition-[box-shadow,scale] duration-[var(--blocks-duration-fast)]',
24
31
  'hover:scale-110 hover:shadow-[var(--blocks-shadow-md)]',
25
32
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-base'
26
33
  ],
@@ -1,3 +1,4 @@
1
+ import { fieldErrorFrame } from '../../internal/field-chrome.js';
1
2
  import { tv } from '../../utils/variants.js';
2
3
  export const textareaVariants = tv({
3
4
  slots: {
@@ -83,9 +84,11 @@ export const textareaVariants = tv({
83
84
  error: { message: 'text-danger' },
84
85
  helper: { message: 'text-text-tertiary' }
85
86
  },
87
+ // The error FRAME is not declared here — it lives in the compound stage
88
+ // below, where it beats `intent` by construction. See the precedence note
89
+ // on the first compound entry.
86
90
  error: {
87
91
  true: {
88
- base: 'border-danger focus-visible:border-danger focus-visible:ring-danger/20',
89
92
  message: 'text-danger'
90
93
  }
91
94
  },
@@ -101,6 +104,17 @@ export const textareaVariants = tv({
101
104
  }
102
105
  },
103
106
  compoundVariants: [
107
+ // ── Validation precedence: `error` beats `intent`, explicitly ────────────
108
+ // Both axes paint the SAME three buckets (border-color plus the focused
109
+ // border/ring tint), so exactly one of them can win. Emitting the error
110
+ // frame here rather than on the `error` axis makes that rule structural:
111
+ // compounds always fold after every axis, so `error: true` overrides
112
+ // whatever `intent` painted regardless of how the axes are ordered above
113
+ // (it used to hinge purely on `error` being DECLARED after `intent`).
114
+ {
115
+ error: true,
116
+ class: { base: fieldErrorFrame('focus-visible') }
117
+ },
104
118
  {
105
119
  variant: 'ghost',
106
120
  intent: ['success', 'warning', 'danger'],
@@ -10,7 +10,11 @@ export const toastVariants = tv({
10
10
  'pointer-events-auto flex items-start gap-3 w-full max-w-sm',
11
11
  'rounded-contain p-4',
12
12
  'shadow-[var(--blocks-shadow-lg)]',
13
- 'transition-[color,background-color,box-shadow,opacity,transform] duration-[var(--blocks-duration-normal)] ease-[var(--blocks-ease-smooth)]'
13
+ // No transform entry: the enter/exit motion is Svelte's `transition:fly`,
14
+ // which runs as a CSS *animation* (keyframes) and ignores
15
+ // `transition-property` entirely. Nothing on this slot sets a
16
+ // scale/translate/rotate utility, so a `transform` entry was dead weight.
17
+ 'transition-[color,background-color,box-shadow,opacity] duration-[var(--blocks-duration-normal)] ease-[var(--blocks-ease-smooth)]'
14
18
  ],
15
19
  icon: 'shrink-0 mt-0.5 w-5 h-5',
16
20
  content: 'flex-1 min-w-0',
@@ -8,7 +8,26 @@ export const toggleVariants = tv({
8
8
  // rectangle switch that reads as inline-toolbar control.
9
9
  track: [
10
10
  'relative inline-flex items-center',
11
- 'transition-[color,background-color,border-color,box-shadow] duration-[var(--blocks-duration-fast)] ease-out',
11
+ 'transition-[color,background-color,border-color,box-shadow,scale] duration-[var(--blocks-duration-fast)] ease-out',
12
+ // Press feedback on the control surface — the same small-element press
13
+ // cue Checkbox took from Badge/Avatar (`scale-95`), with `group-active`
14
+ // so pressing the label squeezes the switch too. It rides the TRACK,
15
+ // not the thumb, for three reasons:
16
+ // 1. the track is the switch's control surface — Checkbox puts the
17
+ // cue on `box`, and the whole subtree (thumb included) squeezes
18
+ // with it, so one class covers the entire control;
19
+ // 2. the `dot` variant *hides* the thumb, so a thumb-mounted cue
20
+ // would silently vanish exactly where the control is smallest;
21
+ // 3. the thumb already drives motion through `translate` (its
22
+ // resting `-translate-y-1/2` plus the per-size `translate-x-*`).
23
+ // Tailwind 4 compiles `scale-*` to the discrete `scale` property,
24
+ // which CSS applies AFTER `translate` about the element's own
25
+ // centre — so a thumb-mounted cue would shrink the knob in place
26
+ // at whichever end of the track it sits, reading as a rendering
27
+ // glitch rather than a press.
28
+ // `scale` is in the transition list above, and reduced motion collapses
29
+ // `--blocks-duration-fast` to 1ms.
30
+ 'group-active:scale-95',
12
31
  'border border-transparent',
13
32
  'peer-focus-visible:ring-2 peer-focus-visible:ring-primary/50',
14
33
  'peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-surface-base'
@@ -104,7 +123,18 @@ export const toggleVariants = tv({
104
123
  variant: 'default',
105
124
  checked: false,
106
125
  class: {
107
- track: 'bg-surface-interactive',
126
+ // Off-state hover steps the boundary up to `border-emphasis` — the
127
+ // same destination Checkbox's unchecked `outlined` box reaches, on
128
+ // the border the track already reserves (`border border-transparent`
129
+ // in the slot base, so nothing shifts).
130
+ // Deliberately NOT a fill step: the off track sits on
131
+ // `surface-interactive`, which resolves to the SAME value as
132
+ // `surface-hover` in light mode (both neutral-100) and as
133
+ // `surface-active` in dark (both neutral-700) — in the library
134
+ // default *and* under the docs Rooms skin. Either fill token would
135
+ // therefore be a silent no-op in one of the two modes; the border is
136
+ // the only step that reads in both.
137
+ track: 'bg-surface-interactive group-hover:border-border-emphasis',
108
138
  thumb: 'translate-x-0'
109
139
  }
110
140
  },
@@ -113,7 +143,11 @@ export const toggleVariants = tv({
113
143
  variant: 'default',
114
144
  checked: false,
115
145
  withBorder: true,
116
- class: { track: 'border-border-strong' }
146
+ // `border-strong` is the top of the border ladder, so the off-state
147
+ // hover above would *weaken* it (strong → emphasis). Pin the hover
148
+ // bucket to strong: an opt-in permanent boundary must not soften when
149
+ // the pointer arrives.
150
+ class: { track: 'border-border-strong group-hover:border-border-strong' }
117
151
  },
118
152
  {
119
153
  variant: 'default',
@@ -122,41 +156,58 @@ export const toggleVariants = tv({
122
156
  class: { track: 'ring-1 ring-inset ring-surface-base/40' }
123
157
  },
124
158
  // ── Checked track per intent (Switch-Pill only) ──
159
+ // Hover/active darken through the intent interaction-layer tokens — the
160
+ // same `bg-<intent>-hover` / `bg-<intent>-active` ladder Button and
161
+ // Checkbox use — via `group-*` so hovering/pressing the label counts too.
162
+ // The border stays on the base intent stop (as in Checkbox), leaving a
163
+ // hairline rim that makes the fill step legible.
125
164
  {
126
165
  variant: 'default',
127
166
  checked: true,
128
167
  intent: 'primary',
129
- class: { track: 'bg-primary border-primary' }
168
+ class: {
169
+ track: 'bg-primary border-primary group-hover:bg-primary-hover group-active:bg-primary-active'
170
+ }
130
171
  },
131
172
  {
132
173
  variant: 'default',
133
174
  checked: true,
134
175
  intent: 'secondary',
135
- class: { track: 'bg-secondary border-secondary' }
176
+ class: {
177
+ track: 'bg-secondary border-secondary group-hover:bg-secondary-hover group-active:bg-secondary-active'
178
+ }
136
179
  },
137
180
  {
138
181
  variant: 'default',
139
182
  checked: true,
140
183
  intent: 'success',
141
- class: { track: 'bg-success border-success' }
184
+ class: {
185
+ track: 'bg-success border-success group-hover:bg-success-hover group-active:bg-success-active'
186
+ }
142
187
  },
143
188
  {
144
189
  variant: 'default',
145
190
  checked: true,
146
191
  intent: 'warning',
147
- class: { track: 'bg-warning border-warning' }
192
+ class: {
193
+ track: 'bg-warning border-warning group-hover:bg-warning-hover group-active:bg-warning-active'
194
+ }
148
195
  },
149
196
  {
150
197
  variant: 'default',
151
198
  checked: true,
152
199
  intent: 'danger',
153
- class: { track: 'bg-danger border-danger' }
200
+ class: {
201
+ track: 'bg-danger border-danger group-hover:bg-danger-hover group-active:bg-danger-active'
202
+ }
154
203
  },
155
204
  {
156
205
  variant: 'default',
157
206
  checked: true,
158
207
  intent: 'neutral',
159
- class: { track: 'bg-neutral border-neutral' }
208
+ class: {
209
+ track: 'bg-neutral border-neutral group-hover:bg-neutral-hover group-active:bg-neutral-active'
210
+ }
160
211
  },
161
212
  // ── Error state: danger boundary on the unchecked track (Switch-Pill) ──
162
213
  // Mirrors Checkbox ("error overrides unchecked border"): the off state
@@ -165,11 +216,16 @@ export const toggleVariants = tv({
165
216
  // The dot-variant twin lives at the END of the compound list so it
166
217
  // folds over the dot-unchecked `border-border-default` (order is
167
218
  // semantic — later compounds win conflicting buckets).
219
+ // `group-hover:border-danger` pins the hover bucket as well — without it
220
+ // the off-state hover step above would repaint the error boundary neutral
221
+ // the moment the pointer arrives, which is exactly when the mark matters.
168
222
  {
169
223
  variant: 'default',
170
224
  error: true,
171
225
  checked: false,
172
- class: { track: 'border-danger peer-focus-visible:ring-danger/40' }
226
+ class: {
227
+ track: 'border-danger group-hover:border-danger peer-focus-visible:ring-danger/40'
228
+ }
173
229
  },
174
230
  // ── Thumb translation per size when checked (Switch-Pill only) ──
175
231
  { variant: 'default', checked: true, size: 'xs', class: { thumb: 'translate-x-4' } },
@@ -193,50 +249,68 @@ export const toggleVariants = tv({
193
249
  {
194
250
  variant: 'dot',
195
251
  checked: false,
196
- class: { track: 'border-border-default' }
252
+ // Off-state hover: `border-default` → `border-emphasis`, the exact
253
+ // ladder Checkbox's unchecked `outlined` box walks (the dot is
254
+ // outline-only, so its boundary is the whole control).
255
+ class: { track: 'border-border-default group-hover:border-border-emphasis' }
197
256
  },
257
+ // Same intent interaction layer as the Switch-Pill above — the dot is a
258
+ // filled intent surface too, just a 14px one.
198
259
  {
199
260
  variant: 'dot',
200
261
  checked: true,
201
262
  intent: 'primary',
202
- class: { track: 'border-primary bg-primary' }
263
+ class: {
264
+ track: 'border-primary bg-primary group-hover:bg-primary-hover group-active:bg-primary-active'
265
+ }
203
266
  },
204
267
  {
205
268
  variant: 'dot',
206
269
  checked: true,
207
270
  intent: 'secondary',
208
- class: { track: 'border-secondary bg-secondary' }
271
+ class: {
272
+ track: 'border-secondary bg-secondary group-hover:bg-secondary-hover group-active:bg-secondary-active'
273
+ }
209
274
  },
210
275
  {
211
276
  variant: 'dot',
212
277
  checked: true,
213
278
  intent: 'success',
214
- class: { track: 'border-success bg-success' }
279
+ class: {
280
+ track: 'border-success bg-success group-hover:bg-success-hover group-active:bg-success-active'
281
+ }
215
282
  },
216
283
  {
217
284
  variant: 'dot',
218
285
  checked: true,
219
286
  intent: 'warning',
220
- class: { track: 'border-warning bg-warning' }
287
+ class: {
288
+ track: 'border-warning bg-warning group-hover:bg-warning-hover group-active:bg-warning-active'
289
+ }
221
290
  },
222
291
  {
223
292
  variant: 'dot',
224
293
  checked: true,
225
294
  intent: 'danger',
226
- class: { track: 'border-danger bg-danger' }
295
+ class: {
296
+ track: 'border-danger bg-danger group-hover:bg-danger-hover group-active:bg-danger-active'
297
+ }
227
298
  },
228
299
  {
229
300
  variant: 'dot',
230
301
  checked: true,
231
302
  intent: 'neutral',
232
- class: { track: 'border-neutral bg-neutral' }
303
+ class: {
304
+ track: 'border-neutral bg-neutral group-hover:bg-neutral-hover group-active:bg-neutral-active'
305
+ }
233
306
  },
234
307
  // ── Error state, dot variant (must fold over dot-unchecked border) ──
308
+ // Pins the hover bucket too, for the same reason as the Switch-Pill twin.
235
309
  {
236
310
  variant: 'dot',
237
311
  error: true,
238
312
  checked: false,
239
- class: { track: 'border-danger' }
313
+ class: { track: 'border-danger group-hover:border-danger' }
240
314
  }
241
315
  ],
242
316
  defaultVariants: {
@@ -1,19 +1,19 @@
1
1
  import { type SlotNames, type VariantProps } from '../../utils/variants.js';
2
2
  export declare const tooltipVariants: ((props?: {
3
3
  open?: boolean | undefined;
4
- intent?: "primary" | "secondary" | "success" | "warning" | "danger" | "neutral" | "info" | undefined;
4
+ intent?: "primary" | "secondary" | "success" | "warning" | "danger" | "neutral" | undefined;
5
5
  size?: "sm" | "md" | "lg" | undefined;
6
6
  } | undefined) => {
7
7
  base: (props?: ({
8
8
  open?: boolean | undefined;
9
- intent?: "primary" | "secondary" | "success" | "warning" | "danger" | "neutral" | "info" | undefined;
9
+ intent?: "primary" | "secondary" | "success" | "warning" | "danger" | "neutral" | undefined;
10
10
  size?: "sm" | "md" | "lg" | undefined;
11
11
  } & {
12
12
  class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
13
13
  }) | undefined) => string;
14
14
  arrow: (props?: ({
15
15
  open?: boolean | undefined;
16
- intent?: "primary" | "secondary" | "success" | "warning" | "danger" | "neutral" | "info" | undefined;
16
+ intent?: "primary" | "secondary" | "success" | "warning" | "danger" | "neutral" | undefined;
17
17
  size?: "sm" | "md" | "lg" | undefined;
18
18
  } & {
19
19
  class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
@@ -48,10 +48,14 @@ export const tooltipVariants = tv({
48
48
  true: { base: 'opacity-100 starting:opacity-0' },
49
49
  false: { base: 'opacity-0' }
50
50
  },
51
+ // The standard six-value palette. `info` is deliberately absent: it belongs
52
+ // to the feedback family (Alert, Toast), where it means "informational, and
53
+ // stable under a rebrand" — a tooltip is a container, and `info` sat one
54
+ // hue-step from `primary` (220 vs 240), so the two were barely
55
+ // distinguishable while implying a distinction that wasn't there.
51
56
  intent: {
52
57
  primary: { base: 'bg-primary text-text-on-primary' },
53
58
  secondary: { base: 'bg-secondary text-text-on-primary' },
54
- info: { base: 'bg-info text-text-on-primary' },
55
59
  success: { base: 'bg-success text-text-on-primary' },
56
60
  warning: { base: 'bg-warning text-text-on-warning' },
57
61
  danger: { base: 'bg-danger text-text-on-primary' },
@@ -13,15 +13,39 @@ export interface PersistentStateConfig<T> {
13
13
  /**
14
14
  * Create a persistent state that automatically syncs with storage
15
15
  * Uses Svelte 5 $state() for reactivity
16
+ *
17
+ * Besides `value`, the returned object exposes `hasStoredValue` — whether an
18
+ * entry for this key exists in storage. That is what lets a consumer tell a
19
+ * *stored empty* value (`[]`, `''`, `null` — the user cleared it) from *nothing
20
+ * stored at all*, so a cleared state can win over a default/seed instead of
21
+ * being re-applied on every load. Two rules keep that signal meaningful: a save
22
+ * that would not change the stored bytes is skipped, and an instance nobody
23
+ * wrote to never creates an entry for its own default (so `reset()` is not
24
+ * undone by the auto-save, and untouched state stays out of storage). Writing
25
+ * the default *back* — clearing — is a real write and does create the entry.
26
+ *
27
+ * Values must round-trip through the configured `serialize`/`deserialize`;
28
+ * `Set`/`Map` do not under the `JSON.stringify` default (they serialize to
29
+ * `{}`), so pass converting functions or store plain arrays/objects.
16
30
  */
17
31
  export declare function createPersistentState<T>(config: PersistentStateConfig<T>): {
18
32
  value: T;
33
+ /**
34
+ * Whether storage currently holds an entry for this key: `true` when
35
+ * construction (or `reload()`) found a parseable entry, and after a write
36
+ * actually reached storage; `false` when the key was absent or corrupt,
37
+ * after `reset()`, and always without a working storage (SSR, private
38
+ * mode). Use it to distinguish a stored empty value from an absent one.
39
+ */
40
+ readonly hasStoredValue: boolean;
19
41
  /**
20
42
  * Reset to default value and clear storage
21
43
  */
22
44
  reset(): void;
23
45
  /**
24
- * Force immediate save (bypasses debounce)
46
+ * Force immediate save (bypasses debounce). A no-op when storage already
47
+ * holds exactly this value, and — like the auto-save — when the instance
48
+ * was never written to and still holds its default.
25
49
  */
26
50
  forceSave(): void;
27
51
  /**
@@ -30,33 +30,88 @@ function debounce(func, wait) {
30
30
  /**
31
31
  * Create a persistent state that automatically syncs with storage
32
32
  * Uses Svelte 5 $state() for reactivity
33
+ *
34
+ * Besides `value`, the returned object exposes `hasStoredValue` — whether an
35
+ * entry for this key exists in storage. That is what lets a consumer tell a
36
+ * *stored empty* value (`[]`, `''`, `null` — the user cleared it) from *nothing
37
+ * stored at all*, so a cleared state can win over a default/seed instead of
38
+ * being re-applied on every load. Two rules keep that signal meaningful: a save
39
+ * that would not change the stored bytes is skipped, and an instance nobody
40
+ * wrote to never creates an entry for its own default (so `reset()` is not
41
+ * undone by the auto-save, and untouched state stays out of storage). Writing
42
+ * the default *back* — clearing — is a real write and does create the entry.
43
+ *
44
+ * Values must round-trip through the configured `serialize`/`deserialize`;
45
+ * `Set`/`Map` do not under the `JSON.stringify` default (they serialize to
46
+ * `{}`), so pass converting functions or store plain arrays/objects.
33
47
  */
34
48
  export function createPersistentState(config) {
35
49
  const { key, defaultValue, storage: storageType = 'localStorage', serialize = JSON.stringify, deserialize = JSON.parse, debounceMs = 300, version = 1 } = config;
36
50
  const storageKey = `urbicon_${key}_v${version}`;
37
51
  const storage = getStorage(storageType);
38
52
  // Initialize state with stored value or default
39
- const initialValue = loadFromStorage();
40
- const state = $state({ value: initialValue });
53
+ const initial = loadFromStorage();
54
+ const state = $state({ value: initial.value, hasStored: initial.stored });
55
+ // A faithful mirror of what storage holds: the exact stored string, or `null`
56
+ // when the key is absent (or unparseable). It must never claim the default is
57
+ // stored when it is not — that would make the absent → stored-empty
58
+ // transition unwritable, which is exactly the state this module exists to
59
+ // preserve (the user cleared the axis).
60
+ let lastWritten = initial.raw;
61
+ // Whether the consumer ever wrote through this instance. Only used to keep an
62
+ // *untouched* instance from creating an entry for its own default.
63
+ let touched = false;
64
+ const defaultSerialized = trySerialize(defaultValue);
65
+ function trySerialize(value) {
66
+ try {
67
+ return serialize(value);
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
41
73
  function loadFromStorage() {
42
74
  if (!storage)
43
- return defaultValue;
75
+ return { value: defaultValue, stored: false, raw: null };
44
76
  try {
45
77
  const stored = storage.getItem(storageKey);
46
78
  if (stored === null)
47
- return defaultValue;
48
- return deserialize(stored);
79
+ return { value: defaultValue, stored: false, raw: null };
80
+ return { value: deserialize(stored), stored: true, raw: stored };
49
81
  }
50
82
  catch (error) {
51
83
  console.warn(`Failed to load persistent state for key "${key}":`, error);
52
- return defaultValue;
84
+ // A corrupt entry counts as *absent*, never as "stored": consumers use
85
+ // `hasStoredValue` to let a stored value win over their own default or
86
+ // seed, and unparseable junk must not win — it would block that default
87
+ // forever. The junk itself is left in place until something is actually
88
+ // written (the first real write overwrites it); an untouched instance
89
+ // does not clean it up, and does not need to — it reads as absent.
90
+ return { value: defaultValue, stored: false, raw: null };
53
91
  }
54
92
  }
55
93
  function saveToStorage(value) {
56
94
  if (!storage)
57
95
  return;
58
96
  try {
59
- storage.setItem(storageKey, serialize(value));
97
+ const next = serialize(value);
98
+ // Storage already holds exactly this — nothing to do.
99
+ if (next === lastWritten)
100
+ return;
101
+ // Never *create* an entry for a value nobody touched. Without this the
102
+ // auto-save effect below would write every key with its own default on
103
+ // mount, making `hasStoredValue` useless (an untouched default would be
104
+ // indistinguishable from a deliberately cleared one) and re-creating the
105
+ // entry `reset()` just removed. Once the consumer has written — including
106
+ // writing the default back, i.e. clearing — the write goes through, even
107
+ // if the key does not exist yet. `forceSave()` obeys the same rule on
108
+ // purpose: a blanket flush (the table exposes one) must not create an
109
+ // entry for every untouched axis, which would retire all their seeds.
110
+ if (!touched && lastWritten === null && next === defaultSerialized)
111
+ return;
112
+ storage.setItem(storageKey, next);
113
+ lastWritten = next;
114
+ state.hasStored = true;
60
115
  }
61
116
  catch (error) {
62
117
  console.warn(`Failed to save persistent state for key "${key}":`, error);
@@ -73,17 +128,44 @@ export function createPersistentState(config) {
73
128
  return state.value;
74
129
  },
75
130
  set value(newValue) {
131
+ // Mark the instance as written-to *before* the state change, so the
132
+ // auto-save effect it triggers knows this is a deliberate value — that is
133
+ // what lets clearing back to the default create an entry.
134
+ touched = true;
76
135
  state.value = newValue;
77
136
  },
137
+ /**
138
+ * Whether storage currently holds an entry for this key: `true` when
139
+ * construction (or `reload()`) found a parseable entry, and after a write
140
+ * actually reached storage; `false` when the key was absent or corrupt,
141
+ * after `reset()`, and always without a working storage (SSR, private
142
+ * mode). Use it to distinguish a stored empty value from an absent one.
143
+ */
144
+ get hasStoredValue() {
145
+ return state.hasStored;
146
+ },
78
147
  /**
79
148
  * Reset to default value and clear storage
80
149
  */
81
150
  reset() {
82
151
  state.value = defaultValue;
83
- storage?.removeItem(storageKey);
152
+ try {
153
+ storage?.removeItem(storageKey);
154
+ }
155
+ catch (error) {
156
+ console.warn(`Failed to clear persistent state for key "${key}":`, error);
157
+ }
158
+ state.hasStored = false;
159
+ // Back to "nothing stored", and untouched again — so the auto-save
160
+ // triggered by the assignment above does not immediately re-create the
161
+ // key we just removed. A later write (including clearing) still lands.
162
+ lastWritten = null;
163
+ touched = false;
84
164
  },
85
165
  /**
86
- * Force immediate save (bypasses debounce)
166
+ * Force immediate save (bypasses debounce). A no-op when storage already
167
+ * holds exactly this value, and — like the auto-save — when the instance
168
+ * was never written to and still holds its default.
87
169
  */
88
170
  forceSave() {
89
171
  saveToStorage(state.value);
@@ -92,7 +174,13 @@ export function createPersistentState(config) {
92
174
  * Reload from storage
93
175
  */
94
176
  reload() {
95
- state.value = loadFromStorage();
177
+ const next = loadFromStorage();
178
+ state.value = next.value;
179
+ state.hasStored = next.stored;
180
+ lastWritten = next.raw;
181
+ // The value now mirrors storage again; nothing here came from the
182
+ // consumer, so an absent key stays absent until the next real write.
183
+ touched = false;
96
184
  }
97
185
  };
98
186
  }
@@ -80,6 +80,35 @@ The dead `tableVariants` export is removed (was unused in any consumer).
80
80
  <Table data={rows} columns={cols} />
81
81
  ```
82
82
 
83
+ ## 3b. Table state snippets renamed (v6.41)
84
+
85
+ The Table's three state snippets are renamed to match the slot names they
86
+ already had in `slotClasses` (`emptyState` / `loadingState` / `errorState`).
87
+ That frees `loading` and `error` for the state props they are everywhere else
88
+ in the library — the Table can now be told it is loading or has failed, which
89
+ previously only the managed `queryFn` path could do.
90
+
91
+ | Before (snippet) | After (snippet) | Freed prop |
92
+ | ---------------- | --------------- | ---------------------- |
93
+ | `empty` | `emptyState` | — |
94
+ | `loading` | `loadingState` | `loading?: boolean` |
95
+ | `error` | `errorState` | `error?: string\|null` |
96
+
97
+ ```svelte
98
+ <!-- before -->
99
+ <Table {items} {columns}>
100
+ {#snippet empty()}<tr><td colspan="99">Nothing here</td></tr>{/snippet}
101
+ </Table>
102
+
103
+ <!-- after — plus the new state props for data you fetch yourself -->
104
+ <Table {items} {columns} loading={pending} error={failure}>
105
+ {#snippet emptyState()}<tr><td colspan="99">Nothing here</td></tr>{/snippet}
106
+ </Table>
107
+ ```
108
+
109
+ With a managed `queryFn` the table still owns both states and ignores the two
110
+ props (DEV warns).
111
+
83
112
  ## 4. Dialog + Drawer intent
84
113
 
85
114
  Dialog and Drawer no longer draw a coloured `border-t-[3px]` accent for