@igstack/app-catalog-frontend-core 6.6.0 → 6.8.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 (141) 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/catalog/AppDetailPanel.widen.test.d.ts +0 -0
  5. package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.d.ts +0 -0
  6. package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.rowMemo.test.d.ts +0 -0
  7. package/dist/esm/__tests__/modules/dismiss/DismissStack.test.d.ts +0 -0
  8. package/dist/esm/__tests__/modules/extensions/slots.test.d.ts +0 -0
  9. package/dist/esm/__tests__/publicEntryResolves.test.d.ts +1 -0
  10. package/dist/esm/appPropsFactory.d.ts +19 -1
  11. package/dist/esm/appPropsFactory.js +4 -3
  12. package/dist/esm/appPropsFactory.js.map +1 -1
  13. package/dist/esm/index.d.ts +15 -2
  14. package/dist/esm/index.js +5 -0
  15. package/dist/esm/index.js.map +1 -1
  16. package/dist/esm/internal.d.ts +7 -1
  17. package/dist/esm/internal.js +0 -2
  18. package/dist/esm/internal.js.map +1 -1
  19. package/dist/esm/modules/appCatalog/context/AppCatalogContext.d.ts +17 -0
  20. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +7 -16
  21. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
  22. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +20 -11
  23. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
  24. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +65 -29
  25. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  26. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +0 -14
  27. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
  28. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js +5 -18
  29. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js.map +1 -1
  30. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.d.ts +12 -1
  31. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +3 -1
  32. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
  33. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +17 -1
  34. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +212 -79
  35. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  36. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +0 -14
  37. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
  38. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.d.ts +1 -2
  39. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +31 -23
  40. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  41. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +5 -0
  42. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
  43. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +5 -3
  44. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  45. package/dist/esm/modules/auth/AuthContext.d.ts +12 -0
  46. package/dist/esm/modules/auth/AuthContext.js +5 -0
  47. package/dist/esm/modules/auth/AuthContext.js.map +1 -1
  48. package/dist/esm/modules/auth/index.d.ts +1 -1
  49. package/dist/esm/modules/dismiss/DismissStack.d.ts +102 -0
  50. package/dist/esm/modules/dismiss/DismissStack.js +82 -0
  51. package/dist/esm/modules/dismiss/DismissStack.js.map +1 -0
  52. package/dist/esm/modules/extensions/ExtensionsContext.d.ts +17 -0
  53. package/dist/esm/modules/extensions/ExtensionsContext.js +15 -0
  54. package/dist/esm/modules/extensions/ExtensionsContext.js.map +1 -0
  55. package/dist/esm/modules/extensions/factories.d.ts +18 -0
  56. package/dist/esm/modules/extensions/factories.js +12 -0
  57. package/dist/esm/modules/extensions/factories.js.map +1 -0
  58. package/dist/esm/modules/extensions/features/resourceDetail.d.ts +92 -0
  59. package/dist/esm/modules/extensions/features/resourceDetail.js +14 -0
  60. package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -0
  61. package/dist/esm/modules/extensions/index.d.ts +79 -0
  62. package/dist/esm/modules/extensions/index.js +26 -0
  63. package/dist/esm/modules/extensions/index.js.map +1 -0
  64. package/dist/esm/modules/extensions/makeSlot.d.ts +46 -0
  65. package/dist/esm/modules/extensions/makeSlot.js +62 -0
  66. package/dist/esm/modules/extensions/makeSlot.js.map +1 -0
  67. package/dist/esm/modules/extensions/types.d.ts +46 -0
  68. package/dist/esm/modules/gallery/Gallery.js +6 -14
  69. package/dist/esm/modules/gallery/Gallery.js.map +1 -1
  70. package/dist/esm/routeTree.gen.d.ts +34 -34
  71. package/dist/esm/routeTree.gen.js +14 -14
  72. package/dist/esm/routeTree.gen.js.map +1 -1
  73. package/dist/esm/router.d.ts +23 -0
  74. package/dist/esm/router.js +5 -0
  75. package/dist/esm/router.js.map +1 -0
  76. package/dist/esm/routes/_layout/app.$slug.d.ts +1 -1
  77. package/dist/esm/routes/_layout/app.$slug_.sub.$subSlug.d.ts +1 -1
  78. package/dist/esm/routes/_layout/catalog.apps.index.d.ts +2 -2
  79. package/dist/esm/routes/_layout/index.d.ts +1 -1
  80. package/dist/esm/routes/_layout/login.d.ts +1 -1
  81. package/dist/esm/routes/_layout/mcp.d.ts +1 -1
  82. package/dist/esm/routes/_layout/service-desks.d.ts +1 -1
  83. package/dist/esm/routes/_layout.d.ts +1 -1
  84. package/dist/esm/routes/_layout.js +13 -2
  85. package/dist/esm/routes/_layout.js.map +1 -1
  86. package/dist/esm/routes/auth.callback.d.ts +1 -1
  87. package/dist/esm/ui/components/error/DefaultErrorComponent.js +9 -2
  88. package/dist/esm/ui/components/error/DefaultErrorComponent.js.map +1 -1
  89. package/dist/esm/ui/components/header/Header.js +0 -1
  90. package/dist/esm/ui/components/header/Header.js.map +1 -1
  91. package/dist/esm/ui/dialog.d.ts +1 -1
  92. package/dist/esm/ui/dialog.js +9 -0
  93. package/dist/esm/ui/dialog.js.map +1 -1
  94. package/dist/esm/ui/table.d.ts +25 -1
  95. package/dist/esm/ui/table.js +20 -3
  96. package/dist/esm/ui/table.js.map +1 -1
  97. package/dist/esm/util/createAcRouter.d.ts +1 -1
  98. package/package.json +19 -11
  99. package/src/App.tsx +34 -3
  100. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +4 -1
  101. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.focus.test.tsx +1 -1
  102. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.widen.test.tsx +53 -0
  103. package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.tsx +115 -0
  104. package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.rowMemo.test.tsx +120 -0
  105. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +3 -5
  106. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +9 -9
  107. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +31 -5
  108. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +3 -3
  109. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +48 -6
  110. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +1 -7
  111. package/src/__tests__/modules/dismiss/DismissStack.test.tsx +330 -0
  112. package/src/__tests__/modules/extensions/slots.test.tsx +252 -0
  113. package/src/__tests__/publicEntryResolves.test.ts +63 -0
  114. package/src/appPropsFactory.ts +23 -3
  115. package/src/index.tsx +32 -2
  116. package/src/internal.ts +7 -1
  117. package/src/modules/appCatalog/context/AppCatalogContext.tsx +21 -0
  118. package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +52 -12
  119. package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +89 -28
  120. package/src/modules/appCatalog/ui/components/ScreenshotGallery.tsx +12 -20
  121. package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +19 -0
  122. package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +396 -111
  123. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +46 -26
  124. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +20 -0
  125. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +17 -3
  126. package/src/modules/auth/AuthContext.tsx +15 -0
  127. package/src/modules/auth/index.ts +1 -0
  128. package/src/modules/dismiss/DismissStack.tsx +224 -0
  129. package/src/modules/extensions/ExtensionsContext.tsx +36 -0
  130. package/src/modules/extensions/factories.ts +19 -0
  131. package/src/modules/extensions/features/resourceDetail.ts +74 -0
  132. package/src/modules/extensions/index.ts +121 -0
  133. package/src/modules/extensions/makeSlot.tsx +123 -0
  134. package/src/modules/extensions/types.ts +51 -0
  135. package/src/modules/gallery/Gallery.tsx +13 -17
  136. package/src/routeTree.gen.ts +31 -31
  137. package/src/router.ts +23 -0
  138. package/src/routes/_layout.tsx +51 -1
  139. package/src/ui/components/error/DefaultErrorComponent.tsx +22 -4
  140. package/src/ui/dialog.tsx +26 -0
  141. package/src/ui/table.tsx +46 -3
@@ -0,0 +1,63 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ const srcDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
7
+
8
+ /**
9
+ * Re-exporting from a bare directory silently strips the types for consumers.
10
+ *
11
+ * `export type { X } from './modules/foo'` emits `from './modules/foo.js'` into
12
+ * `dist/esm/index.d.ts`, but the build writes `dist/esm/modules/foo/index.d.ts`
13
+ * — so the specifier resolves to nothing. Because every consumer runs with
14
+ * `skipLibCheck`, the unresolved import raises no error anywhere: the types
15
+ * quietly degrade to `any`, and a consumer's own typecheck passes while
16
+ * accepting absolutely anything.
17
+ *
18
+ * Measured, not theoretical: `keyof SlotSpec` accepted an arbitrary string from
19
+ * a consuming app, and `PwaAutoUpdateOptions` had been shipping as `any` for
20
+ * however long the export had existed. Writing `./modules/foo/index` fixes it.
21
+ *
22
+ * This checks the source rather than the build output so it runs without one.
23
+ */
24
+ const ENTRY_POINTS = ['index.tsx', 'internal.ts']
25
+
26
+ function relativeSpecifiers(source: string): string[] {
27
+ return [...source.matchAll(/from\s+'(\.[^']+)'/g)]
28
+ .map((match) => match[1])
29
+ .filter((specifier): specifier is string => specifier !== undefined)
30
+ }
31
+
32
+ describe.each(ENTRY_POINTS)('public entry %s', (entry) => {
33
+ const source = readFileSync(resolve(srcDir, entry), 'utf8')
34
+
35
+ it('re-exports no bare directory', () => {
36
+ const offenders = relativeSpecifiers(source).filter((specifier) => {
37
+ const target = resolve(srcDir, specifier)
38
+ return existsSync(target) && statSync(target).isDirectory()
39
+ })
40
+
41
+ // Thrown rather than passed to expect() as a message: the repo's
42
+ // vitest/valid-expect rule allows only one argument, and a bare
43
+ // "expected [x] to equal []" would not explain why it matters.
44
+ if (offenders.length > 0) {
45
+ throw new Error(
46
+ `Bare directory re-export(s) in src/${entry}: ${offenders.join(', ')}.\n` +
47
+ 'Emitted as "<dir>.js", which has no .d.ts, so every type behind it\n' +
48
+ 'becomes `any` for consumers without any error. Append "/index".',
49
+ )
50
+ }
51
+ expect(offenders).toEqual([])
52
+ })
53
+
54
+ it('resolves every relative specifier to a real module', () => {
55
+ const unresolved = relativeSpecifiers(source).filter((specifier) => {
56
+ const base = resolve(srcDir, specifier.replace(/\.js$/, ''))
57
+ return !['', '.ts', '.tsx', '.d.ts'].some((ext) =>
58
+ existsSync(`${base}${ext}`),
59
+ )
60
+ })
61
+ expect(unresolved).toEqual([])
62
+ })
63
+ })
@@ -2,13 +2,33 @@ import { createBrowserHistory } from '@tanstack/react-router'
2
2
  import { createTRPCClient, httpBatchLink } from '@trpc/client'
3
3
  import { AcDb } from './userDb/AcDb'
4
4
  import type { TRPCRouter } from '@igstack/app-catalog-backend-core'
5
+ import type { RegisteredRouter } from '@tanstack/react-router'
5
6
  import type { AppProps } from './App'
6
7
  import type { AcPlugin } from './modules/pluginCore/types'
8
+ import type { AcRouterInitParams } from './types/types'
7
9
  import { createQueryClient } from './api/infra/createQueryClient'
8
- import { createAcRouter } from './util/createAcRouter'
10
+
11
+ export interface AppPropsFactoryOptions {
12
+ /**
13
+ * How to build the router.
14
+ *
15
+ * Injected rather than imported, because the ROUTE TREE belongs to the
16
+ * deployment. A deployment that composes its own tree declares its own
17
+ * `declare module … Register`, and importing the core's `createAcRouter`
18
+ * here would put the core's competing augmentation into that program — a
19
+ * hard `TS2717`, and `import type` would not help: augmentations are
20
+ * collected when the program is built.
21
+ *
22
+ * Using the core's tree unchanged? Pass `createAcRouter` from
23
+ * `@igstack/app-catalog-frontend-core/router`.
24
+ */
25
+ createRouter: (init: AcRouterInitParams) => RegisteredRouter
26
+ }
9
27
 
10
28
  // registerSW();
11
- export function appPropsFactory(): AppProps {
29
+ export function appPropsFactory({
30
+ createRouter,
31
+ }: AppPropsFactoryOptions): AppProps {
12
32
  const trpcClient = createTRPCClient<TRPCRouter>({
13
33
  links: [
14
34
  httpBatchLink({
@@ -22,7 +42,7 @@ export function appPropsFactory(): AppProps {
22
42
  const plugins: AcPlugin[] = [
23
43
  // Future plugins can be added here
24
44
  ]
25
- const router = createAcRouter({
45
+ const router = createRouter({
26
46
  history: createBrowserHistory(),
27
47
  context: {
28
48
  queryClient,
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
- import { X } from 'lucide-react'
3
- import { useEffect, useLayoutEffect, useRef } from 'react'
2
+ import { Maximize2, Minimize2, X } from 'lucide-react'
3
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } 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,8 +42,24 @@ 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)
60
+ // Not persisted: every open starts at the normal width. The toggle is for the
61
+ // one card being read, and the default stays readable for the next one.
62
+ const [isWide, setIsWide] = useState(false)
40
63
 
41
64
  /**
42
65
  * Centre the card ONCE when it opens, then hold that position.
@@ -92,10 +115,24 @@ export function AppDetailPanel({
92
115
  // caret would land on <body> — where the next Tab walks the catalog grid
93
116
  // behind the scrim, since the card has no focus trap.
94
117
  const shownSlug = subResource?.slug ?? app.slug
118
+ const stack = useDismissStack()
95
119
  useEffect(() => {
96
120
  const dialog = dialogRef.current
97
- if (dialog && !dialog.contains(document.activeElement)) dialog.focus()
98
- }, [shownSlug])
121
+ if (!dialog || dialog.contains(document.activeElement)) return
122
+ // Only while the card is on top. A layer opened OVER it — a deployment's
123
+ // dialog on a nested route — owns the caret, and on a cold deep link that
124
+ // layer mounts before the card does, so without this check the card took
125
+ // focus back off the dialog's first field the moment the catalog arrived.
126
+ const top = stack.topElement()
127
+ if (top && top !== dialog) return
128
+ dialog.focus()
129
+ }, [shownSlug, stack])
130
+
131
+ // Escape closes the card. Registered here rather than in AppDetails because
132
+ // this is the layer's element, and because the sub-resource view renders a
133
+ // different body in place of AppDetails — which left that view with no
134
+ // Escape at all while the registration lived in the body.
135
+ useDismissable(dialogRef, onClose)
99
136
 
100
137
  return (
101
138
  <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10">
@@ -122,16 +159,33 @@ export function AppDetailPanel({
122
159
  // `overflow-hidden` belongs on the rounded box: the scrolling child
123
160
  // below carries the band's tint to its own square corners and paints
124
161
  // them over this radius, so the card's top corners read as cut off.
125
- className="relative w-full max-w-[min(1120px,94vw)] overflow-hidden rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200 outline-none"
162
+ // Widening drops the cap only. The pinned top margin is left alone, so
163
+ // the card's top edge stays put and restoring gives back the same layout.
164
+ className={`relative w-full ${isWide ? 'max-w-none' : 'max-w-[min(1120px,94vw)]'} overflow-hidden rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200 outline-none`}
126
165
  >
127
- <button
128
- type="button"
129
- aria-label="Close"
130
- onClick={onClose}
131
- className="absolute top-3 right-3 z-10 rounded-full p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
132
- >
133
- <X className="size-4" />
134
- </button>
166
+ <div className="absolute top-3 right-3 z-10 flex gap-1">
167
+ <button
168
+ type="button"
169
+ aria-label={isWide ? 'Restore width' : 'Widen card'}
170
+ title={isWide ? 'Restore width' : 'Widen card'}
171
+ onClick={() => setIsWide((wide) => !wide)}
172
+ className="rounded-full p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
173
+ >
174
+ {isWide ? (
175
+ <Minimize2 className="size-4" />
176
+ ) : (
177
+ <Maximize2 className="size-4" />
178
+ )}
179
+ </button>
180
+ <button
181
+ type="button"
182
+ aria-label="Close"
183
+ onClick={onClose}
184
+ className="rounded-full p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
185
+ >
186
+ <X className="size-4" />
187
+ </button>
188
+ </div>
135
189
  {/* Keyed by the resource on screen, so the body remounts when the card
136
190
  navigates from one app to another ("View replacement", an access
137
191
  prerequisite's parent) instead of updating in place. Everything
@@ -147,20 +201,27 @@ export function AppDetailPanel({
147
201
  key={shownSlug}
148
202
  className="max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7"
149
203
  >
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
- )}
204
+ {/* Inside the key, so anything a plugin provides here is scoped to
205
+ the resource on screen and is discarded when the card navigates.
206
+ Renders its children untouched when no plugin contributes. */}
207
+ <ResourceDetailProvider
208
+ resource={subResource ?? app}
209
+ parent={subResource ? app : undefined}
210
+ >
211
+ {subResource ? (
212
+ <SubResourceDetailPanel
213
+ subResource={subResource}
214
+ parent={app}
215
+ approvalMethods={approvalMethods}
216
+ onBack={onBackToParent ?? onClose}
217
+ actions={
218
+ <ResourceDetailAccessActions {...subResourceActionsCtx} />
219
+ }
220
+ />
221
+ ) : (
222
+ <AppDetails app={app} onAppClick={onAppClick} />
223
+ )}
224
+ </ResourceDetailProvider>
164
225
  </div>
165
226
  </div>
166
227
  </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