@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 @@
1
+ {"version":3,"file":"accessTabLabel.js","sources":["../../../../../src/modules/appCatalog/utils/accessTabLabel.ts"],"sourcesContent":["import type {\n ApprovalMethod,\n Resource,\n} from '@igstack/app-catalog-backend-core'\n\n/**\n * What the detail card's access tab says about itself.\n *\n * `route` names how you get in — the tab reads `Access — <route>` — and `chip`\n * is the one number worth carrying next to it.\n */\nexport interface AccessTabLabel {\n route: string\n chip?: string\n}\n\n/** Shown when nothing in the entry records a way in. */\nexport const ROUTE_UNDOCUMENTED = 'not documented'\n/** Shown when the resource needs no request at all. */\nexport const ROUTE_FREE = 'Free'\n/** A request with no named channel behind it. */\nexport const ROUTE_REQUEST = 'request'\n\n/**\n * Names the access route for a resource's tab.\n *\n * The point is that the tab answers \"can I get in?\" BEFORE it is opened. That\n * is what lets every entry carry the same four tabs: an access tab holding one\n * sentence saying the route is unknown is a dead end when you have to click to\n * find out, and merely a fact you already read when the label says it.\n *\n * A named channel beats the step count, because the channel is the thing people\n * recognise — most entries route through one or two of them, and \"two steps\"\n * describes effort rather than destination. The count rides in `chip` instead.\n */\nexport function accessTabLabel(\n resource: Resource,\n approvalMethods: ApprovalMethod[],\n): AccessTabLabel {\n const request = resource.accessRequest\n // Children carry their access as top-level fields rather than an\n // `accessRequest`, and the detail card's access section already falls back to\n // them. The tab has to agree with the section below it, or the label promises\n // something the panel does not show.\n const hasContactsOnly =\n (resource.approverSlugs?.length ?? 0) > 0 ||\n Boolean(resource.accessComments)\n\n if (!request) {\n return hasContactsOnly\n ? { route: ROUTE_REQUEST, chip: undefined }\n : { route: ROUTE_UNDOCUMENTED }\n }\n\n const method = approvalMethods.find(\n (m) => m.slug === request.approvalMethodSlug,\n )\n const hasWrittenSteps = Boolean(request.requestPrompt || request.comments)\n const isTwoStep = Boolean(hasWrittenSteps && request.postApprovalInstructions)\n\n // Steps before roles: two-step is the rarer and more consequential shape, and\n // an entry that is both (a named channel AND a follow-up) is exactly the one\n // where \"there is more to do after approval\" is the thing worth knowing.\n const roleCount = request.roles?.length ?? 0\n const chip = isTwoStep\n ? '2 steps'\n : roleCount > 0\n ? `${roleCount} role${roleCount === 1 ? '' : 's'}`\n : undefined\n\n if (method?.type === 'noAccessRequired') return { route: ROUTE_FREE, chip }\n\n // Nothing here can name a channel: `custom` has none by definition, and an\n // `approvalMethodSlug` that resolves to no method is a dangling reference —\n // the section below renders an access box with nothing in it either way.\n const cannotNameRoute = !method || method.type === 'custom'\n\n // `unknown` is the honest \"we did not find out\". A route we cannot name AND\n // cannot describe is the same dead end wearing a different slug: claiming\n // \"request\" there would promise a way in that the panel does not show.\n if (method?.type === 'unknown' || (cannotNameRoute && !hasWrittenSteps)) {\n return { route: ROUTE_UNDOCUMENTED, chip }\n }\n\n // A named desk, bot or team. `shortName` exists because `displayName` is\n // written for prose and the longest in use does not fit a tab. No `&& method`\n // guard: `cannotNameRoute` is `!method || …`, so ruling it out already narrows\n // `method` to present.\n if (!cannotNameRoute) {\n return { route: method.shortName ?? method.displayName, chip }\n }\n\n // Written steps with no channel to name them.\n return { route: isTwoStep ? '2 steps' : ROUTE_REQUEST, chip }\n}\n"],"names":[],"mappings":"AAiBO,MAAM,qBAAqB;AAE3B,MAAM,aAAa;AAEnB,MAAM,gBAAgB;AActB,SAAS,eACd,UACA,iBACgB;AArBX;AAsBL,QAAM,UAAU,SAAS;AAKzB,QAAM,qBACH,cAAS,kBAAT,mBAAwB,WAAU,KAAK,KACxC,QAAQ,SAAS,cAAc;AAEjC,MAAI,CAAC,SAAS;AACZ,WAAO,kBACH,EAAE,OAAO,eAAe,MAAM,OAAA,IAC9B,EAAE,OAAO,mBAAA;AAAA,EACf;AAEA,QAAM,SAAS,gBAAgB;AAAA,IAC7B,CAAC,MAAM,EAAE,SAAS,QAAQ;AAAA,EAAA;AAE5B,QAAM,kBAAkB,QAAQ,QAAQ,iBAAiB,QAAQ,QAAQ;AACzE,QAAM,YAAY,QAAQ,mBAAmB,QAAQ,wBAAwB;AAK7E,QAAM,cAAY,aAAQ,UAAR,mBAAe,WAAU;AAC3C,QAAM,OAAO,YACT,YACA,YAAY,IACV,GAAG,SAAS,QAAQ,cAAc,IAAI,KAAK,GAAG,KAC9C;AAEN,OAAI,iCAAQ,UAAS,2BAA2B,EAAE,OAAO,YAAY,KAAA;AAKrE,QAAM,kBAAkB,CAAC,UAAU,OAAO,SAAS;AAKnD,OAAI,iCAAQ,UAAS,aAAc,mBAAmB,CAAC,iBAAkB;AACvE,WAAO,EAAE,OAAO,oBAAoB,KAAA;AAAA,EACtC;AAMA,MAAI,CAAC,iBAAiB;AACpB,WAAO,EAAE,OAAO,OAAO,aAAa,OAAO,aAAa,KAAA;AAAA,EAC1D;AAGA,SAAO,EAAE,OAAO,YAAY,YAAY,eAAe,KAAA;AACzD;"}
package/dist/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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "6.3.1-alpha-20261003051755",
3
+ "version": "6.5.0",
4
4
  "description": "Frontend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -112,7 +112,7 @@
112
112
  "tailwindcss": "^4.1.7",
113
113
  "tiptap-markdown": "^0.8.0",
114
114
  "tslib": "^2.3.0",
115
- "@igstack/app-catalog-shared-core": "6.3.1-alpha-20261003051755"
115
+ "@igstack/app-catalog-shared-core": "6.5.0"
116
116
  },
117
117
  "devDependencies": {
118
118
  "@tailwindcss/vite": "^4.0.5",
@@ -139,7 +139,7 @@
139
139
  "vite-plugin-static-copy": "^3.1.4",
140
140
  "vite-plugin-svgr": "^4.2.0",
141
141
  "vitest": "^4.1.2",
142
- "@igstack/app-catalog-backend-core": "6.3.1-alpha-20261003051755"
142
+ "@igstack/app-catalog-backend-core": "6.5.0"
143
143
  },
144
144
  "peerDependencies": {
145
145
  "react": "19.1.2",
@@ -148,7 +148,7 @@
148
148
  "vite": "^6.3.5",
149
149
  "vite-plugin-svgr": "^4.2.0"
150
150
  },
151
- "gitHead": "8f69deaf67f4acd8a76750b486f2f40c405d4220",
151
+ "gitHead": "fff8c62c5136b8b1fdb9631ee42c261ba7a35bc6",
152
152
  "scripts": {
153
153
  "build": "vite build",
154
154
  "build:lenient": "vite build --mode lenient",
@@ -33,8 +33,8 @@ vi.mock('~/modules/appCatalog/ui/context/AppCatalogFiltersContext', () => ({
33
33
  })),
34
34
  }))
35
35
  vi.mock('~/modules/auth', () => ({ useUser: vi.fn(() => null) }))
36
- vi.mock('~/modules/appCatalog/ui/detail/CommentsSection', () => ({
37
- CommentsSection: () => <div />,
36
+ vi.mock('~/modules/appCatalog/ui/detail/FeedbackSection', () => ({
37
+ FeedbackSection: () => <div />,
38
38
  }))
39
39
 
40
40
  const { AppDetailPanel } =
@@ -62,6 +62,13 @@ function roleRowCount() {
62
62
  ?.querySelectorAll('tr').length
63
63
  }
64
64
 
65
+ // The roles table lives behind the access tab, so every assertion about it has
66
+ // to open that tab first. An app switch remounts the card body and resets the
67
+ // card to its first tab, so this is needed again on the far side of a switch.
68
+ function openAccessTab() {
69
+ fireEvent.click(screen.getByRole('tab', { name: /^access/i }))
70
+ }
71
+
65
72
  // Two flows reach a different app without closing the card: "View replacement"
66
73
  // on a deprecated entry, and an access prerequisite's parent. Both swap the
67
74
  // `app` prop, and nothing in the chain down to the roles table was keyed by it,
@@ -74,6 +81,7 @@ describe('AppDetailPanel — navigating from one app to another', () => {
74
81
  <AppDetailPanel app={appWithRoles('labvantage', 8)} onClose={vi.fn()} />,
75
82
  )
76
83
 
84
+ openAccessTab()
77
85
  fireEvent.click(
78
86
  screen.getByRole('button', { name: /Show all \(8\) roles/ }),
79
87
  )
@@ -84,6 +92,7 @@ describe('AppDetailPanel — navigating from one app to another', () => {
84
92
  rerender(
85
93
  <AppDetailPanel app={appWithRoles('alation', 7)} onClose={vi.fn()} />,
86
94
  )
95
+ openAccessTab()
87
96
 
88
97
  const toggle = screen.getByRole('button', { name: /Show all \(7\) roles/ })
89
98
  expect(toggle).toHaveAttribute('aria-expanded', 'false')
@@ -99,11 +108,14 @@ describe('AppDetailPanel — navigating from one app to another', () => {
99
108
  <AppDetailPanel app={appWithRoles('labvantage', 8)} onClose={vi.fn()} />,
100
109
  )
101
110
 
111
+ openAccessTab()
102
112
  fireEvent.click(
103
113
  screen.getByRole('button', { name: /Show all \(8\) roles/ }),
104
114
  )
105
115
  // A fresh object for the same app, which is what a refetch hands down: an
106
116
  // identity-based key would throw the reader's own expansion away here.
117
+ // No tab click on this side — the panel stays mounted through a same-app
118
+ // rerender, which is precisely what keeps the expansion.
107
119
  rerender(
108
120
  <AppDetailPanel app={appWithRoles('labvantage', 8)} onClose={vi.fn()} />,
109
121
  )
@@ -144,7 +156,9 @@ describe('AppDetailPanel — focus after an app switch', () => {
144
156
  )
145
157
  // Stands in for the control that was activated to navigate ("View
146
158
  // replacement", a prerequisite's parent): focused, and inside the body
147
- // that is about to be replaced.
159
+ // that is about to be replaced. Deliberately a control inside a tab panel,
160
+ // which is where most of the card's focusable content now lives.
161
+ openAccessTab()
148
162
  const toggle = screen.getByRole('button', {
149
163
  name: /Show all \(8\) roles/,
150
164
  })
@@ -0,0 +1,113 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import '@testing-library/jest-dom/vitest'
5
+
6
+ const state = vi.hoisted(() => ({ sent: [] as unknown[] }))
7
+
8
+ vi.mock('@tanstack/react-query', () => ({
9
+ useMutation: vi.fn((options?: { onSuccess?: () => void }) => ({
10
+ mutate: (vars: unknown) => {
11
+ state.sent.push(vars)
12
+ options?.onSuccess?.()
13
+ },
14
+ isPending: false,
15
+ })),
16
+ useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn() })),
17
+ }))
18
+ vi.mock('~/api/infra/trpc', () => ({
19
+ useTRPC: vi.fn(() => ({
20
+ feedback: {
21
+ add: { mutationOptions: (o: unknown) => o },
22
+ mine: { queryKey: () => ['feedback', 'mine'] },
23
+ },
24
+ })),
25
+ }))
26
+
27
+ const { RequestAppDialog } =
28
+ await import('~/modules/appCatalog/ui/catalog/RequestAppDialog')
29
+
30
+ describe('RequestAppDialog', () => {
31
+ /**
32
+ * The grid is rendered bare in several tests with no TRPCProvider, so the closed
33
+ * dialog must not reach a tRPC hook. A hook cannot be called conditionally, which is
34
+ * why the gate is a component boundary — and why this test exists: inlining the body
35
+ * back into one component breaks six unrelated grid tests with
36
+ * "useTRPC() can only be used inside of a <TRPCProvider>".
37
+ */
38
+ it('mounts nothing at all while closed', () => {
39
+ const { container } = render(
40
+ <RequestAppDialog open={false} onOpenChange={() => {}} />,
41
+ )
42
+ expect(container).toBeEmptyDOMElement()
43
+ })
44
+
45
+ it('pre-fills what the visitor searched for', () => {
46
+ render(
47
+ <RequestAppDialog
48
+ open
49
+ onOpenChange={() => {}}
50
+ initialSubject="hoppy budget tool"
51
+ />,
52
+ )
53
+ // They just typed it into the search box; asking again is a toll on the one
54
+ // person who already told us what is missing.
55
+ expect(screen.getByLabelText(/What are you looking for/)).toHaveValue(
56
+ 'hoppy budget tool',
57
+ )
58
+ })
59
+
60
+ it('refuses to send with no name, then sends a catalog-level ask', async () => {
61
+ state.sent = []
62
+ render(<RequestAppDialog open onOpenChange={() => {}} initialSubject="" />)
63
+
64
+ const send = screen.getByRole('button', { name: /Send request/ })
65
+ expect(send).toBeDisabled()
66
+
67
+ await userEvent.type(
68
+ screen.getByLabelText(/What are you looking for/),
69
+ 'Grafana',
70
+ )
71
+ await userEvent.click(screen.getByRole('button', { name: /Send request/ }))
72
+
73
+ // No resourceSlug: that is what makes it a request for something absent rather
74
+ // than feedback on an existing entry. The server splits them the same way.
75
+ expect(state.sent).toEqual([{ subject: 'Grafana' }])
76
+ })
77
+
78
+ it('carries the optional detail when given, and omits it when blank', async () => {
79
+ state.sent = []
80
+ render(
81
+ <RequestAppDialog
82
+ open
83
+ onOpenChange={() => {}}
84
+ initialSubject="Grafana"
85
+ />,
86
+ )
87
+ await userEvent.type(
88
+ screen.getByLabelText(/Anything that would help/),
89
+ 'the observability team runs it',
90
+ )
91
+ await userEvent.click(screen.getByRole('button', { name: /Send request/ }))
92
+
93
+ expect(state.sent).toEqual([
94
+ { subject: 'Grafana', body: 'the observability team runs it' },
95
+ ])
96
+ })
97
+
98
+ it('confirms rather than leaving them wondering whether it sent', async () => {
99
+ state.sent = []
100
+ render(
101
+ <RequestAppDialog
102
+ open
103
+ onOpenChange={() => {}}
104
+ initialSubject="Grafana"
105
+ />,
106
+ )
107
+ await userEvent.click(screen.getByRole('button', { name: /Send request/ }))
108
+
109
+ // Reporting into silence is how a person learns not to bother.
110
+ expect(screen.getByText(/that is logged/i)).toBeInTheDocument()
111
+ expect(screen.queryByRole('button', { name: /Send request/ })).toBeNull()
112
+ })
113
+ })
@@ -184,12 +184,14 @@ describe('AccessRequestSection — entries with no access information', () => {
184
184
  displayName: 'Ghost',
185
185
  }
186
186
 
187
- it('still renders the box, instead of nothing, when every access field is empty', () => {
187
+ it('still says something, instead of nothing, when every access field is empty', () => {
188
188
  render(<AccessRequestSection app={bare} approvalMethods={[]} />)
189
189
 
190
- expect(
191
- screen.getByRole('heading', { name: 'How to get access' }),
192
- ).toBeInTheDocument()
190
+ // The frame and its "How to get access" heading were removed when access
191
+ // became its own tab — the tab's label states that, and a heading repeating
192
+ // the tab you just clicked is the same fact twice. What #181 actually
193
+ // guarantees is that the reader is TOLD we do not know, rather than shown an
194
+ // empty panel, so that is what this asserts.
193
195
  expect(screen.getByText(/not documented yet/)).toBeInTheDocument()
194
196
  })
195
197
 
@@ -1,5 +1,5 @@
1
1
  import type { Resource } from '@igstack/app-catalog-backend-core'
2
- import { render, screen } from '@testing-library/react'
2
+ import { fireEvent, render, screen } from '@testing-library/react'
3
3
  import { describe, expect, it, vi } from 'vitest'
4
4
  import '@testing-library/jest-dom/vitest'
5
5
 
@@ -28,8 +28,8 @@ vi.mock('~/modules/appCatalog/ui/context/AppCatalogFiltersContext', () => ({
28
28
  useAppCatalogFilters: vi.fn(() => ({ selectedTags: [], toggleTag: vi.fn() })),
29
29
  }))
30
30
  vi.mock('~/modules/auth', () => ({ useUser: vi.fn(() => null) }))
31
- vi.mock('~/modules/appCatalog/ui/detail/CommentsSection', () => ({
32
- CommentsSection: () => <div />,
31
+ vi.mock('~/modules/appCatalog/ui/detail/FeedbackSection', () => ({
32
+ FeedbackSection: () => <div />,
33
33
  }))
34
34
 
35
35
  const { AppDetails } = await import('~/modules/appCatalog/ui/detail/AppDetails')
@@ -42,28 +42,43 @@ const app = {
42
42
  background: 'Built around 2015 for the clinic-facing side of one business.',
43
43
  } as Resource
44
44
 
45
+ // The original contract was positional — history AFTER the description, never
46
+ // instead of it — and the card has since split those two across tabs, so no
47
+ // single DOM holds both. The intent survives unchanged: the description is what
48
+ // the card opens on, and the history is somewhere quieter that you choose to
49
+ // visit. That is what these assert now.
45
50
  describe('AppDetails — Background', () => {
46
- it('renders the history after the description, not instead of it', () => {
51
+ it('opens on the description, not on the history', () => {
47
52
  render(<AppDetails app={app} onClosePanel={vi.fn()} />)
48
53
 
49
- const description = screen.getByRole('heading', { name: 'Description' })
50
- const background = screen.getByRole('heading', { name: 'Background' })
54
+ expect(screen.getByRole('heading', { name: 'Description' })).toBeVisible()
55
+ expect(screen.queryByRole('heading', { name: 'Background' })).toBeNull()
56
+ })
57
+
58
+ it('renders the history under Documentation', async () => {
59
+ render(<AppDetails app={app} onClosePanel={vi.fn()} />)
51
60
 
61
+ fireEvent.click(screen.getByRole('tab', { name: /documentation/i }))
62
+
63
+ expect(screen.getByRole('heading', { name: 'Background' })).toBeVisible()
52
64
  expect(
53
65
  screen.getByText(/Built around 2015 for the clinic-facing side/),
54
66
  ).toBeInTheDocument()
55
- expect(
56
- description.compareDocumentPosition(background) &
57
- Node.DOCUMENT_POSITION_FOLLOWING,
58
- ).toBeTruthy()
59
67
  })
60
68
 
69
+ // Asserted ON the Documentation tab on purpose. Asserting it from Overview would
70
+ // pass whether or not the section works, because Background is in another
71
+ // panel either way — a test that cannot fail is worse than no test.
61
72
  it('leaves no empty section when the entry has no history recorded', () => {
62
73
  const { background: _omitted, ...withoutBackground } = app
63
74
  render(
64
75
  <AppDetails app={withoutBackground as Resource} onClosePanel={vi.fn()} />,
65
76
  )
66
77
 
78
+ fireEvent.click(screen.getByRole('tab', { name: /documentation/i }))
79
+
67
80
  expect(screen.queryByRole('heading', { name: 'Background' })).toBeNull()
81
+ // Proves the panel really is open, so the absence above means something.
82
+ expect(screen.getByRole('heading', { name: 'Sources' })).toBeVisible()
68
83
  })
69
84
  })