@igstack/app-catalog-frontend-core 6.3.1-alpha-20261003051755 → 6.4.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 (61) hide show
  1. package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.d.ts +0 -0
  2. package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.d.ts +0 -0
  3. package/dist/esm/__tests__/modules/appCatalog/utils/accessTabLabel.test.d.ts +1 -0
  4. package/dist/esm/api/infra/trpc.d.ts +42 -21
  5. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -0
  6. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +17 -2
  7. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  8. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +1 -0
  9. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -1
  10. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +11 -4
  11. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
  12. package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js +0 -4
  13. package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js.map +1 -1
  14. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +4 -1
  15. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
  16. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +377 -385
  17. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  18. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.d.ts +20 -0
  19. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js +59 -0
  20. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js.map +1 -0
  21. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +64 -0
  22. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +88 -0
  23. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -0
  24. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +13 -0
  25. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +433 -0
  26. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +1 -0
  27. package/dist/esm/modules/appCatalog/utils/accessTabLabel.d.ts +30 -0
  28. package/dist/esm/modules/appCatalog/utils/accessTabLabel.js +34 -0
  29. package/dist/esm/modules/appCatalog/utils/accessTabLabel.js.map +1 -0
  30. package/dist/index.css +269 -5
  31. package/package.json +4 -4
  32. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +17 -3
  33. package/src/__tests__/modules/appCatalog/ui/components/AccessRequestSection.test.tsx +6 -4
  34. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +25 -10
  35. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +169 -0
  36. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +2 -2
  37. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +32 -9
  38. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +125 -0
  39. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +2 -2
  40. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +181 -0
  41. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +131 -0
  42. package/src/__tests__/modules/appCatalog/utils/accessTabLabel.test.ts +206 -0
  43. package/src/__tests__/ui/components/header/Header.loginHidden.test.tsx +1 -1
  44. package/src/index.css +269 -5
  45. package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +46 -2
  46. package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +3 -0
  47. package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +9 -4
  48. package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +4 -1
  49. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +574 -554
  50. package/src/modules/appCatalog/ui/detail/AppDoor.tsx +97 -0
  51. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +188 -0
  52. package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +605 -0
  53. package/src/modules/appCatalog/utils/accessTabLabel.ts +95 -0
  54. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.d.ts +0 -3
  55. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js +0 -228
  56. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js.map +0 -1
  57. package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.emptyState.test.tsx +0 -64
  58. package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.reviewStatus.test.tsx +0 -93
  59. package/src/modules/appCatalog/ui/detail/CommentsSection.tsx +0 -296
  60. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.emptyState.test.d.ts → AppDetails.doorAndTabs.test.d.ts} +0 -0
  61. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.reviewStatus.test.d.ts → AppDetails.subResourceTab.test.d.ts} +0 -0
@@ -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) {
@@ -1,6 +1,6 @@
1
1
  import type { Resource } from '@igstack/app-catalog-backend-core'
2
2
  import { X } from 'lucide-react'
3
- import { useEffect, useRef } from 'react'
3
+ import { useEffect, useLayoutEffect, useRef } from 'react'
4
4
  import { useAppCatalogContext } from '../../context/AppCatalogContext'
5
5
  import { SubResourceDetailPanel } from '../components/SubResourceDetailPanel'
6
6
  import { AppDetails } from '../detail/AppDetails'
@@ -38,6 +38,47 @@ export function AppDetailPanel({
38
38
  const { approvalMethods } = useAppCatalogContext()
39
39
  const dialogRef = useRef<HTMLDivElement>(null)
40
40
 
41
+ /**
42
+ * Centre the card ONCE when it opens, then hold that position.
43
+ *
44
+ * `my-auto` centred it on every render, so each panel height change pushed both
45
+ * edges and half the delta became upward movement of everything above it —
46
+ * including the tab strip, which moved 113px and took the control the user had
47
+ * just clicked with it (CLS 0.19, "needs improvement").
48
+ *
49
+ * Top-anchoring fixed the shift but looked wrong: a short card sat high on the
50
+ * page. So pin the centred offset instead. The card still opens centred, and
51
+ * anything that grows it afterwards extends downward only — which is what a
52
+ * reader expects when they open a tab with more in it.
53
+ *
54
+ * Only on open and on resize. Deliberately NOT on content change: reacting to
55
+ * content is precisely the bug.
56
+ */
57
+ useLayoutEffect(() => {
58
+ const card = dialogRef.current
59
+ const scroller = card?.parentElement
60
+ if (!card || !scroller) return
61
+
62
+ const pinCentre = () => {
63
+ card.style.marginTop = '0px'
64
+ const style = getComputedStyle(scroller)
65
+ const usable =
66
+ scroller.clientHeight -
67
+ parseFloat(style.paddingTop) -
68
+ parseFloat(style.paddingBottom)
69
+ const slack = usable - card.offsetHeight
70
+ // Negative slack means the card is already taller than the viewport; leave it
71
+ // at the top and let the overlay scroll, rather than pushing its head off.
72
+ card.style.marginTop = `${Math.max(0, Math.round(slack / 2))}px`
73
+ }
74
+
75
+ pinCentre()
76
+ window.addEventListener('resize', pinCentre)
77
+ return () => window.removeEventListener('resize', pinCentre)
78
+ // Keyed on the opened entry: a different card is a new open, and should
79
+ // re-centre for its own height.
80
+ }, [app.slug, subResource?.slug])
81
+
41
82
  // Focus the dialog on mount so Esc hotkeys fire immediately, even when
42
83
  // the card was opened by a mouse click (which leaves focus on the grid row).
43
84
  //
@@ -75,7 +116,10 @@ export function AppDetailPanel({
75
116
  aria-modal="true"
76
117
  aria-label={`${subResource?.displayName ?? app.displayName} details`}
77
118
  tabIndex={-1}
78
- className="relative w-full max-w-[min(1120px,94vw)] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200 outline-none"
119
+ // Centred on open, then held — see the pin effect above. `my-auto` is
120
+ // deliberately absent: it re-centres on EVERY height change, which is the
121
+ // layout-shift bug.
122
+ className="relative w-full max-w-[min(1120px,94vw)] rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200 outline-none"
79
123
  >
80
124
  <button
81
125
  type="button"
@@ -31,6 +31,9 @@ export function AccessPrerequisiteChain({
31
31
 
32
32
  return (
33
33
  <div
34
+ // Part of the access story, so it carries the same anchor the instructions
35
+ // do — a reader (and a test) wants the whole chain, not half of it.
36
+ data-access-section=""
34
37
  className="mt-6 rounded-lg border-[1.5px] border-primary/40 bg-primary/[0.03] p-4"
35
38
  role="note"
36
39
  aria-label="Access requires prerequisite steps"
@@ -235,10 +235,15 @@ const UNDOCUMENTED_WITHOUT_CONTACT =
235
235
  */
236
236
  function AccessBox({ children }: { children: ReactNode }) {
237
237
  return (
238
- <div className="mt-6 rounded-lg border-[1.5px] border-primary/40 bg-primary/[0.03] p-4 space-y-4">
239
- <h3 className="text-sm font-semibold text-foreground">
240
- How to get access
241
- </h3>
238
+ // No frame and no heading. Both existed to mark this out as THE thing on a long
239
+ // scrolling card; now it is its own tab, so the tab's label already says what
240
+ // this is and the panel edge already bounds it. A box inside a box, under a
241
+ // heading that repeats the tab you just clicked, is two statements of the same
242
+ // fact and ink the reader has to look past.
243
+ // A stable anchor for tests, because neither of the old handles survives: the
244
+ // heading is gone, and a sub-resource page has no tab panel to read either.
245
+ // Text and structure both move; this does not.
246
+ <div data-access-section="" className="space-y-4">
242
247
  {children}
243
248
  </div>
244
249
  )
@@ -104,7 +104,10 @@ export function SubResourceDetailPanel({
104
104
 
105
105
  {/* Two-step access section */}
106
106
  {hasAnyAccess && (
107
- <div className="mt-6 space-y-6">
107
+ // The whole two-step region carries the anchor: the step labels naming
108
+ // the parent live out here, not inside the nested AccessRequestSections,
109
+ // so anchoring only those dropped the parent from the chain.
110
+ <div data-access-section="" className="mt-6 space-y-6">
108
111
  <h3 className="text-sm font-medium">How to get access</h3>
109
112
 
110
113
  {hasTwoStepAccess ? (