@urbicon-ui/blocks 6.24.0 → 6.25.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 (58) hide show
  1. package/README.md +14 -0
  2. package/dist/components/Calendar/CalendarTimeGrid.svelte +2 -1
  3. package/dist/components/Calendar/calendar.variants.js +5 -3
  4. package/dist/components/CommandPalette/CommandPalette.svelte +32 -16
  5. package/dist/components/CommandPalette/CommandPalette.svelte.d.ts +1 -1
  6. package/dist/components/CommandPalette/commandPalette.variants.d.ts +10 -0
  7. package/dist/components/CommandPalette/commandPalette.variants.js +9 -7
  8. package/dist/components/CommandPalette/index.d.ts +19 -0
  9. package/dist/components/CompositionBar/CompositionBar.svelte +1 -1
  10. package/dist/components/CompositionBar/composition-bar.variants.js +1 -1
  11. package/dist/components/Planner/planner.variants.js +1 -1
  12. package/dist/components/Sankey/sankey.variants.js +1 -1
  13. package/dist/internal/charts/variants.js +2 -2
  14. package/dist/internal/date-grid/DateGridScaffold.svelte +9 -2
  15. package/dist/internal/date-grid/date-grid.svelte.d.ts +4 -2
  16. package/dist/internal/date-grid/date-grid.svelte.js +63 -7
  17. package/dist/primitives/Avatar/Avatar.svelte +38 -24
  18. package/dist/primitives/Avatar/index.d.ts +1 -1
  19. package/dist/primitives/Button/Button.svelte +12 -25
  20. package/dist/primitives/ButtonGroup/ButtonGroup.svelte +5 -1
  21. package/dist/primitives/Collapsible/Collapsible.svelte +10 -0
  22. package/dist/primitives/Collapsible/index.d.ts +13 -2
  23. package/dist/primitives/ConfirmDialog/ConfirmDialog.svelte +14 -0
  24. package/dist/primitives/ConfirmDialog/index.d.ts +13 -2
  25. package/dist/primitives/Dialog/Dialog.svelte +31 -8
  26. package/dist/primitives/Drawer/Drawer.svelte +29 -7
  27. package/dist/primitives/Input/Input.svelte +7 -1
  28. package/dist/primitives/JourneyTimeline/journey-timeline.variants.js +3 -3
  29. package/dist/primitives/Menu/Menu.svelte +8 -0
  30. package/dist/primitives/Menu/index.d.ts +11 -13
  31. package/dist/primitives/Popover/Popover.svelte +107 -4
  32. package/dist/primitives/Popover/index.d.ts +15 -2
  33. package/dist/primitives/Popover/index.js +1 -1
  34. package/dist/primitives/Popover/popover.variants.d.ts +25 -0
  35. package/dist/primitives/Popover/popover.variants.js +35 -0
  36. package/dist/primitives/Progress/progress.variants.js +1 -1
  37. package/dist/primitives/RadioGroup/radioGroup.variants.js +1 -1
  38. package/dist/primitives/Select/index.d.ts +8 -1
  39. package/dist/primitives/Slider/Slider.svelte +32 -3
  40. package/dist/primitives/Stepper/stepper.variants.js +1 -1
  41. package/dist/primitives/Textarea/Textarea.svelte +5 -1
  42. package/dist/primitives/Tooltip/tooltip.variants.js +13 -2
  43. package/dist/style/foundation.css +30 -3
  44. package/dist/style/interaction.css +19 -2
  45. package/dist/style/semantic.css +72 -1
  46. package/dist/style/themes/neutral.css +10 -1
  47. package/dist/style/themes/ocean.css +5 -1
  48. package/dist/utils/compose-handlers.d.ts +72 -0
  49. package/dist/utils/compose-handlers.js +65 -0
  50. package/dist/utils/figma-token-export.js +1 -1
  51. package/dist/utils/guide.svelte.js +92 -29
  52. package/dist/utils/index.d.ts +1 -1
  53. package/dist/utils/index.js +1 -1
  54. package/dist/utils/overlay-tokens.d.ts +17 -0
  55. package/dist/utils/overlay-tokens.js +30 -0
  56. package/dist/utils/use-floating-panel.svelte.js +9 -3
  57. package/dist/utils/variants.js +5 -1
  58. package/package.json +3 -3
package/README.md CHANGED
@@ -32,6 +32,20 @@ All primitives and components support `unstyled` + `slotClasses` + `preset` for
32
32
 
33
33
  Components use a **custom `tv()` variant engine** (`src/lib/utils/variants.ts`, ~600 LoC, zero-dependency replacement for `tailwind-variants`). Design tokens live in `src/lib/style/` as a three-layer OKLCH system (foundation → semantic → interaction). See the [Architecture Overview](../../docs/ARCHITECTURE.md) for details.
34
34
 
35
+ ## Theming
36
+
37
+ Import a shipped theme after the base styles — `neutral`, `ocean`, `forest`, `rose`, `sunset`:
38
+
39
+ ```css
40
+ @import 'tailwindcss';
41
+ @import '@urbicon-ui/blocks/style/index.css';
42
+ @import '@urbicon-ui/blocks/style/themes/forest.css';
43
+ ```
44
+
45
+ Rolling your own: **a brand colour alone is not a theme.** `surface-*`, `text-*` and `border-*` derive from the neutral chassis (`--color-neutral-*`), not from primary — so re-tint the chassis to your accent's temperature too, or the brand button ends up on cool blue-grey cards. Typography themes the same way (`--text-*`, `--font-*`) in the same `@theme` block.
46
+
47
+ Full walkthrough: [/customization/themes](https://ui.urbicon.de/customization/themes) · canonical reference: `urbicon css-reference theming` (and `typography`).
48
+
35
49
  ## Mint System (Micro-Interactions)
36
50
 
37
51
  Opt-in micro-interactions. Call `registerDefaultMints()` at app startup, then use via prop:
@@ -135,7 +135,8 @@
135
135
  <!-- Current time line -->
136
136
  {#if isToday && currentTimePercent !== null}
137
137
  <div class={slot('currentTimeLine')} style="top: {currentTimePercent}%;">
138
- <div class="absolute -top-1 -left-1 size-2 rounded-full bg-red-500"></div>
138
+ <!-- Dot matches the line: the shared `live` ("now") accent, not danger. -->
139
+ <div class="absolute -top-1 -left-1 size-2 rounded-full bg-live"></div>
139
140
  </div>
140
141
  {/if}
141
142
  </div>
@@ -114,7 +114,9 @@ export const calendarVariants = tv({
114
114
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50'
115
115
  ],
116
116
  allDayArea: ['border-b border-border-hairline'],
117
- currentTimeLine: 'absolute left-0 right-0 z-10 pointer-events-none border-t-2 border-red-500',
117
+ // `live` is the dedicated "now" accent (red by convention, themes via
118
+ // --color-live) — deliberately not `danger`, which signals errors.
119
+ currentTimeLine: 'absolute left-0 right-0 z-10 pointer-events-none border-t-2 border-live',
118
120
  // Week time grid mode (replaces weekGrid when showTimeGrid)
119
121
  weekTimeLayout: ['w-full flex flex-col', 'border-t border-border-hairline'],
120
122
  // Event popover
@@ -191,7 +193,7 @@ export const calendarVariants = tv({
191
193
  yearGrid: 'gap-1 p-1',
192
194
  yearMonth: 'p-1 gap-0.5',
193
195
  yearMonthTitle: 'text-xs mb-0.5',
194
- yearMiniDay: 'text-[9px] size-3',
196
+ yearMiniDay: 'text-3xs size-3',
195
197
  yearMiniDot: 'size-0.5',
196
198
  weekGrid: 'gap-px',
197
199
  weekColumn: 'min-h-16',
@@ -244,7 +246,7 @@ export const calendarVariants = tv({
244
246
  yearGrid: 'gap-2 p-2',
245
247
  yearMonth: 'p-2 gap-1',
246
248
  yearMonthTitle: 'text-sm mb-1',
247
- yearMiniDay: 'text-[10px] size-4',
249
+ yearMiniDay: 'text-3xs size-4',
248
250
  yearMiniDot: 'size-1',
249
251
  weekGrid: 'gap-px',
250
252
  weekColumn: 'min-h-24',
@@ -13,6 +13,7 @@
13
13
  emptyText: emptyTextProp,
14
14
  showFooter = true,
15
15
  open = $bindable(false),
16
+ query = $bindable(''),
16
17
  shortcut = 'mod+k',
17
18
  filter,
18
19
  onSelect,
@@ -32,7 +33,6 @@
32
33
  const blocksConfig = getBlocksConfig();
33
34
  const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
34
35
 
35
- let query = $state('');
36
36
  let selectedIndex = $state(0);
37
37
  let inputEl: HTMLInputElement | undefined = $state();
38
38
 
@@ -58,18 +58,25 @@
58
58
  return items.filter((item) => fn(item, query));
59
59
  });
60
60
 
61
+ /**
62
+ * Entries carry their index into `filtered` so the markup does not have to
63
+ * look it up per row — that was an `indexOf` per item, i.e. quadratic in the
64
+ * item count on every render.
65
+ */
66
+ type GroupEntry = { item: CommandPaletteItem; flatIdx: number };
67
+
61
68
  const grouped = $derived.by(() => {
62
- const groups: { category: string; items: CommandPaletteItem[] }[] = [];
63
- const seen: Record<string, CommandPaletteItem[]> = {};
64
- for (const item of filtered) {
69
+ const groups: { category: string; entries: GroupEntry[] }[] = [];
70
+ const seen: Record<string, GroupEntry[]> = {};
71
+ filtered.forEach((item, flatIdx) => {
65
72
  const cat = item.category ?? '';
66
73
  if (!seen[cat]) {
67
- const arr: CommandPaletteItem[] = [];
74
+ const arr: GroupEntry[] = [];
68
75
  seen[cat] = arr;
69
- groups.push({ category: cat, items: arr });
76
+ groups.push({ category: cat, entries: arr });
70
77
  }
71
- seen[cat].push(item);
72
- }
78
+ seen[cat].push({ item, flatIdx });
79
+ });
73
80
  return groups;
74
81
  });
75
82
 
@@ -129,10 +136,6 @@
129
136
  }
130
137
  }
131
138
 
132
- function getFlatIndex(item: CommandPaletteItem): number {
133
- return filtered.indexOf(item);
134
- }
135
-
136
139
  export function toggle() {
137
140
  setOpen(!open);
138
141
  }
@@ -251,8 +254,7 @@
251
254
  {group.category}
252
255
  </div>
253
256
  {/if}
254
- {#each group.items as item (item.id ?? item.label)}
255
- {@const flatIdx = getFlatIndex(item)}
257
+ {#each group.entries as { item, flatIdx } (item.id ?? item.label)}
256
258
  {@const isHighlighted = flatIdx === selectedIndex}
257
259
  {@const isDisabled = item.disabled ?? false}
258
260
  {#if customItem}
@@ -312,9 +314,23 @@
312
314
  {/if}
313
315
  <span
314
316
  class={unstyled
315
- ? (slotClasses?.itemLabel ?? '')
316
- : styles.itemLabel({ class: slotClasses?.itemLabel })}>{item.label}</span
317
+ ? (slotClasses?.itemText ?? '')
318
+ : styles.itemText({ class: slotClasses?.itemText })}
317
319
  >
320
+ <span
321
+ class={unstyled
322
+ ? (slotClasses?.itemLabel ?? '')
323
+ : styles.itemLabel({ class: slotClasses?.itemLabel })}>{item.label}</span
324
+ >
325
+ {#if item.excerpt}
326
+ <span
327
+ class={unstyled
328
+ ? (slotClasses?.itemExcerpt ?? '')
329
+ : styles.itemExcerpt({ class: slotClasses?.itemExcerpt })}
330
+ >{item.excerpt}</span
331
+ >
332
+ {/if}
333
+ </span>
318
334
  {#if item.shortcut}
319
335
  <kbd
320
336
  class={unstyled
@@ -3,6 +3,6 @@ declare const CommandPalette: import("svelte").Component<CommandPaletteProps, {
3
3
  toggle: () => void;
4
4
  show: () => void;
5
5
  hide: () => void;
6
- }, "open">;
6
+ }, "open" | "query">;
7
7
  type CommandPalette = ReturnType<typeof CommandPalette>;
8
8
  export default CommandPalette;
@@ -62,11 +62,21 @@ export declare const commandPaletteVariants: ((props?: {
62
62
  } & {
63
63
  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;
64
64
  }) | undefined) => string;
65
+ itemText: (props?: ({
66
+ size?: "sm" | "md" | "lg" | undefined;
67
+ } & {
68
+ 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;
69
+ }) | undefined) => string;
65
70
  itemLabel: (props?: ({
66
71
  size?: "sm" | "md" | "lg" | undefined;
67
72
  } & {
68
73
  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;
69
74
  }) | undefined) => string;
75
+ itemExcerpt: (props?: ({
76
+ size?: "sm" | "md" | "lg" | undefined;
77
+ } & {
78
+ 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;
79
+ }) | undefined) => string;
70
80
  itemShortcut: (props?: ({
71
81
  size?: "sm" | "md" | "lg" | undefined;
72
82
  } & {
@@ -16,9 +16,7 @@ export const commandPaletteVariants = tv({
16
16
  inputIcon: 'h-4 w-4 shrink-0 text-text-tertiary',
17
17
  clearButton: 'text-text-quaternary hover:text-text-secondary',
18
18
  list: 'max-h-72 overflow-y-auto p-1.5',
19
- groupLabel: [
20
- 'px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-quaternary'
21
- ],
19
+ groupLabel: ['px-2 py-1.5 text-2xs font-semibold uppercase tracking-wide text-text-quaternary'],
22
20
  // tier: modify — palette items are selectable actions.
23
21
  item: [
24
22
  'flex w-full items-center gap-3 rounded-modify px-3 py-2 text-sm',
@@ -28,20 +26,24 @@ export const commandPaletteVariants = tv({
28
26
  itemDefault: 'text-text-secondary hover:bg-surface-hover',
29
27
  itemDisabled: 'text-text-quaternary cursor-not-allowed opacity-50',
30
28
  itemIcon: 'h-4 w-4 shrink-0',
31
- itemLabel: 'flex-1 text-left',
29
+ itemText: 'flex min-w-0 flex-1 flex-col items-start gap-0.5',
30
+ itemLabel: 'text-left',
31
+ // One line only: excerpts are prose around a search match and would
32
+ // otherwise reflow every row to a different height.
33
+ itemExcerpt: 'w-full truncate text-left text-2xs text-text-tertiary',
32
34
  itemShortcut: [
33
35
  'rounded border border-border-subtle bg-surface-subtle',
34
- 'px-1.5 py-0.5 font-mono text-[10px] text-text-quaternary'
36
+ 'px-1.5 py-0.5 font-mono text-3xs text-text-quaternary'
35
37
  ],
36
38
  empty: 'py-8 text-center text-sm text-text-tertiary',
37
39
  footer: [
38
40
  'flex items-center gap-4 border-t border-border-hairline',
39
- 'px-4 py-2 text-[11px] text-text-quaternary'
41
+ 'px-4 py-2 text-2xs text-text-quaternary'
40
42
  ],
41
43
  footerHint: 'flex items-center gap-1',
42
44
  kbd: [
43
45
  'rounded border border-border-subtle bg-surface-subtle',
44
- 'px-1 py-0.5 font-mono text-[10px]'
46
+ 'px-1 py-0.5 font-mono text-3xs'
45
47
  ],
46
48
  separator: 'my-1'
47
49
  },
@@ -18,6 +18,12 @@ export interface CommandPaletteItem {
18
18
  id?: string;
19
19
  /** Display text shown in the list. */
20
20
  label: string;
21
+ /**
22
+ * Secondary line rendered under the label, truncated to one line. Use it for
23
+ * context the label cannot carry — a description, or an excerpt around a
24
+ * search match.
25
+ */
26
+ excerpt?: string;
21
27
  /** Group header this item belongs to. Items with the same category are grouped together. */
22
28
  category?: string;
23
29
  /** Optional keyboard shortcut displayed on the right side. */
@@ -38,6 +44,8 @@ export interface CommandPaletteItem {
38
44
  *
39
45
  * @tag action
40
46
  * @related Dialog
47
+ * @related Menu
48
+ * @related Combobox
41
49
  *
42
50
  * @example
43
51
  * ```svelte
@@ -76,6 +84,17 @@ export interface CommandPaletteProps {
76
84
  showFooter?: boolean;
77
85
  /** Controls visibility. Supports `bind:open`. @default false */
78
86
  open?: boolean;
87
+ /**
88
+ * Current search text. Supports `bind:query`. Reset to `''` whenever the
89
+ * palette opens.
90
+ *
91
+ * Bind it to source `items` asynchronously — derive your results from the
92
+ * query and pass them back in via `items`, with `filter={() => true}` so the
93
+ * built-in label match does not filter them a second time.
94
+ *
95
+ * @default ''
96
+ */
97
+ query?: string;
79
98
  /**
80
99
  * Register a global keyboard shortcut that toggles the palette.
81
100
  * Set to `false` to disable. @default 'mod+k' (Cmd+K / Ctrl+K)
@@ -318,7 +318,7 @@
318
318
  <span
319
319
  aria-hidden="true"
320
320
  class={[
321
- 'pointer-events-none truncate px-1.5 text-[10px] font-medium tabular-nums',
321
+ 'pointer-events-none truncate px-1.5 text-3xs font-medium tabular-nums',
322
322
  !unstyled && getOnColorClass(item)
323
323
  ]
324
324
  .filter(Boolean)
@@ -71,7 +71,7 @@ export const compositionBarVariants = tv({
71
71
  sm: {
72
72
  legendItem: 'text-xs',
73
73
  legendDot: 'h-2 w-2',
74
- tooltip: 'text-[11px]'
74
+ tooltip: 'text-2xs'
75
75
  },
76
76
  md: {
77
77
  legendItem: 'text-sm',
@@ -67,7 +67,7 @@ export const plannerVariants = tv({
67
67
  headerTitle: 'text-sm',
68
68
  navButton: 'h-7 w-7',
69
69
  weekday: 'text-xs',
70
- weekNumber: 'text-[0.65rem]',
70
+ weekNumber: 'text-3xs',
71
71
  cellDate: 'text-xs h-5 min-w-5',
72
72
  cellWeekday: 'text-xs',
73
73
  cellItems: 'text-xs',
@@ -14,7 +14,7 @@ export const sankeyVariants = tv({
14
14
  'fill-text-primary text-xs font-medium',
15
15
  'transition-[opacity] duration-[var(--blocks-duration-fast)]'
16
16
  ],
17
- nodeValue: ['pointer-events-none select-none', 'fill-text-tertiary text-[10px] tabular-nums'],
17
+ nodeValue: ['pointer-events-none select-none', 'fill-text-tertiary text-3xs tabular-nums'],
18
18
  link: [
19
19
  'cursor-default fill-none',
20
20
  'transition-[opacity,stroke-opacity,stroke-width] duration-[var(--blocks-duration-fast)]',
@@ -17,7 +17,7 @@ export const chartVariants = tv({
17
17
  /** Tick marks. */
18
18
  axisTick: ['stroke-border-subtle'],
19
19
  /** Tick + category labels. */
20
- axisLabel: ['fill-text-tertiary text-[10px] tabular-nums'],
20
+ axisLabel: ['fill-text-tertiary text-3xs tabular-nums'],
21
21
  /** Background gridlines. */
22
22
  grid: ['stroke-border-hairline'],
23
23
  /** Generic data mark (line path, area, point). */
@@ -29,7 +29,7 @@ export const chartVariants = tv({
29
29
  /** Donut center primary label. */
30
30
  centerLabel: ['fill-text-primary text-sm font-medium tabular-nums'],
31
31
  /** Donut center secondary label. */
32
- centerSubLabel: ['fill-text-tertiary text-[10px]'],
32
+ centerSubLabel: ['fill-text-tertiary text-3xs'],
33
33
  /** Legend container (HTML). */
34
34
  legend: ['mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5', 'text-text-secondary text-xs'],
35
35
  /** One legend entry. */
@@ -142,8 +142,15 @@
142
142
  style="position: relative; overflow: hidden;"
143
143
  onmouseleave={() => ctx.setHoveredDate(null)}
144
144
  {@attach swipeable({
145
- onSwipeLeft: () => ctx.navigate(1),
146
- onSwipeRight: () => ctx.navigate(-1),
145
+ // Direction-gated like the header arrows: a swipe at the bound should be
146
+ // inert (no navDirection flip, no clamped no-op emit), matching the
147
+ // disabled arrow button for the same direction.
148
+ onSwipeLeft: () => {
149
+ if (ctx.canGoForward) ctx.navigate(1);
150
+ },
151
+ onSwipeRight: () => {
152
+ if (ctx.canGoBack) ctx.navigate(-1);
153
+ },
147
154
  enabled: swipeEnabled && !ctx.disabled
148
155
  })}
149
156
  >
@@ -106,8 +106,10 @@ export declare class DateGridController {
106
106
  isRangeEnd(date: Date): boolean;
107
107
  isInSelectedRange(date: Date): boolean;
108
108
  isInPreviewRange(date: Date): boolean;
109
- /** Step the view by `delta` units (months / weeks / days; range shifts by its
110
- * own span in weeks). Emits the next reference date and visible range. */
109
+ /** Step the view by `delta` units (months / weeks / days; range slides its
110
+ * window by its own span in days). Every path clamps to [minDate, maxDate];
111
+ * the range window clamps span-preserving. Emits the next reference date and
112
+ * range (visible cell range, or the shifted window for `range`). */
111
113
  navigate(delta: number): void;
112
114
  /** Jump to today and focus it. Clamped to `[minDate, maxDate]`: the header Today
113
115
  * button gates itself on {@link canGoToToday} (a clamped "today" is not today, so
@@ -116,7 +116,18 @@ export class DateGridController {
116
116
  const { canGoBack, canGoForward } = clampMonth(referenceDate.getMonth(), referenceDate.getFullYear(), minDate, maxDate);
117
117
  return { canGoBack, canGoForward };
118
118
  }
119
- // week/day/range: day-granular bounds against the visible edges.
119
+ if (view === 'range') {
120
+ // Range navigation slides the *explicit* window (partial shifts allowed —
121
+ // see the span-preserving clamp in navigate()), so gate on the window
122
+ // edges, not the week-padded visible cells: the padding may spill past a
123
+ // bound while the window itself can still move.
124
+ const { start, end } = this.#orderedRange(referenceDate);
125
+ return {
126
+ canGoBack: !minDate || start.getTime() > stripTime(minDate).getTime(),
127
+ canGoForward: !maxDate || end.getTime() < stripTime(maxDate).getTime()
128
+ };
129
+ }
130
+ // week/day: day-granular bounds against the visible edges.
120
131
  const canGoBack = !minDate || stripTime(this.rangeStart) > stripTime(minDate);
121
132
  const canGoForward = !maxDate || stripTime(this.rangeEnd) < stripTime(maxDate);
122
133
  return { canGoBack, canGoForward };
@@ -232,24 +243,38 @@ export class DateGridController {
232
243
  return isInRange(date, range.start, this.hoveredDate) && !isSameDay(date, range.start);
233
244
  }
234
245
  // ─── Actions ─────────────────────────────────────────────────────────────
235
- /** Step the view by `delta` units (months / weeks / days; range shifts by its
236
- * own span in weeks). Emits the next reference date and visible range. */
246
+ /** Step the view by `delta` units (months / weeks / days; range slides its
247
+ * window by its own span in days). Every path clamps to [minDate, maxDate];
248
+ * the range window clamps span-preserving. Emits the next reference date and
249
+ * range (visible cell range, or the shifted window for `range`). */
237
250
  navigate(delta) {
238
251
  if (delta === 0)
239
252
  return;
240
253
  const { view, referenceDate, minDate, maxDate } = this.#opts;
241
- this.navDirection = delta > 0 ? 'forward' : 'backward';
242
254
  if (view === 'range') {
243
255
  // The range geometry follows the explicit rangeStart/rangeEnd inputs, not
244
256
  // referenceDate, so navigation shifts that window by its own span and emits
245
- // the shifted range — the wrapper rebinds rangeStart/rangeEnd from it.
257
+ // the shifted range — the wrapper rebinds rangeStart/rangeEnd from it. It
258
+ // deliberately bypasses #emitNavigate: that would emit the week-padded
259
+ // *visible* range, and a wrapper rebinding rangeStart/rangeEnd from it
260
+ // would silently grow a mid-week window to full weeks on every step.
261
+ // The shift is clamped span-preserving (see #clampWindowStart), so a swipe
262
+ // — not gated by canGoBack/canGoForward the way the header arrows are —
263
+ // cannot push the window past [minDate, maxDate]. A shift that cannot move
264
+ // at all (already at the bound) emits nothing: unlike week/day, a no-op
265
+ // emit here would rebind rangeStart/rangeEnd to fresh Date objects and
266
+ // fire the consumer's onNavigate with an unchanged window.
246
267
  const { start, end } = this.#orderedRange(referenceDate);
247
268
  const spanDays = daysBetween(start, end) + 1;
248
- const shiftedStart = addDays(start, delta * spanDays);
249
- const shiftedEnd = addDays(end, delta * spanDays);
269
+ const shiftedStart = this.#clampWindowStart(addDays(start, delta * spanDays), spanDays);
270
+ if (shiftedStart.getTime() === start.getTime())
271
+ return;
272
+ this.navDirection = shiftedStart.getTime() > start.getTime() ? 'forward' : 'backward';
273
+ const shiftedEnd = addDays(shiftedStart, spanDays - 1);
250
274
  this.#opts.onNavigate?.(shiftedStart, { start: shiftedStart, end: shiftedEnd });
251
275
  return;
252
276
  }
277
+ this.navDirection = delta > 0 ? 'forward' : 'backward';
253
278
  let next;
254
279
  switch (view) {
255
280
  case 'month': {
@@ -368,6 +393,37 @@ export class DateGridController {
368
393
  const re = stripTime(this.#opts.rangeEnd ?? reference);
369
394
  return rs.getTime() <= re.getTime() ? { start: rs, end: re } : { start: re, end: rs };
370
395
  }
396
+ /** Span-preserving sliding-window clamp for range navigation. A window of
397
+ * `spanDays` days starting at `idealStart` must lie inside [minDate, maxDate]
398
+ * as a whole: the earliest allowed start is minDate, the latest is
399
+ * `maxDate − (spanDays − 1)` (inclusive bounds — the window's last day is
400
+ * `start + spanDays − 1` and must not pass maxDate). Clamping only the start
401
+ * against maxDate would collapse the span; this keeps it exact, allowing
402
+ * partial shifts up to the bound. Degenerate case — the span is longer than
403
+ * the navigable interval, so no start satisfies both bounds — pins the window
404
+ * to minDate (its tail spills past maxDate; matches clampDate's minDate-wins
405
+ * ordering) and warns in DEV instead of crashing, mirroring the inverted-
406
+ * bounds warning in the constructor. */
407
+ #clampWindowStart(idealStart, spanDays) {
408
+ const { minDate, maxDate } = this.#opts;
409
+ const earliest = minDate ? stripTime(minDate) : undefined;
410
+ const latest = maxDate ? addDays(stripTime(maxDate), -(spanDays - 1)) : undefined;
411
+ if (earliest && latest && earliest.getTime() > latest.getTime()) {
412
+ if (import.meta.env?.DEV) {
413
+ console.warn(`[DateGrid] a ${spanDays}-day range window cannot fit between minDate ` +
414
+ `(${minDate?.toLocaleDateString()}) and maxDate (${maxDate?.toLocaleDateString()}) ` +
415
+ `— pinning the window to minDate; its tail spills past maxDate. ` +
416
+ `Widen the bounds or shorten the window.`);
417
+ }
418
+ return earliest;
419
+ }
420
+ let next = idealStart;
421
+ if (latest && next.getTime() > latest.getTime())
422
+ next = latest;
423
+ if (earliest && next.getTime() < earliest.getTime())
424
+ next = earliest;
425
+ return next;
426
+ }
371
427
  /** Pure cell geometry for an arbitrary reference date (no side effects). */
372
428
  #cellsFor(reference) {
373
429
  const wso = this.#opts.weekStartsOn;
@@ -42,35 +42,42 @@
42
42
 
43
43
  const isInteractive = $derived(clickable || interactive || !!onclick);
44
44
 
45
- function getRandomColor(name?: string): string {
46
- if (!name) return '#4b5563'; // gray-600
45
+ // The identity palette lives in `style/semantic.css` (--color-avatar-1..12),
46
+ // so a brand rethemes it without touching this component and dark mode
47
+ // resolves through light-dark() like every other token. Spelled out one
48
+ // `var()` per slot rather than built from a template string: Tailwind's
49
+ // scanner only keeps @theme variables it can see referenced in the source,
50
+ // and a computed `--color-avatar-${n}` is invisible to it — the same reason
51
+ // `internal/charts/utils.ts` enumerates its chart palette literally.
52
+ const identityPalette = [
53
+ 'var(--color-avatar-1)',
54
+ 'var(--color-avatar-2)',
55
+ 'var(--color-avatar-3)',
56
+ 'var(--color-avatar-4)',
57
+ 'var(--color-avatar-5)',
58
+ 'var(--color-avatar-6)',
59
+ 'var(--color-avatar-7)',
60
+ 'var(--color-avatar-8)',
61
+ 'var(--color-avatar-9)',
62
+ 'var(--color-avatar-10)',
63
+ 'var(--color-avatar-11)',
64
+ 'var(--color-avatar-12)'
65
+ ];
47
66
 
48
- // Tailwind *-700 shades dark enough that white text hits WCAG AA 4.5:1
49
- // on every hue. Lighter shades (*-500) used before produced ~3:1 contrast.
50
- const colors = [
51
- '#b91c1c', // red-700
52
- '#c2410c', // orange-700
53
- '#b45309', // amber-700
54
- '#a16207', // yellow-700
55
- '#4d7c0f', // lime-700
56
- '#15803d', // green-700
57
- '#047857', // emerald-700
58
- '#0e7490', // cyan-700
59
- '#0369a1', // sky-700
60
- '#1d4ed8', // blue-700
61
- '#4338ca', // indigo-700
62
- '#6d28d9', // violet-700
63
- '#7e22ce', // purple-700
64
- '#a21caf', // fuchsia-700
65
- '#be185d' // pink-700
66
- ];
67
+ // Deterministic name slot. The same name must always land on the same hue,
68
+ // so this stays a pure function of `name` with no randomness despite the
69
+ // `randomColor` prop name.
70
+ function getIdentityColor(name?: string): string | undefined {
71
+ // No name means no identity to encode — fall through to the `frame` slot's
72
+ // neutral surface tokens instead of inventing a grey.
73
+ if (!name) return undefined;
67
74
 
68
75
  let hash = 0;
69
76
  for (let i = 0; i < name.length; i++) {
70
77
  hash = name.charCodeAt(i) + ((hash << 5) - hash);
71
78
  }
72
79
 
73
- return colors[Math.abs(hash) % colors.length];
80
+ return identityPalette[Math.abs(hash) % identityPalette.length];
74
81
  }
75
82
 
76
83
  function getInitials(name?: string): string {
@@ -83,7 +90,7 @@
83
90
  .slice(0, 2);
84
91
  }
85
92
 
86
- const randomBg = $derived(randomColor ? getRandomColor(name) : undefined);
93
+ const randomBg = $derived(randomColor ? getIdentityColor(name) : undefined);
87
94
 
88
95
  const variantProps: AvatarVariants = $derived({
89
96
  size,
@@ -107,8 +114,15 @@
107
114
  // Inline property names stay kebab-case — a previous camelCase
108
115
  // `backgroundColor` was silently ignored by browsers, dropping the avatar back
109
116
  // to surface-interactive + white text.
117
+ //
118
+ // The initials take `--color-text-on-dark`, the palette's contrast partner: it
119
+ // flips white → neutral-900 in step with the fills going dark → light, so the
120
+ // initials stay legible in both modes. A literal `white` (the previous value)
121
+ // only worked because the old fills were frozen dark in dark mode too.
110
122
  const frameStyle = $derived(
111
- randomColor && randomBg ? `background-color: ${randomBg}; color: white` : undefined
123
+ randomColor && randomBg
124
+ ? `background-color: ${randomBg}; color: var(--color-text-on-dark)`
125
+ : undefined
112
126
  );
113
127
 
114
128
  // `ringColor` recolours the ring on the outer `base`. The ring is a
@@ -34,7 +34,7 @@ export interface AvatarProps extends AvatarVariants, Omit<HTMLAttributes<HTMLDiv
34
34
  children?: Snippet;
35
35
  /** Custom ring color (CSS value). Overrides `ringIntent` when set. */
36
36
  ringColor?: string;
37
- /** Derive a deterministic background color from `name`. The same name always produces the same color, making it easy to visually distinguish users without images. Overrides `intent`. */
37
+ /** Derive a deterministic background color from `name`. The same name always produces the same color, making it easy to visually distinguish users without images. Overrides `intent`. Picks from the 12-slot identity palette (`--color-avatar-1` … `--color-avatar-12`), which resolves light/dark automatically — override those tokens to rebrand it. Without a `name` there is no identity to encode, so the avatar keeps its neutral surface. */
38
38
  randomColor?: boolean;
39
39
  /** Mark the avatar as clickable (adds hover/focus styles and keyboard support). Alias for the `interactive` variant. */
40
40
  clickable?: boolean;
@@ -153,25 +153,15 @@
153
153
  `blocks-intent-{name}` class on the button root scopes the glow color
154
154
  via `mint/styles.css`. */
155
155
 
156
- :global(.blocks-button .blocks-ripple) {
157
- position: absolute;
158
- border-radius: 50%;
159
- background: rgba(255, 255, 255, 0.4);
160
- pointer-events: none;
161
- animation: ripple var(--blocks-duration-slow) var(--blocks-ease-confident);
162
- z-index: 1;
163
- }
164
-
165
- @keyframes ripple {
166
- 0% {
167
- transform: scale(0);
168
- opacity: 1;
169
- }
170
- 100% {
171
- transform: scale(1);
172
- opacity: 0;
173
- }
174
- }
156
+ /* The ripple mint owns the ripple end-to-end: `mint/ripple.ts` appends a
157
+ `<span class="blocks-mint-ripple">` on click and drives it with the Web
158
+ Animations API, tinted `currentColor` so it resolves per variant/intent —
159
+ white on a filled button, the intent hue on outlined/ghost. Button used to
160
+ carry a competing `.blocks-ripple` rule here (a hardcoded
161
+ `rgba(255,255,255,0.4)` plus its own @keyframes) that no element ever
162
+ matched — nothing has emitted that class since the initial commit, and it
163
+ would have been invisible on light variants had it matched. Removed rather
164
+ than tokenised; see the same note in `mint/styles.css` (XC-12/PAG-1). */
175
165
 
176
166
  :global(.sr-only) {
177
167
  position: absolute;
@@ -185,10 +175,7 @@
185
175
  border-width: 0;
186
176
  }
187
177
 
188
- @media (prefers-reduced-motion: reduce) {
189
- :global(.blocks-button .blocks-ripple) {
190
- animation: none !important;
191
- opacity: 0 !important;
192
- }
193
- }
178
+ /* No reduced-motion rule for the ripple here either: `mint/ripple.ts` bails at
179
+ click time on `prefers-reduced-motion`, and `mint/styles.css` hides
180
+ `.blocks-mint-ripple` outright — both on the class that actually exists. */
194
181
  </style>