@igstack/app-catalog-frontend-core 6.3.1-alpha-20261003051755 → 6.5.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 (73) hide show
  1. package/dist/esm/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.d.ts +0 -0
  2. package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.d.ts +0 -0
  3. package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.d.ts +0 -0
  4. package/dist/esm/__tests__/modules/appCatalog/utils/accessTabLabel.test.d.ts +1 -0
  5. package/dist/esm/api/infra/trpc.d.ts +42 -21
  6. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -0
  7. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +19 -0
  8. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
  9. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +17 -2
  10. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  11. package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.d.ts +14 -0
  12. package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js +115 -0
  13. package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js.map +1 -0
  14. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +1 -0
  15. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -1
  16. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +11 -4
  17. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
  18. package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js +0 -4
  19. package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js.map +1 -1
  20. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +4 -1
  21. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
  22. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +377 -385
  23. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  24. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.d.ts +20 -0
  25. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js +59 -0
  26. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js.map +1 -0
  27. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +64 -0
  28. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +90 -0
  29. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -0
  30. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +13 -0
  31. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +433 -0
  32. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +1 -0
  33. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +18 -1
  34. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  35. package/dist/esm/modules/appCatalog/utils/accessTabLabel.d.ts +30 -0
  36. package/dist/esm/modules/appCatalog/utils/accessTabLabel.js +34 -0
  37. package/dist/esm/modules/appCatalog/utils/accessTabLabel.js.map +1 -0
  38. package/dist/index.css +269 -5
  39. package/package.json +4 -4
  40. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +17 -3
  41. package/src/__tests__/modules/appCatalog/ui/catalog/RequestAppDialog.test.tsx +113 -0
  42. package/src/__tests__/modules/appCatalog/ui/components/AccessRequestSection.test.tsx +6 -4
  43. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +25 -10
  44. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +193 -0
  45. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +2 -2
  46. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +32 -9
  47. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +125 -0
  48. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +2 -2
  49. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +181 -0
  50. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +131 -0
  51. package/src/__tests__/modules/appCatalog/utils/accessTabLabel.test.ts +206 -0
  52. package/src/__tests__/ui/components/header/Header.loginHidden.test.tsx +1 -1
  53. package/src/index.css +269 -5
  54. package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +24 -0
  55. package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +46 -2
  56. package/src/modules/appCatalog/ui/catalog/RequestAppDialog.tsx +169 -0
  57. package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +3 -0
  58. package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +9 -4
  59. package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +4 -1
  60. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +574 -554
  61. package/src/modules/appCatalog/ui/detail/AppDoor.tsx +97 -0
  62. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +195 -0
  63. package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +605 -0
  64. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +20 -1
  65. package/src/modules/appCatalog/utils/accessTabLabel.ts +95 -0
  66. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.d.ts +0 -3
  67. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js +0 -228
  68. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js.map +0 -1
  69. package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.emptyState.test.tsx +0 -64
  70. package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.reviewStatus.test.tsx +0 -93
  71. package/src/modules/appCatalog/ui/detail/CommentsSection.tsx +0 -296
  72. /package/dist/esm/__tests__/modules/appCatalog/ui/{detail/CommentsSection.emptyState.test.d.ts → catalog/RequestAppDialog.test.d.ts} +0 -0
  73. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.reviewStatus.test.d.ts → AppDetails.doorAndTabs.test.d.ts} +0 -0
@@ -0,0 +1,131 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import '@testing-library/jest-dom/vitest'
4
+
5
+ // Same stub shape as the empty-state test: only the list query matters here.
6
+ const state = vi.hoisted(() => ({
7
+ items: [] as unknown[],
8
+ openCount: 0,
9
+ }))
10
+
11
+ vi.mock('@tanstack/react-query', () => ({
12
+ useQuery: vi.fn(() => ({
13
+ data: { items: state.items, openCount: state.openCount },
14
+ isLoading: false,
15
+ })),
16
+ useMutation: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
17
+ useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn() })),
18
+ }))
19
+ vi.mock('~/api/infra/trpc', () => ({
20
+ useTRPC: vi.fn(() => ({
21
+ feedback: {
22
+ list: { queryOptions: () => ({ queryKey: ['feedback'] }) },
23
+ add: { mutationOptions: (o: unknown) => o },
24
+ edit: { mutationOptions: (o: unknown) => o },
25
+ dismiss: { mutationOptions: (o: unknown) => o },
26
+ },
27
+ })),
28
+ }))
29
+
30
+ const { FeedbackSection } =
31
+ await import('~/modules/appCatalog/ui/detail/FeedbackSection')
32
+
33
+ const base = {
34
+ id: 'c1',
35
+ authorName: 'Merry Mole',
36
+ body: 'This portal only covers one business unit.',
37
+ subject: null,
38
+ resourceSlug: 'example-app',
39
+ resourceName: 'Example App',
40
+ attachmentIds: [] as string[],
41
+ createdAt: new Date().toISOString(),
42
+ reviewedAt: null as string | null,
43
+ editedAt: null,
44
+ isMine: false,
45
+ canEditUntil: null,
46
+ status: null as string | null,
47
+ reviewerReply: null as string | null,
48
+ }
49
+
50
+ describe('FeedbackSection — review status', () => {
51
+ it('credits applied work with a short label, not a sentence', () => {
52
+ state.items = [{ ...base, status: 'applied' }]
53
+ state.openCount = 0
54
+ render(<FeedbackSection appSlug="example-app" />)
55
+
56
+ // Exactly one: the chip on the row. There is no legend restating it, and the
57
+ // gratitude lives in the reply prose rather than inside the pill.
58
+ expect(screen.getAllByText('Applied')).toHaveLength(1)
59
+ expect(screen.queryByText(/thank you!/)).toBeNull()
60
+ expect(screen.queryByText(/means the catalog now carries/)).toBeNull()
61
+ })
62
+
63
+ it('shows the quieter label for feedback that changed nothing', () => {
64
+ state.items = [{ ...base, status: 'acknowledged' }]
65
+ render(<FeedbackSection appSlug="example-app" />)
66
+
67
+ expect(screen.getAllByText('Seen')).toHaveLength(1)
68
+ })
69
+
70
+ it('renders the maintainer reply under the row', () => {
71
+ state.items = [
72
+ {
73
+ ...base,
74
+ status: 'applied',
75
+ reviewerReply: 'Folded into Background — thank you.',
76
+ },
77
+ ]
78
+ render(<FeedbackSection appSlug="example-app" />)
79
+
80
+ expect(screen.getByText('Catalog team')).toBeInTheDocument()
81
+ expect(
82
+ screen.getByText('Folded into Background — thank you.'),
83
+ ).toBeInTheDocument()
84
+ })
85
+
86
+ it('gives an unreviewed row no chip at all — absence is the signal', () => {
87
+ state.items = [base]
88
+ state.openCount = 1
89
+ render(<FeedbackSection appSlug="example-app" />)
90
+
91
+ expect(screen.queryByText('Applied')).toBeNull()
92
+ expect(screen.queryByText('Seen')).toBeNull()
93
+ expect(screen.queryByText('Open')).toBeNull()
94
+ expect(screen.queryByText('Catalog team')).toBeNull()
95
+ })
96
+
97
+ it('counts unreviewed items on the heading, and hides the count at zero', () => {
98
+ state.items = [base]
99
+ state.openCount = 2
100
+ const { unmount } = render(<FeedbackSection appSlug="example-app" />)
101
+ expect(screen.getByLabelText('2 awaiting review')).toHaveTextContent(
102
+ '2 open',
103
+ )
104
+ unmount()
105
+
106
+ state.openCount = 0
107
+ render(<FeedbackSection appSlug="example-app" />)
108
+ expect(screen.queryByText(/open$/)).toBeNull()
109
+ })
110
+
111
+ it('offers Dismiss on your own unreviewed row, and not once it is answered', () => {
112
+ state.items = [{ ...base, isMine: true }]
113
+ state.openCount = 1
114
+ const { unmount } = render(<FeedbackSection appSlug="example-app" />)
115
+ // No edit window left, but withdrawing is still allowed while nobody has acted.
116
+ expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
117
+ expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull()
118
+ unmount()
119
+
120
+ state.items = [{ ...base, isMine: true, status: 'applied' }]
121
+ render(<FeedbackSection appSlug="example-app" />)
122
+ expect(screen.queryByRole('button', { name: 'Dismiss' })).toBeNull()
123
+ })
124
+
125
+ it('says so when someone sent a bare flag with no words', () => {
126
+ state.items = [{ ...base, body: null }]
127
+ render(<FeedbackSection appSlug="example-app" />)
128
+
129
+ expect(screen.getByText(/no detail given/)).toBeInTheDocument()
130
+ })
131
+ })
@@ -0,0 +1,206 @@
1
+ import type {
2
+ ApprovalMethod,
3
+ Resource,
4
+ } from '@igstack/app-catalog-backend-core'
5
+ import { describe, expect, it } from 'vitest'
6
+ import {
7
+ ROUTE_FREE,
8
+ ROUTE_REQUEST,
9
+ ROUTE_UNDOCUMENTED,
10
+ accessTabLabel,
11
+ } from '~/modules/appCatalog/utils/accessTabLabel'
12
+
13
+ const methods: ApprovalMethod[] = [
14
+ {
15
+ slug: 'access-bot',
16
+ type: 'service',
17
+ displayName: 'Access Bot',
18
+ shortName: 'Bot',
19
+ config: { url: 'https://chat.example/bot' },
20
+ },
21
+ {
22
+ slug: 'long-desk',
23
+ type: 'service',
24
+ // No shortName — the tab has to fall back to this, however long it is.
25
+ displayName: 'Enterprise Business Service Organization',
26
+ config: {},
27
+ },
28
+ {
29
+ slug: 'open',
30
+ type: 'noAccessRequired',
31
+ displayName: 'No Approval Needed',
32
+ config: {},
33
+ },
34
+ { slug: 'custom', type: 'custom', displayName: 'Custom', config: {} },
35
+ { slug: 'unknown', type: 'unknown', displayName: 'Unknown', config: {} },
36
+ ]
37
+
38
+ const res = (over: Partial<Resource>): Resource => ({
39
+ id: 'x',
40
+ slug: 'x',
41
+ displayName: 'X',
42
+ ...over,
43
+ })
44
+
45
+ describe('accessTabLabel', () => {
46
+ it('names a service desk by its shortName', () => {
47
+ const label = accessTabLabel(
48
+ res({ accessRequest: { approvalMethodSlug: 'access-bot' } }),
49
+ methods,
50
+ )
51
+ expect(label.route).toBe('Bot')
52
+ })
53
+
54
+ it('falls back to displayName when no shortName is set', () => {
55
+ const label = accessTabLabel(
56
+ res({ accessRequest: { approvalMethodSlug: 'long-desk' } }),
57
+ methods,
58
+ )
59
+ expect(label.route).toBe('Enterprise Business Service Organization')
60
+ })
61
+
62
+ it('says Free when no request is needed', () => {
63
+ const label = accessTabLabel(
64
+ res({ accessRequest: { approvalMethodSlug: 'open' } }),
65
+ methods,
66
+ )
67
+ expect(label.route).toBe(ROUTE_FREE)
68
+ })
69
+
70
+ it('says not documented for an entry with no accessRequest at all', () => {
71
+ expect(accessTabLabel(res({}), methods).route).toBe(ROUTE_UNDOCUMENTED)
72
+ })
73
+
74
+ it('says not documented for an explicitly unknown method', () => {
75
+ const label = accessTabLabel(
76
+ res({ accessRequest: { approvalMethodSlug: 'unknown' } }),
77
+ methods,
78
+ )
79
+ expect(label.route).toBe(ROUTE_UNDOCUMENTED)
80
+ })
81
+
82
+ // A bare `custom` names no channel and writes down no steps, so it is the
83
+ // same dead end as `unknown` under a different slug.
84
+ it('says not documented for a bare custom method', () => {
85
+ const label = accessTabLabel(
86
+ res({ accessRequest: { approvalMethodSlug: 'custom' } }),
87
+ methods,
88
+ )
89
+ expect(label.route).toBe(ROUTE_UNDOCUMENTED)
90
+ })
91
+
92
+ // A slug that resolves to no registered method. The access section renders an
93
+ // essentially empty box in that case, so the tab must not advertise a route.
94
+ it('says not documented when the method slug resolves to nothing', () => {
95
+ const label = accessTabLabel(
96
+ res({ accessRequest: { approvalMethodSlug: 'gone-away' } }),
97
+ methods,
98
+ )
99
+ expect(label.route).toBe(ROUTE_UNDOCUMENTED)
100
+ })
101
+
102
+ it('still says request for a dangling slug that does write its steps down', () => {
103
+ const label = accessTabLabel(
104
+ res({
105
+ accessRequest: {
106
+ approvalMethodSlug: 'gone-away',
107
+ comments: 'Ask whoever owns it; the channel moved.',
108
+ },
109
+ }),
110
+ methods,
111
+ )
112
+ expect(label.route).toBe(ROUTE_REQUEST)
113
+ })
114
+
115
+ it('says request for a custom method that does write its steps down', () => {
116
+ const label = accessTabLabel(
117
+ res({
118
+ accessRequest: {
119
+ approvalMethodSlug: 'custom',
120
+ comments: 'Ask the team that owns it.',
121
+ },
122
+ }),
123
+ methods,
124
+ )
125
+ expect(label.route).toBe(ROUTE_REQUEST)
126
+ })
127
+
128
+ it('says 2 steps for a custom method with follow-up work after approval', () => {
129
+ const label = accessTabLabel(
130
+ res({
131
+ accessRequest: {
132
+ approvalMethodSlug: 'custom',
133
+ comments: 'Ask the owning team.',
134
+ postApprovalInstructions: 'Then install the client.',
135
+ },
136
+ }),
137
+ methods,
138
+ )
139
+ expect(label.route).toBe('2 steps')
140
+ })
141
+
142
+ // Children keep access as top-level fields rather than an accessRequest; the
143
+ // card's access section already falls back to them, so the tab must agree or
144
+ // the label contradicts the panel underneath it.
145
+ it('treats a child with only contacts as a request, not as undocumented', () => {
146
+ const label = accessTabLabel(
147
+ res({ parentSlug: 'p', approverSlugs: ['team-a'] }),
148
+ methods,
149
+ )
150
+ expect(label.route).toBe(ROUTE_REQUEST)
151
+ })
152
+
153
+ describe('the chip', () => {
154
+ it('counts roles', () => {
155
+ const label = accessTabLabel(
156
+ res({
157
+ accessRequest: {
158
+ approvalMethodSlug: 'access-bot',
159
+ roles: [{ displayName: 'Read' }, { displayName: 'Write' }],
160
+ },
161
+ }),
162
+ methods,
163
+ )
164
+ expect(label.chip).toBe('2 roles')
165
+ })
166
+
167
+ it('singularises one role', () => {
168
+ const label = accessTabLabel(
169
+ res({
170
+ accessRequest: {
171
+ approvalMethodSlug: 'access-bot',
172
+ roles: [{ displayName: 'Read' }],
173
+ },
174
+ }),
175
+ methods,
176
+ )
177
+ expect(label.chip).toBe('1 role')
178
+ })
179
+
180
+ // The named channel stays as the route and the shape rides in the chip:
181
+ // "there is more to do after approval" is the thing worth knowing on an
182
+ // entry that already tells you where to ask.
183
+ it('prefers the step count over the role count when both apply', () => {
184
+ const label = accessTabLabel(
185
+ res({
186
+ accessRequest: {
187
+ approvalMethodSlug: 'access-bot',
188
+ requestPrompt: 'Requesting access.',
189
+ postApprovalInstructions: 'Then run the CLI login.',
190
+ roles: [{ displayName: 'Read' }, { displayName: 'Write' }],
191
+ },
192
+ }),
193
+ methods,
194
+ )
195
+ expect(label).toEqual({ route: 'Bot', chip: '2 steps' })
196
+ })
197
+
198
+ it('is absent when there is nothing to count', () => {
199
+ const label = accessTabLabel(
200
+ res({ accessRequest: { approvalMethodSlug: 'access-bot' } }),
201
+ methods,
202
+ )
203
+ expect(label.chip).toBeUndefined()
204
+ })
205
+ })
206
+ })
@@ -81,7 +81,7 @@ describe('Header login affordance', () => {
81
81
 
82
82
  it('still shows the user menu once authenticated', () => {
83
83
  state.isAuthenticated = true
84
- state.user = { name: 'Igor Golovin', email: 'igolovin@natera.com' }
84
+ state.user = { name: 'Igor Golovin', email: 'igor@example.com' }
85
85
  render(<Header />)
86
86
 
87
87
  expect(screen.getByTestId('user-avatar-button')).toBeInTheDocument()
package/src/index.css CHANGED
@@ -249,9 +249,16 @@
249
249
  padding-left: 1.25rem;
250
250
  }
251
251
 
252
- /* Comment review labels. Plain CSS rather than utilities: there is no success
253
- token in the palette, and a tinted chip needs a colour the theme does not
254
- name. `--chart-3` is the palette's green and already shifts for dark mode. */
252
+ /* Review labels. Plain CSS rather than utilities: there is no success token in the
253
+ palette, and a tinted chip needs a colour the theme does not name. `--chart-3` is
254
+ the palette's green and already shifts for dark mode.
255
+
256
+ Mix percentages are low on purpose. At 75%/72% these measured 3.55:1 and 3.24:1
257
+ against the cream page — under the 4.5:1 floor for 11px text — and the old 600/700
258
+ weights were compensating for that, which is why they shouted and were still hard
259
+ to read. Lowering the mix raises contrast in both themes, so there is no .dark
260
+ override. Verify any change by measurement, not by eye: 60%/55% looks fine and
261
+ still fails. */
255
262
  .comment-chip {
256
263
  display: inline-flex;
257
264
  align-items: center;
@@ -259,12 +266,13 @@
259
266
  border-radius: 999px;
260
267
  padding: 0.05rem 0.5rem;
261
268
  font-size: 0.6875rem;
262
- font-weight: 600;
269
+ font-weight: 500;
263
270
  line-height: 1.5;
264
271
  white-space: nowrap;
265
272
  }
266
273
  .comment-chip-applied {
267
- color: color-mix(in oklab, var(--chart-3) 75%, var(--foreground));
274
+ /* 5.46:1 light, 7.67:1 dark */
275
+ color: color-mix(in oklab, var(--chart-3) 50%, var(--foreground));
268
276
  background: color-mix(in oklab, var(--chart-3) 16%, var(--card));
269
277
  border: 1px solid color-mix(in oklab, var(--chart-3) 35%, var(--card));
270
278
  }
@@ -275,6 +283,262 @@
275
283
  font-weight: 400;
276
284
  }
277
285
 
286
+ /* One register for every section label inside the detail card.
287
+ Measured: the old heading was 13px/500 above 13.5px/400 body — a single
288
+ 100-unit weight step as the ONLY distinction, with no size, tracking or case
289
+ change. It read as "too pale" while already measuring 14.45:1 against the
290
+ card, so darkening was never available; the register was the problem.
291
+ Four signals now instead of one. `--foreground` deliberately: muting drops it
292
+ to 5.46:1 on a label whose complaint was paleness. Margins carry the rest
293
+ (28px between sections, 6px to the label's own body) and there are no rules
294
+ per section — a tab panel's edge already states that boundary. */
295
+ .section-label {
296
+ font-size: 0.6875rem;
297
+ font-weight: 700;
298
+ letter-spacing: 0.06em;
299
+ text-transform: uppercase;
300
+ color: var(--foreground);
301
+ line-height: 1.4;
302
+ }
303
+
304
+ /* The way into the app: icon, name and address fused into ONE pressable
305
+ surface at the top of the detail card. Plain CSS, not utilities — every
306
+ colour here is a measured `color-mix`, and the arbitrary-value classes that
307
+ would be needed are exactly what the JIT can miss.
308
+
309
+ Padding is always present and cancelled by an equal negative margin, so the
310
+ surface arriving on hover never nudges the text inside it. Only colour,
311
+ border, shadow and a 2px lift animate. */
312
+ .app-door {
313
+ display: inline-flex;
314
+ align-items: center;
315
+ gap: 0.9375rem;
316
+ max-width: calc(100% + 2.125rem);
317
+ margin: -0.6875rem -1.0625rem;
318
+ padding: 0.6875rem 1.0625rem;
319
+ border: 1px solid var(--border);
320
+ border-radius: 0.875rem;
321
+ /* A whisper at rest, so the block reads as a surface — therefore pressable —
322
+ before anyone hovers it. Most first-time visitors scan rather than hover. */
323
+ background: color-mix(in oklab, var(--primary) 2.5%, var(--card));
324
+ text-decoration: none;
325
+ color: inherit;
326
+ cursor: pointer;
327
+ transition:
328
+ background-color 0.14s ease-out,
329
+ border-color 0.14s ease-out,
330
+ box-shadow 0.14s ease-out,
331
+ translate 0.14s ease-out;
332
+ }
333
+ .app-door:hover,
334
+ .app-door:focus-visible {
335
+ background: color-mix(in oklab, var(--primary) 7%, var(--card));
336
+ border-color: color-mix(in oklab, var(--primary) 38%, var(--border));
337
+ box-shadow: 0 5px 16px -7px
338
+ color-mix(in oklab, var(--primary) 45%, transparent);
339
+ translate: 0 -2px;
340
+ outline: none;
341
+ }
342
+ .app-door:focus-visible {
343
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 25%, transparent);
344
+ }
345
+ .app-door:active {
346
+ translate: 0 0;
347
+ }
348
+ /* The icon lifts WITH the surface, so the logo reads as part of the press
349
+ rather than something parked beside it. Shadow only, deliberately: the icon
350
+ is either an <img> (no background to tint) or a monogram whose colour is set
351
+ as an INLINE style, which a class cannot override — and recolouring a brand
352
+ logo on hover would be wrong even where it worked. */
353
+ .app-door__icon {
354
+ transition: box-shadow 0.14s ease-out;
355
+ }
356
+ .app-door:hover .app-door__icon,
357
+ .app-door:focus-visible .app-door__icon {
358
+ box-shadow: 0 2px 8px -3px
359
+ color-mix(in oklab, var(--primary) 55%, transparent);
360
+ }
361
+ /* The title must NOT turn brand-coloured on hover. Measured against the hover
362
+ tint: `--foreground` holds 13.35:1 light / 12.60:1 dark, where the darkened
363
+ brand colour manages only 5.17:1 / 3.65:1 — so tinting it DROPPED contrast on
364
+ interaction. Hover, active and focus have to increase contrast, never reduce
365
+ it. The surface, border, shadow, icon tile and glyph already carry the state
366
+ change; the brand tint was the only cue that worked by going paler. */
367
+ .app-door__title {
368
+ display: block;
369
+ font-family: var(--font-serif);
370
+ font-size: 1.5625rem;
371
+ font-weight: 600;
372
+ line-height: 1.2;
373
+ }
374
+ /* One size step past the point where the longest names run the surface past
375
+ the card. Only a handful of entries are this long, but they include some of
376
+ the most-opened ones. */
377
+ .app-door__title--long {
378
+ font-size: 1.3125rem;
379
+ }
380
+ .app-door__url {
381
+ display: block;
382
+ margin-top: 0.25rem;
383
+ font-family: var(--font-mono, ui-monospace, monospace);
384
+ font-size: 0.75rem;
385
+ line-height: 1.4;
386
+ color: var(--muted-foreground);
387
+ overflow: hidden;
388
+ text-overflow: ellipsis;
389
+ white-space: nowrap;
390
+ transition: color 0.14s ease-out;
391
+ }
392
+ .app-door:hover .app-door__url,
393
+ .app-door:focus-visible .app-door__url {
394
+ color: var(--foreground);
395
+ }
396
+ .app-door__glyph {
397
+ flex: none;
398
+ color: var(--muted-foreground);
399
+ opacity: 0.75;
400
+ transition:
401
+ opacity 0.14s ease-out,
402
+ color 0.14s ease-out,
403
+ translate 0.14s ease-out;
404
+ }
405
+ .app-door:hover .app-door__glyph,
406
+ .app-door:focus-visible .app-door__glyph {
407
+ opacity: 1;
408
+ color: color-mix(in oklab, var(--primary) 85%, black);
409
+ translate: 2px -2px;
410
+ }
411
+ /* No hover on a touch screen, so a hover-only affordance is invisible there.
412
+ Show the surface at full strength instead. */
413
+ @media (hover: none) {
414
+ .app-door {
415
+ background: color-mix(in oklab, var(--primary) 6%, var(--card));
416
+ border-color: color-mix(in oklab, var(--primary) 30%, var(--border));
417
+ }
418
+ .app-door__glyph {
419
+ opacity: 1;
420
+ }
421
+ }
422
+
423
+ /* The detail card's hero band: identity, the way in, and the typed-id jumps,
424
+ with the tab strip seated on its bottom edge so the two read as one unit and
425
+ the panel below reads as contents. The tint is faint on purpose — the access
426
+ section carries a stronger one, and two competing tinted panels in the first
427
+ screen was the objection that nearly sank this layout. */
428
+ .detail-band {
429
+ background: color-mix(in oklab, var(--primary) 4.5%, var(--card));
430
+ }
431
+
432
+ /* The tab strip. Horizontally scrollable rather than wrapping: a wrapped strip
433
+ changes the band's height from one entry to the next. */
434
+ .detail-tabs {
435
+ display: flex;
436
+ gap: 2px;
437
+ border-bottom: 1px solid var(--border);
438
+ overflow-x: auto;
439
+ scrollbar-width: none;
440
+ }
441
+ .detail-tabs::-webkit-scrollbar {
442
+ display: none;
443
+ }
444
+ .detail-tab {
445
+ position: relative;
446
+ display: inline-flex;
447
+ align-items: center;
448
+ gap: 0.4375rem;
449
+ padding: 0.625rem 0.875rem 0.6875rem;
450
+ border: 0;
451
+ border-radius: 0.5rem 0.5rem 0 0;
452
+ background: none;
453
+ font-size: 0.84375rem;
454
+ font-weight: 600;
455
+ white-space: nowrap;
456
+ color: var(--muted-foreground);
457
+ cursor: pointer;
458
+ transition:
459
+ color 0.14s ease-out,
460
+ background-color 0.14s ease-out;
461
+ }
462
+ .detail-tab:hover {
463
+ color: var(--foreground);
464
+ background: color-mix(in oklab, var(--primary) 5%, transparent);
465
+ }
466
+ /* The marker sits ON the strip's own border, so the active tab reads as
467
+ attached to the panel below rather than underlined. */
468
+ .detail-tab::after {
469
+ content: '';
470
+ position: absolute;
471
+ left: 0.625rem;
472
+ right: 0.625rem;
473
+ bottom: -1px;
474
+ height: 2.5px;
475
+ border-radius: 2px;
476
+ background: transparent;
477
+ transition: background-color 0.14s ease-out;
478
+ }
479
+ .detail-tab[aria-selected='true'] {
480
+ color: color-mix(in oklab, var(--primary) 85%, black);
481
+ }
482
+ .detail-tab[aria-selected='true']::after {
483
+ background: color-mix(in oklab, var(--primary) 85%, black);
484
+ }
485
+ .detail-tab:focus-visible {
486
+ outline: 2px solid color-mix(in oklab, var(--primary) 85%, black);
487
+ outline-offset: -2px;
488
+ }
489
+ /* Measured: `--muted-foreground` on `--secondary` at this size is 4.65:1 light
490
+ — over the 4.5:1 floor by 0.15, which is not a margin. Mixed toward
491
+ `--foreground` instead: 8.16:1 light, 8.24:1 dark. The active chip formerly
492
+ used the brand colour on a brand tint, which measured 4.46:1 light and
493
+ 3.00:1 dark — failing — so the tint alone now marks "active" and the text
494
+ stays at full `--foreground`. Weight is for emphasis; it is not contrast. */
495
+ .detail-tab__chip {
496
+ border-radius: 999px;
497
+ padding: 2px 7px;
498
+ font-size: 0.65625rem;
499
+ font-weight: 700;
500
+ line-height: 1.3;
501
+ background: var(--secondary);
502
+ color: color-mix(in oklab, var(--foreground) 55%, var(--muted-foreground));
503
+ }
504
+ .detail-tab[aria-selected='true'] .detail-tab__chip {
505
+ background: color-mix(in oklab, var(--primary) 16%, transparent);
506
+ color: var(--foreground);
507
+ }
508
+ /* "Something here is unreviewed", visible from any tab. Decorative, so it is
509
+ exempt on text-contrast grounds — and never the only carrier: the tab's
510
+ accessible name says it too. */
511
+ .detail-tab__dot {
512
+ width: 7px;
513
+ height: 7px;
514
+ flex: none;
515
+ border-radius: 999px;
516
+ background: var(--chart-4, oklch(0.72 0.16 70));
517
+ box-shadow: 0 0 0 2px
518
+ color-mix(in oklab, var(--chart-4, oklch(0.72 0.16 70)) 25%, transparent);
519
+
520
+ /* How many items are still waiting on a maintainer. Amber rather than red: an entry
521
+ with open feedback is disputed, not broken, and red is already the withdraw
522
+ affordance in the same thread. 5.45:1 light, 8.08:1 dark. */
523
+ .feedback-open-count {
524
+ display: inline-flex;
525
+ align-items: center;
526
+ justify-content: center;
527
+ min-width: 1.15rem;
528
+ border-radius: 999px;
529
+ padding: 0.15rem 0.35rem;
530
+ font-size: 0.6875rem;
531
+ font-weight: 600;
532
+ line-height: 1;
533
+ letter-spacing: normal;
534
+ text-transform: none;
535
+ white-space: nowrap;
536
+ color: color-mix(in oklab, var(--chart-4) 45%, var(--foreground));
537
+ background: color-mix(in oklab, var(--chart-4) 18%, var(--card));
538
+ border: 1px solid color-mix(in oklab, var(--chart-4) 38%, var(--card));
539
+ }
540
+ }
541
+
278
542
  /* tw-animate-css ships no reduced-motion rules of its own, so honour the
279
543
  preference here — the overlay animations it provides are all decorative. */
280
544
  @media (prefers-reduced-motion: reduce) {
@@ -13,6 +13,7 @@ import { useUiSettings } from '~/context/UiSettingsContext'
13
13
  import type { AreaIcon } from '~/types/uiSettings'
14
14
  import { AppCatalogContext } from '../../context/AppCatalogContext'
15
15
  import { areaLabel, groupByArea } from '../../utils/areaGrouping'
16
+ import { RequestAppDialog } from './RequestAppDialog'
16
17
 
17
18
  /**
18
19
  * Adaptive-home discovery spine (issue #38, increment 1) — matches the
@@ -347,6 +348,9 @@ function SearchResultsList({
347
348
  const [expandedParents, setExpandedParents] = useState<Set<string>>(
348
349
  () => new Set(),
349
350
  )
351
+ // Asking for something absent. Local state rather than a url param: a half-typed
352
+ // request has no business in a shareable link.
353
+ const [requestOpen, setRequestOpen] = useState(false)
350
354
 
351
355
  const results = useMemo(
352
356
  () => searchResources(apps, searchValue),
@@ -483,6 +487,26 @@ function SearchResultsList({
483
487
  : `${matchCount} result${matchCount === 1 ? '' : 's'}`}
484
488
  </div>
485
489
 
490
+ {/* A zero-result search is the most informative moment in the catalog: someone
491
+ has said exactly what they expected to find. Showing only "No results"
492
+ throws that away and leaves them with nowhere to go. */}
493
+ {matchCount === 0 && !didDeprecatedFallback && (
494
+ <div className="mb-3 px-1">
495
+ <button
496
+ type="button"
497
+ onClick={() => setRequestOpen(true)}
498
+ className="text-sm underline decoration-dotted underline-offset-2 hover:text-foreground text-muted-foreground"
499
+ >
500
+ Not in the catalog? Ask for it to be added
501
+ </button>
502
+ </div>
503
+ )}
504
+ <RequestAppDialog
505
+ open={requestOpen}
506
+ onOpenChange={setRequestOpen}
507
+ initialSubject={searchValue}
508
+ />
509
+
486
510
  {/* #11: deprecated-only fallback notice */}
487
511
  {didDeprecatedFallback && (
488
512
  <div