@joewinke/jatui 0.1.11 → 0.1.19

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 (90) hide show
  1. package/README.md +123 -0
  2. package/package.json +2 -1
  3. package/src/lib/actions/railNav.ts +473 -0
  4. package/src/lib/components/AnnotationLayer.svelte +108 -0
  5. package/src/lib/components/AnnotationPanel.svelte +319 -0
  6. package/src/lib/components/AudioWaveform.svelte +9 -5
  7. package/src/lib/components/AvailabilityModal.svelte +7 -3
  8. package/src/lib/components/AvatarUpload.svelte +27 -4
  9. package/src/lib/components/BookingForm.svelte +11 -9
  10. package/src/lib/components/BurndownChart.svelte +778 -0
  11. package/src/lib/components/Button.svelte +10 -1
  12. package/src/lib/components/CalendarPicker.svelte +3 -3
  13. package/src/lib/components/Card.svelte +2 -2
  14. package/src/lib/components/ChipInput.svelte +21 -15
  15. package/src/lib/components/ColorSelector.svelte +17 -13
  16. package/src/lib/components/CommentThread.svelte +773 -0
  17. package/src/lib/components/ConfirmDialog.svelte +348 -0
  18. package/src/lib/components/ConfirmModal.svelte +78 -11
  19. package/src/lib/components/ContextMenu.svelte +59 -19
  20. package/src/lib/components/CountdownTimer.svelte +1 -1
  21. package/src/lib/components/DateRangePicker.svelte +6 -4
  22. package/src/lib/components/Drawer.svelte +36 -3
  23. package/src/lib/components/EntityPreviewCard.svelte +104 -0
  24. package/src/lib/components/FileDropzone.svelte +493 -0
  25. package/src/lib/components/FilePicker.svelte +83 -14
  26. package/src/lib/components/FileThumbnail.svelte +80 -0
  27. package/src/lib/components/FilterDropdown.svelte +11 -11
  28. package/src/lib/components/HunkDiffView.svelte +348 -0
  29. package/src/lib/components/ImageLightbox.svelte +274 -0
  30. package/src/lib/components/ImageUpload.svelte +58 -9
  31. package/src/lib/components/InlineEdit.svelte +15 -9
  32. package/src/lib/components/InputDialog.svelte +327 -0
  33. package/src/lib/components/LazyImage.svelte +1 -0
  34. package/src/lib/components/LinkShortener.svelte +1 -1
  35. package/src/lib/components/LoadingSpinner.svelte +6 -2
  36. package/src/lib/components/MarkupEditor.svelte +485 -0
  37. package/src/lib/components/MarkupOverlay.svelte +55 -0
  38. package/src/lib/components/MediaWorkbench.svelte +871 -0
  39. package/src/lib/components/MilestoneCard.svelte +1 -1
  40. package/src/lib/components/MilestoneTimeline.svelte +1 -1
  41. package/src/lib/components/Modal.svelte +39 -4
  42. package/src/lib/components/PDFViewer.svelte +105 -0
  43. package/src/lib/components/PdfThumbnail.svelte +3 -1
  44. package/src/lib/components/PhoneInput.svelte +1 -1
  45. package/src/lib/components/ResizablePanel.svelte +4 -4
  46. package/src/lib/components/SearchDropdown.svelte +26 -13
  47. package/src/lib/components/SelectInput.svelte +26 -4
  48. package/src/lib/components/SidebarUserFooter.svelte +1 -1
  49. package/src/lib/components/SignaturePad.svelte +8 -4
  50. package/src/lib/components/SmartImageEditor.svelte +720 -0
  51. package/src/lib/components/SortDropdown.svelte +9 -3
  52. package/src/lib/components/Sparkline.svelte +9 -0
  53. package/src/lib/components/StatusBadge.svelte +20 -18
  54. package/src/lib/components/TextArea.svelte +24 -5
  55. package/src/lib/components/TextInput.svelte +29 -6
  56. package/src/lib/components/ThemeSelector.svelte +15 -4
  57. package/src/lib/components/TimeSlotPicker.svelte +7 -7
  58. package/src/lib/components/UserAvatar.svelte +14 -1
  59. package/src/lib/components/VariablePicker.svelte +170 -0
  60. package/src/lib/components/VoicePlayer.svelte +4 -3
  61. package/src/lib/components/markup.ts +287 -0
  62. package/src/lib/components/messaging/ChannelInfoModal.svelte +9 -9
  63. package/src/lib/components/messaging/ChannelList.svelte +1 -1
  64. package/src/lib/components/messaging/ChannelMembersModal.svelte +1 -1
  65. package/src/lib/components/messaging/CreateChannelModal.svelte +1 -1
  66. package/src/lib/components/messaging/DirectMessageList.svelte +1 -1
  67. package/src/lib/components/messaging/EmojiSelector.svelte +2 -1
  68. package/src/lib/components/messaging/MentionAutocomplete.svelte +1 -1
  69. package/src/lib/components/messaging/MessageAttachment.svelte +3 -3
  70. package/src/lib/components/messaging/MessageAttachmentUpload.svelte +3 -3
  71. package/src/lib/components/messaging/MessageInput.svelte +1 -1
  72. package/src/lib/components/messaging/MessageItem.svelte +6 -3
  73. package/src/lib/components/messaging/NotificationSettingsModal.svelte +1 -1
  74. package/src/lib/components/messaging/QuotedMessageDisplay.svelte +6 -1
  75. package/src/lib/components/messaging/StartDMModal.svelte +1 -1
  76. package/src/lib/components/pipeline/Pipeline.svelte +4 -4
  77. package/src/lib/components/pipeline/PipelineCard.svelte +1 -1
  78. package/src/lib/components/pipeline/PipelineColumn.svelte +8 -3
  79. package/src/lib/index.ts +91 -0
  80. package/src/lib/stores/confirmDialog.svelte.ts +48 -0
  81. package/src/lib/stores/inputDialog.svelte.ts +51 -0
  82. package/src/lib/styles/rail.css +63 -0
  83. package/src/lib/types/annotation.ts +38 -0
  84. package/src/lib/types/comments.ts +97 -0
  85. package/src/lib/types/entityPreview.ts +45 -0
  86. package/src/lib/types/filePicker.ts +2 -0
  87. package/src/lib/types/smartImageEditor.ts +39 -0
  88. package/src/lib/types/templateVars.ts +36 -0
  89. package/src/lib/utils/dateFormatters.ts +12 -10
  90. package/src/lib/utils/taskUtils.ts +21 -7
@@ -98,6 +98,9 @@
98
98
  sm: 'w-24 focus:w-36',
99
99
  md: 'w-32 focus:w-44'
100
100
  }[size]);
101
+
102
+ import { fly } from 'svelte/transition';
103
+ import { cubicOut } from 'svelte/easing';
101
104
  </script>
102
105
 
103
106
  <div class="flex items-center gap-1">
@@ -107,7 +110,7 @@
107
110
  <div class="dropdown dropdown-end flex-shrink-0" onclick={(e) => e.stopPropagation()}>
108
111
  <button
109
112
  tabindex="0"
110
- class="btn {buttonSizeClass} btn-ghost gap-1 font-mono text-[10px] uppercase tracking-wider opacity-70 hover:opacity-100"
113
+ class="btn {buttonSizeClass} btn-ghost gap-1 text-[0.75rem] opacity-70 hover:opacity-100"
111
114
  title="Sort"
112
115
  >
113
116
  <span>{currentIcon}</span>
@@ -115,7 +118,7 @@
115
118
  <span class="text-[9px]">{sortDir === 'asc' ? '▲' : '▼'}</span>
116
119
  </button>
117
120
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
118
- <ul tabindex="0" class="dropdown-content menu {menuSizeClass} bg-base-200 rounded-box z-40 w-36 p-1 shadow-lg border border-base-300">
121
+ <ul tabindex="0" class="dropdown-content menu {menuSizeClass} bg-base-200 rounded-box z-40 w-36 p-1 border border-base-300">
119
122
  {#each options as opt (opt.value)}
120
123
  <li>
121
124
  <button
@@ -137,7 +140,10 @@
137
140
  {#if showFilter}
138
141
  <!-- svelte-ignore a11y_click_events_have_key_events -->
139
142
  <!-- svelte-ignore a11y_no_static_element_interactions -->
140
- <div class="flex-shrink-0" onclick={(e) => e.stopPropagation()}>
143
+ <div class="flex-shrink-0" onclick={(e) => e.stopPropagation()}
144
+ in:fly={{ x: -8, duration: 180, easing: cubicOut }}
145
+ out:fly={{ x: -4, duration: 120, easing: cubicOut }}
146
+ >
141
147
  <input
142
148
  type="text"
143
149
  placeholder={filterPlaceholder}
@@ -13,12 +13,15 @@
13
13
  height = 32,
14
14
  color = 'oklch(var(--p))',
15
15
  fillOpacity = 0.15,
16
+ labels,
16
17
  }: {
17
18
  values: number[];
18
19
  width?: number | 'auto';
19
20
  height?: number;
20
21
  color?: string;
21
22
  fillOpacity?: number;
23
+ /** Optional tooltip label per bar. If provided, shown as SVG title on hover. */
24
+ labels?: string[];
22
25
  } = $props();
23
26
 
24
27
  let el: HTMLDivElement | undefined = $state();
@@ -76,6 +79,12 @@
76
79
  .style('fill', color)
77
80
  .style('fill-opacity', (_, i) => fillOpacity + (1 - fillOpacity) * (i / n));
78
81
 
82
+ bars.each(function (d, i) {
83
+ d3.select(this).selectAll('title').remove();
84
+ const label = labels && labels[i] !== undefined ? labels[i] : String(d);
85
+ d3.select(this).append('title').text(label);
86
+ });
87
+
79
88
  if (shouldAnimate) {
80
89
  bars
81
90
  .attr('y', innerH)
@@ -1,10 +1,4 @@
1
- <script lang="ts">
2
- /**
3
- * StatusBadge Component
4
- *
5
- * Predefined status badge with semantic colors, icons, and dot indicators.
6
- */
7
-
1
+ <script module lang="ts">
8
2
  export type StatusValue =
9
3
  | 'active'
10
4
  | 'inactive'
@@ -20,6 +14,14 @@
20
14
  | 'partial'
21
15
  | 'assigned'
22
16
  | 'available';
17
+ </script>
18
+
19
+ <script lang="ts">
20
+ /**
21
+ * StatusBadge Component
22
+ *
23
+ * Predefined status badge with semantic colors, icons, and dot indicators.
24
+ */
23
25
 
24
26
  interface Props {
25
27
  status: StatusValue;
@@ -50,18 +52,18 @@
50
52
  const statusConfig: Record<string, { text: string; variant: string; icon: string }> = {
51
53
  active: { text: 'Active', variant: 'success', icon: '✓' },
52
54
  inactive: { text: 'Inactive', variant: 'error', icon: '✗' },
53
- pending: { text: 'Pending', variant: 'warning', icon: '' },
55
+ pending: { text: 'Pending', variant: 'warning', icon: '' },
54
56
  completed: { text: 'Completed', variant: 'success', icon: '✓' },
55
57
  cancelled: { text: 'Cancelled', variant: 'error', icon: '✗' },
56
- draft: { text: 'Draft', variant: 'default', icon: '📝' },
57
- scheduled: { text: 'Scheduled', variant: 'info', icon: '📅' },
58
- 'in-progress': { text: 'In Progress', variant: 'primary', icon: '⏱️' },
59
- overdue: { text: 'Overdue', variant: 'error', icon: '⚠️' },
60
- paid: { text: 'Paid', variant: 'success', icon: '💰' },
61
- unpaid: { text: 'Unpaid', variant: 'warning', icon: '💸' },
62
- partial: { text: 'Partial', variant: 'warning', icon: '📊' },
58
+ draft: { text: 'Draft', variant: 'default', icon: '' },
59
+ scheduled: { text: 'Scheduled', variant: 'info', icon: '' },
60
+ 'in-progress': { text: 'In Progress', variant: 'primary', icon: '' },
61
+ overdue: { text: 'Overdue', variant: 'error', icon: '' },
62
+ paid: { text: 'Paid', variant: 'success', icon: '' },
63
+ unpaid: { text: 'Unpaid', variant: 'warning', icon: '' },
64
+ partial: { text: 'Partial', variant: 'warning', icon: '' },
63
65
  assigned: { text: 'Assigned', variant: 'success', icon: '✓' },
64
- available: { text: 'Available', variant: 'info', icon: '' }
66
+ available: { text: 'Available', variant: 'info', icon: '' }
65
67
  };
66
68
 
67
69
  const config = $derived(statusConfig[status] || { text: status || 'Unknown', variant: 'default', icon: '?' });
@@ -135,7 +137,7 @@
135
137
  onkeydown={handleKeyDown}
136
138
  >
137
139
  {#if showDot}
138
- <span class="w-2 h-2 rounded-full {dotColor} mr-1.5"></span>
140
+ <span class="w-2 h-2 rounded-full {dotColor} mr-1.5 {status === 'active' ? 'animate-pulse' : ''}"></span>
139
141
  {/if}
140
142
  {#if showIcon}
141
143
  <span class="mr-1">{config.icon}</span>
@@ -145,7 +147,7 @@
145
147
  {:else}
146
148
  <span class={badgeClass} aria-label={ariaLabel || `Status: ${displayText}`}>
147
149
  {#if showDot}
148
- <span class="w-2 h-2 rounded-full {dotColor} mr-1.5"></span>
150
+ <span class="w-2 h-2 rounded-full {dotColor} mr-1.5 {status === 'active' ? 'animate-pulse' : ''}"></span>
149
151
  {/if}
150
152
  {#if showIcon}
151
153
  <span class="mr-1">{config.icon}</span>
@@ -59,7 +59,7 @@
59
59
 
60
60
  const textareaClass = $derived(
61
61
  [
62
- 'textarea textarea-bordered w-full',
62
+ 'textarea textarea-bordered w-full transition-shadow duration-200',
63
63
  sizeClass,
64
64
  resizeClass,
65
65
  error ? 'textarea-error' : '',
@@ -90,12 +90,30 @@
90
90
  autoResizeTextarea(textareaElement);
91
91
  }
92
92
  });
93
+
94
+ // Error-state shake (transitions.dev). Replays `t-input-shake` when `error`
95
+ // becomes set. See TextInput for the full rationale.
96
+ let hadError = false;
97
+ $effect(() => {
98
+ const hasError = !!error;
99
+ if (hasError && !hadError && textareaElement) {
100
+ const reduce =
101
+ typeof window !== 'undefined' &&
102
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
103
+ if (!reduce) {
104
+ textareaElement.classList.remove('t-input-shake');
105
+ void textareaElement.offsetWidth;
106
+ textareaElement.classList.add('t-input-shake');
107
+ }
108
+ }
109
+ hadError = hasError;
110
+ });
93
111
  </script>
94
112
 
95
113
  <div class="form-control w-full">
96
114
  {#if label}
97
115
  <label for={id} class="label">
98
- <span class="label-text font-medium">
116
+ <span class="text-[0.8125rem] font-medium text-base-content/85">
99
117
  {label}
100
118
  {#if required}
101
119
  <span class="text-error ml-1">*</span>
@@ -121,11 +139,12 @@
121
139
  aria-describedby={error ? `${id}-error` : helpText ? `${id}-help` : undefined}
122
140
  aria-invalid={error ? 'true' : 'false'}
123
141
  style={autoResize ? `min-height: calc(1.5em * ${rows} + 1rem);` : ''}
142
+ onanimationend={() => textareaElement?.classList.remove('t-input-shake')}
124
143
  ></textarea>
125
144
 
126
145
  {#if showCount && maxlength}
127
146
  <div class="label">
128
- <span class="label-text-alt {isOverLimit ? 'text-error' : 'text-base-content/70'}">
147
+ <span class="text-[0.8125rem] {isOverLimit ? 'text-error' : 'text-base-content/45'}">
129
148
  {characterCount}/{maxlength}
130
149
  </span>
131
150
  </div>
@@ -133,11 +152,11 @@
133
152
 
134
153
  {#if error}
135
154
  <div class="label">
136
- <span id="{id}-error" class="label-text-alt text-error" role="alert">{error}</span>
155
+ <span id="{id}-error" class="text-[0.8125rem] text-error" role="alert">{error}</span>
137
156
  </div>
138
157
  {:else if helpText}
139
158
  <div class="label">
140
- <span id="{id}-help" class="label-text-alt text-base-content/70">{helpText}</span>
159
+ <span id="{id}-help" class="text-[0.8125rem] text-base-content/45">{helpText}</span>
141
160
  </div>
142
161
  {/if}
143
162
  </div>
@@ -51,18 +51,39 @@
51
51
  const sizeClass = $derived({ sm: 'input-sm', md: '', lg: 'input-lg' }[size]);
52
52
 
53
53
  const inputClass = $derived(
54
- ['input input-bordered w-full', sizeClass, error ? 'input-error' : '', disabled ? 'input-disabled' : '', className]
54
+ ['input input-bordered w-full transition-shadow duration-200', sizeClass, error ? 'input-error' : '', disabled ? 'input-disabled' : '', className]
55
55
  .filter(Boolean)
56
56
  .join(' ')
57
57
  );
58
58
 
59
59
  const characterCount = $derived(value?.length || 0);
60
60
  const isOverLimit = $derived(maxlength ? characterCount > maxlength : false);
61
+
62
+ // Error-state shake (transitions.dev "Error state shake"). When `error`
63
+ // transitions from empty to set, replay the `t-input-shake` keyframe from the
64
+ // host app's transitions.css. Self-contained reflow-restart so a repeat error
65
+ // re-shakes. Colour stays on the `input-error` class; this owns only motion.
66
+ let inputEl: HTMLInputElement | undefined = $state();
67
+ let hadError = false;
68
+ $effect(() => {
69
+ const hasError = !!error;
70
+ if (hasError && !hadError && inputEl) {
71
+ const reduce =
72
+ typeof window !== 'undefined' &&
73
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
74
+ if (!reduce) {
75
+ inputEl.classList.remove('t-input-shake');
76
+ void inputEl.offsetWidth; // force reflow to restart the animation
77
+ inputEl.classList.add('t-input-shake');
78
+ }
79
+ }
80
+ hadError = hasError;
81
+ });
61
82
  </script>
62
83
 
63
84
  <div class="form-control w-full">
64
85
  <label for={id} class="label">
65
- <span class="label-text font-medium">
86
+ <span class="text-[0.8125rem] font-medium text-base-content/85">
66
87
  {label}
67
88
  {#if required}
68
89
  <span class="text-error ml-1">*</span>
@@ -71,6 +92,7 @@
71
92
  </label>
72
93
 
73
94
  <input
95
+ bind:this={inputEl}
74
96
  {id}
75
97
  {name}
76
98
  {type}
@@ -81,16 +103,17 @@
81
103
  {maxlength}
82
104
  {minlength}
83
105
  {pattern}
84
- {autocomplete}
106
+ autocomplete={autocomplete as AutoFill}
85
107
  bind:value
86
108
  class={inputClass}
87
109
  aria-describedby={error ? `${id}-error` : helpText ? `${id}-help` : undefined}
88
110
  aria-invalid={error ? 'true' : 'false'}
111
+ onanimationend={() => inputEl?.classList.remove('t-input-shake')}
89
112
  />
90
113
 
91
114
  {#if showCount && maxlength}
92
115
  <div class="label">
93
- <span class="label-text-alt {isOverLimit ? 'text-error' : 'text-base-content/70'}">
116
+ <span class="text-[0.8125rem] {isOverLimit ? 'text-error' : 'text-base-content/45'}">
94
117
  {characterCount}/{maxlength}
95
118
  </span>
96
119
  </div>
@@ -98,11 +121,11 @@
98
121
 
99
122
  {#if error}
100
123
  <div class="label">
101
- <span id="{id}-error" class="label-text-alt text-error" role="alert">{error}</span>
124
+ <span id="{id}-error" class="text-[0.8125rem] text-error" role="alert">{error}</span>
102
125
  </div>
103
126
  {:else if helpText}
104
127
  <div class="label">
105
- <span id="{id}-help" class="label-text-alt text-base-content/70">{helpText}</span>
128
+ <span id="{id}-help" class="text-[0.8125rem] text-base-content/45">{helpText}</span>
106
129
  </div>
107
130
  {/if}
108
131
  </div>
@@ -61,6 +61,7 @@
61
61
  onThemeChange?: (theme: string) => void;
62
62
  } = $props();
63
63
 
64
+ // svelte-ignore state_referenced_locally
64
65
  let currentTheme = $state(defaultTheme);
65
66
  let isAnimating = $state(false);
66
67
 
@@ -114,7 +115,7 @@
114
115
  >
115
116
  <div
116
117
  data-theme={currentTheme}
117
- class="bg-base-100 grid shrink-0 grid-cols-2 gap-0.5 rounded-md p-1 shadow-sm transition-transform duration-300 ease-out"
118
+ class="bg-base-100 grid shrink-0 grid-cols-2 gap-0.5 rounded-md p-1 transition-transform duration-300 ease-out"
118
119
  class:theme-spin={isAnimating}
119
120
  >
120
121
  <div class="bg-base-content size-1 rounded-full transition-all duration-300 theme-dot" class:theme-dot-animate={isAnimating} style="--dot-delay: 0ms"></div>
@@ -129,8 +130,8 @@
129
130
  <div
130
131
  tabindex="0"
131
132
  class="dropdown-content bg-base-200 text-base-content rounded-box
132
- top-px max-h-[calc(50vh-6.5rem)] overflow-y-auto border border-white/5
133
- shadow-2xl outline-1 outline-black/5 mb-2 z-50"
133
+ top-px max-h-[calc(50vh-6.5rem)] overflow-y-auto border border-base-300
134
+ mb-2 z-50"
134
135
  >
135
136
  <ul class="menu w-56">
136
137
  <li class="menu-title text-xs">Theme</li>
@@ -145,7 +146,7 @@
145
146
  <div
146
147
  data-theme={theme.name}
147
148
  class="bg-base-100 grid shrink-0 grid-cols-2 gap-0.5
148
- rounded-md p-1 shadow-sm"
149
+ rounded-md p-1"
149
150
  >
150
151
  <div class="bg-base-content size-1 rounded-full"></div>
151
152
  <div class="bg-primary size-1 rounded-full"></div>
@@ -172,6 +173,16 @@
172
173
  </div>
173
174
 
174
175
  <style>
176
+ /* NOTE: DaisyUI v5.0.0 has a stuck-animation bug on `.dropdown-content`
177
+ (`@keyframes dropdown { 0% { opacity: 0 } }` pins opacity to 0). If the
178
+ theme dropdown opens but stays invisible, the consuming project needs
179
+ the global fix in its `app.css`:
180
+
181
+ .dropdown-content { animation: none !important; }
182
+
183
+ Upstream daisyui >= 5.5.x patches this; once you upgrade you can drop
184
+ the override. */
185
+
175
186
  .theme-spin {
176
187
  animation: theme-spin 0.4s cubic-bezier(0.4, 0, 0.2, 1);
177
188
  }
@@ -76,14 +76,14 @@
76
76
  {@const { morning, afternoon, evening } = groupedSlots()}
77
77
  {@const availableCount = slots.filter(s => s.available).length}
78
78
 
79
- <p class="text-sm text-base-content/60 mb-4">
79
+ <p class="text-[0.8125rem] text-base-content/45 mb-4">
80
80
  {availableCount} time{availableCount !== 1 ? 's' : ''} available
81
81
  </p>
82
82
 
83
83
  <div class="space-y-6">
84
84
  {#if morning.length > 0}
85
85
  <div>
86
- <h4 class="text-sm font-medium text-base-content/70 mb-3 flex items-center gap-2">
86
+ <h4 class="text-[0.8125rem] text-base-content/45 mb-3 flex items-center gap-2 tracking-[0.005em]">
87
87
  <!-- Sun icon -->
88
88
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
89
89
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
@@ -95,7 +95,7 @@
95
95
  <button
96
96
  onclick={() => handleSlotClick(slot)}
97
97
  disabled={!slot.available}
98
- class="py-3 px-4 rounded-lg text-sm font-medium transition-all duration-200
98
+ class="py-3 px-4 rounded-lg text-[0.9375rem] font-medium transition-all duration-200
99
99
  {slot.available
100
100
  ? `bg-base-200/50 hover:bg-${accentColor} hover:text-${accentColor}-content`
101
101
  : 'bg-base-200/20 text-base-content/30 cursor-not-allowed line-through'}"
@@ -109,7 +109,7 @@
109
109
 
110
110
  {#if afternoon.length > 0}
111
111
  <div>
112
- <h4 class="text-sm font-medium text-base-content/70 mb-3 flex items-center gap-2">
112
+ <h4 class="text-[0.8125rem] text-base-content/45 mb-3 flex items-center gap-2 tracking-[0.005em]">
113
113
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
114
114
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
115
115
  </svg>
@@ -120,7 +120,7 @@
120
120
  <button
121
121
  onclick={() => handleSlotClick(slot)}
122
122
  disabled={!slot.available}
123
- class="py-3 px-4 rounded-lg text-sm font-medium transition-all duration-200
123
+ class="py-3 px-4 rounded-lg text-[0.9375rem] font-medium transition-all duration-200
124
124
  {slot.available
125
125
  ? `bg-base-200/50 hover:bg-${accentColor} hover:text-${accentColor}-content`
126
126
  : 'bg-base-200/20 text-base-content/30 cursor-not-allowed line-through'}"
@@ -134,7 +134,7 @@
134
134
 
135
135
  {#if evening.length > 0}
136
136
  <div>
137
- <h4 class="text-sm font-medium text-base-content/70 mb-3 flex items-center gap-2">
137
+ <h4 class="text-[0.8125rem] text-base-content/45 mb-3 flex items-center gap-2 tracking-[0.005em]">
138
138
  <!-- Moon icon -->
139
139
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
140
140
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
@@ -146,7 +146,7 @@
146
146
  <button
147
147
  onclick={() => handleSlotClick(slot)}
148
148
  disabled={!slot.available}
149
- class="py-3 px-4 rounded-lg text-sm font-medium transition-all duration-200
149
+ class="py-3 px-4 rounded-lg text-[0.9375rem] font-medium transition-all duration-200
150
150
  {slot.available
151
151
  ? `bg-base-200/50 hover:bg-${accentColor} hover:text-${accentColor}-content`
152
152
  : 'bg-base-200/20 text-base-content/30 cursor-not-allowed line-through'}"
@@ -41,6 +41,19 @@
41
41
  })
42
42
 
43
43
  const label = $derived(name || email || 'Unknown')
44
+
45
+ // Derive a consistent color per user from their name/email
46
+ const avatarBg = $derived.by(() => {
47
+ const source = name || email || ''
48
+ if (!source) return 'oklch(0.65 0.10 240)'
49
+ let hash = 0
50
+ for (let i = 0; i < source.length; i++) {
51
+ hash = source.charCodeAt(i) + ((hash << 5) - hash)
52
+ }
53
+ const hues = [30, 85, 145, 200, 240, 270, 310, 350]
54
+ const hue = hues[Math.abs(hash) % hues.length]
55
+ return `oklch(0.62 0.14 ${hue})`
56
+ })
44
57
  </script>
45
58
 
46
59
  <div
@@ -51,7 +64,7 @@
51
64
  {#if avatarUrl}
52
65
  <img src={avatarUrl} alt={label} class="w-full h-full object-cover" />
53
66
  {:else}
54
- <div class="w-full h-full bg-neutral text-neutral-content flex items-center justify-center font-semibold">
67
+ <div class="w-full h-full flex items-center justify-center font-semibold text-white" style="background: {avatarBg}">
55
68
  {initials}
56
69
  </div>
57
70
  {/if}
@@ -0,0 +1,170 @@
1
+ <script lang="ts">
2
+ /**
3
+ * VariablePicker — browse and insert template merge variables.
4
+ *
5
+ * Renders the available merge variables for a document template, grouped by
6
+ * context (client / org / financial / custom). Clicking a variable inserts
7
+ * its `{{name}}` token at the caret of a bound textarea/input and/or fires
8
+ * an `onInsert` callback.
9
+ *
10
+ * Source of `groups`: the JST server-side registry
11
+ * `getVarsForCategory(category, template.available_vars)` in
12
+ * `src/lib/server/template-vars.ts`. Compute it in a `+page.server.ts` load
13
+ * (the registry is server-only) and pass the result straight in.
14
+ *
15
+ * This is a self-contained panel (not a modal) so it can sit beside a
16
+ * template editor as a sidebar. Wrap it in your own Drawer/Modal if needed.
17
+ */
18
+
19
+ import type { TemplateVar, TemplateVarGroup } from "../types/templateVars"
20
+
21
+ interface Props {
22
+ /** Variable groups to show. From `getVarsForCategory(...)`. */
23
+ groups: TemplateVarGroup[]
24
+ /**
25
+ * Optional textarea/input to insert into. When set, clicking a variable
26
+ * splices `{{name}}` at the current caret, restores focus + caret after
27
+ * the token, and dispatches an `input` event so `bind:value` updates.
28
+ */
29
+ targetEl?: HTMLTextAreaElement | HTMLInputElement | null
30
+ /** Called with the inserted token (`{{name}}`) and the bare var name. */
31
+ onInsert?: (token: string, varName: string) => void
32
+ /** Show the filter box. Default true. */
33
+ searchable?: boolean
34
+ /** Denser layout for narrow sidebars. Default false. */
35
+ compact?: boolean
36
+ /** Panel heading. Default "Variables". */
37
+ title?: string
38
+ /** Shown when there are no groups/vars at all. */
39
+ emptyText?: string
40
+ }
41
+
42
+ let {
43
+ groups,
44
+ targetEl = null,
45
+ onInsert,
46
+ searchable = true,
47
+ compact = false,
48
+ title = "Variables",
49
+ emptyText = "No variables available for this template.",
50
+ }: Props = $props()
51
+
52
+ let query = $state("")
53
+
54
+ const totalVars = $derived(
55
+ groups.reduce((n, g) => n + g.vars.length, 0),
56
+ )
57
+
58
+ // Filter vars by name/label/description, dropping groups left empty.
59
+ const filteredGroups = $derived.by<TemplateVarGroup[]>(() => {
60
+ const q = query.trim().toLowerCase()
61
+ if (!q) return groups
62
+ const out: TemplateVarGroup[] = []
63
+ for (const g of groups) {
64
+ const vars = g.vars.filter(
65
+ (v) =>
66
+ v.name.toLowerCase().includes(q) ||
67
+ v.label.toLowerCase().includes(q) ||
68
+ (v.description?.toLowerCase().includes(q) ?? false),
69
+ )
70
+ if (vars.length > 0) out.push({ ...g, vars })
71
+ }
72
+ return out
73
+ })
74
+
75
+ function tokenFor(v: TemplateVar): string {
76
+ return `{{${v.name}}}`
77
+ }
78
+
79
+ function insert(v: TemplateVar) {
80
+ const token = tokenFor(v)
81
+
82
+ if (targetEl) {
83
+ const el = targetEl
84
+ const start = el.selectionStart ?? el.value.length
85
+ const end = el.selectionEnd ?? el.value.length
86
+ el.value = el.value.slice(0, start) + token + el.value.slice(end)
87
+ const caret = start + token.length
88
+ // Restore focus + caret after the inserted token.
89
+ el.focus()
90
+ el.setSelectionRange(caret, caret)
91
+ // Notify Svelte bindings / listeners.
92
+ el.dispatchEvent(new Event("input", { bubbles: true }))
93
+ }
94
+
95
+ onInsert?.(token, v.name)
96
+ }
97
+ </script>
98
+
99
+ <div
100
+ class="border-base-300 bg-base-100 flex flex-col rounded-box border"
101
+ class:text-sm={compact}
102
+ >
103
+ <div class="border-base-300 flex items-center justify-between border-b px-4 py-3">
104
+ <h3 class="font-semibold">{title}</h3>
105
+ {#if totalVars > 0}
106
+ <span class="badge badge-ghost badge-sm">{totalVars}</span>
107
+ {/if}
108
+ </div>
109
+
110
+ {#if searchable && totalVars > 0}
111
+ <div class="border-base-300 border-b p-3">
112
+ <input
113
+ type="text"
114
+ class="input input-bordered input-sm w-full"
115
+ placeholder="Filter variables…"
116
+ aria-label="Filter variables"
117
+ bind:value={query}
118
+ />
119
+ </div>
120
+ {/if}
121
+
122
+ <div class="flex-1 overflow-y-auto p-2" class:max-h-96={!compact}>
123
+ {#if totalVars === 0}
124
+ <p class="text-base-content/60 px-2 py-6 text-center text-sm">
125
+ {emptyText}
126
+ </p>
127
+ {:else if filteredGroups.length === 0}
128
+ <p class="text-base-content/60 px-2 py-6 text-center text-sm">
129
+ No variables match "{query}".
130
+ </p>
131
+ {:else}
132
+ {#each filteredGroups as group (group.id)}
133
+ <div class="mb-3 last:mb-0">
134
+ <div
135
+ class="text-base-content/45 px-2 pb-1 text-[0.8125rem] tracking-[0.005em]"
136
+ >
137
+ {group.label}
138
+ </div>
139
+ <div class="flex flex-col gap-0.5">
140
+ {#each group.vars as v (v.name)}
141
+ <button
142
+ type="button"
143
+ class="hover:bg-base-200 focus-visible:bg-base-200 group flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors"
144
+ onclick={() => insert(v)}
145
+ title={`Insert ${tokenFor(v)}`}
146
+ >
147
+ <div class="flex w-full items-baseline gap-2">
148
+ <span class="font-medium flex-1 min-w-0 truncate">{v.label}</span>
149
+ <code
150
+ class="text-base-content/60 group-hover:text-primary shrink-0 font-mono text-xs"
151
+ >
152
+ {tokenFor(v)}
153
+ </code>
154
+ </div>
155
+ {#if v.description && !compact}
156
+ <span class="text-base-content/45 text-[0.75rem]">
157
+ {v.description}{#if v.example}
158
+ <span class="text-base-content/45">
159
+ — e.g. {v.example}</span
160
+ >{/if}
161
+ </span>
162
+ {/if}
163
+ </button>
164
+ {/each}
165
+ </div>
166
+ </div>
167
+ {/each}
168
+ {/if}
169
+ </div>
170
+ </div>
@@ -53,6 +53,7 @@
53
53
  let voices = $state<any[]>([])
54
54
  let voicesLoading = $state(false)
55
55
  let showVoices = $state(false)
56
+ // svelte-ignore state_referenced_locally
56
57
  let selectedVoiceId = $state<string | undefined>(voiceId || "alloy")
57
58
 
58
59
  let speed = $state(1.0)
@@ -254,7 +255,7 @@
254
255
  {/if}
255
256
  {#if showControls}
256
257
  <div
257
- class="absolute flex items-center gap-2 bg-base-200 border border-base-300 rounded-full shadow-lg z-50 transition-all {expandDirection === 'right' ? 'right-0' : 'left-0'}"
258
+ class="absolute flex items-center gap-2 bg-base-200 border border-base-300 rounded-full z-50 transition-all {expandDirection === 'right' ? 'right-0' : 'left-0'}"
258
259
  style="min-width: 180px;"
259
260
  >
260
261
  <!-- Play/Pause -->
@@ -330,7 +331,7 @@
330
331
  <!-- Settings panel -->
331
332
  {#if showSettings}
332
333
  <div
333
- class="absolute right-0 top-full z-50 bg-base-200 border border-base-300 rounded-lg shadow-lg p-3 w-64"
334
+ class="absolute right-0 top-full z-50 bg-base-200 border border-base-300 rounded-lg p-3 w-64"
334
335
  style="margin-top: 0.25rem;"
335
336
  >
336
337
  <div class="mb-3">
@@ -364,7 +365,7 @@
364
365
  <!-- Voice selection dropdown -->
365
366
  {#if showVoices}
366
367
  <div
367
- class="absolute right-0 top-full z-50 bg-base-200 border border-base-300 rounded-lg shadow-lg p-2 max-h-64 overflow-y-auto w-64"
368
+ class="absolute right-0 top-full z-50 bg-base-200 border border-base-300 rounded-lg p-2 max-h-64 overflow-y-auto w-64"
368
369
  style="margin-top: 0.25rem;"
369
370
  >
370
371
  {#if voicesLoading}