@adea-ai/ui 0.142.4 → 0.143.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.
- package/dist/components/composites/accent-picker/accent-picker.d.ts +28 -0
- package/dist/components/composites/accent-picker/accent-picker.d.ts.map +1 -0
- package/dist/components/composites/accent-picker/accent-picker.js +97 -0
- package/dist/components/composites/accent-picker/accent-picker.js.map +1 -0
- package/dist/components/composites/accent-picker/index.d.ts +2 -0
- package/dist/components/composites/accent-picker/index.d.ts.map +1 -0
- package/dist/components/composites/accent-picker/index.js +2 -0
- package/dist/components/composites/icon-picker/icon-picker.d.ts +37 -0
- package/dist/components/composites/icon-picker/icon-picker.d.ts.map +1 -0
- package/dist/components/composites/icon-picker/icon-picker.js +187 -0
- package/dist/components/composites/icon-picker/icon-picker.js.map +1 -0
- package/dist/components/composites/icon-picker/image-data-url.d.ts +42 -0
- package/dist/components/composites/icon-picker/image-data-url.d.ts.map +1 -0
- package/dist/components/composites/icon-picker/image-data-url.js +90 -0
- package/dist/components/composites/icon-picker/image-data-url.js.map +1 -0
- package/dist/components/composites/icon-picker/index.d.ts +4 -0
- package/dist/components/composites/icon-picker/index.d.ts.map +1 -0
- package/dist/components/composites/icon-picker/index.js +4 -0
- package/dist/components/composites/icon-picker/shell-icons.d.ts +264 -0
- package/dist/components/composites/icon-picker/shell-icons.d.ts.map +1 -0
- package/dist/components/composites/icon-picker/shell-icons.js +334 -0
- package/dist/components/composites/icon-picker/shell-icons.js.map +1 -0
- package/dist/components/composites/terminal/terminal.js +2 -2
- package/dist/components/composites/terminal/terminal.js.map +1 -1
- package/dist/components/composites/workspace-switcher/index.d.ts +2 -0
- package/dist/components/composites/workspace-switcher/index.d.ts.map +1 -0
- package/dist/components/composites/workspace-switcher/index.js +2 -0
- package/dist/components/composites/workspace-switcher/workspace-switcher.d.ts +44 -0
- package/dist/components/composites/workspace-switcher/workspace-switcher.d.ts.map +1 -0
- package/dist/components/composites/workspace-switcher/workspace-switcher.js +157 -0
- package/dist/components/composites/workspace-switcher/workspace-switcher.js.map +1 -0
- package/dist/components/layout/shell-rail/index.d.ts +2 -0
- package/dist/components/layout/shell-rail/index.d.ts.map +1 -0
- package/dist/components/layout/shell-rail/index.js +2 -0
- package/dist/components/layout/shell-rail/shell-rail.d.ts +36 -0
- package/dist/components/layout/shell-rail/shell-rail.d.ts.map +1 -0
- package/dist/components/layout/shell-rail/shell-rail.js +124 -0
- package/dist/components/layout/shell-rail/shell-rail.js.map +1 -0
- package/dist/components/layout/shell-top-bar/index.d.ts +2 -0
- package/dist/components/layout/shell-top-bar/index.d.ts.map +1 -0
- package/dist/components/layout/shell-top-bar/index.js +2 -0
- package/dist/components/layout/shell-top-bar/shell-top-bar.d.ts +68 -0
- package/dist/components/layout/shell-top-bar/shell-top-bar.d.ts.map +1 -0
- package/dist/components/layout/shell-top-bar/shell-top-bar.js +135 -0
- package/dist/components/layout/shell-top-bar/shell-top-bar.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/r/accent-picker.json +25 -0
- package/dist/r/icon-picker.json +36 -0
- package/dist/r/registry.json +137 -0
- package/dist/r/shell-rail.json +27 -0
- package/dist/r/shell-top-bar.json +23 -0
- package/dist/r/src/components/composites/accent-picker/accent-picker.tsx +85 -0
- package/dist/r/src/components/composites/accent-picker/index.ts +1 -0
- package/dist/r/src/components/composites/icon-picker/icon-picker.tsx +184 -0
- package/dist/r/src/components/composites/icon-picker/image-data-url.ts +107 -0
- package/dist/r/src/components/composites/icon-picker/index.ts +20 -0
- package/dist/r/src/components/composites/icon-picker/shell-icons.ts +166 -0
- package/dist/r/src/components/composites/workspace-switcher/index.ts +5 -0
- package/dist/r/src/components/composites/workspace-switcher/workspace-switcher.tsx +128 -0
- package/dist/r/src/components/layout/shell-rail/index.ts +1 -0
- package/dist/r/src/components/layout/shell-rail/shell-rail.tsx +120 -0
- package/dist/r/src/components/layout/shell-top-bar/index.ts +1 -0
- package/dist/r/src/components/layout/shell-top-bar/shell-top-bar.tsx +185 -0
- package/dist/r/workspace-switcher.json +26 -0
- package/package.json +1 -1
- package/registry.json +137 -0
- package/src/components/composites/accent-picker/accent-picker.tsx +85 -0
- package/src/components/composites/accent-picker/index.ts +1 -0
- package/src/components/composites/icon-picker/icon-picker.tsx +184 -0
- package/src/components/composites/icon-picker/image-data-url.ts +107 -0
- package/src/components/composites/icon-picker/index.ts +20 -0
- package/src/components/composites/icon-picker/shell-icons.ts +166 -0
- package/src/components/composites/workspace-switcher/index.ts +5 -0
- package/src/components/composites/workspace-switcher/workspace-switcher.tsx +128 -0
- package/src/components/layout/shell-rail/index.ts +1 -0
- package/src/components/layout/shell-rail/shell-rail.tsx +120 -0
- package/src/components/layout/shell-top-bar/index.ts +1 -0
- package/src/components/layout/shell-top-bar/shell-top-bar.tsx +185 -0
- package/src/index.ts +5 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { RadioGroup as Radio } from '@kobalte/core/radio-group'
|
|
2
|
+
import { Ban } from 'lucide-solid'
|
|
3
|
+
import type { JSX } from 'solid-js'
|
|
4
|
+
import { For } from 'solid-js'
|
|
5
|
+
import { accents } from '#lib/accent-presets'
|
|
6
|
+
import { cn } from '#lib/utils'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* AccentPicker.
|
|
10
|
+
*
|
|
11
|
+
* A row of accent swatches with a leading "none" choice, for a setting that
|
|
12
|
+
* overrides the accent it would otherwise inherit: a project's icon colour, a
|
|
13
|
+
* workspace's colour over the app's. `null` is a real value, not an empty one:
|
|
14
|
+
* it means "do not override", so the swatch row never has an unchecked state and
|
|
15
|
+
* the control can say what the inherited colour is through `inheritLabel`.
|
|
16
|
+
*
|
|
17
|
+
* The swatches are the theme's own accent presets, each carrying its light and
|
|
18
|
+
* dark colour and switching on the `dark` class, so a swatch is the colour the
|
|
19
|
+
* setting will produce in whichever appearance is in force and the picker needs
|
|
20
|
+
* no theme provider.
|
|
21
|
+
*/
|
|
22
|
+
export type AccentPickerProps = {
|
|
23
|
+
/** The group's accessible name, e.g. "Icon colour". */
|
|
24
|
+
label: string
|
|
25
|
+
/** An accent preset id, or `null` for no override. */
|
|
26
|
+
value: string | null
|
|
27
|
+
onValueChange(value: string | null): void
|
|
28
|
+
/** What `null` means here, e.g. "Inherit workspace accent". Also the first swatch's name. */
|
|
29
|
+
inheritLabel?: string
|
|
30
|
+
disabled?: boolean
|
|
31
|
+
class?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const NONE = '__none__'
|
|
35
|
+
|
|
36
|
+
export function AccentPicker(props: AccentPickerProps) {
|
|
37
|
+
return (
|
|
38
|
+
<Radio
|
|
39
|
+
data-slot="swatch-picker"
|
|
40
|
+
aria-label={props.label}
|
|
41
|
+
value={props.value ?? NONE}
|
|
42
|
+
disabled={props.disabled}
|
|
43
|
+
onChange={(id) => props.onValueChange(id === NONE ? null : id)}
|
|
44
|
+
class={cn('flex flex-wrap items-center gap-2', props.class)}
|
|
45
|
+
>
|
|
46
|
+
<SwatchItem id={NONE} label={props.inheritLabel ?? 'No colour override'}>
|
|
47
|
+
<Ban class="size-4 text-muted-foreground" aria-hidden="true" />
|
|
48
|
+
</SwatchItem>
|
|
49
|
+
<For each={accents}>
|
|
50
|
+
{(accent) => (
|
|
51
|
+
<SwatchItem id={accent.id} label={accent.label}>
|
|
52
|
+
<span
|
|
53
|
+
aria-hidden="true"
|
|
54
|
+
class="block size-full rounded-full bg-(--swatch-light) dark:bg-(--swatch-dark)"
|
|
55
|
+
style={{
|
|
56
|
+
'--swatch-light': accent.light ?? 'currentColor',
|
|
57
|
+
'--swatch-dark': accent.dark ?? accent.light ?? 'currentColor',
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
</SwatchItem>
|
|
61
|
+
)}
|
|
62
|
+
</For>
|
|
63
|
+
</Radio>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function SwatchItem(props: { id: string; label: string; children: JSX.Element }) {
|
|
68
|
+
return (
|
|
69
|
+
<Radio.Item value={props.id} data-accent-option={props.id} class="contents">
|
|
70
|
+
<Radio.ItemInput class="peer" />
|
|
71
|
+
<Radio.ItemLabel
|
|
72
|
+
title={props.label}
|
|
73
|
+
class={cn(
|
|
74
|
+
'flex size-7 cursor-pointer items-center justify-center rounded-full border border-border p-0.5',
|
|
75
|
+
'peer-focus-visible:border-ring peer-focus-visible:ring-3 peer-focus-visible:ring-primary-subtle',
|
|
76
|
+
'data-[checked]:border-primary data-[checked]:ring-1 data-[checked]:ring-primary',
|
|
77
|
+
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50'
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{props.children}
|
|
81
|
+
<span class="sr-only">{props.label}</span>
|
|
82
|
+
</Radio.ItemLabel>
|
|
83
|
+
</Radio.Item>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { AccentPicker, type AccentPickerProps } from './accent-picker'
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { RadioGroup as Radio } from '@kobalte/core/radio-group'
|
|
2
|
+
import { ImageUp, Trash2 } from 'lucide-solid'
|
|
3
|
+
import { For, Show, createSignal, createUniqueId } from 'solid-js'
|
|
4
|
+
import { cn } from '#lib/utils'
|
|
5
|
+
import { Button } from '../../ui/button'
|
|
6
|
+
import {
|
|
7
|
+
MarkImageError,
|
|
8
|
+
markImageFromFile,
|
|
9
|
+
markImageProblemCopy,
|
|
10
|
+
MARK_IMAGE_MAX_SOURCE_BYTES,
|
|
11
|
+
} from './image-data-url'
|
|
12
|
+
import { resolveShellIcon, shellIconEntries } from './shell-icons'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* IconPicker.
|
|
16
|
+
*
|
|
17
|
+
* The control behind "choose an icon" for a workspace or a project: a grid of
|
|
18
|
+
* curated glyphs, and optionally an uploaded picture in their place.
|
|
19
|
+
*
|
|
20
|
+
* It is a single-choice group. The glyph grid is a Kobalte radio group, so
|
|
21
|
+
* arrow keys move the choice and Tab enters and leaves the grid as one stop,
|
|
22
|
+
* which is the contract a 56-cell grid needs to stay usable from a keyboard. An
|
|
23
|
+
* uploaded image is a separate state, not a 57th cell: while `image` is set no
|
|
24
|
+
* glyph is checked, and choosing a glyph asks the caller to drop the image, so
|
|
25
|
+
* the stored mark is never both.
|
|
26
|
+
*
|
|
27
|
+
* The picker does not persist anything. The caller owns `value` and `image`
|
|
28
|
+
* and saves them with the rest of its form; the image arrives already resized
|
|
29
|
+
* to the stored square (see `markImageFromFile`).
|
|
30
|
+
*/
|
|
31
|
+
export type IconPickerProps = {
|
|
32
|
+
/** The group's accessible name, e.g. "Workspace icon". */
|
|
33
|
+
label: string
|
|
34
|
+
/** The checked icon key; ignored while `image` is set. */
|
|
35
|
+
value: string | undefined
|
|
36
|
+
onValueChange(key: string): void
|
|
37
|
+
/** The key the surface treats as unset, labelled "default" in the grid. */
|
|
38
|
+
defaultKey?: string
|
|
39
|
+
/** An uploaded mark (a data URL) currently chosen in place of a glyph. */
|
|
40
|
+
image?: string
|
|
41
|
+
/**
|
|
42
|
+
* Called with the resized upload, or `undefined` for Remove. Omit it for a
|
|
43
|
+
* surface that offers glyphs only; the upload row then does not render.
|
|
44
|
+
*/
|
|
45
|
+
onImageChange?(image: string | undefined): void
|
|
46
|
+
disabled?: boolean
|
|
47
|
+
class?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function IconPicker(props: IconPickerProps) {
|
|
51
|
+
const inputId = createUniqueId()
|
|
52
|
+
const [problem, setProblem] = createSignal<string>()
|
|
53
|
+
const [busy, setBusy] = createSignal(false)
|
|
54
|
+
const [fileInput, setFileInput] = createSignal<HTMLInputElement>()
|
|
55
|
+
|
|
56
|
+
const upload = async (file: File | undefined) => {
|
|
57
|
+
if (!file || !props.onImageChange) return
|
|
58
|
+
setProblem(undefined)
|
|
59
|
+
setBusy(true)
|
|
60
|
+
try {
|
|
61
|
+
props.onImageChange(await markImageFromFile(file))
|
|
62
|
+
} catch (error) {
|
|
63
|
+
setProblem(
|
|
64
|
+
markImageProblemCopy[error instanceof MarkImageError ? error.problem : 'unreadable']
|
|
65
|
+
)
|
|
66
|
+
} finally {
|
|
67
|
+
setBusy(false)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div data-slot="icon-picker" class={cn('flex flex-col gap-3', props.class)}>
|
|
73
|
+
<Show when={props.onImageChange}>
|
|
74
|
+
<div class="flex items-center gap-3" data-slot="icon-picker-upload">
|
|
75
|
+
<span
|
|
76
|
+
aria-hidden="true"
|
|
77
|
+
class="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-muted"
|
|
78
|
+
>
|
|
79
|
+
<Show
|
|
80
|
+
when={props.image}
|
|
81
|
+
fallback={<ImageUp class="size-5 text-muted-foreground" aria-hidden="true" />}
|
|
82
|
+
>
|
|
83
|
+
{(src) => <img src={src()} alt="" class="size-full object-cover" />}
|
|
84
|
+
</Show>
|
|
85
|
+
</span>
|
|
86
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
87
|
+
<input
|
|
88
|
+
ref={setFileInput}
|
|
89
|
+
id={inputId}
|
|
90
|
+
type="file"
|
|
91
|
+
class="sr-only"
|
|
92
|
+
tabIndex={-1}
|
|
93
|
+
// The visible button drives this input; hide the duplicate from assistive
|
|
94
|
+
// technology but keep a name so the form-label rule is satisfied.
|
|
95
|
+
aria-hidden="true"
|
|
96
|
+
aria-label={props.image ? 'Replace image' : 'Upload image'}
|
|
97
|
+
accept="image/png,image/jpeg,image/webp,image/gif"
|
|
98
|
+
disabled={props.disabled}
|
|
99
|
+
onChange={(event) => {
|
|
100
|
+
const input = event.currentTarget
|
|
101
|
+
void upload(input.files?.[0])
|
|
102
|
+
// Clearing the value lets the same file be chosen again after Remove.
|
|
103
|
+
input.value = ''
|
|
104
|
+
}}
|
|
105
|
+
/>
|
|
106
|
+
<Button
|
|
107
|
+
type="button"
|
|
108
|
+
variant="outline"
|
|
109
|
+
size="sm"
|
|
110
|
+
disabled={props.disabled || busy()}
|
|
111
|
+
onClick={() => fileInput()?.click()}
|
|
112
|
+
>
|
|
113
|
+
<ImageUp aria-hidden="true" />
|
|
114
|
+
{props.image ? 'Replace image' : 'Upload image'}
|
|
115
|
+
</Button>
|
|
116
|
+
<Show when={props.image}>
|
|
117
|
+
<Button
|
|
118
|
+
type="button"
|
|
119
|
+
variant="ghost"
|
|
120
|
+
size="sm"
|
|
121
|
+
disabled={props.disabled || busy()}
|
|
122
|
+
onClick={() => {
|
|
123
|
+
setProblem(undefined)
|
|
124
|
+
props.onImageChange?.(undefined)
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
<Trash2 aria-hidden="true" />
|
|
128
|
+
Remove image
|
|
129
|
+
</Button>
|
|
130
|
+
</Show>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
<Show when={problem()}>
|
|
134
|
+
{(message) => (
|
|
135
|
+
<p role="alert" class="text-sm text-destructive">
|
|
136
|
+
{message()}
|
|
137
|
+
</p>
|
|
138
|
+
)}
|
|
139
|
+
</Show>
|
|
140
|
+
<p class="text-xs text-muted-foreground">
|
|
141
|
+
PNG, JPEG, WebP or GIF up to {MARK_IMAGE_MAX_SOURCE_BYTES / 1024 / 1024} MB. It is cropped
|
|
142
|
+
to a square.
|
|
143
|
+
</p>
|
|
144
|
+
</Show>
|
|
145
|
+
<Radio
|
|
146
|
+
aria-label={props.label}
|
|
147
|
+
value={props.image ? '' : (props.value ?? '')}
|
|
148
|
+
disabled={props.disabled}
|
|
149
|
+
onChange={(key) => {
|
|
150
|
+
if (key) props.onValueChange(key)
|
|
151
|
+
}}
|
|
152
|
+
class="grid grid-cols-[repeat(auto-fill,minmax(2.5rem,1fr))] gap-1.5"
|
|
153
|
+
>
|
|
154
|
+
<For each={shellIconEntries}>
|
|
155
|
+
{(entry) => {
|
|
156
|
+
const Icon = resolveShellIcon(entry.key)
|
|
157
|
+
return (
|
|
158
|
+
<Radio.Item value={entry.key} class="contents">
|
|
159
|
+
<Radio.ItemInput class="peer" />
|
|
160
|
+
<Radio.ItemLabel
|
|
161
|
+
title={entry.key === props.defaultKey ? `${entry.label} (default)` : entry.label}
|
|
162
|
+
class={cn(
|
|
163
|
+
'flex size-10 cursor-pointer items-center justify-center rounded-lg border border-border bg-card text-muted-foreground',
|
|
164
|
+
'transition-colors ease-out',
|
|
165
|
+
'hover:bg-surface-hover hover:text-foreground',
|
|
166
|
+
'peer-focus-visible:border-ring peer-focus-visible:ring-3 peer-focus-visible:ring-primary-subtle',
|
|
167
|
+
'data-[checked]:border-primary data-[checked]:bg-primary-subtle data-[checked]:text-primary',
|
|
168
|
+
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50'
|
|
169
|
+
)}
|
|
170
|
+
>
|
|
171
|
+
<Icon class="size-5" aria-hidden="true" />
|
|
172
|
+
<span class="sr-only">
|
|
173
|
+
{entry.label}
|
|
174
|
+
{entry.key === props.defaultKey ? ' (default)' : ''}
|
|
175
|
+
</span>
|
|
176
|
+
</Radio.ItemLabel>
|
|
177
|
+
</Radio.Item>
|
|
178
|
+
)
|
|
179
|
+
}}
|
|
180
|
+
</For>
|
|
181
|
+
</Radio>
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
@@ -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,20 @@
|
|
|
1
|
+
export { IconPicker, type IconPickerProps } from './icon-picker'
|
|
2
|
+
export {
|
|
3
|
+
MARK_IMAGE_MAX_DATA_URL_LENGTH,
|
|
4
|
+
MARK_IMAGE_MAX_SOURCE_BYTES,
|
|
5
|
+
MARK_IMAGE_SIZE,
|
|
6
|
+
MarkImageError,
|
|
7
|
+
centredSquare,
|
|
8
|
+
checkMarkImageFile,
|
|
9
|
+
isMarkImageDataUrl,
|
|
10
|
+
markImageFromFile,
|
|
11
|
+
markImageProblemCopy,
|
|
12
|
+
type MarkImageProblem,
|
|
13
|
+
} from './image-data-url'
|
|
14
|
+
export {
|
|
15
|
+
isShellIconKey,
|
|
16
|
+
resolveShellIcon,
|
|
17
|
+
shellIconEntries,
|
|
18
|
+
shellIconLabel,
|
|
19
|
+
type ShellIconKey,
|
|
20
|
+
} 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
|
+
}
|