@igstack/app-catalog-frontend-core 6.6.0 → 6.7.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 (139) hide show
  1. package/dist/esm/App.d.ts +29 -3
  2. package/dist/esm/App.js +4 -2
  3. package/dist/esm/App.js.map +1 -1
  4. package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.d.ts +0 -0
  5. package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.rowMemo.test.d.ts +0 -0
  6. package/dist/esm/__tests__/modules/dismiss/DismissStack.test.d.ts +0 -0
  7. package/dist/esm/__tests__/modules/extensions/slots.test.d.ts +0 -0
  8. package/dist/esm/__tests__/publicEntryResolves.test.d.ts +1 -0
  9. package/dist/esm/appPropsFactory.d.ts +19 -1
  10. package/dist/esm/appPropsFactory.js +4 -3
  11. package/dist/esm/appPropsFactory.js.map +1 -1
  12. package/dist/esm/index.d.ts +15 -2
  13. package/dist/esm/index.js +5 -0
  14. package/dist/esm/index.js.map +1 -1
  15. package/dist/esm/internal.d.ts +7 -1
  16. package/dist/esm/internal.js +0 -2
  17. package/dist/esm/internal.js.map +1 -1
  18. package/dist/esm/modules/appCatalog/context/AppCatalogContext.d.ts +17 -0
  19. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +7 -16
  20. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
  21. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +20 -11
  22. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
  23. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +39 -17
  24. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  25. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +0 -14
  26. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
  27. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js +5 -18
  28. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js.map +1 -1
  29. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.d.ts +12 -1
  30. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +3 -1
  31. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
  32. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +17 -1
  33. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +212 -79
  34. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  35. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +0 -14
  36. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
  37. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.d.ts +1 -2
  38. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +31 -23
  39. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  40. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +5 -0
  41. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
  42. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +5 -3
  43. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  44. package/dist/esm/modules/auth/AuthContext.d.ts +12 -0
  45. package/dist/esm/modules/auth/AuthContext.js +5 -0
  46. package/dist/esm/modules/auth/AuthContext.js.map +1 -1
  47. package/dist/esm/modules/auth/index.d.ts +1 -1
  48. package/dist/esm/modules/dismiss/DismissStack.d.ts +102 -0
  49. package/dist/esm/modules/dismiss/DismissStack.js +82 -0
  50. package/dist/esm/modules/dismiss/DismissStack.js.map +1 -0
  51. package/dist/esm/modules/extensions/ExtensionsContext.d.ts +17 -0
  52. package/dist/esm/modules/extensions/ExtensionsContext.js +15 -0
  53. package/dist/esm/modules/extensions/ExtensionsContext.js.map +1 -0
  54. package/dist/esm/modules/extensions/factories.d.ts +18 -0
  55. package/dist/esm/modules/extensions/factories.js +12 -0
  56. package/dist/esm/modules/extensions/factories.js.map +1 -0
  57. package/dist/esm/modules/extensions/features/resourceDetail.d.ts +92 -0
  58. package/dist/esm/modules/extensions/features/resourceDetail.js +14 -0
  59. package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -0
  60. package/dist/esm/modules/extensions/index.d.ts +79 -0
  61. package/dist/esm/modules/extensions/index.js +26 -0
  62. package/dist/esm/modules/extensions/index.js.map +1 -0
  63. package/dist/esm/modules/extensions/makeSlot.d.ts +46 -0
  64. package/dist/esm/modules/extensions/makeSlot.js +62 -0
  65. package/dist/esm/modules/extensions/makeSlot.js.map +1 -0
  66. package/dist/esm/modules/extensions/types.d.ts +46 -0
  67. package/dist/esm/modules/gallery/Gallery.js +6 -14
  68. package/dist/esm/modules/gallery/Gallery.js.map +1 -1
  69. package/dist/esm/routeTree.gen.d.ts +34 -34
  70. package/dist/esm/routeTree.gen.js +14 -14
  71. package/dist/esm/routeTree.gen.js.map +1 -1
  72. package/dist/esm/router.d.ts +23 -0
  73. package/dist/esm/router.js +5 -0
  74. package/dist/esm/router.js.map +1 -0
  75. package/dist/esm/routes/_layout/app.$slug.d.ts +1 -1
  76. package/dist/esm/routes/_layout/app.$slug_.sub.$subSlug.d.ts +1 -1
  77. package/dist/esm/routes/_layout/catalog.apps.index.d.ts +2 -2
  78. package/dist/esm/routes/_layout/index.d.ts +1 -1
  79. package/dist/esm/routes/_layout/login.d.ts +1 -1
  80. package/dist/esm/routes/_layout/mcp.d.ts +1 -1
  81. package/dist/esm/routes/_layout/service-desks.d.ts +1 -1
  82. package/dist/esm/routes/_layout.d.ts +1 -1
  83. package/dist/esm/routes/_layout.js +13 -2
  84. package/dist/esm/routes/_layout.js.map +1 -1
  85. package/dist/esm/routes/auth.callback.d.ts +1 -1
  86. package/dist/esm/ui/components/error/DefaultErrorComponent.js +9 -2
  87. package/dist/esm/ui/components/error/DefaultErrorComponent.js.map +1 -1
  88. package/dist/esm/ui/components/header/Header.js +0 -1
  89. package/dist/esm/ui/components/header/Header.js.map +1 -1
  90. package/dist/esm/ui/dialog.d.ts +1 -1
  91. package/dist/esm/ui/dialog.js +9 -0
  92. package/dist/esm/ui/dialog.js.map +1 -1
  93. package/dist/esm/ui/table.d.ts +25 -1
  94. package/dist/esm/ui/table.js +20 -3
  95. package/dist/esm/ui/table.js.map +1 -1
  96. package/dist/esm/util/createAcRouter.d.ts +1 -1
  97. package/package.json +19 -11
  98. package/src/App.tsx +34 -3
  99. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +4 -1
  100. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.focus.test.tsx +1 -1
  101. package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.tsx +115 -0
  102. package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.rowMemo.test.tsx +120 -0
  103. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +3 -5
  104. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +9 -9
  105. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +31 -5
  106. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +3 -3
  107. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +48 -6
  108. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +1 -7
  109. package/src/__tests__/modules/dismiss/DismissStack.test.tsx +330 -0
  110. package/src/__tests__/modules/extensions/slots.test.tsx +252 -0
  111. package/src/__tests__/publicEntryResolves.test.ts +63 -0
  112. package/src/appPropsFactory.ts +23 -3
  113. package/src/index.tsx +32 -2
  114. package/src/internal.ts +7 -1
  115. package/src/modules/appCatalog/context/AppCatalogContext.tsx +21 -0
  116. package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +52 -12
  117. package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +59 -18
  118. package/src/modules/appCatalog/ui/components/ScreenshotGallery.tsx +12 -20
  119. package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +19 -0
  120. package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +396 -111
  121. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +46 -26
  122. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +20 -0
  123. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +17 -3
  124. package/src/modules/auth/AuthContext.tsx +15 -0
  125. package/src/modules/auth/index.ts +1 -0
  126. package/src/modules/dismiss/DismissStack.tsx +224 -0
  127. package/src/modules/extensions/ExtensionsContext.tsx +36 -0
  128. package/src/modules/extensions/factories.ts +19 -0
  129. package/src/modules/extensions/features/resourceDetail.ts +74 -0
  130. package/src/modules/extensions/index.ts +121 -0
  131. package/src/modules/extensions/makeSlot.tsx +123 -0
  132. package/src/modules/extensions/types.ts +51 -0
  133. package/src/modules/gallery/Gallery.tsx +13 -17
  134. package/src/routeTree.gen.ts +31 -31
  135. package/src/router.ts +23 -0
  136. package/src/routes/_layout.tsx +51 -1
  137. package/src/ui/components/error/DefaultErrorComponent.tsx +22 -4
  138. package/src/ui/dialog.tsx +26 -0
  139. package/src/ui/table.tsx +46 -3
package/src/index.tsx CHANGED
@@ -1,4 +1,8 @@
1
1
  export { appPropsFactory } from './appPropsFactory.js'
2
+ export type { AppPropsFactoryOptions } from './appPropsFactory.js'
3
+ // What a deployment's own router factory is handed. Safe on this entry: it
4
+ // names no route tree, so it carries no `declare module` with it.
5
+ export type { AcRouterContext, AcRouterInitParams } from './types/types'
2
6
  export { App } from './App'
3
7
  export type { AppProps } from './App'
4
8
  export type { AreaIcon, AreasSettings, UiSettings } from './types/uiSettings'
@@ -6,5 +10,31 @@ export {
6
10
  DAY_TO_DAY_AREA_KEY,
7
11
  DAY_TO_DAY_TAG,
8
12
  } from './modules/appCatalog/utils/areaGrouping'
9
- export { PwaAutoUpdateProvider, usePwaAutoUpdate } from './modules/pwa'
10
- export type { PwaAutoUpdateOptions, PwaUpdateHandle } from './modules/pwa'
13
+ export { PwaAutoUpdateProvider, usePwaAutoUpdate } from './modules/pwa/index'
14
+ export type { PwaAutoUpdateOptions, PwaUpdateHandle } from './modules/pwa/index'
15
+
16
+ // ── Plugins ───────────────────────────────────────────────────────────────────
17
+ // A deployment registers these via <App extensions={[...]} />. `Resource` is
18
+ // re-exported because the slot payloads name it: without that, a consumer would
19
+ // have to depend on backend-core directly just to type a handler.
20
+ export type {
21
+ AcPlugin,
22
+ PluginSlots,
23
+ PluginWrappers,
24
+ PluginUser,
25
+ SlotSpec,
26
+ WrapperSpec,
27
+ } from './modules/extensions/index'
28
+ export type { Resource } from '@igstack/app-catalog-backend-core'
29
+ // For a deployment that owns its own routes: resolving its own path params
30
+ // against the catalog. Narrow on purpose — see the hook's own comment.
31
+ export { useCatalogResources } from './modules/appCatalog/context/AppCatalogContext'
32
+
33
+ /**
34
+ * Escape handling, for a deployment's own overlays.
35
+ *
36
+ * Call `useDismissable(close)` in any overlay and Escape dismisses whatever
37
+ * opened last, across core and deployment layers alike. Nothing needs to know
38
+ * what else is on screen.
39
+ */
40
+ export { useDismissable, useDismissStack } from './modules/dismiss/DismissStack'
package/src/internal.ts CHANGED
@@ -5,11 +5,17 @@
5
5
  *
6
6
  * No stability guarantees: this moves in lockstep with
7
7
  * `@igstack/app-catalog-test-kit`, which is its only intended consumer.
8
+ *
9
+ * Must stay free of `createAcRouter`. The test kit runs inside a DEPLOYMENT's
10
+ * test program, which declares its own `Register` augmentation for its own
11
+ * route tree — and re-exporting `createAcRouter` here would drag the core's
12
+ * competing augmentation in with it, which is a hard `TS2717`. The router
13
+ * factory is injected instead; see `setRouterFactory` in the test kit. The
14
+ * core's own router is published separately, at `/router`.
8
15
  */
9
16
  export { App } from './App'
10
17
  export type { AppProps } from './App'
11
18
  export { AcDb, dbCacheDbKeys } from './userDb/AcDb'
12
- export { createAcRouter } from './util/createAcRouter'
13
19
  export {
14
20
  SEARCH_STORAGE_KEY,
15
21
  clearSessionState,
@@ -83,3 +83,24 @@ export function useAppCatalogContext(): AppCatalogContextIface {
83
83
  }
84
84
  return context
85
85
  }
86
+
87
+ /**
88
+ * The catalog, for a deployment that owns its own routes.
89
+ *
90
+ * A deployment composing its own route tree has to resolve its own path params
91
+ * against the catalog — `/app/$slug/…/$account` means nothing until `$account`
92
+ * is matched to a resource. It needs the resources to do that, and the loading
93
+ * flag to tell "still fetching" from "no such resource", which is the
94
+ * difference between a spinner and a redirect.
95
+ *
96
+ * Deliberately narrower than `useAppCatalogContext`: persons, groups, tags and
97
+ * approval methods are the core's own rendering concerns, and publishing them
98
+ * would make every one of them part of the supported surface.
99
+ */
100
+ export function useCatalogResources(): {
101
+ resources: Resource[]
102
+ isLoading: boolean
103
+ } {
104
+ const { resources, isLoadingApps } = useAppCatalogContext()
105
+ return { resources, isLoading: isLoadingApps }
106
+ }
@@ -1,6 +1,14 @@
1
1
  import type { Resource } from '@igstack/app-catalog-backend-core'
2
2
  import { ArrowUpRight, Search, X } from 'lucide-react'
3
- import { use, useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
+ import {
4
+ use,
5
+ useCallback,
6
+ useDeferredValue,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from 'react'
4
12
  import { cn } from '~/lib/utils'
5
13
  import { useAppClickHistory } from '../../hooks/useAppClickHistory'
6
14
  import { markdownToPlainText } from '../../utils/markdownToPlainText'
@@ -697,7 +705,29 @@ export function AppCatalogGrid({
697
705
  // on the areas most people need. Within an area, alpha (from browse).
698
706
  const browseGroups = useMemo(() => groupByArea(browse), [browse])
699
707
 
700
- const isSearching = searchValue.trim() !== ''
708
+ /**
709
+ * The query the RESULTS are computed from; the input stays on `searchValue`.
710
+ *
711
+ * Searching rebuilds the ranked result set from scratch on every keystroke —
712
+ * `searchResources` rebuilds its parent index, scores every resource across
713
+ * 15 tiers and sorts — and the first keystroke additionally unmounts the
714
+ * whole discovery spine while the last one remounts it. Doing that between
715
+ * the keypress and the caret moving is what made typing stutter. Deferring
716
+ * hands React permission to render the typed character first and the list
717
+ * after, abandoning a superseded pass instead of finishing one per
718
+ * character.
719
+ *
720
+ * `isSearching` is derived from the DEFERRED value on purpose. Driving the
721
+ * results/spine swap from the live value instead tears the frame: the spine
722
+ * unmounts immediately while the results are still a render behind, so the
723
+ * page blanks for a frame on the first keystroke. Everything the user sees
724
+ * for a given query — the swap, the rows and the highlight marks — comes off
725
+ * one value, so they can never disagree.
726
+ */
727
+ const deferredSearch = useDeferredValue(searchValue)
728
+ const isSearching = deferredSearch.trim() !== ''
729
+ // True while the list is a render behind the box, used only to dim it.
730
+ const isSearchPending = searchValue !== deferredSearch
701
731
  const searchRef = useRef<HTMLInputElement>(null)
702
732
 
703
733
  // ⌘K / Ctrl+K focuses the hero search box from anywhere on the page.
@@ -764,16 +794,26 @@ export function AppCatalogGrid({
764
794
  search box. The discovery spine below is what gets replaced — the
765
795
  shell, hero and search box stay put, so typing never shifts layout. */}
766
796
  {isSearching && (
767
- <SearchResultsList
768
- apps={allResources ?? apps}
769
- searchValue={searchValue}
770
- onAppClick={onAppClick}
771
- onSubClick={onSubClick}
772
- onLaunch={onLaunch}
773
- onClear={() => onSearchChange('')}
774
- keyboardEnabled={!detailOpen}
775
- selectedSubSlug={selectedSubSlug}
776
- />
797
+ <div
798
+ className={`transition-opacity ${
799
+ isSearchPending ? 'opacity-60' : ''
800
+ }`}
801
+ aria-busy={isSearchPending || undefined}
802
+ >
803
+ <SearchResultsList
804
+ apps={allResources ?? apps}
805
+ // Deferred, not live: `Highlight` inside marks the matched text, and
806
+ // feeding it a newer query than the rows were built from highlights
807
+ // substrings the rows do not contain.
808
+ searchValue={deferredSearch}
809
+ onAppClick={onAppClick}
810
+ onSubClick={onSubClick}
811
+ onLaunch={onLaunch}
812
+ onClear={() => onSearchChange('')}
813
+ keyboardEnabled={!detailOpen}
814
+ selectedSubSlug={selectedSubSlug}
815
+ />
816
+ </div>
777
817
  )}
778
818
 
779
819
  {/* Discovery spine: only shown when not searching */}
@@ -1,7 +1,14 @@
1
1
  import type { Resource } from '@igstack/app-catalog-backend-core'
2
2
  import { X } from 'lucide-react'
3
- import { useEffect, useLayoutEffect, useRef } from 'react'
3
+ import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
4
4
  import { useAppCatalogContext } from '../../context/AppCatalogContext'
5
+ import {
6
+ ResourceDetailAccessActions,
7
+ ResourceDetailProvider,
8
+ } from '~/modules/extensions'
9
+ import { useOptionalUser } from '~/modules/auth'
10
+ import { useDismissStack, useDismissable } from '~/modules/dismiss/DismissStack'
11
+ import { getChildResources } from '../../utils/resolveHelpers'
5
12
  import { SubResourceDetailPanel } from '../components/SubResourceDetailPanel'
6
13
  import { AppDetails } from '../detail/AppDetails'
7
14
 
@@ -35,7 +42,20 @@ export function AppDetailPanel({
35
42
  /** Clears `?sub=` and returns to the parent's detail. */
36
43
  onBackToParent?: () => void
37
44
  }) {
38
- const { approvalMethods } = useAppCatalogContext()
45
+ const { approvalMethods, resources: allResources } = useAppCatalogContext()
46
+ const user = useOptionalUser()
47
+
48
+ // The sub-resource branch has no band of its own, so its slot payload is
49
+ // assembled here, where the catalog context already is, and handed down.
50
+ const subResourceActionsCtx = useMemo(
51
+ () => ({
52
+ resource: subResource ?? app,
53
+ parent: subResource ? app : undefined,
54
+ subResources: getChildResources(allResources, app.slug),
55
+ user: user ? { email: user.email, isAdmin: user.isAdmin ?? false } : null,
56
+ }),
57
+ [subResource, app, allResources, user],
58
+ )
39
59
  const dialogRef = useRef<HTMLDivElement>(null)
40
60
 
41
61
  /**
@@ -92,10 +112,24 @@ export function AppDetailPanel({
92
112
  // caret would land on <body> — where the next Tab walks the catalog grid
93
113
  // behind the scrim, since the card has no focus trap.
94
114
  const shownSlug = subResource?.slug ?? app.slug
115
+ const stack = useDismissStack()
95
116
  useEffect(() => {
96
117
  const dialog = dialogRef.current
97
- if (dialog && !dialog.contains(document.activeElement)) dialog.focus()
98
- }, [shownSlug])
118
+ if (!dialog || dialog.contains(document.activeElement)) return
119
+ // Only while the card is on top. A layer opened OVER it — a deployment's
120
+ // dialog on a nested route — owns the caret, and on a cold deep link that
121
+ // layer mounts before the card does, so without this check the card took
122
+ // focus back off the dialog's first field the moment the catalog arrived.
123
+ const top = stack.topElement()
124
+ if (top && top !== dialog) return
125
+ dialog.focus()
126
+ }, [shownSlug, stack])
127
+
128
+ // Escape closes the card. Registered here rather than in AppDetails because
129
+ // this is the layer's element, and because the sub-resource view renders a
130
+ // different body in place of AppDetails — which left that view with no
131
+ // Escape at all while the registration lived in the body.
132
+ useDismissable(dialogRef, onClose)
99
133
 
100
134
  return (
101
135
  <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10">
@@ -147,20 +181,27 @@ export function AppDetailPanel({
147
181
  key={shownSlug}
148
182
  className="max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7"
149
183
  >
150
- {subResource ? (
151
- <SubResourceDetailPanel
152
- subResource={subResource}
153
- parent={app}
154
- approvalMethods={approvalMethods}
155
- onBack={onBackToParent ?? onClose}
156
- />
157
- ) : (
158
- <AppDetails
159
- app={app}
160
- onAppClick={onAppClick}
161
- onClosePanel={onClose}
162
- />
163
- )}
184
+ {/* Inside the key, so anything a plugin provides here is scoped to
185
+ the resource on screen and is discarded when the card navigates.
186
+ Renders its children untouched when no plugin contributes. */}
187
+ <ResourceDetailProvider
188
+ resource={subResource ?? app}
189
+ parent={subResource ? app : undefined}
190
+ >
191
+ {subResource ? (
192
+ <SubResourceDetailPanel
193
+ subResource={subResource}
194
+ parent={app}
195
+ approvalMethods={approvalMethods}
196
+ onBack={onBackToParent ?? onClose}
197
+ actions={
198
+ <ResourceDetailAccessActions {...subResourceActionsCtx} />
199
+ }
200
+ />
201
+ ) : (
202
+ <AppDetails app={app} onAppClick={onAppClick} />
203
+ )}
204
+ </ResourceDetailProvider>
164
205
  </div>
165
206
  </div>
166
207
  </div>
@@ -1,4 +1,5 @@
1
1
  import { useMemo, useRef } from 'react'
2
+ import { useDismissable } from '~/modules/dismiss/DismissStack'
2
3
 
3
4
  import type { Resource } from '@igstack/app-catalog-backend-core'
4
5
 
@@ -24,8 +25,15 @@ export function ScreenshotGallery({
24
25
  onOpenChange,
25
26
  title,
26
27
  }: ScreenshotGalleryProps) {
27
- // Track whether Gallery is in fullscreen — if so, block Radix from closing on Escape
28
- const isFullscreenRef = useRef(false)
28
+ /**
29
+ * The dialog is dismissable through the stack, not through Radix.
30
+ *
31
+ * It no longer needs to know whether the gallery inside it is fullscreen:
32
+ * fullscreen registers above this layer while it is on, so Escape reaches it
33
+ * first and this dialog only hears Escape once fullscreen is off.
34
+ */
35
+ const contentRef = useRef<HTMLDivElement>(null)
36
+ useDismissable(contentRef, () => onOpenChange(false), { enabled: open })
29
37
 
30
38
  // Transform screenshot IDs to URLs. Capped at 2000px — plenty for on-screen
31
39
  // viewing (incl. fullscreen zoom) while avoiding multi-MB original downloads
@@ -47,33 +55,17 @@ export function ScreenshotGallery({
47
55
  return (
48
56
  <Dialog open={open} onOpenChange={onOpenChange}>
49
57
  <DialogContent
58
+ ref={contentRef}
50
59
  aria-describedby={undefined}
51
60
  className="h-[85vh] w-full max-w-[calc(100vw-2rem)] sm:max-w-[calc(100vw-3rem)] md:max-w-[calc(100vw-4rem)] p-0 overflow-hidden"
52
61
  showCloseButton={true}
53
- onEscapeKeyDown={(e) => {
54
- // Stop propagation so the Esc event doesn't reach outer document listeners
55
- // (e.g. SearchResultsList's raw keydown handler, which would clear search).
56
- e.stopPropagation()
57
- // If Gallery is in fullscreen, its capture listener already handled Escape.
58
- // Prevent Radix from also closing the dialog on the same event.
59
- if (isFullscreenRef.current) {
60
- e.preventDefault()
61
- }
62
- }}
63
62
  >
64
63
  <VisuallyHidden>
65
64
  <DialogTitle>
66
65
  {title || `${app.abbreviation || app.displayName} screenshots`}
67
66
  </DialogTitle>
68
67
  </VisuallyHidden>
69
- <Gallery
70
- images={images}
71
- initialIndex={initialIndex}
72
- title={title}
73
- onFullscreenChange={(fs) => {
74
- isFullscreenRef.current = fs
75
- }}
76
- />
68
+ <Gallery images={images} initialIndex={initialIndex} title={title} />
77
69
  </DialogContent>
78
70
  </Dialog>
79
71
  )
@@ -2,6 +2,7 @@ import type {
2
2
  AppApprovalMethod,
3
3
  Resource,
4
4
  } from '@igstack/app-catalog-backend-core'
5
+ import type { ReactNode } from 'react'
5
6
  import { ArrowLeft, ExternalLink } from 'lucide-react'
6
7
  import { Button } from '~/ui/button'
7
8
  import { Badge } from '~/ui/badge'
@@ -13,6 +14,16 @@ interface SubResourceDetailPanelProps {
13
14
  parent: Resource
14
15
  approvalMethods: AppApprovalMethod[]
15
16
  onBack: () => void
17
+ /**
18
+ * Plugin actions for this sub-resource, rendered under the title.
19
+ *
20
+ * Passed in rather than rendered here on purpose. The slot's payload needs the
21
+ * catalog context and the signed-in user, and calling those hooks in this
22
+ * component would break the tests that render it bare — the same failure mode
23
+ * a `useTRPC()` call has in `AppCatalogGrid`. Optional, so those tests keep
24
+ * passing untouched.
25
+ */
26
+ actions?: ReactNode
16
27
  }
17
28
 
18
29
  function StepBadge({ step }: { step: number }) {
@@ -28,6 +39,7 @@ export function SubResourceDetailPanel({
28
39
  parent,
29
40
  approvalMethods,
30
41
  onBack,
42
+ actions,
31
43
  }: SubResourceDetailPanelProps) {
32
44
  // Mirrors AccessRequestSection's fallback: a sub-resource usually documents
33
45
  // access through top-level `approverSlugs`/`accessComments`, not through an
@@ -102,6 +114,13 @@ export function SubResourceDetailPanel({
102
114
  )}
103
115
  </div>
104
116
 
117
+ {/* ABOVE the gate below on purpose: an account with no approver, owner
118
+ or access comments renders no access region at all, and that is
119
+ exactly the case where an approver most needs the action. */}
120
+ {actions ? (
121
+ <div className="mt-4 flex items-center gap-2">{actions}</div>
122
+ ) : null}
123
+
105
124
  {/* Two-step access section */}
106
125
  {hasAnyAccess && (
107
126
  // The whole two-step region carries the anchor: the step labels naming