@xenide-io/the-old-ui-theme 0.9.9 → 0.9.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenide-io/the-old-ui-theme",
3
- "version": "0.9.9",
3
+ "version": "0.9.11",
4
4
  "description": "Props-driven React components and theme tokens inspired by classic interface design — optimized for Next.js apps and internal tools.",
5
5
  "author": "Xenide",
6
6
  "license": "MIT",
@@ -865,7 +865,7 @@ export function SuiteAiPanel({
865
865
  data-test="ask-ai-input"
866
866
  className="max-h-36 min-h-10 flex-1 resize-none bg-transparent px-1.5 py-2 text-sm leading-relaxed text-ph-ink outline-none placeholder:text-ph-mutedtext"
867
867
  />
868
- {loading ? (
868
+ {waiting ? (
869
869
  <Tooltip content="Stop generating">
870
870
  <button
871
871
  type="button"
@@ -877,15 +877,6 @@ export function SuiteAiPanel({
877
877
  <Square className="h-3.5 w-3.5 fill-current" />
878
878
  </button>
879
879
  </Tooltip>
880
- ) : isWaitingForReply ? (
881
- <span
882
- className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-ph-muted text-ph-subtle"
883
- data-test="ask-ai-waiting"
884
- role="status"
885
- aria-label="Shelly is responding"
886
- >
887
- <Spinner className="h-4 w-4 animate-spin" />
888
- </span>
889
880
  ) : (
890
881
  <Tooltip content="Send message">
891
882
  <button
@@ -6,7 +6,7 @@ import { SuiteClientsProjectsDirectory } from "@/suite/components/suite-clients-
6
6
  describe("SuiteClientsProjectsDirectory", () => {
7
7
  afterEach(cleanup);
8
8
 
9
- it("uses roving focus and arrow keys for the icon radiogroup", async () => {
9
+ it("offers the Palette icon library instead of an RPG radiogroup", async () => {
10
10
  const user = userEvent.setup();
11
11
 
12
12
  render(
@@ -19,19 +19,11 @@ describe("SuiteClientsProjectsDirectory", () => {
19
19
  );
20
20
 
21
21
  await user.click(screen.getByRole("button", { name: "New client" }));
22
- const castle = screen.getByRole("radio", { name: "Castle" });
23
- const castleFlag = screen.getByRole("radio", { name: "Castle flag" });
24
22
 
25
- expect(castle).toHaveAttribute("tabindex", "0");
26
- expect(castleFlag).toHaveAttribute("tabindex", "-1");
27
-
28
- castle.focus();
29
- await user.keyboard("{ArrowRight}");
30
- expect(castleFlag).toHaveAttribute("aria-checked", "true");
31
- expect(castleFlag).toHaveFocus();
32
-
33
- await user.keyboard("{Home}");
34
- expect(screen.getByRole("radio", { name: "Arena" })).toHaveFocus();
23
+ expect(
24
+ screen.getByRole("button", { name: "Browse library" }),
25
+ ).toBeInTheDocument();
26
+ expect(screen.queryByRole("radio")).toBeNull();
35
27
  });
36
28
 
37
29
  it("moves focus between the mobile client list and detail view", async () => {
@@ -6,12 +6,13 @@ import {
6
6
  useRef,
7
7
  useState,
8
8
  type KeyboardEvent,
9
+ type ReactNode,
9
10
  } from "react";
10
11
  import {
11
12
  ArrowLeft,
12
- MediaImagePlus as ImagePlus,
13
13
  MoreHoriz as MoreHorizontal,
14
14
  Plus,
15
+ Prohibition,
15
16
  Search,
16
17
  Trash,
17
18
  } from "iconoir-react";
@@ -26,6 +27,8 @@ import {
26
27
  Select,
27
28
  } from "@/components/ui";
28
29
  import { cn } from "@/lib/cn";
30
+ import { SuiteEntityIcon } from "./suite-entity-icon";
31
+ import { SuitePaletteLibrary } from "./suite-palette-library";
29
32
 
30
33
  export interface SuiteDirectoryClient {
31
34
  id: string;
@@ -50,6 +53,8 @@ export interface SuiteDirectoryFields {
50
53
  icon: string;
51
54
  color: string;
52
55
  iconImageFile?: File | null;
56
+ /** Icon chosen from a hosted library (e.g. Palette); wins over `iconImageFile`. */
57
+ iconImageUrl?: string | null;
53
58
  clearIconImage?: boolean;
54
59
  }
55
60
 
@@ -80,186 +85,81 @@ export interface SuiteClientsProjectsDirectoryProps {
80
85
  fields: SuiteDirectoryProjectFields,
81
86
  ) => Promise<SuiteDirectoryProject | void>;
82
87
  onDeleteProject?: (project: SuiteDirectoryProject) => Promise<void>;
88
+ /**
89
+ * Optional hosted icon library (e.g. Palette). When provided, the picker
90
+ * shows a "Browse library" button; the render prop receives `onPick`, which
91
+ * sets the chosen icon URL as the item's custom image.
92
+ */
93
+ renderIconLibrary?: (args: {
94
+ onPick: (icon: { url: string; name?: string }) => void;
95
+ }) => ReactNode;
83
96
  dataTest?: string;
84
97
  }
85
98
 
86
- const DEFAULT_ICON = "castle-emblem";
99
+ const DEFAULT_ICON = "";
87
100
  const DEFAULT_COLOR = "#6366f1";
88
101
 
89
- const DIRECTORY_ICON_OPTIONS = [
90
- ["arena", "Arena"],
91
- ["bridge", "Bridge"],
92
- ["capitol", "Capitol"],
93
- ["castle-emblem", "Castle"],
94
- ["castle-flag", "Castle flag"],
95
- ["lighthouse", "Lighthouse"],
96
- ["tower", "Tower"],
97
- ["wooden-sign", "Sign"],
98
- ["anvil", "Anvil"],
99
- ["book", "Book"],
100
- ["chessboard", "Chessboard"],
101
- ["cog", "Settings"],
102
- ["hammer", "Build"],
103
- ["light-bulb", "Idea"],
104
- ["microphone", "Communication"],
105
- ["quill-ink", "Writing"],
106
- ["scroll-unfurled", "Notes"],
107
- ["hourglass", "Planning"],
108
- ["wrench", "Tools"],
109
- ["archer", "Archer"],
110
- ["archery-target", "Target"],
111
- ["axe", "Axe"],
112
- ["campfire", "Campfire"],
113
- ["compass", "Compass"],
114
- ["crossed-swords", "Battle"],
115
- ["knight-helmet", "Knight"],
116
- ["mountains", "Mountains"],
117
- ["shield", "Shield"],
118
- ["ship-emblem", "Voyage"],
119
- ["sword", "Sword"],
120
- ["trophy", "Trophy"],
121
- ["apple", "Apple"],
122
- ["butterfly", "Butterfly"],
123
- ["clover", "Clover"],
124
- ["dragon", "Dragon"],
125
- ["fire", "Fire"],
126
- ["flower", "Flower"],
127
- ["flowers", "Flowers"],
128
- ["grass", "Grass"],
129
- ["leaf", "Leaf"],
130
- ["pine-tree", "Pine tree"],
131
- ["sun", "Sun"],
132
- ["water-drop", "Water"],
133
- ["aura", "Aura"],
134
- ["crystal-ball", "Crystal ball"],
135
- ["diamond", "Diamond"],
136
- ["explosion", "Explosion"],
137
- ["fire-symbol", "Fire symbol"],
138
- ["gem", "Gem"],
139
- ["lightning-trio", "Lightning"],
140
- ["potion", "Potion"],
141
- ["rune-stone", "Rune stone"],
142
- ["skull", "Skull"],
143
- ["tentacle", "Tentacle"],
144
- ["wolf-head", "Wolf"],
145
- ["anchor", "Anchor"],
146
- ["beer", "Beer"],
147
- ["carrot", "Carrot"],
148
- ["cat", "Cat"],
149
- ["crown", "Crown"],
150
- ["gold-bar", "Gold bar"],
151
- ["helmet", "Helmet"],
152
- ["key", "Key"],
153
- ["three-keys", "Keys"],
154
- ] as const;
155
-
156
- type DirectoryIconOption = (typeof DIRECTORY_ICON_OPTIONS)[number][0];
157
-
158
- function isDirectoryIcon(value: string): value is DirectoryIconOption {
159
- return DIRECTORY_ICON_OPTIONS.some(([name]) => name === value);
160
- }
161
-
162
102
  function DirectoryIconPicker({
163
- value,
164
103
  preview,
104
+ label,
105
+ color,
165
106
  onFileChange,
166
107
  onClear,
167
- onChange,
108
+ onBrowse,
168
109
  }: {
169
- value: string;
170
110
  preview?: string | null;
111
+ label?: string;
112
+ color?: string | null;
171
113
  onFileChange: (file: File | null) => void;
172
114
  onClear: () => void;
173
- onChange: (value: string) => void;
115
+ onBrowse?: () => void;
174
116
  }) {
175
- const iconRefs = useRef<Record<string, HTMLButtonElement | null>>({});
176
-
177
- function moveSelection(event: KeyboardEvent<HTMLButtonElement>, index: number) {
178
- let nextIndex: number | null = null;
179
- if (event.key === "ArrowRight" || event.key === "ArrowDown") {
180
- nextIndex = (index + 1) % DIRECTORY_ICON_OPTIONS.length;
181
- } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
182
- nextIndex =
183
- (index - 1 + DIRECTORY_ICON_OPTIONS.length) % DIRECTORY_ICON_OPTIONS.length;
184
- } else if (event.key === "Home") {
185
- nextIndex = 0;
186
- } else if (event.key === "End") {
187
- nextIndex = DIRECTORY_ICON_OPTIONS.length - 1;
188
- }
189
-
190
- if (nextIndex === null) return;
191
- event.preventDefault();
192
- const [nextIcon] = DIRECTORY_ICON_OPTIONS[nextIndex];
193
- onChange(nextIcon);
194
- iconRefs.current[nextIcon]?.focus();
195
- }
196
-
197
117
  return (
198
118
  <div className="space-y-2">
199
119
  <span className="mb-1.5 block text-sm font-medium text-ph-ink">Icon</span>
200
- <div
201
- role="radiogroup"
202
- aria-label="Icon"
203
- className="grid grid-cols-6 gap-1.5 rounded-lg border border-ph-border p-1.5 sm:grid-cols-8"
204
- >
205
- {DIRECTORY_ICON_OPTIONS.map(([name, label], index) => {
206
- const selected = value === name;
207
- return (
120
+ <div className="rounded-lg border border-ph-border bg-ph-muted p-3">
121
+ <div className="flex items-center gap-3">
122
+ <SuiteEntityIcon
123
+ imageUrl={preview}
124
+ label={label}
125
+ color={color}
126
+ tinted
127
+ className="h-9 w-9 text-base"
128
+ />
129
+ <div className="min-w-0 flex-1">
130
+ <p className="text-sm font-medium text-ph-ink">
131
+ {preview ? "Custom image" : "No icon yet"}
132
+ </p>
133
+ <p className="text-xs text-ph-mutedtext">
134
+ {preview
135
+ ? "Shown on cards and lists."
136
+ : "Browse the library or upload your own."}
137
+ </p>
138
+ </div>
139
+ {onBrowse ? (
208
140
  <button
209
- key={name}
210
141
  type="button"
211
- role="radio"
212
- aria-label={label}
213
- aria-checked={selected}
214
- tabIndex={selected ? 0 : -1}
215
- title={label}
216
- onClick={() => onChange(name)}
217
- onKeyDown={(event) => moveSelection(event, index)}
218
- ref={(element) => {
219
- iconRefs.current[name] = element;
220
- }}
221
- className={cn(
222
- "flex h-9 items-center justify-center rounded-md text-ph-mutedtext hover:bg-ph-muted hover:text-ph-ink",
223
- selected &&
224
- "bg-ph-muted text-ph-ink ring-2 ring-ph-brand ring-inset",
225
- )}
142
+ onClick={onBrowse}
143
+ className="rounded-md border border-ph-border px-3 py-1.5 text-xs font-medium text-ph-ink hover:border-ph-brand hover:bg-ph-surface"
226
144
  >
227
- <i
228
- className={cn("ra", `ra-${name}`, "text-base leading-none")}
229
- aria-hidden
230
- />
145
+ Browse library
231
146
  </button>
232
- );
233
- })}
234
- </div>
235
- <div className="rounded-lg border border-ph-border bg-ph-muted p-2">
236
- <div className="flex items-center gap-2 text-sm font-medium text-ph-ink">
237
- <ImagePlus className="h-4 w-4" aria-hidden /> Custom image
147
+ ) : null}
148
+ </div>
149
+ <div className="mt-3">
150
+ <FileUpload
151
+ accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,.svg"
152
+ onChange={(event) => onFileChange(event.target.files?.[0] ?? null)}
153
+ />
238
154
  </div>
239
- <p className="mt-1 text-xs text-ph-mutedtext">
240
- PNG, JPG, GIF, WebP, or SVG — max 512 KB.
241
- </p>
242
- <FileUpload
243
- accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,.svg"
244
- onChange={(event) => onFileChange(event.target.files?.[0] ?? null)}
245
- />
246
155
  {preview ? (
247
- <div className="mt-2 flex items-center gap-2">
248
- {/* eslint-disable-next-line @next/next/no-img-element */}
249
- <img
250
- src={preview}
251
- alt=""
252
- className="h-8 w-8 rounded object-cover"
253
- />
254
- <button
255
- type="button"
256
- className="text-xs text-ph-mutedtext hover:text-ph-ink"
257
- onClick={onClear}
258
- >
259
- <Trash className="mr-1 inline h-3.5 w-3.5" aria-hidden /> Remove
260
- custom image
261
- </button>
262
- </div>
156
+ <button
157
+ type="button"
158
+ className="mt-2 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-ph-mutedtext hover:bg-ph-surface hover:text-ph-ink"
159
+ onClick={onClear}
160
+ >
161
+ <Trash className="h-3.5 w-3.5" aria-hidden /> Remove image
162
+ </button>
263
163
  ) : null}
264
164
  </div>
265
165
  </div>
@@ -271,27 +171,15 @@ function DirectoryIcon({
271
171
  }: {
272
172
  item: SuiteDirectoryClient | SuiteDirectoryProject;
273
173
  }) {
274
- const icon =
275
- item.icon && isDirectoryIcon(item.icon) ? item.icon : DEFAULT_ICON;
276
174
  return (
277
- <span
278
- className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-white"
279
- style={{ backgroundColor: item.color || "var(--ph-brand)" }}
280
- >
281
- {item.icon_image ? (
282
- // eslint-disable-next-line @next/next/no-img-element
283
- <img
284
- src={item.icon_image}
285
- alt=""
286
- className="h-full w-full rounded-lg object-cover"
287
- />
288
- ) : (
289
- <i
290
- className={cn("ra", `ra-${icon}`, "text-base leading-none")}
291
- aria-hidden
292
- />
293
- )}
294
- </span>
175
+ <SuiteEntityIcon
176
+ imageUrl={item.icon_image}
177
+ label={item.name}
178
+ color={item.color}
179
+ tinted
180
+ className="h-8 w-8 text-sm"
181
+ roundedClass="rounded-lg"
182
+ />
295
183
  );
296
184
  }
297
185
 
@@ -319,6 +207,7 @@ export function SuiteClientsProjectsDirectory({
319
207
  onCreateProject,
320
208
  onUpdateProject,
321
209
  onDeleteProject,
210
+ renderIconLibrary,
322
211
  dataTest = "suite-clients-projects-directory",
323
212
  }: SuiteClientsProjectsDirectoryProps) {
324
213
  const [query, setQuery] = useState("");
@@ -332,6 +221,7 @@ export function SuiteClientsProjectsDirectory({
332
221
  });
333
222
  const [draftClientId, setDraftClientId] = useState<string | null>(null);
334
223
  const [preview, setPreview] = useState<string | null>(null);
224
+ const [libraryOpen, setLibraryOpen] = useState(false);
335
225
  const [draftArchived, setDraftArchived] = useState(false);
336
226
  const [confirming, setConfirming] = useState<Confirming | null>(null);
337
227
  const [saving, setSaving] = useState(false);
@@ -411,6 +301,7 @@ export function SuiteClientsProjectsDirectory({
411
301
  icon: DEFAULT_ICON,
412
302
  color: DEFAULT_COLOR,
413
303
  iconImageFile: null,
304
+ iconImageUrl: null,
414
305
  clearIconImage: false,
415
306
  });
416
307
  setPreview(null);
@@ -426,9 +317,10 @@ export function SuiteClientsProjectsDirectory({
426
317
  setMutationError(null);
427
318
  setDraft({
428
319
  name: item.name,
429
- icon: item.icon && isDirectoryIcon(item.icon) ? item.icon : DEFAULT_ICON,
320
+ icon: item.icon ?? DEFAULT_ICON,
430
321
  color: item.color || DEFAULT_COLOR,
431
322
  iconImageFile: null,
323
+ iconImageUrl: null,
432
324
  clearIconImage: false,
433
325
  });
434
326
  setPreview(item.icon_image || null);
@@ -454,6 +346,7 @@ export function SuiteClientsProjectsDirectory({
454
346
  setDraft((current) => ({
455
347
  ...current,
456
348
  iconImageFile: file,
349
+ iconImageUrl: null,
457
350
  clearIconImage: false,
458
351
  }));
459
352
  setPreview(URL.createObjectURL(file));
@@ -465,10 +358,24 @@ export function SuiteClientsProjectsDirectory({
465
358
  setDraft((current) => ({
466
359
  ...current,
467
360
  iconImageFile: null,
361
+ iconImageUrl: null,
468
362
  clearIconImage: true,
469
363
  }));
470
364
  }
471
365
 
366
+ function pickFromLibrary(icon: { url: string; name?: string }) {
367
+ if (preview?.startsWith("blob:")) URL.revokeObjectURL(preview);
368
+ setMutationError(null);
369
+ setPreview(icon.url);
370
+ setDraft((current) => ({
371
+ ...current,
372
+ iconImageFile: null,
373
+ iconImageUrl: icon.url,
374
+ clearIconImage: false,
375
+ }));
376
+ setLibraryOpen(false);
377
+ }
378
+
472
379
  function closeEditor() {
473
380
  if (!saving) setEditing(null);
474
381
  }
@@ -516,10 +423,7 @@ export function SuiteClientsProjectsDirectory({
516
423
  try {
517
424
  await onUpdateProject(project, {
518
425
  name: project.name,
519
- icon:
520
- project.icon && isDirectoryIcon(project.icon)
521
- ? project.icon
522
- : DEFAULT_ICON,
426
+ icon: project.icon ?? DEFAULT_ICON,
523
427
  color: project.color || DEFAULT_COLOR,
524
428
  clientId: project.clientId ?? null,
525
429
  isArchived: !project.isArchived,
@@ -845,20 +749,31 @@ export function SuiteClientsProjectsDirectory({
845
749
  }
846
750
  />
847
751
  <DirectoryIconPicker
848
- value={isDirectoryIcon(draft.icon) ? draft.icon : DEFAULT_ICON}
849
752
  preview={preview}
753
+ label={draft.name}
754
+ color={draft.color}
850
755
  onFileChange={chooseImage}
851
756
  onClear={clearImage}
852
- onChange={(icon) => {
853
- if (preview) clearImage();
854
- setDraft((current) => ({ ...current, icon }));
855
- }}
757
+ onBrowse={() => setLibraryOpen(true)}
856
758
  />
857
759
  <div>
858
760
  <span className="mb-1.5 block text-sm font-medium text-ph-ink">
859
761
  Colour
860
762
  </span>
861
- <div className="flex flex-wrap gap-2">
763
+ <div className="flex flex-wrap items-center gap-2">
764
+ <button
765
+ type="button"
766
+ aria-label="No colour"
767
+ aria-pressed={!draft.color}
768
+ title="No colour"
769
+ onClick={() => setDraft({ ...draft, color: "" })}
770
+ className={cn(
771
+ "flex h-8 w-8 items-center justify-center rounded-full border border-ph-border bg-ph-surface text-ph-mutedtext ring-offset-2",
772
+ !draft.color && "ring-2 ring-ph-brand",
773
+ )}
774
+ >
775
+ <Prohibition className="h-4 w-4" aria-hidden />
776
+ </button>
862
777
  {[
863
778
  "#6366f1",
864
779
  "#0ea5e9",
@@ -881,16 +796,53 @@ export function SuiteClientsProjectsDirectory({
881
796
  style={{ backgroundColor: color }}
882
797
  />
883
798
  ))}
884
- <Input
885
- hideLabel
886
- label="Custom colour"
887
- type="color"
888
- value={draft.color}
889
- onChange={(event) =>
890
- setDraft({ ...draft, color: event.target.value })
891
- }
892
- className="h-8 w-8 p-0"
893
- />
799
+ <label
800
+ className={cn(
801
+ "relative inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-full ring-1 ring-ph-border ring-offset-2",
802
+ ![
803
+ "#6366f1",
804
+ "#0ea5e9",
805
+ "#10b981",
806
+ "#f59e0b",
807
+ "#ef4444",
808
+ "#a855f7",
809
+ "#64748b",
810
+ ].includes(draft.color) && "ring-2 ring-ph-brand",
811
+ )}
812
+ style={{
813
+ background:
814
+ "conic-gradient(from 0deg, #ef4444, #f59e0b, #eab308, #22c55e, #06b6d4, #3b82f6, #8b5cf6, #ec4899, #ef4444)",
815
+ }}
816
+ title="Custom colour"
817
+ >
818
+ <span className="flex h-4 w-4 items-center justify-center rounded-full bg-ph-surface">
819
+ {[
820
+ "#6366f1",
821
+ "#0ea5e9",
822
+ "#10b981",
823
+ "#f59e0b",
824
+ "#ef4444",
825
+ "#a855f7",
826
+ "#64748b",
827
+ ].includes(draft.color) ? (
828
+ <Plus className="h-3 w-3 text-ph-ink" aria-hidden />
829
+ ) : (
830
+ <span
831
+ className="h-2.5 w-2.5 rounded-full"
832
+ style={{ backgroundColor: draft.color }}
833
+ />
834
+ )}
835
+ </span>
836
+ <input
837
+ type="color"
838
+ aria-label="Custom colour"
839
+ value={draft.color}
840
+ onChange={(event) =>
841
+ setDraft({ ...draft, color: event.target.value })
842
+ }
843
+ className="sr-only"
844
+ />
845
+ </label>
894
846
  </div>
895
847
  </div>
896
848
  {editing?.kind.includes("project") ? (
@@ -919,6 +871,17 @@ export function SuiteClientsProjectsDirectory({
919
871
  ) : null}
920
872
  </div>
921
873
  </Modal>
874
+ {libraryOpen ? (
875
+ <Modal
876
+ open
877
+ onClose={() => setLibraryOpen(false)}
878
+ title="Icon library"
879
+ >
880
+ {renderIconLibrary
881
+ ? renderIconLibrary({ onPick: pickFromLibrary })
882
+ : <SuitePaletteLibrary onPick={pickFromLibrary} />}
883
+ </Modal>
884
+ ) : null}
922
885
  <Modal
923
886
  open={confirming !== null}
924
887
  onClose={() => setConfirming(null)}
@@ -0,0 +1,80 @@
1
+ 'use client';
2
+
3
+ import type { CSSProperties } from 'react';
4
+ import { paletteIconSrc } from '@/suite/lib/palette-api';
5
+ import { cn } from '@/lib/cn';
6
+
7
+ /**
8
+ * A client/project icon.
9
+ *
10
+ * Shows the stored image when there is one; otherwise a neutral placeholder —
11
+ * a tinted rounded square with the entity's initial, or a small dot when no
12
+ * label is available. No icon font is involved.
13
+ */
14
+ export function SuiteEntityIcon({
15
+ imageUrl,
16
+ label,
17
+ color,
18
+ className,
19
+ roundedClass = 'rounded-md',
20
+ style,
21
+ tinted = false,
22
+ }: {
23
+ imageUrl?: string | null;
24
+ label?: string | null;
25
+ color?: string | null;
26
+ className?: string;
27
+ roundedClass?: string;
28
+ style?: CSSProperties;
29
+ /** Recolour a monochrome icon to `color` (via CSS mask). */
30
+ tinted?: boolean;
31
+ }) {
32
+ const hasColor =
33
+ typeof color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(color);
34
+ const tint = hasColor ? (color as string) : null;
35
+ const neutralBackground = 'var(--ph-muted, rgba(127,127,127,0.08))';
36
+
37
+ if (imageUrl) {
38
+ const src = tinted && tint ? paletteIconSrc(imageUrl, tint) : imageUrl;
39
+ return (
40
+ <span
41
+ className={cn(
42
+ 'inline-flex shrink-0 items-center justify-center overflow-hidden leading-none',
43
+ roundedClass,
44
+ className,
45
+ )}
46
+ style={{
47
+ backgroundColor: tint ? `${tint}18` : neutralBackground,
48
+ ...style,
49
+ }}
50
+ >
51
+ {/* eslint-disable-next-line @next/next/no-img-element */}
52
+ <img src={src ?? undefined} alt="" className="h-full w-full object-cover" />
53
+ </span>
54
+ );
55
+ }
56
+
57
+ const initial = label?.trim().charAt(0).toUpperCase();
58
+
59
+ return (
60
+ <span
61
+ className={cn(
62
+ 'inline-flex shrink-0 items-center justify-center font-semibold leading-none',
63
+ roundedClass,
64
+ className,
65
+ )}
66
+ style={{
67
+ color: tint ?? 'var(--ph-mutedtext)',
68
+ backgroundColor: tint ? `${tint}18` : neutralBackground,
69
+ ...style,
70
+ }}
71
+ aria-hidden="true"
72
+ >
73
+ {initial ? (
74
+ <span className="text-[0.7em]">{initial}</span>
75
+ ) : (
76
+ <span className="block h-1.5 w-1.5 rounded-full bg-current opacity-40" />
77
+ )}
78
+ </span>
79
+ );
80
+ }