@igstack/app-catalog-frontend-core 6.9.0 → 6.10.1

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 (80) hide show
  1. package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.d.ts +0 -0
  2. package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.d.ts +0 -0
  3. package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.d.ts +0 -0
  4. package/dist/esm/__tests__/modules/appCatalog/ui/detail/composeComments.test.d.ts +1 -0
  5. package/dist/esm/__tests__/ui/detail/CommunityInput.test.d.ts +1 -0
  6. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +8 -0
  7. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +1 -0
  8. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  9. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +237 -218
  10. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  11. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.d.ts +17 -0
  12. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js +187 -0
  13. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js.map +1 -0
  14. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.d.ts +9 -0
  15. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js +91 -0
  16. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js.map +1 -0
  17. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.d.ts +17 -0
  18. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js +328 -0
  19. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js.map +1 -0
  20. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.d.ts +15 -0
  21. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js +26 -0
  22. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js.map +1 -0
  23. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +8 -19
  24. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +49 -37
  25. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
  26. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.d.ts +30 -0
  27. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js +50 -0
  28. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js.map +1 -0
  29. package/dist/esm/modules/appCatalog/ui/detail/communityError.d.ts +6 -0
  30. package/dist/esm/modules/appCatalog/ui/detail/communityError.js +8 -0
  31. package/dist/esm/modules/appCatalog/ui/detail/communityError.js.map +1 -0
  32. package/dist/esm/modules/appCatalog/ui/detail/composeComments.d.ts +27 -0
  33. package/dist/esm/modules/appCatalog/ui/detail/composeComments.js +66 -0
  34. package/dist/esm/modules/appCatalog/ui/detail/composeComments.js.map +1 -0
  35. package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.d.ts +11 -0
  36. package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.js +15 -0
  37. package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.js.map +1 -0
  38. package/dist/esm/modules/extensions/features/resourceDetail.d.ts +38 -0
  39. package/dist/esm/modules/extensions/features/resourceDetail.js +4 -0
  40. package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -1
  41. package/dist/esm/modules/extensions/index.d.ts +1 -1
  42. package/dist/esm/modules/extensions/index.js.map +1 -1
  43. package/dist/index.css +501 -49
  44. package/package.json +4 -4
  45. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +5 -2
  46. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.enter.test.tsx +5 -2
  47. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +9 -10
  48. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.behindThisCard.test.tsx +402 -0
  49. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +8 -4
  50. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +5 -2
  51. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.knowMore.test.tsx +175 -0
  52. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +5 -2
  53. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +5 -2
  54. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +5 -2
  55. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.tsx +281 -0
  56. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.tsx +179 -0
  57. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.tsx +423 -0
  58. package/src/__tests__/modules/appCatalog/ui/detail/composeComments.test.ts +170 -0
  59. package/src/__tests__/ui/detail/CommunityInput.test.tsx +39 -0
  60. package/src/index.css +501 -49
  61. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +292 -289
  62. package/src/modules/appCatalog/ui/detail/CommentsFeed.tsx +254 -0
  63. package/src/modules/appCatalog/ui/detail/CommentsHelp.tsx +102 -0
  64. package/src/modules/appCatalog/ui/detail/CommunityInput.tsx +419 -0
  65. package/src/modules/appCatalog/ui/detail/CommunitySection.tsx +41 -0
  66. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +77 -71
  67. package/src/modules/appCatalog/ui/detail/communityDraft.ts +80 -0
  68. package/src/modules/appCatalog/ui/detail/communityError.ts +10 -0
  69. package/src/modules/appCatalog/ui/detail/composeComments.ts +103 -0
  70. package/src/modules/appCatalog/ui/detail/usePendingCount.ts +26 -0
  71. package/src/modules/extensions/features/resourceDetail.ts +28 -0
  72. package/src/modules/extensions/index.ts +2 -0
  73. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +0 -13
  74. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +0 -433
  75. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +0 -1
  76. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +0 -181
  77. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +0 -131
  78. package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +0 -605
  79. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{FeedbackSection.emptyState.test.d.ts → AppDetails.behindThisCard.test.d.ts} +0 -0
  80. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{FeedbackSection.reviewStatus.test.d.ts → AppDetails.knowMore.test.d.ts} +0 -0
@@ -3,17 +3,11 @@ import type {
3
3
  SourceReference,
4
4
  } from '@igstack/app-catalog-backend-core'
5
5
  import { useSearch } from '@tanstack/react-router'
6
- import { CalendarPlus, Clock, ExternalLink, Plus, Trash2 } from 'lucide-react'
6
+ import { ExternalLink, Plus, Trash2 } from 'lucide-react'
7
7
  import React from 'react'
8
8
  import { useHotkeys } from 'react-hotkeys-hook'
9
9
  import { Badge } from '~/ui/badge'
10
10
  import { Button } from '~/ui/button'
11
- import {
12
- Accordion,
13
- AccordionContent,
14
- AccordionItem,
15
- AccordionTrigger,
16
- } from '~/ui/accordion'
17
11
  import { AccessRequestSection } from '../components/AccessRequestSection'
18
12
  import { AccessPrerequisiteChain } from '../components/AccessPrerequisiteChain'
19
13
  import { QuickJumpBar, hasQuickJumps } from '../components/QuickJumpBar'
@@ -31,8 +25,13 @@ import { formatRelativeTime } from '../../utils/formatRelativeTime'
31
25
  import { TierVariantsSection } from '../components/TierVariantsSection'
32
26
  import { SubResourcesSection } from '../components/SubResourcesSection'
33
27
  import { getChildResources } from '../../utils/resolveHelpers'
34
- import { ResourceDetailAccessActions } from '~/modules/extensions'
35
- import { FeedbackSection } from './FeedbackSection'
28
+ import {
29
+ ResourceDetailAccessActions,
30
+ ResourceDetailCardSource,
31
+ } from '~/modules/extensions'
32
+ import { CommunitySection } from './CommunitySection'
33
+ import type { CommunitySectionHandle } from './CommunitySection'
34
+ import { usePendingCount } from './usePendingCount'
36
35
  import { displayUrl } from '~/modules/appCatalog/utils/displayUrl'
37
36
  import { accessTabLabel } from '~/modules/appCatalog/utils/accessTabLabel'
38
37
  import { AppDoor } from './AppDoor'
@@ -45,9 +44,28 @@ const TAB = {
45
44
  access: 'access',
46
45
  resources: 'resources',
47
46
  documentation: 'documentation',
48
- notes: 'notes',
47
+ behind: 'behind',
49
48
  } as const
50
49
 
50
+ /**
51
+ * One of Behind this card's top-level groups. A level above `Section`: its
52
+ * title is a plain heading, and a hairline separates it from the group before.
53
+ */
54
+ function Group({
55
+ title,
56
+ children,
57
+ }: {
58
+ title: string
59
+ children: React.ReactNode
60
+ }) {
61
+ return (
62
+ <section className="behind-group">
63
+ <h2 className="behind-group__title">{title}</h2>
64
+ {children}
65
+ </section>
66
+ )
67
+ }
68
+
51
69
  /** A labelled block inside a tab. One register for all of them. */
52
70
  function Section({
53
71
  title,
@@ -123,19 +141,6 @@ export function AppDetails({
123
141
  }) {
124
142
  const [isGalleryOpen, setIsGalleryOpen] = React.useState(false)
125
143
  const [galleryInitialIndex, setGalleryInitialIndex] = React.useState(0)
126
- /**
127
- * Set by the band's "Suggest a change", which has to do two things the button
128
- * cannot do alone: bring the notes tab forward, and open the composer inside it.
129
- * Held here because the band and the panel are siblings — neither can reach the
130
- * other, and routing it through the URL would put a transient intent in a
131
- * shareable link.
132
- *
133
- * A count, not a flag: the composer closes itself on send, so asking a second
134
- * time has to be distinguishable from the first ask still being set. As a
135
- * boolean the second click changed nothing and the button was dead for the rest
136
- * of the card's life.
137
- */
138
- const [composeRequests, setComposeRequests] = React.useState(0)
139
144
 
140
145
  const { approvalMethods, resources: allResources } = useAppCatalogContext()
141
146
  const { recordClick } = useAppClickHistory()
@@ -144,6 +149,39 @@ export function AppDetails({
144
149
  const user = useUser()
145
150
  const isAdmin = user?.isAdmin ?? false
146
151
  const { state: filterState } = useAppCatalogFilters()
152
+ // Agrees with the Pending badges in the feed: composed by the same rules.
153
+ const behindCount = usePendingCount(app.slug, app.claims)
154
+
155
+ // "Last checked 10 hours ago · added 7 months ago" under the sources.
156
+ const lastChecked = app.freshness?.lastCheckedAt
157
+ const dateParts = []
158
+ if (lastChecked) {
159
+ const suffix = app.freshness?.isStale ? ' (may be out of date)' : ''
160
+ dateParts.push(`Last checked ${formatRelativeTime(lastChecked)}${suffix}`)
161
+ }
162
+ if (app.createdAt) {
163
+ const addedLabel = dateParts.length > 0 ? 'added' : 'Added'
164
+ dateParts.push(`${addedLabel} ${formatRelativeTime(app.createdAt)}`)
165
+ }
166
+ const datesLine = dateParts.join(' · ')
167
+ const datesTitle = [
168
+ app.freshness?.lastContentChangeAt &&
169
+ `Content last changed ${app.freshness.lastContentChangeAt}`,
170
+ lastChecked && `Last checked ${lastChecked}`,
171
+ app.createdAt && `Added ${app.createdAt}`,
172
+ ]
173
+ .filter(Boolean)
174
+ .join(' · ')
175
+
176
+ // "Share what you know" asks for the input by bumping this. A counter, not a
177
+ // flag, so a second press while already on the tab still re-focuses. The
178
+ // effect runs after the click's render has mounted the tab's panel, so the
179
+ // ref is attached by then; opening the tab by hand bumps nothing.
180
+ const knowMoreRef = React.useRef<CommunitySectionHandle>(null)
181
+ const [shareAsk, setShareAsk] = React.useState(0)
182
+ React.useEffect(() => {
183
+ if (shareAsk) knowMoreRef.current?.focus()
184
+ }, [shareAsk])
147
185
 
148
186
  // Arriving with `?sub=` means the visitor picked one child out of a search —
149
187
  // so the card has to open ON that child's tab. Landing on Overview and
@@ -240,6 +278,8 @@ export function AppDetails({
240
278
  ? (app.childrenLabel ?? 'Resources')
241
279
  : 'Environments'
242
280
 
281
+ const hasDocumentationTab = (app.links?.length ?? 0) > 0 || !!app.background
282
+
243
283
  const tabs: DetailTab[] = [
244
284
  {
245
285
  id: TAB.overview,
@@ -367,263 +407,232 @@ export function AppDetails({
367
407
  } satisfies DetailTab,
368
408
  ]
369
409
  : []),
410
+ ...(hasDocumentationTab
411
+ ? [
412
+ {
413
+ id: TAB.documentation,
414
+ label: 'Documentation',
415
+ render: () => (
416
+ <>
417
+ {app.links && app.links.length > 0 && (
418
+ <Section title="Documentation" first>
419
+ <div className="space-y-0.5">
420
+ {app.links.map((link) => (
421
+ <a
422
+ key={link.url}
423
+ href={link.url}
424
+ target="_blank"
425
+ rel="noopener noreferrer"
426
+ className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary truncate"
427
+ >
428
+ <ExternalLink className="size-3 shrink-0" />
429
+ {link.title || displayUrl(link.url)}
430
+ </a>
431
+ ))}
432
+ </div>
433
+ </Section>
434
+ )}
435
+ {/* Background: why the app exists at all. Deliberately quiet — a
436
+ reader who came to get access has already passed everything they
437
+ need, and history should not compete with the description. */}
438
+ {app.background && (
439
+ <Section title="Background" first={!app.links?.length}>
440
+ <MarkdownText className="prose prose-sm text-muted-foreground max-w-none text-sm [&_p]:mt-0 [&_p]:mb-2 [&_p:last-child]:mb-0">
441
+ {app.background}
442
+ </MarkdownText>
443
+ </Section>
444
+ )}
445
+ </>
446
+ ),
447
+ } satisfies DetailTab,
448
+ ]
449
+ : []),
370
450
  {
371
- id: TAB.documentation,
372
- label: 'Documentation',
451
+ id: TAB.behind,
452
+ label: 'Behind this card',
453
+ chip: behindCount ? String(behindCount) : undefined,
454
+ ariaLabel: behindCount
455
+ ? `Behind this card, ${behindCount} waiting for review`
456
+ : undefined,
373
457
  render: () => (
374
- <>
375
- {app.links && app.links.length > 0 && (
376
- <Section title="Documentation" first>
377
- <div className="space-y-0.5">
378
- {app.links.map((link) => (
379
- <a
380
- key={link.url}
381
- href={link.url}
382
- target="_blank"
383
- rel="noopener noreferrer"
384
- className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary truncate"
385
- >
386
- <ExternalLink className="size-3 shrink-0" />
387
- {link.title || displayUrl(link.url)}
388
- </a>
389
- ))}
390
- </div>
391
- </Section>
392
- )}
393
- {/* Background: why the app exists at all. Deliberately quiet — a
394
- reader who came to get access has already passed everything they
395
- need, and history should not compete with the description. */}
396
- {app.background && (
397
- <Section title="Background">
398
- <MarkdownText className="prose prose-sm text-muted-foreground max-w-none text-sm [&_p]:mt-0 [&_p]:mb-2 [&_p:last-child]:mb-0">
399
- {app.background}
400
- </MarkdownText>
401
- </Section>
402
- )}
458
+ <div className="behind-panel">
459
+ <CommunitySection
460
+ ref={knowMoreRef}
461
+ appSlug={app.slug}
462
+ appName={app.displayName}
463
+ claims={app.claims}
464
+ />
403
465
 
404
- <Section title="Sources" first={!app.background}>
405
- {isAdmin ? (
406
- <>
407
- <ul className="space-y-2">
408
- {displaySources.map((url, index) => {
409
- const isDraft =
410
- draftSource !== null && index === sourceUrls.length
411
- return (
412
- <li
413
- key={isDraft ? 'draft' : `${index}-${url}`}
414
- className="group flex items-center gap-2 text-xs"
415
- >
416
- <span className="text-muted-foreground shrink-0 tabular-nums">
417
- {index + 1}.
418
- </span>
419
- <InlineEditableField
420
- value={url}
421
- initialEditMode={isDraft}
422
- onCancel={
423
- isDraft ? () => setDraftSource(null) : undefined
424
- }
425
- onSave={(newUrl) => {
426
- if (isDraft) {
427
- setDraftSource(null)
428
- if (newUrl) {
466
+ {(isAdmin ||
467
+ sourceUrls.length > 0 ||
468
+ app.createdAt ||
469
+ app.freshness?.lastCheckedAt) && (
470
+ <Group title="Kept up to date from">
471
+ {isAdmin ? (
472
+ <>
473
+ <ul className="space-y-2">
474
+ {displaySources.map((url, index) => {
475
+ const isDraft =
476
+ draftSource !== null && index === sourceUrls.length
477
+ return (
478
+ <li
479
+ key={isDraft ? 'draft' : `${index}-${url}`}
480
+ className="group flex items-center gap-2 text-xs"
481
+ >
482
+ <span className="text-muted-foreground shrink-0 tabular-nums">
483
+ {index + 1}.
484
+ </span>
485
+ <InlineEditableField
486
+ value={url}
487
+ initialEditMode={isDraft}
488
+ onCancel={
489
+ isDraft ? () => setDraftSource(null) : undefined
490
+ }
491
+ onSave={(newUrl) => {
492
+ if (isDraft) {
493
+ setDraftSource(null)
494
+ if (newUrl) {
495
+ updateApp.mutate({
496
+ id: app.id,
497
+ data: { sources: [...sourceUrls, newUrl] },
498
+ })
499
+ }
500
+ } else {
501
+ const next = [...sourceUrls]
502
+ next[index] = newUrl
429
503
  updateApp.mutate({
430
504
  id: app.id,
431
- data: { sources: [...sourceUrls, newUrl] },
505
+ data: { sources: next.filter(Boolean) },
432
506
  })
433
507
  }
434
- } else {
435
- const next = [...sourceUrls]
436
- next[index] = newUrl
437
- updateApp.mutate({
438
- id: app.id,
439
- data: { sources: next.filter(Boolean) },
440
- })
508
+ }}
509
+ placeholder="https://..."
510
+ viewClassName="flex-1 min-w-0"
511
+ renderView={(val) =>
512
+ val ? (
513
+ <a
514
+ href={val}
515
+ target="_blank"
516
+ rel="noopener noreferrer"
517
+ className="hover:text-primary inline-flex items-center gap-1 truncate"
518
+ >
519
+ {displayUrl(val)}
520
+ <ExternalLink className="size-3 shrink-0" />
521
+ </a>
522
+ ) : (
523
+ <span className="text-muted-foreground">—</span>
524
+ )
441
525
  }
442
- }}
443
- placeholder="https://..."
444
- viewClassName="flex-1 min-w-0"
445
- renderView={(val) =>
446
- val ? (
447
- <a
448
- href={val}
449
- target="_blank"
450
- rel="noopener noreferrer"
451
- className="hover:text-primary inline-flex items-center gap-1 truncate"
452
- >
453
- {displayUrl(val)}
454
- <ExternalLink className="size-3 shrink-0" />
455
- </a>
456
- ) : (
457
- <span className="text-muted-foreground">—</span>
458
- )
459
- }
460
- />
461
- {!isDraft && pulseByUrl.has(url) && (
462
- <SourcePulse
463
- pulse={pulseByUrl.get(url)!}
464
- className="ml-auto"
465
526
  />
466
- )}
467
- {!isDraft && (
468
- <Button
469
- type="button"
470
- variant="ghost"
471
- size="icon-sm"
472
- aria-label="Remove source"
473
- className="shrink-0 text-muted-foreground hover:text-destructive"
474
- onClick={() => {
475
- const next = sourceUrls.filter(
476
- (_, i) => i !== index,
477
- )
478
- updateApp.mutate({
479
- id: app.id,
480
- data: { sources: next },
481
- })
482
- }}
483
- >
484
- <Trash2 className="size-3.5" />
485
- </Button>
486
- )}
487
- </li>
488
- )
489
- })}
490
- </ul>
491
- <Button
492
- type="button"
493
- variant="ghost"
494
- size="sm"
495
- className="mt-2 gap-1 text-muted-foreground"
496
- onClick={() => setDraftSource('')}
497
- >
498
- <Plus className="size-3.5" />
499
- Add source
500
- </Button>
501
- </>
502
- ) : (
503
- <ul className="space-y-2">
504
- {sourceUrls.map((url, index) => (
505
- // `group` so hovering anywhere on the row fades that source's
506
- // track in — the mark sits in a right-hand column, and row
507
- // hover is what ties the two ends of a wide row together.
508
- <li
509
- key={index}
510
- className="group flex items-center gap-2 text-xs"
511
- >
512
- <span className="text-muted-foreground shrink-0 tabular-nums">
513
- {index + 1}.
514
- </span>
515
- {url ? (
516
- <a
517
- href={url}
518
- target="_blank"
519
- rel="noopener noreferrer"
520
- className="hover:text-primary inline-flex min-w-0 items-center gap-1 truncate"
521
- >
522
- {displayUrl(url)}
523
- <ExternalLink className="size-3 shrink-0" />
524
- </a>
525
- ) : (
526
- <span className="text-muted-foreground">—</span>
527
- )}
528
- {pulseByUrl.has(url) && (
529
- <SourcePulse
530
- pulse={pulseByUrl.get(url)!}
531
- className="ml-auto"
532
- />
533
- )}
534
- </li>
535
- ))}
536
- </ul>
537
- )}
538
- </Section>
539
-
540
- {/* These used to sit unlabelled in a 64px gap between two sections,
541
- which is what made that gap look like a mistake. Labelled, in
542
- their own block. "Dates" rather than "Added / updated" on purpose:
543
- a label that repeats the first word of every line below it adds
544
- nothing, and it made "Added"/"Updated" ambiguous to anything
545
- matching on text — including this card's own tests. */}
546
- {(app.createdAt || app.freshness?.lastCheckedAt) && (
547
- <Section title="Dates">
548
- <div className="flex flex-wrap gap-x-5 gap-y-1">
549
- {app.createdAt && (
550
- <span
551
- className="inline-flex items-center gap-1 text-xs text-muted-foreground"
552
- title={app.createdAt}
553
- >
554
- <CalendarPlus className="size-3" />
555
- Added {formatRelativeTime(app.createdAt)}
556
- </span>
557
- )}
558
- {app.freshness?.lastCheckedAt && (
559
- <span
560
- className="inline-flex items-center gap-1 text-xs text-muted-foreground"
561
- title={
562
- app.freshness.lastContentChangeAt
563
- ? `Content last changed ${app.freshness.lastContentChangeAt} · last checked ${app.freshness.lastCheckedAt}`
564
- : `Last checked ${app.freshness.lastCheckedAt}`
565
- }
527
+ {!isDraft && pulseByUrl.has(url) && (
528
+ <SourcePulse
529
+ pulse={pulseByUrl.get(url)!}
530
+ className="ml-auto"
531
+ />
532
+ )}
533
+ {!isDraft && (
534
+ <Button
535
+ type="button"
536
+ variant="ghost"
537
+ size="icon-sm"
538
+ aria-label="Remove source"
539
+ className="shrink-0 text-muted-foreground hover:text-destructive"
540
+ onClick={() => {
541
+ const next = sourceUrls.filter(
542
+ (_, i) => i !== index,
543
+ )
544
+ updateApp.mutate({
545
+ id: app.id,
546
+ data: { sources: next },
547
+ })
548
+ }}
549
+ >
550
+ <Trash2 className="size-3.5" />
551
+ </Button>
552
+ )}
553
+ </li>
554
+ )
555
+ })}
556
+ </ul>
557
+ <Button
558
+ type="button"
559
+ variant="ghost"
560
+ size="sm"
561
+ className="mt-2 gap-1 text-muted-foreground"
562
+ onClick={() => setDraftSource('')}
566
563
  >
567
- <Clock className="size-3" />
568
- {/* "Updated" means the content changed, not that a check
569
- ran — entries predating the field have none, so fall
570
- back. */}
571
- Updated{' '}
572
- {formatRelativeTime(
573
- app.freshness.lastContentChangeAt ??
574
- app.freshness.lastCheckedAt,
575
- )}
576
- {app.freshness.isStale && (
577
- <span className="italic opacity-75">
578
- {' '}
579
- · may be out of date
564
+ <Plus className="size-3.5" />
565
+ Add source
566
+ </Button>
567
+ </>
568
+ ) : (
569
+ <ul className="space-y-2">
570
+ {sourceUrls.map((url, index) => (
571
+ // `group` so hovering anywhere on the row fades that source's
572
+ // track in — the mark sits in a right-hand column, and row
573
+ // hover is what ties the two ends of a wide row together.
574
+ <li
575
+ key={index}
576
+ className="group flex items-center gap-2 text-xs"
577
+ >
578
+ <span className="text-muted-foreground shrink-0 tabular-nums">
579
+ {index + 1}.
580
580
  </span>
581
- )}
582
- </span>
583
- )}
584
- </div>
585
- </Section>
581
+ {url ? (
582
+ <a
583
+ href={url}
584
+ target="_blank"
585
+ rel="noopener noreferrer"
586
+ className="hover:text-primary inline-flex min-w-0 items-center gap-1 truncate"
587
+ >
588
+ {displayUrl(url)}
589
+ <ExternalLink className="size-3 shrink-0" />
590
+ </a>
591
+ ) : (
592
+ <span className="text-muted-foreground">—</span>
593
+ )}
594
+ {pulseByUrl.has(url) && (
595
+ <SourcePulse
596
+ pulse={pulseByUrl.get(url)!}
597
+ className="ml-auto"
598
+ />
599
+ )}
600
+ </li>
601
+ ))}
602
+ </ul>
603
+ )}
604
+ {datesLine && (
605
+ <p className="behind-dates" title={datesTitle}>
606
+ {datesLine}
607
+ </p>
608
+ )}
609
+ </Group>
586
610
  )}
587
611
 
588
- {/* Technical information: AI-facing fields, de-emphasized/collapsed */}
589
612
  {(app.aiPrompt || app.aiMemory) && (
590
- <Accordion type="single" collapsible className="mt-7">
591
- <AccordionItem
592
- value="technical-info"
593
- className="border rounded-lg px-4"
594
- >
595
- <AccordionTrigger className="text-sm hover:no-underline py-3">
596
- Technical information
597
- </AccordionTrigger>
598
- <AccordionContent className="pb-3 space-y-3">
599
- {app.aiPrompt && (
600
- <div>
601
- <h4 className="section-label mb-1">AI Prompt</h4>
602
- <p className="text-xs whitespace-pre-wrap">
603
- {app.aiPrompt}
604
- </p>
605
- </div>
606
- )}
607
- {app.aiMemory && (
608
- <div>
609
- <h4 className="section-label mb-1">AI Memory</h4>
610
- <p className="text-xs whitespace-pre-wrap">
611
- {app.aiMemory}
612
- </p>
613
- </div>
614
- )}
615
- </AccordionContent>
616
- </AccordionItem>
617
- </Accordion>
613
+ <Group title="How the AI maintains this card">
614
+ {app.aiPrompt && (
615
+ <>
616
+ <p className="behind-ai__label">Prompt</p>
617
+ <p className="behind-ai">{app.aiPrompt}</p>
618
+ </>
619
+ )}
620
+ {app.aiMemory && (
621
+ <>
622
+ <p className="behind-ai__label">Memory</p>
623
+ <p className="behind-ai">{app.aiMemory}</p>
624
+ </>
625
+ )}
626
+ </Group>
618
627
  )}
619
- </>
620
- ),
621
- },
622
- {
623
- id: TAB.notes,
624
- label: 'Notes & requests',
625
- render: () => (
626
- <FeedbackSection appSlug={app.slug} openComposer={composeRequests} />
628
+
629
+ <div className="behind-card-source">
630
+ <ResourceDetailCardSource
631
+ appSlug={app.slug}
632
+ appName={app.displayName}
633
+ />
634
+ </div>
635
+ </div>
627
636
  ),
628
637
  },
629
638
  ]
@@ -655,30 +664,12 @@ export function AppDetails({
655
664
  : 'Deprecated'}
656
665
  </Badge>
657
666
  )}
658
- {/* In the band, not at the foot of the notes panel.
659
- Down there it was invisible twice over: below the fold of a panel, and
660
- behind a tab nobody opens unless they already know what is in it — so
661
- the one affordance the feature exists for was the hardest thing on the
662
- card to find. Up here it is present on every tab, which is the whole
663
- reason the band does not scroll away.
664
- `mr-10` keeps it clear of the dialog's own close button at top-right. */}
665
- {/* Plugin actions sit beside "Suggest a change", sharing its reasoning:
666
- the band is mounted on every tab and never scrolls away. A slot in a
667
- tab panel would be invisible until a second click, and one inside
668
- SubResourceDetailPanel's `hasAnyAccess` gate would vanish for exactly
669
- the resources that need help most.
670
- Renders nothing when no plugin contributes, so the band keeps its
671
- current shape in the open-source build. */}
667
+ {/* Plugin actions live in the band because it is mounted on every tab and
668
+ never scrolls away; a slot in a tab panel would be invisible until a
669
+ second click. Renders nothing when no plugin contributes.
670
+ `mr-10` keeps them clear of the dialog's close button at top-right. */}
672
671
  <div className="ml-auto mr-10 flex items-center gap-2">
673
672
  <ResourceDetailAccessActions {...accessActionsCtx} />
674
- <Button
675
- type="button"
676
- size="sm"
677
- variant="outline"
678
- onClick={() => setComposeRequests((n) => n + 1)}
679
- >
680
- ✎ Suggest a change
681
- </Button>
682
673
  </div>
683
674
  </div>
684
675
 
@@ -780,8 +771,20 @@ export function AppDetails({
780
771
  // `?sub=` means a child was picked out of a search, so the card opens
781
772
  // on the tab that holds it rather than making them hunt for it.
782
773
  forcedTab={subSlug && hasResourcesTab ? TAB.resources : undefined}
783
- requestTab={composeRequests > 0 ? TAB.notes : undefined}
784
- requestNonce={composeRequests}
774
+ trailing={(select) => (
775
+ <button
776
+ type="button"
777
+ className="detail-share"
778
+ aria-label="Share what you know"
779
+ onClick={() => {
780
+ select(TAB.behind)
781
+ setShareAsk((n) => n + 1)
782
+ }}
783
+ >
784
+ <span className="max-sm:sr-only">Share what you know</span>
785
+ <Plus className="size-4 sm:hidden" aria-hidden="true" />
786
+ </button>
787
+ )}
785
788
  />
786
789
  </div>
787
790