@igstack/app-catalog-frontend-core 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/esm/__tests__/integration/harness/given.d.ts +1 -0
  2. package/dist/esm/__tests__/integration/skewWarning.integration.test.d.ts +0 -0
  3. package/dist/esm/__tests__/integration/tools/AppDetailTools.d.ts +5 -0
  4. package/dist/esm/__tests__/integration/tools/CatalogTools.d.ts +15 -1
  5. package/dist/esm/__tests__/modules/appCatalog/utils/resolveHelpers.test.d.ts +1 -0
  6. package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.d.ts +40 -0
  7. package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.js +40 -0
  8. package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.js.map +1 -0
  9. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.d.ts +17 -0
  10. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +61 -0
  11. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -0
  12. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +9 -2
  13. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
  14. package/dist/esm/modules/appCatalog/ui/components/OnboardingCard.js +1 -1
  15. package/dist/esm/modules/appCatalog/ui/components/OnboardingCard.js.map +1 -1
  16. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.d.ts +6 -0
  17. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +95 -31
  18. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
  19. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +9 -1
  20. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +12 -3
  21. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  22. package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js +3 -2
  23. package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js.map +1 -1
  24. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.d.ts +5 -0
  25. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +73 -18
  26. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
  27. package/dist/esm/modules/appCatalog/ui/launcher/AttributionFooter.d.ts +9 -0
  28. package/dist/esm/modules/appCatalog/ui/launcher/AttributionFooter.js +31 -0
  29. package/dist/esm/modules/appCatalog/ui/launcher/AttributionFooter.js.map +1 -0
  30. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.d.ts +16 -0
  31. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js +40 -0
  32. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js.map +1 -0
  33. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.d.ts +19 -0
  34. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js +389 -0
  35. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js.map +1 -0
  36. package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.d.ts +13 -0
  37. package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.js +45 -0
  38. package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.js.map +1 -0
  39. package/dist/esm/modules/appCatalog/ui/launcher/monogram.d.ts +4 -0
  40. package/dist/esm/modules/appCatalog/ui/launcher/monogram.js +41 -0
  41. package/dist/esm/modules/appCatalog/ui/launcher/monogram.js.map +1 -0
  42. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +40 -6
  43. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  44. package/dist/esm/modules/appCatalog/utils/markdownToPlainText.d.ts +1 -1
  45. package/dist/esm/modules/appCatalog/utils/markdownToPlainText.js.map +1 -1
  46. package/dist/esm/modules/appCatalog/utils/resolveHelpers.d.ts +10 -0
  47. package/dist/esm/modules/appCatalog/utils/resolveHelpers.js +17 -0
  48. package/dist/esm/modules/appCatalog/utils/resolveHelpers.js.map +1 -1
  49. package/dist/esm/routeTree.gen.d.ts +1 -1
  50. package/dist/esm/routes/_layout/app.$slug.d.ts +1 -1
  51. package/dist/esm/routes/_layout/app._slug.js +4 -3
  52. package/dist/esm/routes/_layout/app._slug.js.map +1 -1
  53. package/dist/esm/routes/_layout/catalog.apps.index.js +2 -1
  54. package/dist/esm/routes/_layout/catalog.apps.index.js.map +1 -1
  55. package/dist/esm/routes/_layout/index.js +2 -1
  56. package/dist/esm/routes/_layout/index.js.map +1 -1
  57. package/dist/esm/routes/_layout/service-desks.js +2 -1
  58. package/dist/esm/routes/_layout/service-desks.js.map +1 -1
  59. package/dist/esm/types/uiSettings.d.ts +15 -0
  60. package/dist/esm/ui/components/header/Header.js +1 -1
  61. package/dist/esm/ui/components/header/Header.js.map +1 -1
  62. package/dist/index.css +73 -66
  63. package/package.json +4 -4
  64. package/src/__tests__/integration/appCatalog.integration.test.ts +106 -0
  65. package/src/__tests__/integration/appDeepLink.integration.test.ts +50 -24
  66. package/src/__tests__/integration/appFreshness.integration.test.ts +3 -3
  67. package/src/__tests__/integration/crossRefLinkListView.integration.test.ts +8 -8
  68. package/src/__tests__/integration/harness/given.tsx +9 -1
  69. package/src/__tests__/integration/replacementLink.integration.test.ts +6 -2
  70. package/src/__tests__/integration/searchDeprecatedFallback.integration.test.ts +21 -16
  71. package/src/__tests__/integration/setup/testSetup.ts +1 -0
  72. package/src/__tests__/integration/skewWarning.integration.test.ts +56 -0
  73. package/src/__tests__/integration/tools/AppDetailTools.ts +13 -0
  74. package/src/__tests__/integration/tools/CatalogTools.ts +62 -11
  75. package/src/__tests__/integration/tools/GalleryTools.ts +7 -1
  76. package/src/__tests__/modules/appCatalog/utils/markdownToPlainText.test.ts +5 -5
  77. package/src/__tests__/modules/appCatalog/utils/resolveHelpers.test.ts +79 -0
  78. package/src/index.css +73 -66
  79. package/src/modules/appCatalog/hooks/useSessionSyncedState.ts +83 -0
  80. package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +89 -0
  81. package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +31 -2
  82. package/src/modules/appCatalog/ui/components/OnboardingCard.tsx +1 -1
  83. package/src/modules/appCatalog/ui/components/PersonBadge.tsx +103 -30
  84. package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +22 -1
  85. package/src/modules/appCatalog/ui/context/AppCatalogFiltersContext.tsx +11 -5
  86. package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +90 -23
  87. package/src/modules/appCatalog/ui/launcher/AttributionFooter.tsx +43 -0
  88. package/src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx +52 -0
  89. package/src/modules/appCatalog/ui/launcher/LauncherHome.tsx +547 -0
  90. package/src/modules/appCatalog/ui/launcher/ResourceIcon.tsx +54 -0
  91. package/src/modules/appCatalog/ui/launcher/monogram.ts +41 -0
  92. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +75 -13
  93. package/src/modules/appCatalog/utils/markdownToPlainText.ts +1 -1
  94. package/src/modules/appCatalog/utils/resolveHelpers.ts +31 -0
  95. package/src/routes/_layout/app.$slug.tsx +7 -4
  96. package/src/routes/_layout/catalog.apps.index.tsx +4 -1
  97. package/src/routes/_layout/index.tsx +5 -1
  98. package/src/routes/_layout/service-desks.tsx +4 -1
  99. package/src/types/uiSettings.ts +15 -0
  100. package/src/ui/components/header/Header.tsx +3 -1
@@ -1,6 +1,7 @@
1
- import { Check, Copy, User } from 'lucide-react'
1
+ import { Check, Copy, Mail, User } from 'lucide-react'
2
2
  import { useCallback, useEffect, useRef, useState } from 'react'
3
3
  import { Badge } from '~/ui/badge'
4
+ import { Popover, PopoverContent, PopoverTrigger } from '~/ui/popover'
4
5
  import { useAppCatalogContext } from '~/modules/appCatalog'
5
6
  import { getPersonBySlug } from '~/modules/appCatalog/utils/resolveHelpers'
6
7
 
@@ -8,6 +9,12 @@ interface PersonBadgeProps {
8
9
  slug: string
9
10
  }
10
11
 
12
+ /**
13
+ * Person chip. Clicking it opens a small popover exposing BOTH the full name
14
+ * and the email, each with its own copy button — so the user picks what to copy
15
+ * (previously a single button copied only the email). Email also links to
16
+ * mailto:. Falls back to just the name (no popover) when no email is known.
17
+ */
11
18
  export function PersonBadge({ slug }: PersonBadgeProps) {
12
19
  const { persons } = useAppCatalogContext()
13
20
  const person = getPersonBySlug(persons, slug)
@@ -15,55 +22,121 @@ export function PersonBadge({ slug }: PersonBadgeProps) {
15
22
  const displayName = person
16
23
  ? `${person.firstName} ${person.lastName}`.trim() || slug
17
24
  : slug
18
-
19
25
  const email = person?.email
20
26
 
27
+ // No email → nothing to pick between; render a plain, non-interactive chip.
28
+ if (!email) {
29
+ return (
30
+ <Badge
31
+ variant="outline"
32
+ className="font-normal inline-flex items-center gap-1"
33
+ title={displayName}
34
+ >
35
+ <User className="size-3" />
36
+ {displayName}
37
+ </Badge>
38
+ )
39
+ }
40
+
21
41
  return (
22
- <Badge
23
- variant="outline"
24
- className="font-normal inline-flex items-center gap-1"
25
- title={email ? `${displayName} (${email})` : displayName}
26
- >
27
- <User className="size-3" />
28
- {displayName}
29
- {email && <CopyEmailButton email={email} />}
30
- </Badge>
42
+ <Popover>
43
+ <PopoverTrigger asChild>
44
+ <button
45
+ type="button"
46
+ className="inline-flex"
47
+ title={`${displayName} · ${email}`}
48
+ >
49
+ <Badge
50
+ variant="outline"
51
+ className="font-normal inline-flex items-center gap-1 cursor-pointer hover:border-primary transition-colors"
52
+ >
53
+ <User className="size-3" />
54
+ {displayName}
55
+ <Copy className="size-3 opacity-50" />
56
+ </Badge>
57
+ </button>
58
+ </PopoverTrigger>
59
+ <PopoverContent align="start" className="w-64 p-2">
60
+ <CopyRow
61
+ icon={<User className="size-3.5" />}
62
+ label="Name"
63
+ value={displayName}
64
+ />
65
+ <CopyRow
66
+ icon={<Mail className="size-3.5" />}
67
+ label="Email"
68
+ value={email}
69
+ href={`mailto:${email}`}
70
+ />
71
+ </PopoverContent>
72
+ </Popover>
31
73
  )
32
74
  }
33
75
 
34
- function CopyEmailButton({ email }: { email: string }) {
76
+ /** One copyable field (label + value + copy button), value optionally a link. */
77
+ function CopyRow({
78
+ icon,
79
+ label,
80
+ value,
81
+ href,
82
+ }: {
83
+ icon: React.ReactNode
84
+ label: string
85
+ value: string
86
+ href?: string
87
+ }) {
35
88
  const [copied, setCopied] = useState(false)
36
89
  const timeoutRef = useRef<NodeJS.Timeout | null>(null)
37
90
 
38
- useEffect(() => {
39
- return () => {
91
+ useEffect(
92
+ () => () => {
40
93
  if (timeoutRef.current) clearTimeout(timeoutRef.current)
41
- }
42
- }, [])
94
+ },
95
+ [],
96
+ )
43
97
 
44
98
  const handleCopy = useCallback(
45
99
  (e: React.MouseEvent) => {
46
100
  e.stopPropagation()
47
- navigator.clipboard.writeText(email)
101
+ void navigator.clipboard.writeText(value)
48
102
  setCopied(true)
49
103
  if (timeoutRef.current) clearTimeout(timeoutRef.current)
50
104
  timeoutRef.current = setTimeout(() => setCopied(false), 2000)
51
105
  },
52
- [email],
106
+ [value],
53
107
  )
54
108
 
55
109
  return (
56
- <button
57
- onClick={handleCopy}
58
- className="ml-0.5 hover:text-primary transition-colors"
59
- title={copied ? 'Copied!' : `Copy ${email}`}
60
- type="button"
61
- >
62
- {copied ? (
63
- <Check className="size-3 text-green-600" />
64
- ) : (
65
- <Copy className="size-3" />
66
- )}
67
- </button>
110
+ <div className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted">
111
+ <span className="text-muted-foreground shrink-0">{icon}</span>
112
+ <span className="min-w-0 flex-1">
113
+ <span className="block text-[10px] uppercase tracking-wide text-muted-foreground">
114
+ {label}
115
+ </span>
116
+ {href ? (
117
+ <a
118
+ href={href}
119
+ className="block truncate text-[13px] text-primary hover:underline"
120
+ >
121
+ {value}
122
+ </a>
123
+ ) : (
124
+ <span className="block truncate text-[13px]">{value}</span>
125
+ )}
126
+ </span>
127
+ <button
128
+ type="button"
129
+ onClick={handleCopy}
130
+ className="shrink-0 rounded p-1 text-muted-foreground hover:text-primary hover:bg-background transition-colors"
131
+ title={copied ? 'Copied!' : `Copy ${label.toLowerCase()}`}
132
+ aria-label={`Copy ${label.toLowerCase()}`}
133
+ >
134
+ {copied ? (
135
+ <Check className="size-3.5 text-green-600" />
136
+ ) : (
137
+ <Copy className="size-3.5" />
138
+ )}
139
+ </button>
140
+ </div>
68
141
  )
69
142
  }
@@ -25,6 +25,14 @@ import { markdownToPlainText } from '~/modules/appCatalog/utils/markdownToPlainT
25
25
 
26
26
  interface SubResourcesSectionProps {
27
27
  subResources: Resource[]
28
+ /**
29
+ * Initial filter text (#38 item C). When the user reached this app by
30
+ * searching a term that matched a sub-resource, seed the sub-resource filter
31
+ * with that term so the matched child is revealed instead of buried in a
32
+ * long list (e.g. searching "biom" → open AWS Console → the biomarker
33
+ * account is pre-filtered). Only applied when it actually matches a child.
34
+ */
35
+ initialSearch?: string
28
36
  }
29
37
 
30
38
  function getTierBadgeVariant(
@@ -90,9 +98,22 @@ function CopyAccountIdButton({ accountId }: { accountId: string }) {
90
98
 
91
99
  export function SubResourcesSection({
92
100
  subResources,
101
+ initialSearch,
93
102
  }: SubResourcesSectionProps) {
94
103
  const { groups } = useAppCatalogContext()
95
- const [search, setSearch] = useState('')
104
+ // Seed the filter with the incoming query ONLY if it matches a child, so we
105
+ // reveal the matched sub-resource without hiding everything on a non-match.
106
+ const seededSearch = useMemo(() => {
107
+ const q = initialSearch?.trim().toLowerCase()
108
+ if (!q) return ''
109
+ const hit = subResources.some(
110
+ (sr) =>
111
+ sr.displayName.toLowerCase().includes(q) ||
112
+ (sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)),
113
+ )
114
+ return hit ? initialSearch!.trim() : ''
115
+ }, [initialSearch, subResources])
116
+ const [search, setSearch] = useState(seededSearch)
96
117
  const [tierFilter, setTierFilter] = useState<string>('all')
97
118
 
98
119
  const uniqueTiers = useMemo(() => {
@@ -2,6 +2,10 @@ import type { ReactNode } from 'react'
2
2
  import { createContext, use, useMemo } from 'react'
3
3
  import { useAppCatalogContext } from '../../context/AppCatalogContext'
4
4
  import { useUrlSyncedState } from '../../hooks/useUrlSyncedState'
5
+ import {
6
+ SEARCH_STORAGE_KEY,
7
+ useSessionSyncedState,
8
+ } from '../../hooks/useSessionSyncedState'
5
9
  import {
6
10
  decodeFiltersParam,
7
11
  encodeFiltersParam,
@@ -100,11 +104,13 @@ export function AppCatalogFiltersProvider({
100
104
  encode: encodeFiltersParam,
101
105
  })
102
106
 
103
- // Search value is synced to URL (key `q`) so it survives navigation —
104
- // e.g. auto-opening an app's detail page when the query narrows to one match
105
- // (the filters provider is mounted per-route and remounts on navigation).
106
- const [searchValue, setSearchValue] = useUrlSyncedState<string>({
107
- key: 'q',
107
+ // Search value persists in sessionStorage (not the URL) so it survives the
108
+ // per-route remount of this provider — e.g. auto-opening an app's detail page
109
+ // when the query narrows to one match (#10) WITHOUT leaking `?q=` into every
110
+ // shared/bookmarked app link (#27). The synchronous store read/write also
111
+ // removes the effect-ordering race the URL sync had (#10).
112
+ const [searchValue, setSearchValue] = useSessionSyncedState<string>({
113
+ key: SEARCH_STORAGE_KEY,
108
114
  defaultValue: '',
109
115
  decode: (value) => value,
110
116
  encode: (value) => (value === '' ? undefined : value),
@@ -8,7 +8,7 @@ import {
8
8
  getCoreRowModel,
9
9
  useReactTable,
10
10
  } from '@tanstack/react-table'
11
- import { AppWindow, ExternalLink, Plus, Trash2, X } from 'lucide-react'
11
+ import { AppWindow, Clock, ExternalLink, Plus, Trash2, X } from 'lucide-react'
12
12
  import React, { useState } from 'react'
13
13
  import { useHotkeys } from 'react-hotkeys-hook'
14
14
  import { cn } from '~/lib/utils'
@@ -29,6 +29,9 @@ import {
29
29
  TableRow,
30
30
  } from '~/ui/table'
31
31
  import { AccessRequestSection } from '../components/AccessRequestSection'
32
+ import { AccessPrerequisiteChain } from '../components/AccessPrerequisiteChain'
33
+ import { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'
34
+ import { PersonBadge } from '../components/PersonBadge'
32
35
  import { useUser } from '~/modules/auth'
33
36
  import { InlineEditableField } from '../components/InlineEditableField'
34
37
  import { MarkdownText } from '../components/MarkdownText'
@@ -173,6 +176,10 @@ function AppScreenshot({ app }: { app: Resource }) {
173
176
 
174
177
  function TiersAndSubResourcesPanel({ app }: { app: Resource }) {
175
178
  const { resources } = useAppCatalogContext()
179
+ // #38 item C: the active catalog search term. When the user reached this app
180
+ // by searching something that matched a child, seed the sub-resource filter
181
+ // with it so the matched child is revealed.
182
+ const { state: filterState } = useAppCatalogFilters()
176
183
  const appSubResources = React.useMemo(
177
184
  () => getChildResources(resources, app.slug),
178
185
  [resources, app.slug],
@@ -187,14 +194,17 @@ function TiersAndSubResourcesPanel({ app }: { app: Resource }) {
187
194
  )}
188
195
  {appSubResources.length > 0 && (
189
196
  <div className="mt-6">
190
- <SubResourcesSection subResources={appSubResources} />
197
+ <SubResourcesSection
198
+ subResources={appSubResources}
199
+ initialSearch={filterState.searchValue}
200
+ />
191
201
  </div>
192
202
  )}
193
203
  </>
194
204
  )
195
205
  }
196
206
 
197
- function AppDetails({
207
+ export function AppDetails({
198
208
  app,
199
209
  onAppClick,
200
210
  onClosePanel,
@@ -269,7 +279,7 @@ function AppDetails({
269
279
  <AppIcon app={app} className="size-16" />
270
280
  <div className="-mx-3 flex-1 min-w-0">
271
281
  <div className="flex items-center gap-2 px-3">
272
- <div className="text-2xl font-semibold min-w-0">
282
+ <div className="font-serif text-2xl font-semibold min-w-0">
273
283
  {app.abbreviation
274
284
  ? `${app.displayName} (${app.abbreviation})`
275
285
  : app.displayName}
@@ -333,15 +343,36 @@ function AppDetails({
333
343
  target="_blank"
334
344
  rel="noopener noreferrer"
335
345
  onClick={() => recordClick(app.slug)}
336
- className="inline-flex items-center gap-1 rounded-md py-1 text-sm text-blue-600 hover:bg-accent/30 hover:underline dark:text-blue-400 transition-all"
346
+ className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs text-muted-foreground hover:border-primary hover:text-primary transition-all"
347
+ title="Open app in new tab (secondary — see access info below)"
337
348
  >
338
349
  {app.appUrl.replace(/https?:\/\//g, '')}
339
- <ExternalLink className="size-3.5 shrink-0 opacity-40 transition-opacity" />
350
+ <ExternalLink className="size-3 shrink-0 opacity-60" />
340
351
  </a>
341
352
  ) : (
342
353
  <span className="text-muted-foreground">—</span>
343
354
  )}
344
355
  </div>
356
+ {/* Freshness date near the header (this is the same signal that
357
+ drives "New this week"), so the date is visible in the card
358
+ itself, not only in the muted footer. */}
359
+ {app.freshness?.lastCheckedAt && (
360
+ <div className="mt-1 px-3">
361
+ <span
362
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground"
363
+ title={app.freshness.lastCheckedAt}
364
+ >
365
+ <Clock className="size-3" />
366
+ Updated {formatRelativeTime(app.freshness.lastCheckedAt)}
367
+ {app.freshness.isStale && (
368
+ <span className="italic opacity-75">
369
+ {' '}
370
+ · may be out of date
371
+ </span>
372
+ )}
373
+ </span>
374
+ </div>
375
+ )}
345
376
  </div>
346
377
  </div>
347
378
  </div>
@@ -387,6 +418,13 @@ function AppDetails({
387
418
  )
388
419
  })()}
389
420
 
421
+ {/* Two-step access (#38 D): for a nested resource, show the parent-first
422
+ prerequisite chain before this resource's own access instructions. */}
423
+ <AccessPrerequisiteChain resource={app} onOpenParent={onAppClick} />
424
+
425
+ {/* Access Request Section — hero of the detail, shown before description */}
426
+ <AccessRequestSection app={app} approvalMethods={approvalMethods} />
427
+
390
428
  {/* Description */}
391
429
  <div className="mt-6">
392
430
  <h3 className="mb-2 text-sm font-medium">Description</h3>
@@ -429,8 +467,17 @@ function AppDetails({
429
467
  </div>
430
468
  )}
431
469
 
432
- {/* Access Request Section */}
433
- <AccessRequestSection app={app} approvalMethods={approvalMethods} />
470
+ {/* Owner who is responsible for this resource. Distinct from the
471
+ access approver (who decides access requests); see domain model. */}
472
+ {app.ownerPersonSlug && (
473
+ <div className="mt-6">
474
+ <h3 className="mb-1 text-sm font-medium">Owner</h3>
475
+ <p className="mb-2 text-xs text-muted-foreground">
476
+ Who is responsible for this resource
477
+ </p>
478
+ <PersonBadge slug={app.ownerPersonSlug} />
479
+ </div>
480
+ )}
434
481
 
435
482
  {/* Tier Variants and Sub-Resources */}
436
483
  <TiersAndSubResourcesPanel app={app} />
@@ -606,18 +653,7 @@ function AppDetails({
606
653
  )}
607
654
  </div>
608
655
 
609
- {/* Freshness: muted "last checked" line, rarely-needed metadata. The
610
- backend owns the semantic (freshness.isStale); we only render. */}
611
- {app.freshness?.lastCheckedAt && (
612
- <p className="mt-4 text-xs text-muted-foreground">
613
- <span title={app.freshness.lastCheckedAt}>
614
- Last checked {formatRelativeTime(app.freshness.lastCheckedAt)}
615
- </span>
616
- {app.freshness.isStale && (
617
- <span className="italic opacity-75"> · may be out of date</span>
618
- )}
619
- </p>
620
- )}
656
+ {/* Freshness now shown near the header (see "Updated " above). */}
621
657
  </div>
622
658
 
623
659
  {/* Screenshot Gallery Dialog */}
@@ -742,6 +778,9 @@ export function AppCatalogGrid({
742
778
  onAppClick,
743
779
  })
744
780
 
781
+ // Launch history for the secondary "open in new tab" action on each row.
782
+ const { recordClick: recordLaunch } = useAppClickHistory()
783
+
745
784
  // Build a map of parentSlug -> matched child resource displayName for search annotation
746
785
  const { resources: allResources2 } = useAppCatalogContext()
747
786
  const matchedSubResourceMap = React.useMemo(() => {
@@ -847,8 +886,36 @@ export function AppCatalogGrid({
847
886
  </div>
848
887
  ),
849
888
  },
889
+ {
890
+ // Secondary "launch" action (#31): the primary row click opens the
891
+ // detail panel (how to get access); this quiet ↗ button is the fast
892
+ // "I just want the URL" jump. stopPropagation so it doesn't also open
893
+ // the panel. Only shown when the resource has a URL.
894
+ id: 'launch',
895
+ header: '',
896
+ cell: ({ row }) =>
897
+ row.original.appUrl ? (
898
+ <a
899
+ href={row.original.appUrl}
900
+ target="_blank"
901
+ rel="noopener noreferrer"
902
+ aria-label={`Open ${row.original.displayName} in a new tab`}
903
+ title={`Open ${row.original.displayName}`}
904
+ onClick={(e) => {
905
+ e.stopPropagation()
906
+ recordLaunch(row.original.slug)
907
+ }}
908
+ className="inline-flex size-8 items-center justify-center rounded-full border text-muted-foreground opacity-0 transition-opacity hover:border-primary hover:text-primary group-hover:opacity-100 focus-visible:opacity-100"
909
+ >
910
+ <ExternalLink className="size-4" />
911
+ </a>
912
+ ) : null,
913
+ meta: {
914
+ className: 'w-[56px] text-right',
915
+ },
916
+ },
850
917
  ],
851
- [searchQuery, matchedSubResourceMap],
918
+ [searchQuery, matchedSubResourceMap, recordLaunch],
852
919
  )
853
920
 
854
921
  // Create a single table instance with all apps
@@ -937,7 +1004,7 @@ export function AppCatalogGrid({
937
1004
  className="px-4 py-6 sticky top-[49px] bg-muted/90 backdrop-blur z-10"
938
1005
  >
939
1006
  <div className="flex items-center justify-center">
940
- <span className="font-bold text-lg tracking-widest uppercase leading-loose text-muted-foreground">
1007
+ <span className="font-serif font-semibold text-lg tracking-widest uppercase leading-loose text-muted-foreground">
941
1008
  {group.groupName}
942
1009
  </span>
943
1010
  </div>
@@ -963,7 +1030,7 @@ export function AppCatalogGrid({
963
1030
  }}
964
1031
  onClick={() => handleAppClick(row.original)}
965
1032
  className={cn(
966
- 'border-b cursor-pointer transition-colors',
1033
+ 'group border-b cursor-pointer transition-colors',
967
1034
  selectedApp?.id === row.original.id
968
1035
  ? 'bg-blue-100 dark:bg-blue-950 hover:bg-blue-200 dark:hover:bg-blue-900'
969
1036
  : 'hover:bg-muted/30',
@@ -0,0 +1,43 @@
1
+ import { ExternalLink, Github } from 'lucide-react'
2
+ import { useUiSettings } from '~/context/UiSettingsContext'
3
+
4
+ /**
5
+ * Subtle attribution line for the home view (author + repo links). Content is
6
+ * supplied by the consuming app via `UiSettings.attribution` — the OSS core
7
+ * hard-codes nothing company-specific. Renders nothing when unset.
8
+ *
9
+ * `kind: 'oss'` links get a GitHub mark; everything else gets a generic
10
+ * external-link icon (proprietary repos, etc.).
11
+ */
12
+ export function AttributionFooter() {
13
+ const { attribution } = useUiSettings()
14
+ if (!attribution || (!attribution.madeBy && !attribution.links?.length)) {
15
+ return null
16
+ }
17
+
18
+ return (
19
+ <footer className="mt-16 border-t border-border/60 pt-5 text-center text-[12.5px] text-muted-foreground">
20
+ {attribution.madeBy && <span>{attribution.madeBy}</span>}
21
+ {attribution.links && attribution.links.length > 0 && (
22
+ <div className="mt-1.5 flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
23
+ {attribution.links.map((link) => (
24
+ <a
25
+ key={link.url}
26
+ href={link.url}
27
+ target="_blank"
28
+ rel="noopener noreferrer"
29
+ className="inline-flex items-center gap-1 hover:text-primary transition-colors"
30
+ >
31
+ {link.kind === 'oss' ? (
32
+ <Github className="size-3.5" />
33
+ ) : (
34
+ <ExternalLink className="size-3.5" />
35
+ )}
36
+ {link.label}
37
+ </a>
38
+ ))}
39
+ </div>
40
+ )}
41
+ </footer>
42
+ )
43
+ }
@@ -0,0 +1,52 @@
1
+ import type { Resource } from '@igstack/app-catalog-backend-core'
2
+ import { AppDetails } from '../grid/AppCatalogGrid'
3
+
4
+ /**
5
+ * Centered detail card for the launcher (#38, item B). Renders the rich
6
+ * AppDetails (access-hero, sub-resources, tiers) as a large centered modal card
7
+ * over the launcher backdrop, instead of the old grid + resizable split-pane.
8
+ *
9
+ * Escape handling: this component does NOT globally bind Escape. Escape-to-close
10
+ * is owned by AppDetails' own key handling (which first closes an open
11
+ * screenshot gallery, then closes the card), so pressing Esc inside the gallery
12
+ * returns to the card — not all the way to the home page (#38 Esc-stacking bug).
13
+ */
14
+ export function LauncherDetailPanel({
15
+ app,
16
+ onClose,
17
+ onAppClick,
18
+ }: {
19
+ app: Resource
20
+ onClose: () => void
21
+ onAppClick?: (app: Resource) => void
22
+ }) {
23
+ return (
24
+ <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10">
25
+ {/* scrim */}
26
+ <button
27
+ type="button"
28
+ aria-label="Close details panel"
29
+ onClick={onClose}
30
+ className="fixed inset-0 -z-10 bg-black/40 backdrop-blur-[2px] animate-in fade-in"
31
+ />
32
+ {/* centered card — wide enough for data tables (sub-resources have 5
33
+ columns incl. "Access Contacts"/"AWS Account" that wrapped at 760px);
34
+ prose inside AppDetails is width-capped separately so it stays
35
+ readable. Caps at 94vw so it never touches the edges. */}
36
+ <div
37
+ role="dialog"
38
+ aria-modal="true"
39
+ aria-label={`${app.displayName} details`}
40
+ className="relative w-full max-w-[min(1120px,94vw)] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200"
41
+ >
42
+ <div className="max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7">
43
+ <AppDetails
44
+ app={app}
45
+ onAppClick={onAppClick}
46
+ onClosePanel={onClose}
47
+ />
48
+ </div>
49
+ </div>
50
+ </div>
51
+ )
52
+ }