@igstack/app-catalog-frontend-core 0.10.1 → 0.11.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 (88) 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/SubResourcesSection.d.ts +9 -1
  17. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +12 -3
  18. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  19. package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js +3 -2
  20. package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js.map +1 -1
  21. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.d.ts +5 -0
  22. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +56 -10
  23. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
  24. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.d.ts +12 -0
  25. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js +48 -0
  26. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js.map +1 -0
  27. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.d.ts +19 -0
  28. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js +355 -0
  29. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js.map +1 -0
  30. package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.d.ts +13 -0
  31. package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.js +45 -0
  32. package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.js.map +1 -0
  33. package/dist/esm/modules/appCatalog/ui/launcher/monogram.d.ts +4 -0
  34. package/dist/esm/modules/appCatalog/ui/launcher/monogram.js +41 -0
  35. package/dist/esm/modules/appCatalog/ui/launcher/monogram.js.map +1 -0
  36. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +40 -6
  37. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  38. package/dist/esm/modules/appCatalog/utils/markdownToPlainText.d.ts +1 -1
  39. package/dist/esm/modules/appCatalog/utils/markdownToPlainText.js.map +1 -1
  40. package/dist/esm/modules/appCatalog/utils/resolveHelpers.d.ts +10 -0
  41. package/dist/esm/modules/appCatalog/utils/resolveHelpers.js +17 -0
  42. package/dist/esm/modules/appCatalog/utils/resolveHelpers.js.map +1 -1
  43. package/dist/esm/routeTree.gen.d.ts +1 -1
  44. package/dist/esm/routes/_layout/app.$slug.d.ts +1 -1
  45. package/dist/esm/routes/_layout/app._slug.js +4 -3
  46. package/dist/esm/routes/_layout/app._slug.js.map +1 -1
  47. package/dist/esm/routes/_layout/catalog.apps.index.js +2 -1
  48. package/dist/esm/routes/_layout/catalog.apps.index.js.map +1 -1
  49. package/dist/esm/routes/_layout/index.js +2 -1
  50. package/dist/esm/routes/_layout/index.js.map +1 -1
  51. package/dist/esm/routes/_layout/service-desks.js +2 -1
  52. package/dist/esm/routes/_layout/service-desks.js.map +1 -1
  53. package/dist/esm/ui/components/header/Header.js +1 -1
  54. package/dist/esm/ui/components/header/Header.js.map +1 -1
  55. package/dist/index.css +73 -66
  56. package/package.json +4 -4
  57. package/src/__tests__/integration/appCatalog.integration.test.ts +106 -0
  58. package/src/__tests__/integration/appDeepLink.integration.test.ts +50 -24
  59. package/src/__tests__/integration/crossRefLinkListView.integration.test.ts +8 -8
  60. package/src/__tests__/integration/harness/given.tsx +9 -1
  61. package/src/__tests__/integration/replacementLink.integration.test.ts +6 -2
  62. package/src/__tests__/integration/searchDeprecatedFallback.integration.test.ts +21 -16
  63. package/src/__tests__/integration/setup/testSetup.ts +1 -0
  64. package/src/__tests__/integration/skewWarning.integration.test.ts +56 -0
  65. package/src/__tests__/integration/tools/AppDetailTools.ts +13 -0
  66. package/src/__tests__/integration/tools/CatalogTools.ts +60 -11
  67. package/src/__tests__/modules/appCatalog/utils/markdownToPlainText.test.ts +5 -5
  68. package/src/__tests__/modules/appCatalog/utils/resolveHelpers.test.ts +79 -0
  69. package/src/index.css +73 -66
  70. package/src/modules/appCatalog/hooks/useSessionSyncedState.ts +83 -0
  71. package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +89 -0
  72. package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +31 -2
  73. package/src/modules/appCatalog/ui/components/OnboardingCard.tsx +1 -1
  74. package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +22 -1
  75. package/src/modules/appCatalog/ui/context/AppCatalogFiltersContext.tsx +11 -5
  76. package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +68 -10
  77. package/src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx +55 -0
  78. package/src/modules/appCatalog/ui/launcher/LauncherHome.tsx +497 -0
  79. package/src/modules/appCatalog/ui/launcher/ResourceIcon.tsx +54 -0
  80. package/src/modules/appCatalog/ui/launcher/monogram.ts +41 -0
  81. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +75 -13
  82. package/src/modules/appCatalog/utils/markdownToPlainText.ts +1 -1
  83. package/src/modules/appCatalog/utils/resolveHelpers.ts +31 -0
  84. package/src/routes/_layout/app.$slug.tsx +7 -4
  85. package/src/routes/_layout/catalog.apps.index.tsx +4 -1
  86. package/src/routes/_layout/index.tsx +5 -1
  87. package/src/routes/_layout/service-desks.tsx +4 -1
  88. package/src/ui/components/header/Header.tsx +3 -1
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { Resource } from '@igstack/app-catalog-backend-core'
3
+ import {
4
+ getAccessPrerequisiteChain,
5
+ getParentResource,
6
+ } from '~/modules/appCatalog/utils/resolveHelpers'
7
+
8
+ const r = (over: Partial<Resource> & { slug: string }): Resource =>
9
+ ({ id: over.slug, displayName: over.slug, ...over }) as Resource
10
+
11
+ describe('getParentResource', () => {
12
+ it('returns undefined for a root resource', () => {
13
+ const root = r({ slug: 'aws-console' })
14
+ expect(getParentResource([root], root)).toBeUndefined()
15
+ })
16
+
17
+ it('finds the immediate parent by slug', () => {
18
+ const parent = r({ slug: 'aws-console' })
19
+ const child = r({ slug: 'acct-prod', parentSlug: 'aws-console' })
20
+ expect(getParentResource([parent, child], child)?.slug).toBe('aws-console')
21
+ })
22
+ })
23
+
24
+ describe('getAccessPrerequisiteChain (#38 two-step access)', () => {
25
+ it('is empty for a root resource', () => {
26
+ const root = r({
27
+ slug: 'gitlab',
28
+ accessRequest: { approvalMethodSlug: 'x' },
29
+ })
30
+ expect(getAccessPrerequisiteChain([root], root)).toEqual([])
31
+ })
32
+
33
+ it('is empty when the parent has NO access policy (nothing to require first)', () => {
34
+ const parent = r({ slug: 'aws-console' }) // no accessRequest
35
+ const child = r({ slug: 'acct', parentSlug: 'aws-console' })
36
+ expect(getAccessPrerequisiteChain([parent, child], child)).toEqual([])
37
+ })
38
+
39
+ it('returns the access-bearing parent for a nested resource', () => {
40
+ const parent = r({
41
+ slug: 'aws-console',
42
+ accessRequest: { approvalMethodSlug: 'it-helpdesk' },
43
+ })
44
+ const child = r({ slug: 'acct-prod', parentSlug: 'aws-console' })
45
+ const chain = getAccessPrerequisiteChain([parent, child], child)
46
+ expect(chain.map((c) => c.slug)).toEqual(['aws-console'])
47
+ })
48
+
49
+ it('orders multi-level chains root → nearest parent', () => {
50
+ const root = r({
51
+ slug: 'cloud',
52
+ accessRequest: { approvalMethodSlug: 'a' },
53
+ })
54
+ const mid = r({
55
+ slug: 'aws-console',
56
+ parentSlug: 'cloud',
57
+ accessRequest: { approvalMethodSlug: 'b' },
58
+ })
59
+ const leaf = r({ slug: 'acct', parentSlug: 'aws-console' })
60
+ const chain = getAccessPrerequisiteChain([root, mid, leaf], leaf)
61
+ expect(chain.map((c) => c.slug)).toEqual(['cloud', 'aws-console'])
62
+ })
63
+
64
+ it('does not loop on a cyclic parent reference', () => {
65
+ const a = r({
66
+ slug: 'a',
67
+ parentSlug: 'b',
68
+ accessRequest: { approvalMethodSlug: 'x' },
69
+ })
70
+ const b = r({
71
+ slug: 'b',
72
+ parentSlug: 'a',
73
+ accessRequest: { approvalMethodSlug: 'y' },
74
+ })
75
+ // must terminate
76
+ const chain = getAccessPrerequisiteChain([a, b], a)
77
+ expect(chain.length).toBeLessThanOrEqual(2)
78
+ })
79
+ })
package/src/index.css CHANGED
@@ -1,52 +1,59 @@
1
1
  /* @import url(./modules/resourceJump/ui/JumpMainButton.css); */
2
2
  @import 'tailwindcss';
3
+ /* Warm, crafted type to match the hand-drawn logo (see issue #31 redesign):
4
+ Fraunces = characterful display serif, Nunito Sans = friendly readable body. */
5
+ @import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Nunito+Sans:wght@400;500;600;700;800&display=swap');
3
6
  /* Other module imports this css, and we need to tell tailwind to scan our tsx files */
4
7
  @source ".";
5
8
 
6
9
  /* @import 'tw-animate-css'; */
7
10
 
8
11
  @layer base {
12
+ /* Warm "crayon" palette derived from the hand-drawn app-catalog logo
13
+ (orange #dc6827, amber #eca832, coral #e4724a, blue #3782c2, green #65a443)
14
+ on a cream paper background. Replaces the previous generic purple theme
15
+ that read as an AI template. See issue #31. */
9
16
  :root {
10
- --background: oklch(0.9842 0.0034 247.8575);
11
- --foreground: oklch(0.2795 0.0368 260.031);
12
- --card: oklch(1 0 0);
13
- --card-foreground: oklch(0.2795 0.0368 260.031);
14
- --popover: oklch(1 0 0);
15
- --popover-foreground: oklch(0.2795 0.0368 260.031);
16
- --primary: oklch(0.5854 0.2041 277.1173);
17
- --primary-foreground: oklch(1 0 0);
18
- --secondary: oklch(0.9276 0.0058 264.5313);
19
- --secondary-foreground: oklch(0.3729 0.0306 259.7328);
20
- --muted: oklch(0.967 0.0029 264.5419);
21
- --muted-foreground: oklch(0.551 0.0234 264.3637);
22
- --accent: oklch(0.9299 0.0334 272.7879);
23
- --accent-foreground: oklch(0.3729 0.0306 259.7328);
17
+ --background: oklch(0.9825 0.0125 84); /* warm cream paper */
18
+ --foreground: oklch(0.28 0.02 55); /* warm near-black ink */
19
+ --card: oklch(0.995 0.004 84);
20
+ --card-foreground: oklch(0.28 0.02 55);
21
+ --popover: oklch(0.995 0.004 84);
22
+ --popover-foreground: oklch(0.28 0.02 55);
23
+ --primary: oklch(0.62 0.17 45); /* logo orange #dc6827 */
24
+ --primary-foreground: oklch(0.99 0.01 84);
25
+ --secondary: oklch(0.94 0.018 80); /* warm sunk panel */
26
+ --secondary-foreground: oklch(0.36 0.03 55);
27
+ --muted: oklch(0.955 0.012 82);
28
+ --muted-foreground: oklch(0.52 0.025 60);
29
+ --accent: oklch(0.93 0.05 70); /* soft amber accent */
30
+ --accent-foreground: oklch(0.36 0.04 55);
24
31
  --destructive: oklch(0.6368 0.2078 25.3313);
25
32
  --destructive-foreground: oklch(1 0 0);
26
- --border: oklch(0.8717 0.0093 258.3382);
27
- --input: oklch(0.8717 0.0093 258.3382);
28
- --ring: oklch(0.5854 0.2041 277.1173);
29
- --chart-1: oklch(0.5854 0.2041 277.1173);
30
- --chart-2: oklch(0.5106 0.2301 276.9656);
31
- --chart-3: oklch(0.4568 0.2146 277.0229);
32
- --chart-4: oklch(0.3984 0.1773 277.3662);
33
- --chart-5: oklch(0.3588 0.1354 278.6973);
34
- --sidebar: oklch(0.967 0.0029 264.5419);
35
- --sidebar-foreground: oklch(0.2795 0.0368 260.031);
36
- --sidebar-primary: oklch(0.5854 0.2041 277.1173);
37
- --sidebar-primary-foreground: oklch(1 0 0);
38
- --sidebar-accent: oklch(0.9299 0.0334 272.7879);
39
- --sidebar-accent-foreground: oklch(0.3729 0.0306 259.7328);
40
- --sidebar-border: oklch(0.8717 0.0093 258.3382);
41
- --sidebar-ring: oklch(0.5854 0.2041 277.1173);
33
+ --border: oklch(0.9 0.018 80); /* warm hairline */
34
+ --input: oklch(0.9 0.018 80);
35
+ --ring: oklch(0.62 0.17 45);
36
+ --chart-1: oklch(0.62 0.17 45); /* orange */
37
+ --chart-2: oklch(0.62 0.14 230); /* blue #3782c2 */
38
+ --chart-3: oklch(0.68 0.14 135); /* green #65a443 */
39
+ --chart-4: oklch(0.75 0.14 75); /* amber #eca832 */
40
+ --chart-5: oklch(0.66 0.15 20); /* coral #e4724a */
41
+ --sidebar: oklch(0.955 0.012 82);
42
+ --sidebar-foreground: oklch(0.28 0.02 55);
43
+ --sidebar-primary: oklch(0.62 0.17 45);
44
+ --sidebar-primary-foreground: oklch(0.99 0.01 84);
45
+ --sidebar-accent: oklch(0.93 0.05 70);
46
+ --sidebar-accent-foreground: oklch(0.36 0.04 55);
47
+ --sidebar-border: oklch(0.9 0.018 80);
48
+ --sidebar-ring: oklch(0.62 0.17 45);
42
49
  --font-sans:
43
- ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
50
+ 'Nunito Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
44
51
  'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
45
- --font-serif: Merriweather, serif;
52
+ --font-serif: 'Fraunces', Georgia, serif;
46
53
  --font-mono:
47
54
  ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
48
55
  'Courier New', monospace;
49
- --radius: 0.5rem;
56
+ --radius: 0.875rem; /* rounder corners to match the friendly logo */
50
57
  --shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
51
58
  --shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
52
59
  --shadow-sm:
@@ -73,44 +80,44 @@
73
80
  --eh-context-border: #ff7926;
74
81
  }
75
82
 
76
- /* https://tweakcn.com/editor/theme */
83
+ /* Warm-dark equivalent of the crayon palette (see issue #31). */
77
84
  .dark {
78
- --background: hsl(212.29deg 18.32% 13.93%);
79
- --foreground: oklch(0.9288 0.0126 255.5078);
80
- --card: oklch(0.2795 0.0368 260.031);
81
- --card-foreground: oklch(0.9288 0.0126 255.5078);
82
- --popover: oklch(0.2795 0.0368 260.031);
83
- --popover-foreground: oklch(0.9288 0.0126 255.5078);
84
- --primary: oklch(0.6801 0.1583 276.9349);
85
- --primary-foreground: oklch(0.2077 0.0398 265.7549);
86
- --secondary: oklch(0.3351 0.0331 260.912);
87
- --secondary-foreground: oklch(0.8717 0.0093 258.3382);
88
- --muted: oklch(0.2795 0.0368 260.031);
89
- --muted-foreground: oklch(0.7137 0.0192 261.3246);
90
- --accent: oklch(0.3729 0.0306 259.7328);
91
- --accent-foreground: oklch(0.8717 0.0093 258.3382);
85
+ --background: oklch(0.19 0.012 60); /* warm charcoal, not blue-gray */
86
+ --foreground: oklch(0.93 0.012 84);
87
+ --card: oklch(0.23 0.014 58);
88
+ --card-foreground: oklch(0.93 0.012 84);
89
+ --popover: oklch(0.23 0.014 58);
90
+ --popover-foreground: oklch(0.93 0.012 84);
91
+ --primary: oklch(0.7 0.16 48); /* brightened logo orange for dark */
92
+ --primary-foreground: oklch(0.2 0.012 55);
93
+ --secondary: oklch(0.3 0.016 58);
94
+ --secondary-foreground: oklch(0.9 0.012 84);
95
+ --muted: oklch(0.26 0.014 58);
96
+ --muted-foreground: oklch(0.72 0.018 70);
97
+ --accent: oklch(0.34 0.03 60);
98
+ --accent-foreground: oklch(0.9 0.012 84);
92
99
  --destructive: oklch(0.6368 0.2078 25.3313);
93
100
  --destructive-foreground: oklch(0.2077 0.0398 265.7549);
94
- --border: oklch(0.4461 0.0263 256.8018);
95
- --input: oklch(0.4461 0.0263 256.8018);
96
- --ring: oklch(0.6801 0.1583 276.9349);
97
- --chart-1: oklch(0.6801 0.1583 276.9349);
98
- --chart-2: oklch(0.5854 0.2041 277.1173);
99
- --chart-3: oklch(0.5106 0.2301 276.9656);
100
- --chart-4: oklch(0.4568 0.2146 277.0229);
101
- --chart-5: oklch(0.3984 0.1773 277.3662);
102
- --sidebar: oklch(0.2795 0.0368 260.031);
103
- --sidebar-foreground: oklch(0.9288 0.0126 255.5078);
104
- --sidebar-primary: oklch(0.6801 0.1583 276.9349);
105
- --sidebar-primary-foreground: oklch(0.2077 0.0398 265.7549);
106
- --sidebar-accent: oklch(0.3729 0.0306 259.7328);
107
- --sidebar-accent-foreground: oklch(0.8717 0.0093 258.3382);
108
- --sidebar-border: oklch(0.4461 0.0263 256.8018);
109
- --sidebar-ring: oklch(0.6801 0.1583 276.9349);
101
+ --border: oklch(0.36 0.016 58);
102
+ --input: oklch(0.36 0.016 58);
103
+ --ring: oklch(0.7 0.16 48);
104
+ --chart-1: oklch(0.7 0.16 48);
105
+ --chart-2: oklch(0.66 0.13 230);
106
+ --chart-3: oklch(0.7 0.14 135);
107
+ --chart-4: oklch(0.78 0.14 75);
108
+ --chart-5: oklch(0.68 0.15 20);
109
+ --sidebar: oklch(0.23 0.014 58);
110
+ --sidebar-foreground: oklch(0.93 0.012 84);
111
+ --sidebar-primary: oklch(0.7 0.16 48);
112
+ --sidebar-primary-foreground: oklch(0.2 0.012 55);
113
+ --sidebar-accent: oklch(0.34 0.03 60);
114
+ --sidebar-accent-foreground: oklch(0.9 0.012 84);
115
+ --sidebar-border: oklch(0.36 0.016 58);
116
+ --sidebar-ring: oklch(0.7 0.16 48);
110
117
  --font-sans:
111
- ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
118
+ 'Nunito Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
112
119
  'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
113
- --font-serif: Merriweather, serif;
120
+ --font-serif: 'Fraunces', Georgia, serif;
114
121
  --font-mono:
115
122
  ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
116
123
  'Courier New', monospace;
@@ -0,0 +1,83 @@
1
+ import { useCallback, useState } from 'react'
2
+
3
+ /**
4
+ * Options for useSessionSyncedState hook
5
+ */
6
+ export interface UseSessionSyncedStateOptions<T> {
7
+ /** The sessionStorage key */
8
+ key: string
9
+ /** Default value when the stored value is absent */
10
+ defaultValue: T
11
+ /** Optional decoder to transform the stored string to a state value */
12
+ decode?: (storedValue: string) => T
13
+ /** Optional encoder to transform the state value to a string (return undefined to clear) */
14
+ encode?: (stateValue: T) => string | undefined
15
+ }
16
+
17
+ /**
18
+ * Hook for state that persists in `sessionStorage` instead of the URL.
19
+ *
20
+ * Why this exists (see #27): the catalog search value used to be
21
+ * synced to the URL (`?q=`) so it survived the per-route remount of
22
+ * `AppCatalogFiltersProvider` (#10). But `q` leaked into every shared/bookmarked
23
+ * app link. Backing the value with `sessionStorage` keeps the #10 guarantee —
24
+ * the value survives the remount and the auto-navigate to a single match — while
25
+ * keeping the URL clean.
26
+ *
27
+ * Key features vs {@link useUrlSyncedState}:
28
+ * - Initializes synchronously from `sessionStorage` on mount.
29
+ * - Writes synchronously in the setter (no async effect), which also removes the
30
+ * effect-ordering race #10 fought (a child auto-navigate effect could run
31
+ * before the provider's async state->URL sync).
32
+ * - Never navigates, so it never touches the URL.
33
+ *
34
+ * Defensive against environments without a functional `sessionStorage`
35
+ * (some CI jsdom setups) — falls back to plain in-memory state.
36
+ */
37
+ export function useSessionSyncedState<T>({
38
+ key,
39
+ defaultValue,
40
+ decode,
41
+ encode,
42
+ }: UseSessionSyncedStateOptions<T>): [T, (value: T) => void] {
43
+ const [state, setStateInternal] = useState<T>(() => {
44
+ try {
45
+ // The try/catch is the real guard against a missing/throwing
46
+ // sessionStorage (SSR, locked-down jsdom); the access itself is typed
47
+ // non-null, so no optional chaining is needed here.
48
+ const stored = globalThis.sessionStorage.getItem(key)
49
+ if (stored !== null) {
50
+ return decode ? decode(stored) : (stored as T)
51
+ }
52
+ } catch {
53
+ // sessionStorage unavailable — fall through to the default
54
+ }
55
+ return defaultValue
56
+ })
57
+
58
+ const setState = useCallback(
59
+ (value: T) => {
60
+ setStateInternal(value)
61
+ try {
62
+ const encoded = encode ? encode(value) : (value as string | undefined)
63
+ if (encoded === undefined) {
64
+ globalThis.sessionStorage.removeItem(key)
65
+ } else {
66
+ globalThis.sessionStorage.setItem(key, encoded)
67
+ }
68
+ } catch {
69
+ // sessionStorage unavailable — state still works in-memory
70
+ }
71
+ },
72
+ [key, encode],
73
+ )
74
+
75
+ return [state, setState]
76
+ }
77
+
78
+ /**
79
+ * sessionStorage key backing the catalog search value (#27). Exported so tests
80
+ * (and any tooling that needs to seed a "returning user" search) reference the
81
+ * same key as the provider.
82
+ */
83
+ export const SEARCH_STORAGE_KEY = 'app-catalog:search'
@@ -0,0 +1,89 @@
1
+ import type { Resource } from '@igstack/app-catalog-backend-core'
2
+ import { ArrowRight, KeyRound } from 'lucide-react'
3
+ import { useAppCatalogContext } from '../../context/AppCatalogContext'
4
+ import { getAccessPrerequisiteChain } from '../../utils/resolveHelpers'
5
+
6
+ /**
7
+ * Two-step (prerequisite) access banner (#38 item D). When the viewed resource
8
+ * is a child whose ancestor(s) carry an access policy, the domain model says
9
+ * the user must obtain the parent's access FIRST. That chain is invisible
10
+ * otherwise, so surface it explicitly as ordered steps:
11
+ *
12
+ * Step 1 — get access to <parent> (e.g. AWS Console via IT Helpdesk)
13
+ * Step 2 — get access to THIS resource (contact its owner / access contacts)
14
+ *
15
+ * Renders nothing for root resources or children with no access-bearing parent.
16
+ * `onOpenParent` lets the user jump to the parent's full access instructions.
17
+ */
18
+ export function AccessPrerequisiteChain({
19
+ resource,
20
+ onOpenParent,
21
+ }: {
22
+ resource: Resource
23
+ onOpenParent?: (parent: Resource) => void
24
+ }) {
25
+ const { resources } = useAppCatalogContext()
26
+ const chain = getAccessPrerequisiteChain(resources, resource)
27
+ if (chain.length === 0) return null
28
+
29
+ // Steps = each prerequisite ancestor (root→nearest), then this resource last.
30
+ const steps = [...chain, resource]
31
+
32
+ return (
33
+ <div
34
+ className="mt-6 rounded-lg border-[1.5px] border-primary/40 bg-primary/[0.03] p-4"
35
+ role="note"
36
+ aria-label="Access requires prerequisite steps"
37
+ >
38
+ <h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-foreground">
39
+ <KeyRound className="size-4 text-primary" />
40
+ Two-step access
41
+ </h3>
42
+ <p className="mb-3 text-xs text-muted-foreground">
43
+ This resource is nested — you need access to its parent first, then to
44
+ the resource itself.
45
+ </p>
46
+ <ol className="space-y-2">
47
+ {steps.map((r, i) => {
48
+ const isLast = i === steps.length - 1
49
+ return (
50
+ <li key={r.slug} className="flex items-start gap-2.5 text-sm">
51
+ <span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-[11px] font-bold text-primary-foreground">
52
+ {i + 1}
53
+ </span>
54
+ <span className="min-w-0">
55
+ <span className="text-muted-foreground">Get access to </span>
56
+ {isLast ? (
57
+ <span className="font-semibold">this resource</span>
58
+ ) : onOpenParent ? (
59
+ <button
60
+ type="button"
61
+ onClick={() => onOpenParent(r)}
62
+ className="inline-flex items-center gap-1 font-semibold text-primary hover:underline"
63
+ >
64
+ {r.displayName}
65
+ <ArrowRight className="size-3" />
66
+ </button>
67
+ ) : (
68
+ <span className="font-semibold">{r.displayName}</span>
69
+ )}
70
+ {!isLast && (
71
+ <span className="text-muted-foreground">
72
+ {' '}
73
+ (see its access instructions)
74
+ </span>
75
+ )}
76
+ {isLast && (
77
+ <span className="text-muted-foreground">
78
+ {' '}
79
+ — details below.
80
+ </span>
81
+ )}
82
+ </span>
83
+ </li>
84
+ )
85
+ })}
86
+ </ol>
87
+ </div>
88
+ )
89
+ }
@@ -153,9 +153,38 @@ export function AccessRequestSection({
153
153
  // Early return if no access request
154
154
  if (!accessRequest) return null
155
155
 
156
+ // Access UX (#31): some methods carry no clickable target — `noAccessRequired`
157
+ // (open), `unknown` (undocumented), and bare `custom` with no comments. The
158
+ // old UI rendered nothing for `custom`, leaving a blank/dead section. Instead
159
+ // surface an explicit, actionable line so the user is never stranded:
160
+ // - open → "No request needed — just open it."
161
+ // - unknown / undocumented custom → route them to the owner (shown below).
162
+ const isOpen = approvalMethod?.type === 'noAccessRequired'
163
+ const hasWrittenSteps = Boolean(
164
+ accessRequest.requestPrompt || accessRequest.comments,
165
+ )
166
+ const isUndocumented =
167
+ approvalMethod?.type === 'unknown' ||
168
+ (approvalMethod?.type === 'custom' && !hasWrittenSteps)
169
+
156
170
  return (
157
- <div className="mt-6 space-y-4">
158
- <h3 className="text-sm font-medium">Access Request</h3>
171
+ <div className="mt-6 rounded-lg border-[1.5px] border-primary/40 bg-primary/[0.03] p-4 space-y-4">
172
+ <h3 className="text-sm font-semibold text-foreground">
173
+ How to get access
174
+ </h3>
175
+
176
+ {isOpen && (
177
+ <p className="text-sm font-medium text-primary">
178
+ ✓ Open to everyone — no request needed. Just open it.
179
+ </p>
180
+ )}
181
+
182
+ {isUndocumented && (
183
+ <p className="text-sm text-muted-foreground">
184
+ Access process not documented yet — contact the owner below to find
185
+ out.
186
+ </p>
187
+ )}
159
188
 
160
189
  {/* Approval Method */}
161
190
  {approvalMethod && approvalMethod.type !== 'custom' && (
@@ -53,7 +53,7 @@ export function OnboardingCard({
53
53
  </Button>
54
54
 
55
55
  <div className="pr-10">
56
- <h2 className="text-lg font-bold mb-2">{title}</h2>
56
+ <h2 className="font-serif text-lg font-semibold mb-2">{title}</h2>
57
57
  <p className="text-muted-foreground mb-3 text-sm">{description}</p>
58
58
 
59
59
  <div className="bg-muted/50 rounded-lg p-3">
@@ -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),