@tldiagram/core-ui 1.94.1 → 1.94.3

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.
@@ -18,8 +18,6 @@ import {
18
18
  PopoverBody,
19
19
  PopoverContent,
20
20
  PopoverTrigger,
21
- Radio,
22
- RadioGroup,
23
21
  Tag,
24
22
  TagCloseButton,
25
23
  TagLabel,
@@ -45,6 +43,173 @@ import TagUpsert from './TagUpsert'
45
43
 
46
44
  import { useViewEditorContext } from '../pages/ViewEditor/context'
47
45
 
46
+ function normalizeTechnologyLabel(value: string): string {
47
+ return value.trim().replace(/\s+/g, ' ').toLowerCase()
48
+ }
49
+
50
+ function splitTechnologyLabel(value: string): string[] {
51
+ return value.split(',').map((part) => part.trim()).filter(Boolean)
52
+ }
53
+
54
+ function findCatalogItemByLabel(index: Awaited<ReturnType<typeof getTechnologyCatalogIndex>>, label: string): TechnologyCatalogItem | null {
55
+ const normalized = normalizeTechnologyLabel(label)
56
+ if (!normalized) return null
57
+
58
+ const bySlugMatch = index.bySlug.get(label.trim())
59
+ if (bySlugMatch) return bySlugMatch
60
+
61
+ return index.items.find((item) => (
62
+ normalizeTechnologyLabel(item.name) === normalized ||
63
+ normalizeTechnologyLabel(item.nameShort) === normalized ||
64
+ normalizeTechnologyLabel(item.defaultSlug) === normalized
65
+ )) ?? null
66
+ }
67
+
68
+ function dedupeTechnologyLinks(links: TechnologyConnector[]): TechnologyConnector[] {
69
+ const seenCatalog = new Set<string>()
70
+ const seenCustom = new Set<string>()
71
+ const result: TechnologyConnector[] = []
72
+ let primarySet = false
73
+
74
+ // Sort links to process primary ones first, ensuring they are preserved during deduping
75
+ const sortedLinks = [...links].sort((a, b) => {
76
+ const aPrimary = !!(a.is_primary_icon ?? (a as any).isPrimaryIcon)
77
+ const bPrimary = !!(b.is_primary_icon ?? (b as any).isPrimaryIcon)
78
+ if (aPrimary && !bPrimary) return -1
79
+ if (!aPrimary && bPrimary) return 1
80
+ return 0
81
+ })
82
+
83
+ for (const link of sortedLinks) {
84
+ const label = link.label.trim()
85
+ if (!label) continue
86
+
87
+ const isPrimary = !!(link.is_primary_icon ?? (link as any).isPrimaryIcon)
88
+
89
+ if (link.type === 'catalog' && link.slug) {
90
+ const slug = link.slug.trim()
91
+ const key = slug.toLowerCase()
92
+ if (seenCatalog.has(key)) continue
93
+ seenCatalog.add(key)
94
+ result.push({
95
+ type: 'catalog',
96
+ slug,
97
+ label,
98
+ is_primary_icon: !primarySet && isPrimary,
99
+ })
100
+ if (isPrimary) primarySet = true
101
+ continue
102
+ }
103
+
104
+ const key = normalizeTechnologyLabel(label)
105
+ if (seenCustom.has(key)) continue
106
+ seenCustom.add(key)
107
+ result.push({ type: 'custom', label, is_primary_icon: false })
108
+ }
109
+
110
+ return result.slice(0, 3)
111
+ }
112
+
113
+ async function normalizeInitialTechnologyLinks(element: LibraryElement): Promise<TechnologyConnector[]> {
114
+ const rawLinks = element.technology_connectors ?? []
115
+ const legacyLabels = splitTechnologyLabel(element.technology ?? '')
116
+
117
+ if (rawLinks.length === 0 && legacyLabels.length === 0) return []
118
+
119
+ const index = await getTechnologyCatalogIndex()
120
+ const normalized: TechnologyConnector[] = []
121
+
122
+ const pushLabel = (label: string, isPrimaryIcon = false) => {
123
+ const match = findCatalogItemByLabel(index, label)
124
+ if (match) {
125
+ normalized.push({
126
+ type: 'catalog',
127
+ slug: match.defaultSlug,
128
+ label: match.name,
129
+ is_primary_icon: isPrimaryIcon,
130
+ })
131
+ } else {
132
+ normalized.push({ type: 'custom', label: label.trim(), is_primary_icon: false })
133
+ }
134
+ }
135
+
136
+ if (rawLinks.length > 0) {
137
+ for (const link of rawLinks) {
138
+ if (link.type === 'catalog') {
139
+ const match = link.slug ? index.bySlug.get(link.slug) : null
140
+ normalized.push({
141
+ type: 'catalog',
142
+ slug: link.slug,
143
+ label: match?.name ?? link.label,
144
+ is_primary_icon: !!(link.is_primary_icon ?? (link as any).isPrimaryIcon),
145
+ })
146
+ } else {
147
+ const parts = splitTechnologyLabel(link.label)
148
+ if (parts.length > 1) {
149
+ for (const part of parts) pushLabel(part)
150
+ } else {
151
+ pushLabel(link.label)
152
+ }
153
+ }
154
+ }
155
+ } else {
156
+ for (const label of legacyLabels) {
157
+ pushLabel(label)
158
+ }
159
+ }
160
+
161
+ // If no catalog item is primary, try to match against element.logo_url or fallback to first catalog item
162
+ const deduped = dedupeTechnologyLinks(normalized)
163
+ const hasPrimary = deduped.some(l => l.type === 'catalog' && l.is_primary_icon)
164
+ if (!hasPrimary) {
165
+ let bestMatchIndex = -1
166
+ if (element.logo_url) {
167
+ bestMatchIndex = deduped.findIndex(l => l.type === 'catalog' && l.slug && element.logo_url?.toLowerCase().includes(l.slug.toLowerCase()))
168
+ }
169
+
170
+ if (bestMatchIndex !== -1) {
171
+ deduped[bestMatchIndex].is_primary_icon = true
172
+ } else {
173
+ const firstCatalog = deduped.find(l => l.type === 'catalog')
174
+ if (firstCatalog) {
175
+ firstCatalog.is_primary_icon = true
176
+ }
177
+ }
178
+ }
179
+
180
+ return deduped
181
+ }
182
+
183
+ function buildTechnologyFingerprintPayload(
184
+ element: LibraryElement,
185
+ links: TechnologyConnector[],
186
+ type: string,
187
+ ) {
188
+ const normalizedLinks = links.map((link) => ({
189
+ type: link.type,
190
+ slug: link.type === 'catalog' ? link.slug : undefined,
191
+ label: link.label,
192
+ is_primary_icon: !!link.is_primary_icon,
193
+ }))
194
+ const normalizedType = type.trim().toLowerCase()
195
+ const technology = links.map((link) => link.label).join(', ')
196
+
197
+ return {
198
+ name: element.name,
199
+ description: element.description ?? '',
200
+ kind: normalizedType,
201
+ technology,
202
+ url: element.url ?? '',
203
+ logo_url: element.logo_url ?? '',
204
+ technology_connectors: normalizedLinks,
205
+ tags: element.tags ?? [],
206
+ repo: element.repo,
207
+ branch: element.branch,
208
+ file_path: element.file_path,
209
+ language: element.language,
210
+ }
211
+ }
212
+
48
213
  export interface ElementPanelProps extends ElementPanelSlots {
49
214
  isOpen: boolean
50
215
  onClose: () => void
@@ -101,6 +266,8 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
101
266
  }, [technologyQuery])
102
267
 
103
268
  useEffect(() => {
269
+ let cancelled = false
270
+
104
271
  if (element) {
105
272
  setName(element.name)
106
273
  setDescription(element.description ?? '')
@@ -108,43 +275,42 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
108
275
  setTypeQuery('')
109
276
  setTypeResults([])
110
277
  setUrl(element.url ?? '')
111
- const linksFromElement = element.technology_connectors ?? []
112
- if (linksFromElement.length > 0) {
113
- setTechnologyConnectors(linksFromElement)
114
- } else if (element.technology) {
115
- setTechnologyConnectors([{ type: 'custom', label: element.technology }])
116
- } else {
117
- setTechnologyConnectors([])
118
- }
119
278
  setTags(element.tags ?? [])
120
279
  setExplicitLogoClear(false)
121
280
 
122
- // Initialize autosave fingerprint based on a payload normalized the same way as saves.
123
- const initialLinks: TechnologyConnector[] = linksFromElement.length > 0
124
- ? linksFromElement
125
- : (element.technology ? [{ type: 'custom', label: element.technology }] : [])
126
- const normalizedLinks = initialLinks.map((link) => ({
127
- type: link.type,
128
- slug: link.type === 'catalog' ? link.slug : undefined,
129
- label: link.label,
130
- is_primary_icon: !!link.is_primary_icon,
281
+ const linksFromElement = (element.technology_connectors ?? []).map(tl => ({
282
+ ...tl,
283
+ is_primary_icon: !!(tl.is_primary_icon ?? (tl as any).isPrimaryIcon),
131
284
  }))
132
- const normalizedType = (element.kind ?? '').trim().toLowerCase()
133
- const technology = initialLinks.map((link) => link.label).join(', ')
134
- lastSavedFingerprintRef.current = JSON.stringify({
135
- name: element.name,
136
- description: element.description ?? '',
137
- kind: normalizedType,
138
- technology,
139
- url: element.url ?? '',
140
- logo_url: element.logo_url ?? '',
141
- technology_connectors: normalizedLinks,
142
- tags: element.tags ?? [],
143
- repo: element.repo,
144
- branch: element.branch,
145
- file_path: element.file_path,
146
- language: element.language,
147
- })
285
+ const fallbackLinks: TechnologyConnector[] = linksFromElement.length > 0
286
+ ? linksFromElement
287
+ : (element.technology ? [{ type: 'custom', label: element.technology, is_primary_icon: false }] : [])
288
+ setTechnologyConnectors(fallbackLinks)
289
+ lastSavedFingerprintRef.current = JSON.stringify(buildTechnologyFingerprintPayload(
290
+ element,
291
+ fallbackLinks,
292
+ element.kind ?? '',
293
+ ))
294
+
295
+ normalizeInitialTechnologyLinks(element)
296
+ .then((initialLinks) => {
297
+ if (cancelled) return
298
+ setTechnologyConnectors(initialLinks)
299
+ lastSavedFingerprintRef.current = JSON.stringify(buildTechnologyFingerprintPayload(
300
+ element,
301
+ initialLinks,
302
+ element.kind ?? '',
303
+ ))
304
+ })
305
+ .catch(() => {
306
+ if (cancelled) return
307
+ setTechnologyConnectors(fallbackLinks)
308
+ lastSavedFingerprintRef.current = JSON.stringify(buildTechnologyFingerprintPayload(
309
+ element,
310
+ fallbackLinks,
311
+ element.kind ?? '',
312
+ ))
313
+ })
148
314
  } else {
149
315
  setName('')
150
316
  setDescription('')
@@ -160,17 +326,21 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
160
326
  setExplicitLogoClear(false)
161
327
  lastSavedFingerprintRef.current = ''
162
328
  }
329
+
330
+ return () => {
331
+ cancelled = true
332
+ }
163
333
  }, [element, isOpen])
164
334
 
165
335
  const buildPayloadAndFingerprint = useCallback(async () => {
166
- const primaryLink = technologyLinks.find((link) => link.type === 'catalog' && link.is_primary_icon && link.slug)
336
+ const primaryLink = technologyLinks.find((link) => link.type === 'catalog' && !!(link.is_primary_icon ?? (link as any).isPrimaryIcon) && link.slug)
167
337
  const primarySlug = primaryLink?.slug
168
338
 
169
339
  const normalizedLinks = technologyLinks.map((link) => ({
170
340
  type: link.type,
171
341
  slug: link.type === 'catalog' ? link.slug : undefined,
172
342
  label: link.label,
173
- is_primary_icon: !!link.is_primary_icon,
343
+ is_primary_icon: !!(link.is_primary_icon ?? (link as any).isPrimaryIcon),
174
344
  }))
175
345
 
176
346
  const normalizedType = type.trim().toLowerCase()
@@ -179,7 +349,7 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
179
349
  if (explicitLogoClear) {
180
350
  logoUrl = ''
181
351
  }
182
- if (primarySlug) {
352
+ if (!explicitLogoClear && primarySlug) {
183
353
  const cached = technologyMeta[primarySlug]
184
354
  if (cached?.iconUrl) {
185
355
  logoUrl = cached.iconUrl
@@ -378,43 +548,31 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
378
548
  }
379
549
 
380
550
  const removeTechnology = (linkToRemove: TechnologyConnector) => {
381
- setTechnologyConnectors((prev) => {
382
- const next = prev.filter((link) => !(link.type === linkToRemove.type && link.slug === linkToRemove.slug && link.label === linkToRemove.label))
383
- const hasPrimaryCatalog = next.some((link) => link.type === 'catalog' && !!link.is_primary_icon)
384
- if (hasPrimaryCatalog) return next
385
-
386
- const firstCatalogIndex = next.findIndex((link) => link.type === 'catalog' && !!link.slug)
387
- if (firstCatalogIndex === -1) return next
388
-
389
- return next.map((link, index) => ({
390
- ...link,
391
- is_primary_icon: index === firstCatalogIndex,
392
- }))
393
- })
551
+ if (linkToRemove.type === 'catalog' && linkToRemove.is_primary_icon) {
552
+ setExplicitLogoClear(true)
553
+ }
554
+ setTechnologyConnectors((prev) => prev.filter((link) => (
555
+ !(link.type === linkToRemove.type && link.slug === linkToRemove.slug && link.label === linkToRemove.label)
556
+ )))
394
557
  scheduleAutoSave()
395
558
  }
396
559
 
397
- const markPrimaryIcon = (selectedSlug: string) => {
560
+ const togglePrimaryIcon = (selectedSlug: string) => {
561
+ const isDeselecting = selectedPrimarySlug === selectedSlug
398
562
  setTechnologyConnectors((prev) => prev.map((link) => {
399
563
  if (link.type !== 'catalog') {
400
564
  return { ...link, is_primary_icon: false }
401
565
  }
402
566
  return {
403
567
  ...link,
404
- is_primary_icon: link.slug === selectedSlug,
568
+ is_primary_icon: !isDeselecting && link.slug === selectedSlug,
405
569
  }
406
570
  }))
407
- setExplicitLogoClear(false)
571
+ setExplicitLogoClear(isDeselecting)
408
572
  scheduleAutoSave()
409
573
  }
410
574
 
411
- const clearPrimaryIcon = () => {
412
- setTechnologyConnectors((prev) => prev.map((link) => ({ ...link, is_primary_icon: false })))
413
- setExplicitLogoClear(true)
414
- scheduleAutoSave()
415
- }
416
-
417
- const selectedPrimarySlug = technologyLinks.find((link) => link.type === 'catalog' && !!link.is_primary_icon && !!link.slug)?.slug ?? ''
575
+ const selectedPrimarySlug = technologyLinks.find((link) => link.type === 'catalog' && !!(link.is_primary_icon ?? (link as any).isPrimaryIcon) && !!link.slug)?.slug ?? ''
418
576
 
419
577
  const commitTypeFromQuery = () => {
420
578
  if (isReadOnly) return
@@ -437,7 +595,7 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
437
595
  if (isReadOnly || !name.trim()) return
438
596
  setLoading(true)
439
597
  try {
440
- const primaryLink = technologyLinks.find((link) => link.type === 'catalog' && link.is_primary_icon && link.slug)
598
+ const primaryLink = technologyLinks.find((link) => link.type === 'catalog' && !!(link.is_primary_icon ?? (link as any).isPrimaryIcon) && link.slug)
441
599
  const primaryMetadata = primaryLink?.slug
442
600
  ? (technologyMeta[primaryLink.slug] ?? await getTechnologyCatalogItemBySlug(primaryLink.slug))
443
601
  : null
@@ -446,7 +604,7 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
446
604
  type: link.type,
447
605
  slug: link.type === 'catalog' ? link.slug : undefined,
448
606
  label: link.label,
449
- is_primary_icon: !!link.is_primary_icon,
607
+ is_primary_icon: !!(link.is_primary_icon ?? (link as any).isPrimaryIcon),
450
608
  }))
451
609
 
452
610
  const normalizedType = type.trim().toLowerCase()
@@ -457,7 +615,7 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
457
615
  kind: normalizedType,
458
616
  technology: technologyLinks.map((link) => link.label).join(', '),
459
617
  url,
460
- logo_url: primaryMetadata?.iconUrl ?? '',
618
+ logo_url: explicitLogoClear ? '' : (primaryMetadata?.iconUrl ?? ''),
461
619
  technology_connectors: normalizedLinks,
462
620
  tags,
463
621
  repo: element?.repo,
@@ -703,11 +861,24 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
703
861
  {technologyLinks.map((link) => {
704
862
  const meta = link.slug ? technologyMeta[link.slug] : undefined
705
863
  const sourceUrl = meta?.websiteUrl || meta?.docsUrl
864
+ const isSelectable = link.type === 'catalog' && !!link.slug && !isReadOnly
865
+ const isPrimaryIcon = link.type === 'catalog' && !!(link.is_primary_icon ?? (link as any).isPrimaryIcon) && !!link.slug
706
866
  return (
707
867
  <WrapItem key={`${link.type}:${link.slug ?? link.label}`}>
708
868
  <Popover trigger={isMobile ? 'click' : 'hover'} placement="top" closeOnBlur>
709
869
  <PopoverTrigger>
710
- <Tag size="sm" variant="subtle" bg="whiteAlpha.100" border="1px solid" borderColor="whiteAlpha.200" cursor="pointer">
870
+ <Tag
871
+ size="sm"
872
+ variant="subtle"
873
+ bg={isPrimaryIcon ? 'blue.500' : 'whiteAlpha.100'}
874
+ border="1px solid"
875
+ borderColor={isPrimaryIcon ? 'blue.300' : 'whiteAlpha.200'}
876
+ color={isPrimaryIcon ? 'white' : undefined}
877
+ cursor={isSelectable ? 'pointer' : 'default'}
878
+ onClick={() => {
879
+ if (isSelectable && link.slug) togglePrimaryIcon(link.slug)
880
+ }}
881
+ >
711
882
  <TagLabel color="white">
712
883
  {link.type === 'catalog' && meta && (
713
884
  <Box as="img" src={resolveWithBase(meta.iconUrl)} alt={link.label} boxSize="12px" objectFit="contain" display="inline-block" mr={1.5} verticalAlign="middle" />
@@ -715,7 +886,13 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
715
886
  {link.label}
716
887
  </TagLabel>
717
888
  {!isReadOnly && (
718
- <TagCloseButton onClick={() => removeTechnology(link)} />
889
+ <TagCloseButton
890
+ onClick={(e) => {
891
+ e.preventDefault()
892
+ e.stopPropagation()
893
+ removeTechnology(link)
894
+ }}
895
+ />
719
896
  )}
720
897
  </Tag>
721
898
  </PopoverTrigger>
@@ -739,28 +916,6 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
739
916
  })}
740
917
  </Wrap>
741
918
 
742
- <VStack align="stretch" spacing={1}>
743
- <Text fontSize="xs" color="gray.400">Canvas icon (optional)</Text>
744
- <Button size="xs" variant="ghost" w="fit-content" onClick={clearPrimaryIcon} isDisabled={isReadOnly}>
745
- None
746
- </Button>
747
- <RadioGroup value={selectedPrimarySlug} onChange={markPrimaryIcon}>
748
- <VStack align="stretch" spacing={1}>
749
- {technologyLinks.filter((link) => link.type === 'catalog' && !!link.slug).map((link) => (
750
- <Radio
751
- key={`primary-${link.slug}`}
752
- value={link.slug}
753
- isDisabled={isReadOnly}
754
- size="sm"
755
- colorScheme="blue"
756
- >
757
- <Text fontSize="xs" color="gray.200">{link.label}</Text>
758
- </Radio>
759
- ))}
760
- </VStack>
761
- </RadioGroup>
762
- </VStack>
763
-
764
919
  <Text fontSize="10px" color="gray.500">Maximum 3 linked technologies.</Text>
765
920
  </VStack>
766
921
  </FormControl>
@@ -5,9 +5,7 @@ export function ZoomOutIcon({ size = 14, strokeWidth = 3 }: { size?: number, str
5
5
  return (
6
6
  <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
7
7
  strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round">
8
- <circle cx="11" cy="11" r="8" />
9
- <line x1="21" y1="21" x2="16.65" y2="16.65" />
10
- <line x1="8" y1="11" x2="14" y2="11" />
8
+ <path d="M19 11a8 8 0 1 1-16 0 8 8 0 0 1 16 0M21 21l-4.343-4.343M8 11h6" />
11
9
  </svg>
12
10
  )
13
11
  }
@@ -16,10 +14,7 @@ export function ZoomInIcon({ size = 14, strokeWidth = 3 }: { size?: number, stro
16
14
  return (
17
15
  <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
18
16
  strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round">
19
- <circle cx="11" cy="11" r="8" />
20
- <line x1="21" y1="21" x2="16.65" y2="16.65" />
21
- <line x1="11" y1="8" x2="11" y2="14" />
22
- <line x1="8" y1="11" x2="14" y2="11" />
17
+ <path d="M19 11a8 8 0 1 1-16 0 8 8 0 0 1 16 0M21 21l-4.343-4.343M11 8v6M8 11h6" />
23
18
  </svg>
24
19
  )
25
20
  }
@@ -1,13 +1,14 @@
1
1
  import { Box, Divider, Flex, HStack, Tag, Text, VStack } from '@chakra-ui/react'
2
- import { TYPE_COLORS, type PlacedElement } from '../types'
2
+ import { TYPE_COLORS, type PlacedElement, type Tag as TagType } from '../types'
3
3
 
4
4
  interface Props {
5
5
  data: PlacedElement & { hasChildLink?: boolean }
6
6
  /** Bounding rect of the node element in screen (viewport) coordinates */
7
7
  anchorRect: DOMRect
8
+ tagColors?: Record<string, TagType>
8
9
  }
9
10
 
10
- export default function NodeHoverCard({ data, anchorRect }: Props) {
11
+ export default function NodeHoverCard({ data, anchorRect, tagColors }: Props) {
11
12
  const color = TYPE_COLORS[data.kind ?? ''] ?? 'gray'
12
13
 
13
14
  // Position the card centred above the node using fixed coordinates so it
@@ -101,19 +102,23 @@ export default function NodeHoverCard({ data, anchorRect }: Props) {
101
102
  {/* Tags */}
102
103
  {data.tags && data.tags.length > 0 && (
103
104
  <HStack spacing={1} flexWrap="wrap">
104
- {data.tags.map((tag) => (
105
- <Tag
106
- key={tag}
107
- size="sm"
108
- variant="outline"
109
- colorScheme="gray"
110
- fontSize="9px"
111
- borderColor="rgba(255,255,255,0.1)"
112
- color="gray.500"
113
- >
114
- {tag}
115
- </Tag>
116
- ))}
105
+ {data.tags.map((tag) => {
106
+ const tagColor = tagColors?.[tag]?.color
107
+ return (
108
+ <Tag
109
+ key={tag}
110
+ size="sm"
111
+ variant="subtle"
112
+ bg={tagColor ? `color-mix(in srgb, ${tagColor} 12%, transparent)` : 'whiteAlpha.100'}
113
+ color={tagColor || 'gray.400'}
114
+ fontSize="9px"
115
+ borderColor={tagColor ? `color-mix(in srgb, ${tagColor} 30%, transparent)` : 'rgba(255,255,255,0.1)'}
116
+ borderWidth="1px"
117
+ >
118
+ {tag}
119
+ </Tag>
120
+ )
121
+ })}
117
122
  </HStack>
118
123
  )}
119
124
 
@@ -168,7 +168,7 @@ function buildNodes(
168
168
  }))
169
169
 
170
170
  const derivedPrimaryIconPath = (() => {
171
- const selected = obj.technology_connectors?.find((link) => link.type === 'catalog' && !!link.is_primary_icon && !!link.slug)
171
+ const selected = obj.technology_connectors?.find((link) => link.type === 'catalog' && !!(link.is_primary_icon ?? (link as any).isPrimaryIcon) && !!link.slug)
172
172
  if (!selected?.slug) return null
173
173
  return resolveIconPath(`/icons/${selected.slug}.png`)
174
174
  })()
@@ -132,7 +132,7 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
132
132
  }
133
133
  }, [showMiniOnboarding])
134
134
  useEffect(() => {
135
- const loader = api.explore.load()
135
+ const loader = sharedToken ? api.explore.loadShared(sharedToken) : api.explore.load()
136
136
  loader.then((d) => {
137
137
  if (d.password_required) {
138
138
  setLoading(false)
@@ -147,23 +147,27 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
147
147
  // Fetch tag colors and layers once data is loaded (authenticated users only).
148
148
  // Only fetch from root tree nodes child/nested diagrams would duplicate the same layers.
149
149
  useEffect(() => {
150
- if (!data) return
150
+ if (!data || sharedToken) return
151
151
  let cancelled = false
152
152
  const rootIds = (data.tree ?? []).map(n => n.id)
153
153
  const fetchTagData = async () => {
154
- const diagramLayers = await Promise.all(
155
- rootIds.map(id => api.workspace.views.layers.list(id)),
156
- )
157
- if (!cancelled) {
158
- // Deduplicate by layer ID in case of any API overlap
159
- const seen = new Set<number>()
160
- const unique = diagramLayers.flat().filter(l => seen.has(l.id) ? false : (seen.add(l.id), true))
161
- setLayers(unique)
154
+ try {
155
+ const diagramLayers = await Promise.all(
156
+ rootIds.map(id => api.workspace.views.layers.list(id)),
157
+ )
158
+ if (!cancelled) {
159
+ // Deduplicate by layer ID in case of any API overlap
160
+ const seen = new Set<number>()
161
+ const unique = diagramLayers.flat().filter(l => seen.has(l.id) ? false : (seen.add(l.id), true))
162
+ setLayers(unique)
163
+ }
164
+ } catch {
165
+ // intentionally empty: layers are not available for public shared pages
162
166
  }
163
167
  }
164
168
  void fetchTagData()
165
169
  return () => { cancelled = true }
166
- }, [data])
170
+ }, [data, sharedToken])
167
171
 
168
172
  const handleCanvasReady = useCallback(() => {
169
173
  setCanvasReady(true)
@@ -400,7 +404,8 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
400
404
  const InfiniteZoom = forwardRef<InfiniteZoomHandle, Props>(InfiniteZoomInner)
401
405
  export default InfiniteZoom
402
406
 
403
- export function SharedInfiniteZoom(props: Props) {
407
+ export const SharedInfiniteZoom = forwardRef<InfiniteZoomHandle, Props>((props, ref) => {
404
408
  const { token } = useParams()
405
- return <InfiniteZoomInner {...props} sharedToken={token} />
406
- }
409
+ const effectiveToken = props.sharedToken ?? token
410
+ return <InfiniteZoom {...props} ref={ref} sharedToken={effectiveToken} />
411
+ })
@@ -190,8 +190,6 @@ export function useViewData({
190
190
  const treeData = useStore((state) => state.treeData)
191
191
  const allElements = useStore((state) => state.allElements)
192
192
  const setAllElements = useStore((state) => state.setAllElements)
193
- const libraryRefresh = useStore((state) => state.libraryRefresh)
194
- const setLibraryRefresh = useStore((state) => state.setLibraryRefresh)
195
193
  const hydrateViewContent = useStore((state) => state.hydrateViewContent)
196
194
  const resetCanvas = useStore((state) => state.resetCanvas)
197
195
  const removeElementPlacement = useStore((state) => state.removeElementPlacement)
@@ -268,7 +266,7 @@ export function useViewData({
268
266
 
269
267
  // ── Keep all-org elements for inline adder ──────────────────────────────────
270
268
  const allElementsQuery = useQuery({
271
- queryKey: ['elements', 'list', libraryRefresh],
269
+ queryKey: ['elements', 'list'],
272
270
  queryFn: () => api.elements.list(),
273
271
  })
274
272
 
@@ -671,8 +669,6 @@ export function useViewData({
671
669
  incomingLinks,
672
670
  treeData,
673
671
  allElements,
674
- libraryRefresh,
675
- setLibraryRefresh,
676
672
  existingElementIds,
677
673
  // Stable refs
678
674
  viewElementsRef,
@@ -260,7 +260,15 @@ function ViewEditorInner({
260
260
  const [tagColors, setTagColors] = useState<Record<string, Tag>>({})
261
261
 
262
262
  useEffect(() => {
263
- // api.workspace.orgs.tagColors.list().then(setTagColors).catch(() => { /* skip */ })
263
+ api.workspace.orgs.tagColors.list().then((res) => {
264
+ if (Array.isArray(res)) {
265
+ const next: Record<string, Tag> = {}
266
+ res.forEach(t => { next[t.name] = t })
267
+ setTagColors(next)
268
+ } else {
269
+ setTagColors(res)
270
+ }
271
+ }).catch(() => { /* skip */ })
264
272
  }, [])
265
273
 
266
274
  const [layers, setLayers] = useState<import('../../types').ViewLayer[]>([])
@@ -422,7 +430,7 @@ function ViewEditorInner({
422
430
  view, setView, viewElements, setViewElements, connectors, setConnectors,
423
431
  rfNodes, setRfNodes, rfEdges, setRfEdges,
424
432
  linksMap, setLinksMap, parentLinksMap, setParentLinksMap,
425
- treeData, allElements, libraryRefresh,
433
+ treeData, allElements,
426
434
  existingElementIds,
427
435
  viewElementsRef, linksMapRef, parentLinksMapRef, incomingLinksRef,
428
436
  treeDataRef, rfNodesRef, rfEdgesRef, viewIdRef,
@@ -1376,7 +1384,7 @@ function ViewEditorInner({
1376
1384
  <ElementLibrary
1377
1385
  existingElementIds={existingElementIds}
1378
1386
  existingElements={existingElements}
1379
- onCreateNew={handleCreateNewLibrary} refresh={libraryRefresh}
1387
+ onCreateNew={handleCreateNewLibrary}
1380
1388
  isOpen={libraryOpen} onClose={handleCloseLibrary}
1381
1389
  onTapAdd={canEdit ? handleTapAdd : undefined}
1382
1390
  onFindElement={handleFindElement}