@adea-ai/ui 0.142.4 → 0.144.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 (98) hide show
  1. package/dist/components/composites/accent-picker/accent-picker.d.ts +28 -0
  2. package/dist/components/composites/accent-picker/accent-picker.d.ts.map +1 -0
  3. package/dist/components/composites/accent-picker/accent-picker.js +107 -0
  4. package/dist/components/composites/accent-picker/accent-picker.js.map +1 -0
  5. package/dist/components/composites/accent-picker/index.d.ts +2 -0
  6. package/dist/components/composites/accent-picker/index.d.ts.map +1 -0
  7. package/dist/components/composites/accent-picker/index.js +2 -0
  8. package/dist/components/composites/icon-picker/icon-picker.d.ts +52 -0
  9. package/dist/components/composites/icon-picker/icon-picker.d.ts.map +1 -0
  10. package/dist/components/composites/icon-picker/icon-picker.js +265 -0
  11. package/dist/components/composites/icon-picker/icon-picker.js.map +1 -0
  12. package/dist/components/composites/icon-picker/image-data-url.d.ts +42 -0
  13. package/dist/components/composites/icon-picker/image-data-url.d.ts.map +1 -0
  14. package/dist/components/composites/icon-picker/image-data-url.js +90 -0
  15. package/dist/components/composites/icon-picker/image-data-url.js.map +1 -0
  16. package/dist/components/composites/icon-picker/index.d.ts +4 -0
  17. package/dist/components/composites/icon-picker/index.d.ts.map +1 -0
  18. package/dist/components/composites/icon-picker/index.js +4 -0
  19. package/dist/components/composites/icon-picker/shell-icons.d.ts +264 -0
  20. package/dist/components/composites/icon-picker/shell-icons.d.ts.map +1 -0
  21. package/dist/components/composites/icon-picker/shell-icons.js +334 -0
  22. package/dist/components/composites/icon-picker/shell-icons.js.map +1 -0
  23. package/dist/components/composites/settings/settings-navigation.js +1 -1
  24. package/dist/components/composites/settings/settings-navigation.js.map +1 -1
  25. package/dist/components/composites/settings/settings.d.ts +8 -14
  26. package/dist/components/composites/settings/settings.d.ts.map +1 -1
  27. package/dist/components/composites/settings/settings.js +88 -37
  28. package/dist/components/composites/settings/settings.js.map +1 -1
  29. package/dist/components/composites/terminal/terminal.js +2 -2
  30. package/dist/components/composites/terminal/terminal.js.map +1 -1
  31. package/dist/components/composites/tree/tree.d.ts +6 -0
  32. package/dist/components/composites/tree/tree.d.ts.map +1 -1
  33. package/dist/components/composites/tree/tree.js +35 -14
  34. package/dist/components/composites/tree/tree.js.map +1 -1
  35. package/dist/components/composites/workspace-switcher/index.d.ts +2 -0
  36. package/dist/components/composites/workspace-switcher/index.d.ts.map +1 -0
  37. package/dist/components/composites/workspace-switcher/index.js +2 -0
  38. package/dist/components/composites/workspace-switcher/workspace-switcher.d.ts +44 -0
  39. package/dist/components/composites/workspace-switcher/workspace-switcher.d.ts.map +1 -0
  40. package/dist/components/composites/workspace-switcher/workspace-switcher.js +157 -0
  41. package/dist/components/composites/workspace-switcher/workspace-switcher.js.map +1 -0
  42. package/dist/components/layout/shell-rail/index.d.ts +2 -0
  43. package/dist/components/layout/shell-rail/index.d.ts.map +1 -0
  44. package/dist/components/layout/shell-rail/index.js +2 -0
  45. package/dist/components/layout/shell-rail/shell-rail.d.ts +36 -0
  46. package/dist/components/layout/shell-rail/shell-rail.d.ts.map +1 -0
  47. package/dist/components/layout/shell-rail/shell-rail.js +124 -0
  48. package/dist/components/layout/shell-rail/shell-rail.js.map +1 -0
  49. package/dist/components/layout/shell-top-bar/index.d.ts +2 -0
  50. package/dist/components/layout/shell-top-bar/index.d.ts.map +1 -0
  51. package/dist/components/layout/shell-top-bar/index.js +2 -0
  52. package/dist/components/layout/shell-top-bar/shell-top-bar.d.ts +68 -0
  53. package/dist/components/layout/shell-top-bar/shell-top-bar.d.ts.map +1 -0
  54. package/dist/components/layout/shell-top-bar/shell-top-bar.js +135 -0
  55. package/dist/components/layout/shell-top-bar/shell-top-bar.js.map +1 -0
  56. package/dist/index.d.ts +5 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +8 -1
  59. package/dist/r/accent-picker.json +25 -0
  60. package/dist/r/icon-picker.json +37 -0
  61. package/dist/r/registry.json +141 -1
  62. package/dist/r/settings.json +3 -1
  63. package/dist/r/shell-rail.json +27 -0
  64. package/dist/r/shell-top-bar.json +23 -0
  65. package/dist/r/src/components/composites/accent-picker/accent-picker.tsx +106 -0
  66. package/dist/r/src/components/composites/accent-picker/index.ts +1 -0
  67. package/dist/r/src/components/composites/icon-picker/icon-picker.tsx +246 -0
  68. package/dist/r/src/components/composites/icon-picker/image-data-url.ts +107 -0
  69. package/dist/r/src/components/composites/icon-picker/index.ts +25 -0
  70. package/dist/r/src/components/composites/icon-picker/shell-icons.ts +166 -0
  71. package/dist/r/src/components/composites/settings/settings-navigation.tsx +1 -1
  72. package/dist/r/src/components/composites/settings/settings.tsx +40 -5
  73. package/dist/r/src/components/composites/tree/tree.tsx +25 -0
  74. package/dist/r/src/components/composites/workspace-switcher/index.ts +5 -0
  75. package/dist/r/src/components/composites/workspace-switcher/workspace-switcher.tsx +128 -0
  76. package/dist/r/src/components/layout/shell-rail/index.ts +1 -0
  77. package/dist/r/src/components/layout/shell-rail/shell-rail.tsx +120 -0
  78. package/dist/r/src/components/layout/shell-top-bar/index.ts +1 -0
  79. package/dist/r/src/components/layout/shell-top-bar/shell-top-bar.tsx +185 -0
  80. package/dist/r/workspace-switcher.json +26 -0
  81. package/package.json +1 -1
  82. package/registry.json +141 -1
  83. package/src/components/composites/accent-picker/accent-picker.tsx +106 -0
  84. package/src/components/composites/accent-picker/index.ts +1 -0
  85. package/src/components/composites/icon-picker/icon-picker.tsx +246 -0
  86. package/src/components/composites/icon-picker/image-data-url.ts +107 -0
  87. package/src/components/composites/icon-picker/index.ts +25 -0
  88. package/src/components/composites/icon-picker/shell-icons.ts +166 -0
  89. package/src/components/composites/settings/settings-navigation.tsx +1 -1
  90. package/src/components/composites/settings/settings.tsx +40 -5
  91. package/src/components/composites/tree/tree.tsx +25 -0
  92. package/src/components/composites/workspace-switcher/index.ts +5 -0
  93. package/src/components/composites/workspace-switcher/workspace-switcher.tsx +128 -0
  94. package/src/components/layout/shell-rail/index.ts +1 -0
  95. package/src/components/layout/shell-rail/shell-rail.tsx +120 -0
  96. package/src/components/layout/shell-top-bar/index.ts +1 -0
  97. package/src/components/layout/shell-top-bar/shell-top-bar.tsx +185 -0
  98. package/src/index.ts +5 -0
@@ -0,0 +1,246 @@
1
+ import { RadioGroup as Radio } from '@kobalte/core/radio-group'
2
+ import { ChevronDown, ImageUp, Trash2 } from 'lucide-solid'
3
+ import type { JSX } from 'solid-js'
4
+ import { For, Show, createSignal, createUniqueId, splitProps } from 'solid-js'
5
+ import { Dynamic } from 'solid-js/web'
6
+ import { cn } from '#lib/utils'
7
+ import { Button } from '../../ui/button'
8
+ import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover'
9
+ import {
10
+ MarkImageError,
11
+ markImageFromFile,
12
+ markImageProblemCopy,
13
+ MARK_IMAGE_MAX_SOURCE_BYTES,
14
+ } from './image-data-url'
15
+ import { resolveShellIcon, shellIconEntries, shellIconLabel } from './shell-icons'
16
+
17
+ /**
18
+ * IconPicker.
19
+ *
20
+ * The control behind "choose an icon" for a workspace or a project: a grid of
21
+ * curated glyphs, and optionally an uploaded picture in their place.
22
+ *
23
+ * It is a single-choice group. The glyph grid is a Kobalte radio group, so
24
+ * arrow keys move the choice and Tab enters and leaves the grid as one stop,
25
+ * which is the contract a 56-cell grid needs to stay usable from a keyboard. An
26
+ * uploaded image is a separate state, not a 57th cell: while `image` is set no
27
+ * glyph is checked, and choosing a glyph asks the caller to drop the image, so
28
+ * the stored mark is never both.
29
+ *
30
+ * The picker does not persist anything. The caller owns `value` and `image`
31
+ * and saves them with the rest of its form; the image arrives already resized
32
+ * to the stored square (see `markImageFromFile`).
33
+ */
34
+ export type IconPickerProps = {
35
+ /** The group's accessible name, e.g. "Workspace icon". */
36
+ label: string
37
+ /** The checked icon key; ignored while `image` is set. */
38
+ value: string | undefined
39
+ onValueChange(key: string): void
40
+ /** The key the surface treats as unset, labelled "default" in the grid. */
41
+ defaultKey?: string
42
+ /** An uploaded mark (a data URL) currently chosen in place of a glyph. */
43
+ image?: string
44
+ /**
45
+ * Called with the resized upload, or `undefined` for Remove. Omit it for a
46
+ * surface that offers glyphs only; the upload row then does not render.
47
+ */
48
+ onImageChange?(image: string | undefined): void
49
+ disabled?: boolean
50
+ class?: string
51
+ }
52
+
53
+ export function IconPicker(props: IconPickerProps) {
54
+ const inputId = createUniqueId()
55
+ const [problem, setProblem] = createSignal<string>()
56
+ const [busy, setBusy] = createSignal(false)
57
+ const [fileInput, setFileInput] = createSignal<HTMLInputElement>()
58
+
59
+ const upload = async (file: File | undefined) => {
60
+ if (!file || !props.onImageChange) return
61
+ setProblem(undefined)
62
+ setBusy(true)
63
+ try {
64
+ props.onImageChange(await markImageFromFile(file))
65
+ } catch (error) {
66
+ setProblem(
67
+ markImageProblemCopy[error instanceof MarkImageError ? error.problem : 'unreadable']
68
+ )
69
+ } finally {
70
+ setBusy(false)
71
+ }
72
+ }
73
+
74
+ return (
75
+ <div data-slot="icon-picker" class={cn('flex flex-col gap-3', props.class)}>
76
+ <Show when={props.onImageChange}>
77
+ <div class="flex items-center gap-3" data-slot="icon-picker-upload">
78
+ <span
79
+ aria-hidden="true"
80
+ class="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-muted"
81
+ >
82
+ <Show
83
+ when={props.image}
84
+ fallback={<ImageUp class="size-5 text-muted-foreground" aria-hidden="true" />}
85
+ >
86
+ {(src) => <img src={src()} alt="" class="size-full object-cover" />}
87
+ </Show>
88
+ </span>
89
+ <div class="flex flex-wrap items-center gap-2">
90
+ <input
91
+ ref={setFileInput}
92
+ id={inputId}
93
+ type="file"
94
+ class="sr-only"
95
+ tabIndex={-1}
96
+ // The visible button drives this input; hide the duplicate from assistive
97
+ // technology but keep a name so the form-label rule is satisfied.
98
+ aria-hidden="true"
99
+ aria-label={props.image ? 'Replace image' : 'Upload image'}
100
+ accept="image/png,image/jpeg,image/webp,image/gif"
101
+ disabled={props.disabled}
102
+ onChange={(event) => {
103
+ const input = event.currentTarget
104
+ void upload(input.files?.[0])
105
+ // Clearing the value lets the same file be chosen again after Remove.
106
+ input.value = ''
107
+ }}
108
+ />
109
+ <Button
110
+ type="button"
111
+ variant="outline"
112
+ size="sm"
113
+ disabled={props.disabled || busy()}
114
+ onClick={() => fileInput()?.click()}
115
+ >
116
+ <ImageUp aria-hidden="true" />
117
+ {props.image ? 'Replace image' : 'Upload image'}
118
+ </Button>
119
+ <Show when={props.image}>
120
+ <Button
121
+ type="button"
122
+ variant="ghost"
123
+ size="sm"
124
+ disabled={props.disabled || busy()}
125
+ onClick={() => {
126
+ setProblem(undefined)
127
+ props.onImageChange?.(undefined)
128
+ }}
129
+ >
130
+ <Trash2 aria-hidden="true" />
131
+ Remove image
132
+ </Button>
133
+ </Show>
134
+ </div>
135
+ </div>
136
+ <Show when={problem()}>
137
+ {(message) => (
138
+ <p role="alert" class="text-sm text-destructive">
139
+ {message()}
140
+ </p>
141
+ )}
142
+ </Show>
143
+ <p class="text-xs text-muted-foreground">
144
+ PNG, JPEG, WebP or GIF up to {MARK_IMAGE_MAX_SOURCE_BYTES / 1024 / 1024} MB. It is cropped
145
+ to a square.
146
+ </p>
147
+ </Show>
148
+ <Radio
149
+ aria-label={props.label}
150
+ value={props.image ? '' : (props.value ?? '')}
151
+ disabled={props.disabled}
152
+ onChange={(key) => {
153
+ if (key) props.onValueChange(key)
154
+ }}
155
+ class="grid grid-cols-[repeat(auto-fill,minmax(2.5rem,1fr))] gap-1.5"
156
+ >
157
+ <For each={shellIconEntries}>
158
+ {(entry) => {
159
+ const Icon = resolveShellIcon(entry.key)
160
+ return (
161
+ <Radio.Item value={entry.key} class="contents">
162
+ <Radio.ItemInput class="peer" />
163
+ <Radio.ItemLabel
164
+ title={entry.key === props.defaultKey ? `${entry.label} (default)` : entry.label}
165
+ class={cn(
166
+ 'flex size-10 cursor-pointer items-center justify-center rounded-lg border border-border bg-card text-muted-foreground',
167
+ 'transition-colors ease-out',
168
+ 'hover:bg-surface-hover hover:text-foreground',
169
+ 'peer-focus-visible:border-ring peer-focus-visible:ring-3 peer-focus-visible:ring-primary-subtle',
170
+ 'data-[checked]:border-primary data-[checked]:bg-primary-subtle data-[checked]:text-primary',
171
+ 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50'
172
+ )}
173
+ >
174
+ <Icon class="size-5" aria-hidden="true" />
175
+ <span class="sr-only">
176
+ {entry.label}
177
+ {entry.key === props.defaultKey ? ' (default)' : ''}
178
+ </span>
179
+ </Radio.ItemLabel>
180
+ </Radio.Item>
181
+ )
182
+ }}
183
+ </For>
184
+ </Radio>
185
+ </div>
186
+ )
187
+ }
188
+
189
+ /**
190
+ * IconPickerDropdown.
191
+ *
192
+ * The compact form of the picker for a settings row: one button showing the
193
+ * current mark, opening the full grid (and the upload row, when the host offers
194
+ * it) in a popover. Choosing a glyph applies it and closes the popover; an
195
+ * upload or Remove leaves it open so the result is visible. The host owns the
196
+ * value exactly as it does for `IconPicker`.
197
+ */
198
+ export type IconPickerDropdownProps = IconPickerProps & {
199
+ /** The mark shown on the trigger; defaults to the checked glyph or the uploaded image. */
200
+ trigger?: JSX.Element
201
+ }
202
+
203
+ export function IconPickerDropdown(props: IconPickerDropdownProps) {
204
+ const [open, setOpen] = createSignal(false)
205
+ const [local, picker] = splitProps(props, ['trigger', 'class'])
206
+ const Current = () => resolveShellIcon(props.value ?? props.defaultKey ?? '')
207
+ return (
208
+ <Popover open={open()} onOpenChange={setOpen} placement="bottom-start">
209
+ <PopoverTrigger
210
+ as={Button}
211
+ type="button"
212
+ variant="outline"
213
+ data-slot="icon-picker-trigger"
214
+ aria-label={`${props.label}: ${props.image ? 'uploaded image' : shellIconLabel(props.value ?? props.defaultKey ?? '')}`}
215
+ disabled={props.disabled}
216
+ class={cn('gap-2', local.class)}
217
+ >
218
+ <span aria-hidden="true" class="flex size-5 items-center justify-center overflow-hidden">
219
+ <Show
220
+ when={local.trigger}
221
+ fallback={
222
+ <Show
223
+ when={props.image}
224
+ fallback={<Dynamic component={Current()} class="size-4" aria-hidden="true" />}
225
+ >
226
+ {(src) => <img src={src()} alt="" class="size-full rounded-sm object-cover" />}
227
+ </Show>
228
+ }
229
+ >
230
+ {local.trigger}
231
+ </Show>
232
+ </span>
233
+ <ChevronDown class="size-4 text-muted-foreground" aria-hidden="true" />
234
+ </PopoverTrigger>
235
+ <PopoverContent class="w-[min(22rem,var(--kb-popper-available-width))]">
236
+ <IconPicker
237
+ {...picker}
238
+ onValueChange={(key) => {
239
+ picker.onValueChange(key)
240
+ setOpen(false)
241
+ }}
242
+ />
243
+ </PopoverContent>
244
+ </Popover>
245
+ )
246
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Turning an uploaded picture into the small square mark a workspace stores.
3
+ *
4
+ * The mark travels with the workspace record (it is shown in every list the
5
+ * workspace appears in), so it is resized to a fixed square and re-encoded
6
+ * before it leaves the browser rather than stored as the user's original file.
7
+ * Re-encoding also drops metadata and any embedded script an SVG could carry:
8
+ * only raster images are accepted and the result is always a `data:image/webp`
9
+ * or `data:image/png` URL.
10
+ */
11
+
12
+ /** The edge, in pixels, of the stored square. 2x the largest tile the apps draw. */
13
+ export const MARK_IMAGE_SIZE = 128
14
+ /** The largest source file read, before resizing. */
15
+ export const MARK_IMAGE_MAX_SOURCE_BYTES = 8 * 1024 * 1024
16
+ /** The largest encoded result kept; a stored mark never exceeds this many characters. */
17
+ export const MARK_IMAGE_MAX_DATA_URL_LENGTH = 48 * 1024
18
+
19
+ const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
20
+
21
+ export type MarkImageProblem = 'type' | 'size' | 'unreadable' | 'too-large'
22
+
23
+ /** Thrown by {@link markImageFromFile}; `problem` keys {@link markImageProblemCopy}. */
24
+ export class MarkImageError extends Error {
25
+ readonly problem: MarkImageProblem
26
+ constructor(problem: MarkImageProblem) {
27
+ super(problem)
28
+ this.name = 'MarkImageError'
29
+ this.problem = problem
30
+ }
31
+ }
32
+
33
+ export const markImageProblemCopy: Readonly<Record<MarkImageProblem, string>> = {
34
+ type: 'Choose a PNG, JPEG, WebP or GIF image.',
35
+ size: 'That image is larger than 8 MB. Choose a smaller one.',
36
+ unreadable: 'That image could not be read. Try a different file.',
37
+ 'too-large': 'That image is too detailed to store as a mark. Try a simpler one.',
38
+ }
39
+
40
+ /** Whether a stored value is a mark image this module could have produced. */
41
+ export function isMarkImageDataUrl(value: unknown): value is string {
42
+ return (
43
+ typeof value === 'string' &&
44
+ value.length <= MARK_IMAGE_MAX_DATA_URL_LENGTH &&
45
+ /^data:image\/(?:webp|png);base64,[A-Za-z0-9+/]+={0,2}$/.test(value)
46
+ )
47
+ }
48
+
49
+ /** Pre-flight checks that need no decoding; `undefined` means the file may be read. */
50
+ export function checkMarkImageFile(
51
+ file: Readonly<{ type: string; size: number }>
52
+ ): MarkImageProblem | undefined {
53
+ if (!ACCEPTED_TYPES.has(file.type)) return 'type'
54
+ if (file.size > MARK_IMAGE_MAX_SOURCE_BYTES) return 'size'
55
+ return undefined
56
+ }
57
+
58
+ /** The source rectangle of the largest centred square, so the mark is a cover crop. */
59
+ export function centredSquare(
60
+ width: number,
61
+ height: number
62
+ ): Readonly<{ x: number; y: number; side: number }> {
63
+ const side = Math.min(width, height)
64
+ return { x: Math.floor((width - side) / 2), y: Math.floor((height - side) / 2), side }
65
+ }
66
+
67
+ /**
68
+ * Resize an image file to the stored mark. Rejects with a {@link MarkImageError}
69
+ * so the caller can show `markImageProblemCopy[error.problem]`.
70
+ */
71
+ export async function markImageFromFile(file: File): Promise<string> {
72
+ const problem = checkMarkImageFile(file)
73
+ if (problem) throw new MarkImageError(problem)
74
+ let bitmap: ImageBitmap
75
+ try {
76
+ bitmap = await createImageBitmap(file)
77
+ } catch {
78
+ throw new MarkImageError('unreadable')
79
+ }
80
+ try {
81
+ const canvas = document.createElement('canvas')
82
+ canvas.width = MARK_IMAGE_SIZE
83
+ canvas.height = MARK_IMAGE_SIZE
84
+ const context = canvas.getContext('2d')
85
+ if (!context) throw new MarkImageError('unreadable')
86
+ const crop = centredSquare(bitmap.width, bitmap.height)
87
+ context.drawImage(
88
+ bitmap,
89
+ crop.x,
90
+ crop.y,
91
+ crop.side,
92
+ crop.side,
93
+ 0,
94
+ 0,
95
+ MARK_IMAGE_SIZE,
96
+ MARK_IMAGE_SIZE
97
+ )
98
+ // WebP where the browser can encode it; PNG is the always-available fallback.
99
+ for (const type of ['image/webp', 'image/png']) {
100
+ const url = canvas.toDataURL(type, 0.85)
101
+ if (url.startsWith(`data:${type}`) && isMarkImageDataUrl(url)) return url
102
+ }
103
+ throw new MarkImageError('too-large')
104
+ } finally {
105
+ bitmap.close()
106
+ }
107
+ }
@@ -0,0 +1,25 @@
1
+ export {
2
+ IconPicker,
3
+ IconPickerDropdown,
4
+ type IconPickerDropdownProps,
5
+ type IconPickerProps,
6
+ } from './icon-picker'
7
+ export {
8
+ MARK_IMAGE_MAX_DATA_URL_LENGTH,
9
+ MARK_IMAGE_MAX_SOURCE_BYTES,
10
+ MARK_IMAGE_SIZE,
11
+ MarkImageError,
12
+ centredSquare,
13
+ checkMarkImageFile,
14
+ isMarkImageDataUrl,
15
+ markImageFromFile,
16
+ markImageProblemCopy,
17
+ type MarkImageProblem,
18
+ } from './image-data-url'
19
+ export {
20
+ isShellIconKey,
21
+ resolveShellIcon,
22
+ shellIconEntries,
23
+ shellIconLabel,
24
+ type ShellIconKey,
25
+ } from './shell-icons'
@@ -0,0 +1,166 @@
1
+ import {
2
+ Anchor,
3
+ Bot,
4
+ Box,
5
+ BookOpen,
6
+ Brain,
7
+ Briefcase,
8
+ Building2,
9
+ Camera,
10
+ ChartColumn,
11
+ Cloud,
12
+ Code,
13
+ Coffee,
14
+ Compass,
15
+ Cpu,
16
+ Database,
17
+ Dumbbell,
18
+ Flag,
19
+ Flame,
20
+ FlaskConical,
21
+ Folder,
22
+ FolderGit2,
23
+ Gamepad2,
24
+ Gem,
25
+ Globe,
26
+ GraduationCap,
27
+ Hammer,
28
+ Heart,
29
+ House,
30
+ Key,
31
+ Layers,
32
+ Leaf,
33
+ Lightbulb,
34
+ type LucideIcon,
35
+ Map as MapIcon,
36
+ Megaphone,
37
+ MessagesSquare,
38
+ Moon,
39
+ Mountain,
40
+ Music,
41
+ Newspaper,
42
+ Package,
43
+ Palette,
44
+ Plane,
45
+ Puzzle,
46
+ Rocket,
47
+ Server,
48
+ Shapes,
49
+ Shield,
50
+ ShoppingCart,
51
+ Sparkles,
52
+ Star,
53
+ Sun,
54
+ Target,
55
+ Terminal,
56
+ Trophy,
57
+ Users,
58
+ Utensils,
59
+ Wallet,
60
+ Wrench,
61
+ Zap,
62
+ } from 'lucide-solid'
63
+
64
+ /**
65
+ * The icons a workspace or a project may choose for itself.
66
+ *
67
+ * A curated set rather than the whole Lucide catalogue, for two reasons the
68
+ * picker depends on: the keys are persisted, so each one is a promise that the
69
+ * glyph keeps existing, and every icon here is imported by name so a consumer's
70
+ * bundle pays for these few dozen glyphs and not for the library.
71
+ *
72
+ * `home`, `box` and `folder` lead the list because they are the defaults the
73
+ * apps already use: Home workspaces carry `home`, other workspaces `box` and
74
+ * projects `folder`. Keys are lowercase kebab-case so they are valid project
75
+ * `iconKey`s as well as workspace logo values.
76
+ */
77
+ export const shellIconEntries = [
78
+ { key: 'home', label: 'Home', icon: House },
79
+ { key: 'box', label: 'Box', icon: Box },
80
+ { key: 'folder', label: 'Folder', icon: Folder },
81
+ { key: 'git-folder', label: 'Repository', icon: FolderGit2 },
82
+ { key: 'briefcase', label: 'Briefcase', icon: Briefcase },
83
+ { key: 'building', label: 'Building', icon: Building2 },
84
+ { key: 'users', label: 'Team', icon: Users },
85
+ { key: 'rocket', label: 'Rocket', icon: Rocket },
86
+ { key: 'star', label: 'Star', icon: Star },
87
+ { key: 'heart', label: 'Heart', icon: Heart },
88
+ { key: 'code', label: 'Code', icon: Code },
89
+ { key: 'terminal', label: 'Terminal', icon: Terminal },
90
+ { key: 'cpu', label: 'Chip', icon: Cpu },
91
+ { key: 'server', label: 'Server', icon: Server },
92
+ { key: 'database', label: 'Database', icon: Database },
93
+ { key: 'cloud', label: 'Cloud', icon: Cloud },
94
+ { key: 'globe', label: 'Globe', icon: Globe },
95
+ { key: 'bot', label: 'Agent', icon: Bot },
96
+ { key: 'brain', label: 'Brain', icon: Brain },
97
+ { key: 'sparkles', label: 'Sparkles', icon: Sparkles },
98
+ { key: 'lightbulb', label: 'Idea', icon: Lightbulb },
99
+ { key: 'flask', label: 'Lab', icon: FlaskConical },
100
+ { key: 'book', label: 'Book', icon: BookOpen },
101
+ { key: 'graduation', label: 'Study', icon: GraduationCap },
102
+ { key: 'newspaper', label: 'News', icon: Newspaper },
103
+ { key: 'megaphone', label: 'Marketing', icon: Megaphone },
104
+ { key: 'chart', label: 'Chart', icon: ChartColumn },
105
+ { key: 'wallet', label: 'Finance', icon: Wallet },
106
+ { key: 'cart', label: 'Shop', icon: ShoppingCart },
107
+ { key: 'package', label: 'Package', icon: Package },
108
+ { key: 'layers', label: 'Layers', icon: Layers },
109
+ { key: 'puzzle', label: 'Puzzle', icon: Puzzle },
110
+ { key: 'shield', label: 'Security', icon: Shield },
111
+ { key: 'key', label: 'Key', icon: Key },
112
+ { key: 'wrench', label: 'Tools', icon: Wrench },
113
+ { key: 'hammer', label: 'Build', icon: Hammer },
114
+ { key: 'zap', label: 'Bolt', icon: Zap },
115
+ { key: 'flame', label: 'Flame', icon: Flame },
116
+ { key: 'target', label: 'Target', icon: Target },
117
+ { key: 'trophy', label: 'Trophy', icon: Trophy },
118
+ { key: 'flag', label: 'Flag', icon: Flag },
119
+ { key: 'compass', label: 'Compass', icon: Compass },
120
+ { key: 'map', label: 'Map', icon: MapIcon },
121
+ { key: 'plane', label: 'Travel', icon: Plane },
122
+ { key: 'anchor', label: 'Anchor', icon: Anchor },
123
+ { key: 'mountain', label: 'Mountain', icon: Mountain },
124
+ { key: 'leaf', label: 'Garden', icon: Leaf },
125
+ { key: 'sun', label: 'Sun', icon: Sun },
126
+ { key: 'moon', label: 'Moon', icon: Moon },
127
+ { key: 'palette', label: 'Design', icon: Palette },
128
+ { key: 'camera', label: 'Camera', icon: Camera },
129
+ { key: 'music', label: 'Music', icon: Music },
130
+ { key: 'gamepad', label: 'Games', icon: Gamepad2 },
131
+ { key: 'coffee', label: 'Coffee', icon: Coffee },
132
+ { key: 'utensils', label: 'Kitchen', icon: Utensils },
133
+ { key: 'dumbbell', label: 'Gym', icon: Dumbbell },
134
+ { key: 'gem', label: 'Gem', icon: Gem },
135
+ { key: 'messages', label: 'Chat', icon: MessagesSquare },
136
+ { key: 'shapes', label: 'Shapes', icon: Shapes },
137
+ ] as const satisfies readonly Readonly<{ key: string; label: string; icon: LucideIcon }>[]
138
+
139
+ export type ShellIconKey = (typeof shellIconEntries)[number]['key']
140
+
141
+ const shellIconByKey: ReadonlyMap<string, LucideIcon> = new Map(
142
+ shellIconEntries.map((entry) => [entry.key, entry.icon])
143
+ )
144
+
145
+ /** Whether a stored string names an icon in the curated set. */
146
+ export function isShellIconKey(value: string): value is ShellIconKey {
147
+ return shellIconByKey.has(value)
148
+ }
149
+
150
+ /**
151
+ * The glyph for a stored key, or `fallback` when the key is not in the set.
152
+ * Callers pass the fallback their surface wants (a workspace falls back to
153
+ * `box`, a project to `folder`), so an unknown key degrades to the default
154
+ * rather than to an empty tile.
155
+ */
156
+ export function resolveShellIcon(
157
+ key: string | null | undefined,
158
+ fallback: ShellIconKey = 'folder'
159
+ ): LucideIcon {
160
+ return (key ? shellIconByKey.get(key) : undefined) ?? shellIconByKey.get(fallback)!
161
+ }
162
+
163
+ /** The picker's accessible name for a key. */
164
+ export function shellIconLabel(key: string): string {
165
+ return shellIconEntries.find((entry) => entry.key === key)?.label ?? key
166
+ }
@@ -182,7 +182,7 @@ export function SettingsNavigation(props: SettingsNavigationProps) {
182
182
  class={cn(
183
183
  sidebarNavItemClass,
184
184
  sidebarNavItemStateClass.idle,
185
- 'h-row-lg justify-start border-0 px-3 py-2 text-left data-[selected]:bg-primary-subtle data-[selected]:text-foreground',
185
+ 'h-row-lg justify-start border-0 px-3 py-2 text-left text-foreground data-[selected]:bg-primary-subtle',
186
186
  strip() ? 'w-auto shrink-0' : 'w-full'
187
187
  )}
188
188
  >
@@ -1,7 +1,9 @@
1
+ import { CircleHelp } from 'lucide-solid'
1
2
  import type { ComponentProps, JSX } from 'solid-js'
2
3
  import { createUniqueId, Show, splitProps } from 'solid-js'
3
4
  import { createFormFieldContext, FormFieldContext } from '#lib/form-field'
4
5
  import { cn } from '#lib/utils'
6
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip'
5
7
  import { Heading } from '../../ui/typography'
6
8
 
7
9
  /**
@@ -18,10 +20,29 @@ import { Heading } from '../../ui/typography'
18
20
  * the first control inside it. `SettingsField` is the visual stacked layout;
19
21
  * use `FormField` when the control needs label, help, or validation wiring.
20
22
  */
23
+ function HelpTip(props: { label: string; help: string }) {
24
+ return (
25
+ <Tooltip>
26
+ <TooltipTrigger
27
+ as="button"
28
+ type="button"
29
+ data-slot="settings-row-help"
30
+ aria-label={`About ${props.label}`}
31
+ class="inline-flex size-4 items-center justify-center rounded-full text-muted-foreground hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
32
+ >
33
+ <CircleHelp class="size-3.5" aria-hidden="true" />
34
+ </TooltipTrigger>
35
+ <TooltipContent>{props.help}</TooltipContent>
36
+ </Tooltip>
37
+ )
38
+ }
39
+
21
40
  export function SettingsSection(
22
41
  props: ComponentProps<'section'> & {
23
42
  title: string
24
43
  description?: string
44
+ /** A short explanation behind a help icon beside the title; prefer it to a long description. */
45
+ help?: string
25
46
  /** A control for the section as a whole, e.g. a master toggle. */
26
47
  action?: JSX.Element
27
48
  /** Use a spaced content stack for fields, alerts, and cards instead of divided rows. */
@@ -32,6 +53,7 @@ export function SettingsSection(
32
53
  'class',
33
54
  'title',
34
55
  'description',
56
+ 'help',
35
57
  'action',
36
58
  'bodyLayout',
37
59
  'children',
@@ -45,9 +67,12 @@ export function SettingsSection(
45
67
  >
46
68
  <div class="flex items-start justify-between gap-4">
47
69
  <div class="flex min-w-0 flex-col gap-0.5">
48
- <Heading as="h2" size="card">
49
- {local.title}
50
- </Heading>
70
+ <div class="flex items-center gap-1.5">
71
+ <Heading as="h2" size="card">
72
+ {local.title}
73
+ </Heading>
74
+ <Show when={local.help}>{(help) => <HelpTip label={local.title} help={help()} />}</Show>
75
+ </div>
51
76
  <Show when={local.description}>
52
77
  <p class="max-w-prose text-sm text-muted-foreground text-pretty">{local.description}</p>
53
78
  </Show>
@@ -82,6 +107,12 @@ export function SettingsRow(
82
107
  props: ComponentProps<'div'> & {
83
108
  label: string
84
109
  description?: string
110
+ /**
111
+ * A short explanation behind a help icon beside the label. Prefer it to a
112
+ * long description: the row stays quiet and the explanation is one hover or
113
+ * focus away.
114
+ */
115
+ help?: string
85
116
  /** Render a vertical stack when the control needs the full width. */
86
117
  orientation?: 'horizontal' | 'vertical'
87
118
  /** Hide the divider this row would otherwise contribute. */
@@ -100,6 +131,7 @@ export function SettingsRow(
100
131
  'class',
101
132
  'label',
102
133
  'description',
134
+ 'help',
103
135
  'orientation',
104
136
  'bare',
105
137
  'flush',
@@ -132,8 +164,11 @@ export function SettingsRow(
132
164
  {...rest}
133
165
  >
134
166
  <div class="flex min-w-0 flex-col gap-0.5">
135
- <div id={labelId} data-slot="settings-row-label" class="text-sm font-medium">
136
- {local.label}
167
+ <div class="flex items-center gap-1.5">
168
+ <div id={labelId} data-slot="settings-row-label" class="text-sm font-medium">
169
+ {local.label}
170
+ </div>
171
+ <Show when={local.help}>{(help) => <HelpTip label={local.label} help={help()} />}</Show>
137
172
  </div>
138
173
  <Show when={local.description}>
139
174
  <p id={descriptionId} class="max-w-prose text-sm text-muted-foreground text-pretty">