@igstack/app-catalog-frontend-core 6.9.0 → 6.10.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 (78) hide show
  1. package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.d.ts +0 -0
  2. package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.d.ts +0 -0
  3. package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.d.ts +0 -0
  4. package/dist/esm/__tests__/modules/appCatalog/ui/detail/composeComments.test.d.ts +1 -0
  5. package/dist/esm/__tests__/ui/detail/CommunityInput.test.d.ts +1 -0
  6. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +8 -0
  7. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +254 -232
  8. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  9. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.d.ts +17 -0
  10. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js +187 -0
  11. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js.map +1 -0
  12. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.d.ts +9 -0
  13. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js +91 -0
  14. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js.map +1 -0
  15. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.d.ts +17 -0
  16. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js +328 -0
  17. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js.map +1 -0
  18. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.d.ts +15 -0
  19. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js +26 -0
  20. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js.map +1 -0
  21. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +8 -19
  22. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +49 -37
  23. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
  24. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.d.ts +30 -0
  25. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js +50 -0
  26. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js.map +1 -0
  27. package/dist/esm/modules/appCatalog/ui/detail/communityError.d.ts +6 -0
  28. package/dist/esm/modules/appCatalog/ui/detail/communityError.js +8 -0
  29. package/dist/esm/modules/appCatalog/ui/detail/communityError.js.map +1 -0
  30. package/dist/esm/modules/appCatalog/ui/detail/composeComments.d.ts +27 -0
  31. package/dist/esm/modules/appCatalog/ui/detail/composeComments.js +66 -0
  32. package/dist/esm/modules/appCatalog/ui/detail/composeComments.js.map +1 -0
  33. package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.d.ts +11 -0
  34. package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.js +15 -0
  35. package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.js.map +1 -0
  36. package/dist/esm/modules/extensions/features/resourceDetail.d.ts +38 -0
  37. package/dist/esm/modules/extensions/features/resourceDetail.js +4 -0
  38. package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -1
  39. package/dist/esm/modules/extensions/index.d.ts +1 -1
  40. package/dist/esm/modules/extensions/index.js.map +1 -1
  41. package/dist/index.css +525 -49
  42. package/package.json +4 -4
  43. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +5 -2
  44. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.enter.test.tsx +5 -2
  45. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +9 -10
  46. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.behindThisCard.test.tsx +402 -0
  47. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +8 -4
  48. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +5 -2
  49. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.knowMore.test.tsx +175 -0
  50. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +5 -2
  51. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +5 -2
  52. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +5 -2
  53. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.tsx +281 -0
  54. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.tsx +179 -0
  55. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.tsx +423 -0
  56. package/src/__tests__/modules/appCatalog/ui/detail/composeComments.test.ts +170 -0
  57. package/src/__tests__/ui/detail/CommunityInput.test.tsx +39 -0
  58. package/src/index.css +525 -49
  59. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +310 -295
  60. package/src/modules/appCatalog/ui/detail/CommentsFeed.tsx +254 -0
  61. package/src/modules/appCatalog/ui/detail/CommentsHelp.tsx +102 -0
  62. package/src/modules/appCatalog/ui/detail/CommunityInput.tsx +419 -0
  63. package/src/modules/appCatalog/ui/detail/CommunitySection.tsx +41 -0
  64. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +77 -71
  65. package/src/modules/appCatalog/ui/detail/communityDraft.ts +80 -0
  66. package/src/modules/appCatalog/ui/detail/communityError.ts +10 -0
  67. package/src/modules/appCatalog/ui/detail/composeComments.ts +103 -0
  68. package/src/modules/appCatalog/ui/detail/usePendingCount.ts +26 -0
  69. package/src/modules/extensions/features/resourceDetail.ts +28 -0
  70. package/src/modules/extensions/index.ts +2 -0
  71. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +0 -13
  72. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +0 -433
  73. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +0 -1
  74. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +0 -181
  75. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +0 -131
  76. package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +0 -605
  77. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{FeedbackSection.emptyState.test.d.ts → AppDetails.behindThisCard.test.d.ts} +0 -0
  78. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{FeedbackSection.reviewStatus.test.d.ts → AppDetails.knowMore.test.d.ts} +0 -0
@@ -1 +1 @@
1
- {"version":3,"file":"DetailTabs.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/DetailTabs.tsx"],"sourcesContent":["import React from 'react'\nimport { cn } from '~/lib/utils'\nimport { useUrlSyncedState } from '~/modules/appCatalog/hooks/useUrlSyncedState'\n\nexport interface DetailTab {\n /** Stable id — this is what lands in `?tab=`, so treat it as public. */\n id: string\n label: string\n /** One short count beside the label (\"430\", \"55 roles\", \"2 steps\"). */\n chip?: string\n /** Marks unreviewed content. Also stated in the accessible name. */\n dot?: boolean\n /** Spoken instead of `label` when the chip and dot need explaining. */\n ariaLabel?: string\n render: () => React.ReactNode\n}\n\n/**\n * The detail card's tabs.\n *\n * Every entry gets them, including thin ones. Two earlier rules were worse:\n * hiding a tab with little in it turned \"we have not documented the way in\"\n * into a dead end you had to click to discover, and showing them only on heavy\n * entries made the card's shape change between apps, so \"where is access?\" had\n * two answers and no habit could form across a few hundred entries. Naming the\n * route in the tab itself removes the reason to hide anything — the label tells\n * you whether opening it is worth it.\n *\n * The active tab lives in the url so a card is linkable mid-conversation. A\n * `forcedTab` (today: arriving with `?sub=`) wins over `?tab=`, because a url\n * that selects a child and then shows a different tab is lying about itself.\n *\n * Panels mount only while active, which also resets their internal state on\n * the way out — the same choice the card makes when it navigates between apps.\n */\nexport function DetailTabs({\n tabs,\n forcedTab,\n requestTab,\n requestNonce,\n band,\n bandClassName,\n}: {\n tabs: DetailTab[]\n /** Overrides `?tab=` while set. */\n forcedTab?: string\n /**\n * A one-shot \"please show this tab\" from outside the strip — the band's\n * \"Suggest a change\" uses it.\n *\n * It has to come through here rather than the caller writing `?tab=` itself:\n * `useUrlSyncedState` keeps its own `useState` seeded once from the URL and only\n * ever pushes state OUT to the URL, so a second instance writing the same key\n * does not update this one. The two then fight — the strip's next render sees the\n * URL disagreeing with its own state and navigates back over it.\n */\n requestTab?: string\n /**\n * Bumped by the caller every time it asks again. Without it a repeat request is\n * indistinguishable from the same standing value, so the effect below does not\n * re-fire and the second ask does nothing — which is what made the band's\n * button work exactly once per card.\n */\n requestNonce?: number\n /**\n * Identity and actions, shown ABOVE the strip and inside the same tinted\n * surface. Owned here rather than by the caller because the band and the\n * strip have to be one visual unit with the panel outside it — render the\n * panel inside the band and it inherits the tint, which is the mistake this\n * prop exists to make impossible.\n */\n band?: React.ReactNode\n /** Cancels the dialog's own padding so the tint reaches the card's edges. */\n bandClassName?: string\n}) {\n /**\n * The first tab is what the card opens on, so naming it in the URL says nothing.\n * `encode` returning undefined drops the param, which keeps `?tab=` out of a link\n * until someone has actually chosen something other than the default — and keeps a\n * shared link honest about what the sender was looking at.\n */\n const defaultTabId = tabs[0]?.id\n const [urlTab, setUrlTab] = useUrlSyncedState<string>({\n key: 'tab',\n defaultValue: '',\n encode: (value) => (value && value !== defaultTabId ? value : undefined),\n })\n\n const ids = tabs.map((t) => t.id)\n\n /**\n * Tab ids are public — they are in `?tab=` in links people have already shared.\n * Renaming one would silently drop those onto the first tab, so a retired id\n * keeps resolving to its replacement instead.\n */\n const RETIRED: Record<string, string> = { metadata: 'documentation' }\n\n /**\n * `forcedTab` picks the tab you ARRIVE on, not the tab you are stuck on.\n *\n * It used to win over `?tab=` for as long as it was set, and `?sub=` keeps it set\n * for the whole life of the card — so on any entry reached by a sub-resource\n * search (667 accounts on one of them) the strip was dead: every click wrote\n * nothing and the tab never moved. The same override also silently killed the\n * band's \"Suggest a change\", which navigates by writing `?tab=`.\n *\n * So it applies until the user expresses a preference, and then gets out of the\n * way. `userChose` is what distinguishes \"no opinion yet\" from \"chose the tab\n * that happens to be the forced one\".\n */\n const [userChose, setUserChose] = React.useState(false)\n\n // Applied when it changes, through the state that actually owns the tab.\n React.useEffect(() => {\n if (!requestTab) return\n setUserChose(true)\n setUrlTab(requestTab)\n }, [requestTab, requestNonce, setUrlTab])\n /**\n * Releasing the force must not move the view.\n *\n * Arriving with `?sub=` forces the sub-resources tab, but only through\n * `forcedTab` — the tab's own state is still the default. Clearing the\n * selection drops `?sub=`, the force goes with it, and the strip fell back to\n * that stale default: the reader was moved to Overview while looking at the\n * table they had just cleared a filter on. So when the force is released\n * before the user picked anything, the forced tab becomes the real choice.\n *\n * Adjusted during render rather than in an effect, the pattern React\n * documents for responding to a prop change: an effect would paint one frame\n * of Overview first, mounting that panel only to tear it down again.\n */\n const [lastForced, setLastForced] = React.useState(forcedTab)\n if (forcedTab !== lastForced) {\n setLastForced(forcedTab)\n if (lastForced && !forcedTab && !userChose) setUrlTab(lastForced)\n }\n\n const asked = userChose || !forcedTab ? urlTab : forcedTab\n const requested = RETIRED[asked] ?? asked\n const activeId = ids.includes(requested) ? requested : (ids[0] ?? '')\n const active = tabs.find((t) => t.id === activeId)\n\n // Arrow keys move between tabs, per the tablist pattern. Selection follows\n // focus, which is correct here because every panel is already loaded.\n const onKeyDown = (event: React.KeyboardEvent) => {\n const delta =\n event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0\n if (!delta) return\n event.preventDefault()\n const from = ids.indexOf(activeId)\n const next = ids[(from + delta + ids.length) % ids.length]\n if (!next) return\n setUserChose(true)\n setUrlTab(next)\n // Focus follows selection, or the arrow keys would walk the visual\n // selection away from the keyboard's position. By id rather than by\n // position in a NodeList: each tab already carries one, and an id lookup\n // says \"missing\" with a null instead of an out-of-range index.\n document.getElementById(`detail-tab-${next}`)?.focus()\n }\n\n return (\n <>\n <div className={cn('detail-band', bandClassName)}>\n {band}\n <div role=\"tablist\" className=\"detail-tabs mt-5\" onKeyDown={onKeyDown}>\n {tabs.map((tab) => {\n const selected = tab.id === activeId\n return (\n <button\n key={tab.id}\n type=\"button\"\n role=\"tab\"\n id={`detail-tab-${tab.id}`}\n aria-selected={selected}\n aria-controls={`detail-panel-${tab.id}`}\n // Only the selected tab stays in the tab order; the rest are\n // reached with the arrow keys, as the pattern requires.\n tabIndex={selected ? 0 : -1}\n aria-label={tab.ariaLabel}\n className=\"detail-tab\"\n onClick={() => {\n setUserChose(true)\n setUrlTab(tab.id)\n }}\n >\n {tab.label}\n {tab.chip && (\n <span className=\"detail-tab__chip\">{tab.chip}</span>\n )}\n {tab.dot && (\n <span className=\"detail-tab__dot\" aria-hidden=\"true\" />\n )}\n </button>\n )\n })}\n </div>\n </div>\n {active && (\n <div\n role=\"tabpanel\"\n id={`detail-panel-${active.id}`}\n aria-labelledby={`detail-tab-${active.id}`}\n // Focusable so that tabbing out of the strip lands in the content it\n // controls rather than skipping past it.\n tabIndex={0}\n className=\"pt-5 outline-none\"\n >\n {active.render()}\n </div>\n )}\n </>\n )\n}\n"],"names":["React","_a"],"mappings":"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"}
1
+ {"version":3,"file":"DetailTabs.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/DetailTabs.tsx"],"sourcesContent":["import React from 'react'\nimport { cn } from '~/lib/utils'\nimport { useUrlSyncedState } from '~/modules/appCatalog/hooks/useUrlSyncedState'\n\nexport interface DetailTab {\n /** Stable id — this is what lands in `?tab=`, so treat it as public. */\n id: string\n label: string\n /** One short count beside the label (\"430\", \"55 roles\", \"2 steps\"). */\n chip?: string\n /** Marks unreviewed content. Also stated in the accessible name. */\n dot?: boolean\n /** Spoken instead of `label` when the chip and dot need explaining. */\n ariaLabel?: string\n render: () => React.ReactNode\n}\n\n/**\n * The detail card's tabs.\n *\n * Every entry gets them, including thin ones. Two earlier rules were worse:\n * hiding a tab with little in it turned \"we have not documented the way in\"\n * into a dead end you had to click to discover, and showing them only on heavy\n * entries made the card's shape change between apps, so \"where is access?\" had\n * two answers and no habit could form across a few hundred entries. Naming the\n * route in the tab itself removes the reason to hide anything — the label tells\n * you whether opening it is worth it.\n *\n * The active tab lives in the url so a card is linkable mid-conversation. A\n * `forcedTab` (today: arriving with `?sub=`) wins over `?tab=`, because a url\n * that selects a child and then shows a different tab is lying about itself.\n *\n * Panels mount only while active, which also resets their internal state on\n * the way out — the same choice the card makes when it navigates between apps.\n */\nexport function DetailTabs({\n tabs,\n forcedTab,\n band,\n bandClassName,\n trailing,\n}: {\n tabs: DetailTab[]\n /** Overrides `?tab=` while set. */\n forcedTab?: string\n /**\n * Identity and actions, shown ABOVE the strip and inside the same tinted\n * surface. Owned here rather than by the caller because the band and the\n * strip have to be one visual unit with the panel outside it — render the\n * panel inside the band and it inherits the tint, which is the mistake this\n * prop exists to make impossible.\n */\n band?: React.ReactNode\n /** Cancels the dialog's own padding so the tint reaches the card's edges. */\n bandClassName?: string\n /**\n * Shown at the right end of the strip, outside the tablist so it is not\n * announced as a tab. Handed `select` because the strip owns the active tab:\n * a caller writing `?tab=` itself would fight `useUrlSyncedState`, which keeps\n * its own state and only pushes it out to the url.\n */\n trailing?: (select: (id: string) => void) => React.ReactNode\n}) {\n /**\n * The first tab is what the card opens on, so naming it in the URL says nothing.\n * `encode` returning undefined drops the param, which keeps `?tab=` out of a link\n * until someone has actually chosen something other than the default — and keeps a\n * shared link honest about what the sender was looking at.\n */\n const defaultTabId = tabs[0]?.id\n const [urlTab, setUrlTab] = useUrlSyncedState<string>({\n key: 'tab',\n defaultValue: '',\n encode: (value) => (value && value !== defaultTabId ? value : undefined),\n })\n\n const ids = tabs.map((t) => t.id)\n\n /**\n * Tab ids are public — they are in `?tab=` in links people have already shared.\n * Renaming one would silently drop those onto the first tab, so a retired id\n * keeps resolving to its replacement instead.\n */\n const RETIRED: Record<string, string> = {\n metadata: 'documentation',\n notes: 'behind',\n }\n\n /**\n * Where a link to a tab the entry does not have lands instead. A thin entry\n * has no Documentation tab, and links to it were mostly about sources and\n * dates, which Behind this card holds.\n */\n const ABSENT: Record<string, string> = {\n documentation: 'behind',\n }\n\n /**\n * `forcedTab` picks the tab you ARRIVE on, not the tab you are stuck on.\n *\n * `?sub=` stays set for the card's whole life, so forcing it permanently would\n * make the strip unclickable. It applies until the user expresses a preference,\n * and then gets out of the way. `userChose` is what distinguishes \"no opinion\n * yet\" from \"chose the tab that happens to be the forced one\".\n */\n const [userChose, setUserChose] = React.useState(false)\n /**\n * Releasing the force must not move the view.\n *\n * Arriving with `?sub=` forces the sub-resources tab, but only through\n * `forcedTab` — the tab's own state is still the default. Clearing the\n * selection drops `?sub=`, the force goes with it, and the strip fell back to\n * that stale default: the reader was moved to Overview while looking at the\n * table they had just cleared a filter on. So when the force is released\n * before the user picked anything, the forced tab becomes the real choice.\n *\n * Adjusted during render rather than in an effect, the pattern React\n * documents for responding to a prop change: an effect would paint one frame\n * of Overview first, mounting that panel only to tear it down again.\n */\n const [lastForced, setLastForced] = React.useState(forcedTab)\n if (forcedTab !== lastForced) {\n setLastForced(forcedTab)\n if (lastForced && !forcedTab && !userChose) setUrlTab(lastForced)\n }\n\n const asked = userChose || !forcedTab ? urlTab : forcedTab\n const retired = RETIRED[asked] ?? asked\n const requested = ids.includes(retired)\n ? retired\n : (ABSENT[retired] ?? retired)\n const activeId = ids.includes(requested) ? requested : (ids[0] ?? '')\n const active = tabs.find((t) => t.id === activeId)\n\n const select = (id: string) => {\n setUserChose(true)\n setUrlTab(id)\n // Scroll the active tab into view on narrow screens where tabs overflow.\n requestAnimationFrame(() => {\n const tabElement = document.getElementById(`detail-tab-${id}`)\n tabElement?.scrollIntoView({\n behavior: 'smooth',\n block: 'nearest',\n inline: 'center',\n })\n })\n }\n\n // Arrow keys move between tabs, per the tablist pattern. Selection follows\n // focus, which is correct here because every panel is already loaded.\n const onKeyDown = (event: React.KeyboardEvent) => {\n const delta =\n event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0\n if (!delta) return\n event.preventDefault()\n const from = ids.indexOf(activeId)\n const next = ids[(from + delta + ids.length) % ids.length]\n if (!next) return\n select(next)\n // Focus follows selection, or the arrow keys would walk the visual\n // selection away from the keyboard's position. By id rather than by\n // position in a NodeList: each tab already carries one, and an id lookup\n // says \"missing\" with a null instead of an out-of-range index.\n document.getElementById(`detail-tab-${next}`)?.focus()\n }\n\n return (\n <>\n <div className={cn('detail-band', bandClassName)}>\n {band}\n <div className=\"detail-tabs-row mt-5\">\n <div role=\"tablist\" className=\"detail-tabs\" onKeyDown={onKeyDown}>\n {tabs.map((tab) => {\n const selected = tab.id === activeId\n return (\n <button\n key={tab.id}\n type=\"button\"\n role=\"tab\"\n id={`detail-tab-${tab.id}`}\n aria-selected={selected}\n aria-controls={`detail-panel-${tab.id}`}\n // Only the selected tab stays in the tab order; the rest are\n // reached with the arrow keys, as the pattern requires.\n tabIndex={selected ? 0 : -1}\n aria-label={tab.ariaLabel}\n className=\"detail-tab\"\n onClick={() => select(tab.id)}\n >\n {tab.label}\n {tab.chip && (\n <span className=\"detail-tab__chip\">{tab.chip}</span>\n )}\n {tab.dot && (\n <span className=\"detail-tab__dot\" aria-hidden=\"true\" />\n )}\n </button>\n )\n })}\n </div>\n {trailing && (\n <div className=\"detail-tabs-trailing\">{trailing(select)}</div>\n )}\n </div>\n </div>\n {active && (\n <div\n role=\"tabpanel\"\n id={`detail-panel-${active.id}`}\n aria-labelledby={`detail-tab-${active.id}`}\n // Focusable so that tabbing out of the strip lands in the content it\n // controls rather than skipping past it.\n tabIndex={0}\n className=\"pt-5 outline-none\"\n >\n {active.render()}\n </div>\n )}\n </>\n )\n}\n"],"names":["React","_a"],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Where a half-written note lives while it is not sent.
3
+ *
4
+ * The input sits inside a tab panel, and a panel unmounts while its tab is not
5
+ * the active one — so component state alone loses whatever someone typed or
6
+ * attached the moment they look at another tab. Losing someone's words is the
7
+ * one failure this feature cannot afford: reporting has to cost almost nothing,
8
+ * and "I typed it and it vanished" is how a person learns not to bother.
9
+ *
10
+ * Session rather than local storage: a draft is in-flight work, not a
11
+ * preference. Keyed by slug so two entries do not share one draft. Storage can
12
+ * throw outright in a locked-down context; a lost draft is not worth taking the
13
+ * card down over, so every access swallows that.
14
+ */
15
+ /** One already-uploaded image waiting to be sent with the words. */
16
+ export interface PendingImage {
17
+ id: string;
18
+ name: string;
19
+ }
20
+ export declare function readDraft(slug: string): string;
21
+ export declare function writeDraft(slug: string, value: string): void;
22
+ /**
23
+ * Images upload the moment they are picked, so the draft holds ids rather than
24
+ * files. Dropping an id here does NOT delete the image; the server sweeps
25
+ * whatever is never sent, which keeps "remove" instant.
26
+ */
27
+ export declare function readAttachments(slug: string): PendingImage[];
28
+ export declare function writeAttachments(slug: string, images: PendingImage[]): void;
29
+ /** The stored image, at whatever size the browser lays it out. */
30
+ export declare function attachmentUrl(id: string): string;
@@ -0,0 +1,50 @@
1
+ const draftKey = (slug) => `ac.feedback.draft.${slug}`;
2
+ const imagesKey = (slug) => `ac.feedback.images.${slug}`;
3
+ function readDraft(slug) {
4
+ try {
5
+ return sessionStorage.getItem(draftKey(slug)) ?? "";
6
+ } catch {
7
+ return "";
8
+ }
9
+ }
10
+ function writeDraft(slug, value) {
11
+ try {
12
+ if (value) sessionStorage.setItem(draftKey(slug), value);
13
+ else sessionStorage.removeItem(draftKey(slug));
14
+ } catch {
15
+ }
16
+ }
17
+ function readAttachments(slug) {
18
+ try {
19
+ const raw = sessionStorage.getItem(imagesKey(slug));
20
+ if (!raw) return [];
21
+ const parsed = JSON.parse(raw);
22
+ if (!Array.isArray(parsed)) return [];
23
+ return parsed.filter(
24
+ (entry) => typeof entry === "object" && entry !== null && typeof entry.id === "string" && typeof entry.name === "string"
25
+ );
26
+ } catch {
27
+ return [];
28
+ }
29
+ }
30
+ function writeAttachments(slug, images) {
31
+ try {
32
+ if (images.length > 0) {
33
+ sessionStorage.setItem(imagesKey(slug), JSON.stringify(images));
34
+ } else {
35
+ sessionStorage.removeItem(imagesKey(slug));
36
+ }
37
+ } catch {
38
+ }
39
+ }
40
+ function attachmentUrl(id) {
41
+ return `/api/feedback-attachments/${id}`;
42
+ }
43
+ export {
44
+ attachmentUrl,
45
+ readAttachments,
46
+ readDraft,
47
+ writeAttachments,
48
+ writeDraft
49
+ };
50
+ //# sourceMappingURL=communityDraft.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"communityDraft.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/communityDraft.ts"],"sourcesContent":["/**\n * Where a half-written note lives while it is not sent.\n *\n * The input sits inside a tab panel, and a panel unmounts while its tab is not\n * the active one — so component state alone loses whatever someone typed or\n * attached the moment they look at another tab. Losing someone's words is the\n * one failure this feature cannot afford: reporting has to cost almost nothing,\n * and \"I typed it and it vanished\" is how a person learns not to bother.\n *\n * Session rather than local storage: a draft is in-flight work, not a\n * preference. Keyed by slug so two entries do not share one draft. Storage can\n * throw outright in a locked-down context; a lost draft is not worth taking the\n * card down over, so every access swallows that.\n */\n\n/** One already-uploaded image waiting to be sent with the words. */\nexport interface PendingImage {\n id: string\n name: string\n}\n\nconst draftKey = (slug: string) => `ac.feedback.draft.${slug}`\nconst imagesKey = (slug: string) => `ac.feedback.images.${slug}`\n\nexport function readDraft(slug: string): string {\n try {\n return sessionStorage.getItem(draftKey(slug)) ?? ''\n } catch {\n return ''\n }\n}\n\nexport function writeDraft(slug: string, value: string): void {\n try {\n if (value) sessionStorage.setItem(draftKey(slug), value)\n else sessionStorage.removeItem(draftKey(slug))\n } catch {\n /* see the module comment */\n }\n}\n\n/**\n * Images upload the moment they are picked, so the draft holds ids rather than\n * files. Dropping an id here does NOT delete the image; the server sweeps\n * whatever is never sent, which keeps \"remove\" instant.\n */\nexport function readAttachments(slug: string): PendingImage[] {\n try {\n const raw = sessionStorage.getItem(imagesKey(slug))\n if (!raw) return []\n const parsed: unknown = JSON.parse(raw)\n if (!Array.isArray(parsed)) return []\n return parsed.filter(\n (entry): entry is PendingImage =>\n typeof entry === 'object' &&\n entry !== null &&\n typeof (entry as PendingImage).id === 'string' &&\n typeof (entry as PendingImage).name === 'string',\n )\n } catch {\n return []\n }\n}\n\nexport function writeAttachments(slug: string, images: PendingImage[]): void {\n try {\n if (images.length > 0) {\n sessionStorage.setItem(imagesKey(slug), JSON.stringify(images))\n } else {\n sessionStorage.removeItem(imagesKey(slug))\n }\n } catch {\n /* see the module comment */\n }\n}\n\n/** The stored image, at whatever size the browser lays it out. */\nexport function attachmentUrl(id: string): string {\n return `/api/feedback-attachments/${id}`\n}\n"],"names":[],"mappings":"AAqBA,MAAM,WAAW,CAAC,SAAiB,qBAAqB,IAAI;AAC5D,MAAM,YAAY,CAAC,SAAiB,sBAAsB,IAAI;AAEvD,SAAS,UAAU,MAAsB;AAC9C,MAAI;AACF,WAAO,eAAe,QAAQ,SAAS,IAAI,CAAC,KAAK;AAAA,EACnD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,WAAW,MAAc,OAAqB;AAC5D,MAAI;AACF,QAAI,MAAO,gBAAe,QAAQ,SAAS,IAAI,GAAG,KAAK;AAAA,QAClD,gBAAe,WAAW,SAAS,IAAI,CAAC;AAAA,EAC/C,QAAQ;AAAA,EAER;AACF;AAOO,SAAS,gBAAgB,MAA8B;AAC5D,MAAI;AACF,UAAM,MAAM,eAAe,QAAQ,UAAU,IAAI,CAAC;AAClD,QAAI,CAAC,IAAK,QAAO,CAAA;AACjB,UAAM,SAAkB,KAAK,MAAM,GAAG;AACtC,QAAI,CAAC,MAAM,QAAQ,MAAM,UAAU,CAAA;AACnC,WAAO,OAAO;AAAA,MACZ,CAAC,UACC,OAAO,UAAU,YACjB,UAAU,QACV,OAAQ,MAAuB,OAAO,YACtC,OAAQ,MAAuB,SAAS;AAAA,IAAA;AAAA,EAE9C,QAAQ;AACN,WAAO,CAAA;AAAA,EACT;AACF;AAEO,SAAS,iBAAiB,MAAc,QAA8B;AAC3E,MAAI;AACF,QAAI,OAAO,SAAS,GAAG;AACrB,qBAAe,QAAQ,UAAU,IAAI,GAAG,KAAK,UAAU,MAAM,CAAC;AAAA,IAChE,OAAO;AACL,qBAAe,WAAW,UAAU,IAAI,CAAC;AAAA,IAC3C;AAAA,EACF,QAAQ;AAAA,EAER;AACF;AAGO,SAAS,cAAc,IAAoB;AAChD,SAAO,6BAA6B,EAAE;AACxC;"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The server's reason when it gave a readable one ("Up to 3 images"), else a
3
+ * generic line. Validation failures arrive as serialized issue lists, which
4
+ * are not for a reader.
5
+ */
6
+ export declare function formatCommunityError(error: unknown): string;
@@ -0,0 +1,8 @@
1
+ function formatCommunityError(error) {
2
+ const reason = error instanceof Error ? error.message.trim().replace(/\.$/, "") : "";
3
+ return reason && !/^[[{]/.test(reason) ? reason : "Something went wrong";
4
+ }
5
+ export {
6
+ formatCommunityError
7
+ };
8
+ //# sourceMappingURL=communityError.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"communityError.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/communityError.ts"],"sourcesContent":["/**\n * The server's reason when it gave a readable one (\"Up to 3 images\"), else a\n * generic line. Validation failures arrive as serialized issue lists, which\n * are not for a reader.\n */\nexport function formatCommunityError(error: unknown): string {\n const reason =\n error instanceof Error ? error.message.trim().replace(/\\.$/, '') : ''\n return reason && !/^[[{]/.test(reason) ? reason : 'Something went wrong'\n}\n"],"names":[],"mappings":"AAKO,SAAS,qBAAqB,OAAwB;AAC3D,QAAM,SACJ,iBAAiB,QAAQ,MAAM,QAAQ,OAAO,QAAQ,OAAO,EAAE,IAAI;AACrE,SAAO,UAAU,CAAC,QAAQ,KAAK,MAAM,IAAI,SAAS;AACpD;"}
@@ -0,0 +1,27 @@
1
+ import { Claim, FeedbackView as Note } from '@igstack/app-catalog-backend-core';
2
+ /** `pending` = still being checked; `used` = the card now relies on it. */
3
+ export type CommentBadge = 'pending' | 'used' | null;
4
+ /** One row of the Comments feed. */
5
+ export interface CommentEntry {
6
+ key: string;
7
+ /** Null when a claim names no origin at all. */
8
+ who: string | null;
9
+ /** Raw ISO date or instant, for the `title`. */
10
+ at: string;
11
+ /** Null for a note sent without words; the feed says what it carried instead. */
12
+ text: string | null;
13
+ badge: CommentBadge;
14
+ /** The visitor's row, when the entry is one — what Edit, Dismiss and screenshots hang off. */
15
+ note?: Note;
16
+ }
17
+ /**
18
+ * Everything said about an entry, one entry per visitor note, newest first.
19
+ *
20
+ * A claim whose origin is `feedback:<id>` belongs to that note: it sets the
21
+ * note's badge and is not shown again. A claim with no note in the list (its
22
+ * row is gone, or it came from a person, team or URL) stands on its own, in
23
+ * its own words. Archived claims are not shown.
24
+ */
25
+ export declare function composeComments(notes: Note[], claims: Claim[]): CommentEntry[];
26
+ /** The Behind this card count: exactly the feed's Pending badges. */
27
+ export declare function countPending(notes: Note[], claims: Claim[]): number;
@@ -0,0 +1,66 @@
1
+ const FEEDBACK_ORIGIN = "feedback:";
2
+ function noteBadge(note, linked) {
3
+ if (note.status === null) return "pending";
4
+ if (linked.some((c) => c.status === "review")) return "pending";
5
+ if (note.status === "acknowledged") return null;
6
+ if (linked.some((c) => c.status === "active")) return "used";
7
+ return null;
8
+ }
9
+ function claimWho(origin) {
10
+ if (!origin) return null;
11
+ if (origin.startsWith(FEEDBACK_ORIGIN)) return "A visitor";
12
+ if (/^https?:\/\//.test(origin)) {
13
+ try {
14
+ return new URL(origin).host;
15
+ } catch {
16
+ return origin;
17
+ }
18
+ }
19
+ return origin;
20
+ }
21
+ const time = (at) => Date.parse(at) || 0;
22
+ function composeComments(notes, claims) {
23
+ var _a;
24
+ const ids = new Set(notes.map((n) => n.id));
25
+ const byNote = /* @__PURE__ */ new Map();
26
+ const standalone = [];
27
+ for (const claim of claims) {
28
+ if (claim.status === "archived") continue;
29
+ const id = ((_a = claim.origin) == null ? void 0 : _a.startsWith(FEEDBACK_ORIGIN)) ? claim.origin.slice(FEEDBACK_ORIGIN.length) : null;
30
+ if (id !== null && ids.has(id)) {
31
+ byNote.set(id, [...byNote.get(id) ?? [], claim]);
32
+ } else {
33
+ standalone.push(claim);
34
+ }
35
+ }
36
+ const entries = [
37
+ ...notes.map(
38
+ (note) => ({
39
+ key: note.id,
40
+ who: note.authorName,
41
+ at: note.createdAt,
42
+ text: note.body,
43
+ badge: noteBadge(note, byNote.get(note.id) ?? []),
44
+ note
45
+ })
46
+ ),
47
+ ...standalone.map(
48
+ (claim, i) => ({
49
+ key: `claim-${i}`,
50
+ who: claimWho(claim.origin),
51
+ at: claim.addedAt,
52
+ text: claim.text,
53
+ badge: claim.status === "active" ? "used" : "pending"
54
+ })
55
+ )
56
+ ];
57
+ return entries.sort((a, b) => time(b.at) - time(a.at));
58
+ }
59
+ function countPending(notes, claims) {
60
+ return composeComments(notes, claims).filter((e) => e.badge === "pending").length;
61
+ }
62
+ export {
63
+ composeComments,
64
+ countPending
65
+ };
66
+ //# sourceMappingURL=composeComments.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"composeComments.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/composeComments.ts"],"sourcesContent":["import type {\n Claim,\n FeedbackView as Note,\n} from '@igstack/app-catalog-backend-core'\n\n/** `pending` = still being checked; `used` = the card now relies on it. */\nexport type CommentBadge = 'pending' | 'used' | null\n\n/** One row of the Comments feed. */\nexport interface CommentEntry {\n key: string\n /** Null when a claim names no origin at all. */\n who: string | null\n /** Raw ISO date or instant, for the `title`. */\n at: string\n /** Null for a note sent without words; the feed says what it carried instead. */\n text: string | null\n badge: CommentBadge\n /** The visitor's row, when the entry is one — what Edit, Dismiss and screenshots hang off. */\n note?: Note\n}\n\nconst FEEDBACK_ORIGIN = 'feedback:'\n\nfunction noteBadge(note: Note, linked: Claim[]): CommentBadge {\n if (note.status === null) return 'pending'\n if (linked.some((c) => c.status === 'review')) return 'pending'\n if (note.status === 'acknowledged') return null\n if (linked.some((c) => c.status === 'active')) return 'used'\n return null\n}\n\n/** Who said a claim that has no note in the list. A URL reads as its host. */\nfunction claimWho(origin: string | undefined): string | null {\n if (!origin) return null\n if (origin.startsWith(FEEDBACK_ORIGIN)) return 'A visitor'\n if (/^https?:\\/\\//.test(origin)) {\n try {\n return new URL(origin).host\n } catch {\n return origin\n }\n }\n return origin\n}\n\nconst time = (at: string) => Date.parse(at) || 0\n\n/**\n * Everything said about an entry, one entry per visitor note, newest first.\n *\n * A claim whose origin is `feedback:<id>` belongs to that note: it sets the\n * note's badge and is not shown again. A claim with no note in the list (its\n * row is gone, or it came from a person, team or URL) stands on its own, in\n * its own words. Archived claims are not shown.\n */\nexport function composeComments(\n notes: Note[],\n claims: Claim[],\n): CommentEntry[] {\n const ids = new Set(notes.map((n) => n.id))\n const byNote = new Map<string, Claim[]>()\n const standalone: Claim[] = []\n for (const claim of claims) {\n if (claim.status === 'archived') continue\n const id = claim.origin?.startsWith(FEEDBACK_ORIGIN)\n ? claim.origin.slice(FEEDBACK_ORIGIN.length)\n : null\n if (id !== null && ids.has(id)) {\n byNote.set(id, [...(byNote.get(id) ?? []), claim])\n } else {\n standalone.push(claim)\n }\n }\n const entries: CommentEntry[] = [\n ...notes.map(\n (note): CommentEntry => ({\n key: note.id,\n who: note.authorName,\n at: note.createdAt,\n text: note.body,\n badge: noteBadge(note, byNote.get(note.id) ?? []),\n note,\n }),\n ),\n ...standalone.map(\n (claim, i): CommentEntry => ({\n key: `claim-${i}`,\n who: claimWho(claim.origin),\n at: claim.addedAt,\n text: claim.text,\n badge: claim.status === 'active' ? 'used' : 'pending',\n }),\n ),\n ]\n return entries.sort((a, b) => time(b.at) - time(a.at))\n}\n\n/** The Behind this card count: exactly the feed's Pending badges. */\nexport function countPending(notes: Note[], claims: Claim[]): number {\n return composeComments(notes, claims).filter((e) => e.badge === 'pending')\n .length\n}\n"],"names":[],"mappings":"AAsBA,MAAM,kBAAkB;AAExB,SAAS,UAAU,MAAY,QAA+B;AAC5D,MAAI,KAAK,WAAW,KAAM,QAAO;AACjC,MAAI,OAAO,KAAK,CAAC,MAAM,EAAE,WAAW,QAAQ,EAAG,QAAO;AACtD,MAAI,KAAK,WAAW,eAAgB,QAAO;AAC3C,MAAI,OAAO,KAAK,CAAC,MAAM,EAAE,WAAW,QAAQ,EAAG,QAAO;AACtD,SAAO;AACT;AAGA,SAAS,SAAS,QAA2C;AAC3D,MAAI,CAAC,OAAQ,QAAO;AACpB,MAAI,OAAO,WAAW,eAAe,EAAG,QAAO;AAC/C,MAAI,eAAe,KAAK,MAAM,GAAG;AAC/B,QAAI;AACF,aAAO,IAAI,IAAI,MAAM,EAAE;AAAA,IACzB,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;AAEA,MAAM,OAAO,CAAC,OAAe,KAAK,MAAM,EAAE,KAAK;AAUxC,SAAS,gBACd,OACA,QACgB;AArClB;AAsCE,QAAM,MAAM,IAAI,IAAI,MAAM,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;AAC1C,QAAM,6BAAa,IAAA;AACnB,QAAM,aAAsB,CAAA;AAC5B,aAAW,SAAS,QAAQ;AAC1B,QAAI,MAAM,WAAW,WAAY;AACjC,UAAM,OAAK,WAAM,WAAN,mBAAc,WAAW,oBAChC,MAAM,OAAO,MAAM,gBAAgB,MAAM,IACzC;AACJ,QAAI,OAAO,QAAQ,IAAI,IAAI,EAAE,GAAG;AAC9B,aAAO,IAAI,IAAI,CAAC,GAAI,OAAO,IAAI,EAAE,KAAK,IAAK,KAAK,CAAC;AAAA,IACnD,OAAO;AACL,iBAAW,KAAK,KAAK;AAAA,IACvB;AAAA,EACF;AACA,QAAM,UAA0B;AAAA,IAC9B,GAAG,MAAM;AAAA,MACP,CAAC,UAAwB;AAAA,QACvB,KAAK,KAAK;AAAA,QACV,KAAK,KAAK;AAAA,QACV,IAAI,KAAK;AAAA,QACT,MAAM,KAAK;AAAA,QACX,OAAO,UAAU,MAAM,OAAO,IAAI,KAAK,EAAE,KAAK,EAAE;AAAA,QAChD;AAAA,MAAA;AAAA,IACF;AAAA,IAEF,GAAG,WAAW;AAAA,MACZ,CAAC,OAAO,OAAqB;AAAA,QAC3B,KAAK,SAAS,CAAC;AAAA,QACf,KAAK,SAAS,MAAM,MAAM;AAAA,QAC1B,IAAI,MAAM;AAAA,QACV,MAAM,MAAM;AAAA,QACZ,OAAO,MAAM,WAAW,WAAW,SAAS;AAAA,MAAA;AAAA,IAC9C;AAAA,EACF;AAEF,SAAO,QAAQ,KAAK,CAAC,GAAG,MAAM,KAAK,EAAE,EAAE,IAAI,KAAK,EAAE,EAAE,CAAC;AACvD;AAGO,SAAS,aAAa,OAAe,QAAyB;AACnE,SAAO,gBAAgB,OAAO,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,UAAU,SAAS,EACtE;AACL;"}
@@ -0,0 +1,11 @@
1
+ import { Claim } from '@igstack/app-catalog-backend-core';
2
+ /**
3
+ * How many entries in the Comments feed say Pending: open notes, notes whose
4
+ * claim is still in review, and review claims with no note of their own.
5
+ *
6
+ * The same query the feed runs, so the tab count and the feed share one cache
7
+ * entry and one request, and are composed by the same rules. Zero while
8
+ * loading or on error: until the notes arrive, a claim cannot be matched to
9
+ * its note, and a count that is not known yet is not shown.
10
+ */
11
+ export declare function usePendingCount(appSlug: string, claims?: Claim[]): number;
@@ -0,0 +1,15 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { useTRPC } from "../../../../api/infra/trpc.js";
3
+ import { countPending } from "./composeComments.js";
4
+ const NO_CLAIMS = [];
5
+ function usePendingCount(appSlug, claims = NO_CLAIMS) {
6
+ const trpc = useTRPC();
7
+ const { data, error } = useQuery(
8
+ trpc.feedback.list.queryOptions({ resourceSlug: appSlug })
9
+ );
10
+ return data || error ? countPending((data == null ? void 0 : data.items) ?? [], claims) : 0;
11
+ }
12
+ export {
13
+ usePendingCount
14
+ };
15
+ //# sourceMappingURL=usePendingCount.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"usePendingCount.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/usePendingCount.ts"],"sourcesContent":["import type { Claim } from '@igstack/app-catalog-backend-core'\nimport { useQuery } from '@tanstack/react-query'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { countPending } from './composeComments'\n\nconst NO_CLAIMS: Claim[] = []\n\n/**\n * How many entries in the Comments feed say Pending: open notes, notes whose\n * claim is still in review, and review claims with no note of their own.\n *\n * The same query the feed runs, so the tab count and the feed share one cache\n * entry and one request, and are composed by the same rules. Zero while\n * loading or on error: until the notes arrive, a claim cannot be matched to\n * its note, and a count that is not known yet is not shown.\n */\nexport function usePendingCount(\n appSlug: string,\n claims: Claim[] = NO_CLAIMS,\n): number {\n const trpc = useTRPC()\n const { data, error } = useQuery(\n trpc.feedback.list.queryOptions({ resourceSlug: appSlug }),\n )\n return data || error ? countPending(data?.items ?? [], claims) : 0\n}\n"],"names":[],"mappings":";;;AAKA,MAAM,YAAqB,CAAA;AAWpB,SAAS,gBACd,SACA,SAAkB,WACV;AACR,QAAM,OAAO,QAAA;AACb,QAAM,EAAE,MAAM,MAAA,IAAU;AAAA,IACtB,KAAK,SAAS,KAAK,aAAa,EAAE,cAAc,SAAS;AAAA,EAAA;AAE3D,SAAO,QAAQ,QAAQ,cAAa,6BAAM,UAAS,CAAA,GAAI,MAAM,IAAI;AACnE;"}
@@ -46,6 +46,30 @@ export interface ResourceDetailSlots {
46
46
  parent: Resource;
47
47
  user: PluginUser | null;
48
48
  };
49
+ /**
50
+ * "What happens next" line at the foot of the "How comments work" popover,
51
+ * beside the Comments heading on Behind this card.
52
+ *
53
+ * Tells the reader what becomes of the text they send. The OSS default is
54
+ * `Every note is reviewed. Facts that check out update the card and are
55
+ * marked Used as source.` A deployment with its own review loop substitutes
56
+ * its own text.
57
+ */
58
+ resourceDetailFeedbackNote: {
59
+ appSlug: string;
60
+ appName: string;
61
+ };
62
+ /**
63
+ * Where this card's own definition lives, at the foot of Behind this card.
64
+ *
65
+ * A quiet link such as "View this card's source". Only the deployment knows
66
+ * where its catalog entries are kept, so the core has no default and renders
67
+ * nothing here unless a plugin contributes.
68
+ */
69
+ resourceDetailCardSource: {
70
+ appSlug: string;
71
+ appName: string;
72
+ };
49
73
  }
50
74
  /**
51
75
  * What the core knows when it decides a slot's LAYOUT, before any row renders.
@@ -96,6 +120,20 @@ export declare const ResourceSubResourceRowActions: {
96
120
  }): import('react').ReactNode;
97
121
  displayName: string;
98
122
  };
123
+ export declare const ResourceDetailFeedbackNote: {
124
+ (props: {
125
+ appSlug: string;
126
+ appName: string;
127
+ }): import('react').ReactNode;
128
+ displayName: string;
129
+ };
130
+ export declare const ResourceDetailCardSource: {
131
+ (props: {
132
+ appSlug: string;
133
+ appName: string;
134
+ }): import('react').ReactNode;
135
+ displayName: string;
136
+ };
99
137
  export declare const ResourceDetailProvider: {
100
138
  (props: {
101
139
  resource: Resource;
@@ -5,9 +5,13 @@ const ResourceDetailAccessActions = mkSlot("resourceDetailAccessActions");
5
5
  const ResourceSubResourceRowActions = mkSlot(
6
6
  "resourceSubResourceRowActions"
7
7
  );
8
+ const ResourceDetailFeedbackNote = mkSlot("resourceDetailFeedbackNote");
9
+ const ResourceDetailCardSource = mkSlot("resourceDetailCardSource");
8
10
  const ResourceDetailProvider = mkWrapper("resourceDetailProvider");
9
11
  export {
10
12
  ResourceDetailAccessActions,
13
+ ResourceDetailCardSource,
14
+ ResourceDetailFeedbackNote,
11
15
  ResourceDetailProvider,
12
16
  ResourceSubResourceRowActions
13
17
  };
@@ -1 +1 @@
1
- {"version":3,"file":"resourceDetail.js","sources":["../../../../../src/modules/extensions/features/resourceDetail.ts"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { slotFactory, wrapperFactory } from '../factories'\nimport type { PluginUser } from '../types'\n\n/**\n * Leaf slots on the resource detail card.\n *\n * Payloads are additive-only. Adding a field is invisible to existing plugins;\n * renaming or removing one breaks them at compile time on their next core bump,\n * which is the intended moment for that to surface.\n */\nexport interface ResourceDetailSlots {\n /**\n * Actions on the detail card's header band.\n *\n * The band and not a tab panel: tab panels mount only while active and\n * Overview is first, so a slot inside a tab is invisible until a second click.\n */\n resourceDetailAccessActions: {\n /** The resource on screen — a top-level app OR one of its sub-resources. */\n resource: Resource\n /** Present only on a sub-resource view. Its absence is how a plugin tells the two apart. */\n parent?: Resource\n /** Children of `parent ?? resource`: the family's sub-resources. */\n subResources: Resource[]\n user: PluginUser | null\n }\n\n /**\n * One cell per row of the sub-resources table, in a column of its own.\n *\n * Called once per rendered row, which is the opposite shape to\n * `resourceDetailAccessActions` — that one is called once and handed the whole\n * list. Use this when the action belongs to a single child.\n *\n * Two things to know before filling it. The table renders EVERY row with no\n * virtualisation, so a contribution here is mounted as many times as the\n * family has children (hundreds, in a large catalog) — keep the cell cheap and\n * defer anything expensive until it is interacted with. And the column only\n * appears when a plugin actually fills it, so a build with no plugin renders\n * the table exactly as before. A plugin that serves only some parents says so\n * in `appliesTo` (see `ResourceDetailSlotScopes`), so every other parent's\n * table gets no column rather than a column of empty cells.\n */\n resourceSubResourceRowActions: {\n /** The child this row is showing. */\n resource: Resource\n /** The entry the row hangs off. Always present here, unlike the header slot. */\n parent: Resource\n user: PluginUser | null\n }\n}\n\n/**\n * What the core knows when it decides a slot's LAYOUT, before any row renders.\n *\n * The input to a plugin's `appliesTo` predicate for that slot. A contribution\n * renders as a component, so the core cannot ask it whether it would render\n * nothing; the plugin has to say up front which scopes it serves.\n */\nexport interface ResourceDetailSlotScopes {\n resourceSubResourceRowActions: {\n /** The entry whose sub-resources table is being laid out. */\n parent: Resource\n }\n}\n\n/**\n * Wrappers on the resource detail card.\n *\n * `resourceDetailProvider` is mounted inside the panel's `key={shownSlug}`\n * subtree, so anything a plugin provides here is scoped to the resource on\n * screen and is discarded when the card navigates. That is what lets two slots\n * share per-resource state without the plugin writing any cleanup.\n */\nexport interface ResourceDetailWrappers {\n resourceDetailProvider: {\n resource: Resource\n parent?: Resource\n }\n}\n\nconst mkSlot = slotFactory<ResourceDetailSlots>()\nconst mkWrapper = wrapperFactory<ResourceDetailWrappers>()\n\nexport const ResourceDetailAccessActions = mkSlot('resourceDetailAccessActions')\nexport const ResourceSubResourceRowActions = mkSlot(\n 'resourceSubResourceRowActions',\n)\nexport const ResourceDetailProvider = mkWrapper('resourceDetailProvider')\n"],"names":[],"mappings":";AAkFA,MAAM,SAAS,YAAA;AACf,MAAM,YAAY,eAAA;AAEX,MAAM,8BAA8B,OAAO,6BAA6B;AACxE,MAAM,gCAAgC;AAAA,EAC3C;AACF;AACO,MAAM,yBAAyB,UAAU,wBAAwB;"}
1
+ {"version":3,"file":"resourceDetail.js","sources":["../../../../../src/modules/extensions/features/resourceDetail.ts"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { slotFactory, wrapperFactory } from '../factories'\nimport type { PluginUser } from '../types'\n\n/**\n * Leaf slots on the resource detail card.\n *\n * Payloads are additive-only. Adding a field is invisible to existing plugins;\n * renaming or removing one breaks them at compile time on their next core bump,\n * which is the intended moment for that to surface.\n */\nexport interface ResourceDetailSlots {\n /**\n * Actions on the detail card's header band.\n *\n * The band and not a tab panel: tab panels mount only while active and\n * Overview is first, so a slot inside a tab is invisible until a second click.\n */\n resourceDetailAccessActions: {\n /** The resource on screen — a top-level app OR one of its sub-resources. */\n resource: Resource\n /** Present only on a sub-resource view. Its absence is how a plugin tells the two apart. */\n parent?: Resource\n /** Children of `parent ?? resource`: the family's sub-resources. */\n subResources: Resource[]\n user: PluginUser | null\n }\n\n /**\n * One cell per row of the sub-resources table, in a column of its own.\n *\n * Called once per rendered row, which is the opposite shape to\n * `resourceDetailAccessActions` — that one is called once and handed the whole\n * list. Use this when the action belongs to a single child.\n *\n * Two things to know before filling it. The table renders EVERY row with no\n * virtualisation, so a contribution here is mounted as many times as the\n * family has children (hundreds, in a large catalog) — keep the cell cheap and\n * defer anything expensive until it is interacted with. And the column only\n * appears when a plugin actually fills it, so a build with no plugin renders\n * the table exactly as before. A plugin that serves only some parents says so\n * in `appliesTo` (see `ResourceDetailSlotScopes`), so every other parent's\n * table gets no column rather than a column of empty cells.\n */\n resourceSubResourceRowActions: {\n /** The child this row is showing. */\n resource: Resource\n /** The entry the row hangs off. Always present here, unlike the header slot. */\n parent: Resource\n user: PluginUser | null\n }\n\n /**\n * \"What happens next\" line at the foot of the \"How comments work\" popover,\n * beside the Comments heading on Behind this card.\n *\n * Tells the reader what becomes of the text they send. The OSS default is\n * `Every note is reviewed. Facts that check out update the card and are\n * marked Used as source.` A deployment with its own review loop substitutes\n * its own text.\n */\n resourceDetailFeedbackNote: {\n appSlug: string\n appName: string\n }\n\n /**\n * Where this card's own definition lives, at the foot of Behind this card.\n *\n * A quiet link such as \"View this card's source\". Only the deployment knows\n * where its catalog entries are kept, so the core has no default and renders\n * nothing here unless a plugin contributes.\n */\n resourceDetailCardSource: {\n appSlug: string\n appName: string\n }\n}\n\n/**\n * What the core knows when it decides a slot's LAYOUT, before any row renders.\n *\n * The input to a plugin's `appliesTo` predicate for that slot. A contribution\n * renders as a component, so the core cannot ask it whether it would render\n * nothing; the plugin has to say up front which scopes it serves.\n */\nexport interface ResourceDetailSlotScopes {\n resourceSubResourceRowActions: {\n /** The entry whose sub-resources table is being laid out. */\n parent: Resource\n }\n}\n\n/**\n * Wrappers on the resource detail card.\n *\n * `resourceDetailProvider` is mounted inside the panel's `key={shownSlug}`\n * subtree, so anything a plugin provides here is scoped to the resource on\n * screen and is discarded when the card navigates. That is what lets two slots\n * share per-resource state without the plugin writing any cleanup.\n */\nexport interface ResourceDetailWrappers {\n resourceDetailProvider: {\n resource: Resource\n parent?: Resource\n }\n}\n\nconst mkSlot = slotFactory<ResourceDetailSlots>()\nconst mkWrapper = wrapperFactory<ResourceDetailWrappers>()\n\nexport const ResourceDetailAccessActions = mkSlot('resourceDetailAccessActions')\nexport const ResourceSubResourceRowActions = mkSlot(\n 'resourceSubResourceRowActions',\n)\nexport const ResourceDetailFeedbackNote = mkSlot('resourceDetailFeedbackNote')\nexport const ResourceDetailCardSource = mkSlot('resourceDetailCardSource')\nexport const ResourceDetailProvider = mkWrapper('resourceDetailProvider')\n"],"names":[],"mappings":";AA4GA,MAAM,SAAS,YAAA;AACf,MAAM,YAAY,eAAA;AAEX,MAAM,8BAA8B,OAAO,6BAA6B;AACxE,MAAM,gCAAgC;AAAA,EAC3C;AACF;AACO,MAAM,6BAA6B,OAAO,4BAA4B;AACtE,MAAM,2BAA2B,OAAO,0BAA0B;AAClE,MAAM,yBAAyB,UAAU,wBAAwB;"}
@@ -6,7 +6,7 @@ export { slotFactory, wrapperFactory } from './factories.js';
6
6
  export { makeSlot, makeWrapper } from './makeSlot.js';
7
7
  export type { PluginUser, PluginRegistration } from './types.js';
8
8
  export type { AssertDisjoint } from './types.js';
9
- export { ResourceDetailAccessActions, ResourceSubResourceRowActions, ResourceDetailProvider, } from './features/resourceDetail.js';
9
+ export { ResourceDetailAccessActions, ResourceSubResourceRowActions, ResourceDetailFeedbackNote, ResourceDetailCardSource, ResourceDetailProvider, } from './features/resourceDetail.js';
10
10
  export type { ResourceDetailSlotScopes, ResourceDetailSlots, ResourceDetailWrappers, } from './features/resourceDetail.js';
11
11
  /**
12
12
  * Every leaf slot in the core, composed from the per-feature specs.
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":["../../../../src/modules/extensions/index.ts"],"sourcesContent":["import { useMemo } from 'react'\nimport type { ReactNode } from 'react'\n// Relative, not `~`: this file is on the published entry's graph and therefore\n// in every consumer's program, and `~` is the CORE's alias — a consumer only\n// resolves it if it has mapped it, which the example app has not.\nimport { useOptionalUser } from '../auth/AuthContext'\nimport type {\n ResourceDetailSlotScopes,\n ResourceDetailSlots,\n ResourceDetailWrappers,\n} from './features/resourceDetail'\nimport type { AssertDisjoint, PluginUser } from './types'\n\nimport { useExtensions } from './ExtensionsContext'\n\nexport { ExtensionsContext, useExtensions } from './ExtensionsContext'\nexport { slotFactory, wrapperFactory } from './factories'\nexport { makeSlot, makeWrapper } from './makeSlot'\nexport type { PluginUser, PluginRegistration } from './types'\nexport type { AssertDisjoint } from './types'\nexport {\n ResourceDetailAccessActions,\n ResourceSubResourceRowActions,\n ResourceDetailProvider,\n} from './features/resourceDetail'\nexport type {\n ResourceDetailSlotScopes,\n ResourceDetailSlots,\n ResourceDetailWrappers,\n} from './features/resourceDetail'\n\n/**\n * Every leaf slot in the core, composed from the per-feature specs.\n *\n * Adding a feature means a new `features/<name>.ts` plus one member here and one\n * line in the guard below. No existing feature file is touched.\n */\nexport type SlotSpec = ResourceDetailSlots\n\n/** Every wrapper slot in the core. */\nexport type WrapperSpec = ResourceDetailWrappers\n\n/** The layout scope of every slot whose layout depends on more than presence. */\nexport type SlotScopeSpec = ResourceDetailSlotScopes\n\n// Guard: with a second feature this becomes\n// `AssertDisjoint<ResourceDetailSlots, ServiceDeskSlots>` and fails with the\n// duplicated id spelled out in the error. With one feature it is trivially true,\n// and is kept so the next feature inherits the check rather than inventing it.\nconst _slotIdsAreDisjoint: AssertDisjoint<SlotSpec, Record<never, never>> = true\nvoid _slotIdsAreDisjoint\n\n/**\n * A plugin's leaf-slot handlers. Each receives exactly its slot's payload.\n *\n * Registering an unknown slot, or reading a field the slot does not pass, is a\n * compile error — which is the whole point of composing `SlotSpec` rather than\n * typing handlers as `(props: unknown)`.\n */\nexport type PluginSlots = {\n [N in keyof SlotSpec]?: (props: SlotSpec[N]) => ReactNode\n}\n\n/** A plugin's wrapper handlers. `children` is injected by the core. */\nexport type PluginWrappers = {\n [N in keyof WrapperSpec]?: (\n props: WrapperSpec[N] & { children: ReactNode },\n ) => ReactNode\n}\n\n/**\n * Which layout scopes a plugin's slot contribution serves. Absent = all of them.\n *\n * Only slots listed in `SlotScopeSpec` take a predicate: those are the slots\n * whose layout the core decides before rendering any contribution.\n */\nexport type PluginAppliesTo = {\n [N in keyof SlotScopeSpec]?: (scope: SlotScopeSpec[N]) => boolean\n}\n\n/**\n * One deployment-specific feature's contribution to the core's UI.\n *\n * Passed to `<App extensions={[...]} />` as an array, not a keyed map: a map\n * holds one value per key, so a second plugin at the same slot would silently\n * replace the first and two wrappers around one subtree would be impossible.\n *\n * The array must be a module-level constant. The core iterates it in render\n * order, so rebuilding it per render would change hook call order inside the\n * plugins and violate the rules of hooks.\n */\nexport interface AcPlugin {\n /** Identity, used in error reports and test assertions. */\n name: string\n slots?: PluginSlots\n wrappers?: PluginWrappers\n appliesTo?: PluginAppliesTo\n}\n\n/**\n * Whether any registered plugin fills this slot.\n *\n * For the cases where rendering the slot is not enough and the core has to\n * change its own layout around it — a table column, say, which would otherwise\n * be an empty column with a header and no cells in every build that registers\n * nothing. Rendering an empty slot costs nothing; reserving space for one is\n * what this is for.\n *\n * Use it ONLY for layout. A slot must never be the only route to something the\n * core itself needs, so branching behaviour on a plugin's presence is a design\n * smell even where branching layout is not.\n *\n * A slot in `SlotScopeSpec` also takes the scope being laid out, and counts only\n * plugins whose `appliesTo` accepts it. An `undefined` scope means there is\n * nothing to decide against, so the slot reads as unfilled.\n */\nexport function useSlotFilled<TSlot extends keyof SlotSpec>(\n slot: TSlot,\n ...scope: TSlot extends keyof SlotScopeSpec\n ? [scope: SlotScopeSpec[TSlot] | undefined]\n : []\n): boolean {\n const plugins = useExtensions()\n const scoped = scope.length > 0\n const value = scope[0]\n if (scoped && value === undefined) return false\n return plugins.some((plugin) => {\n if (!plugin.slots?.[slot]) return false\n const applies = plugin.appliesTo?.[slot] as\n | ((s: typeof value) => boolean)\n | undefined\n return !scoped || !applies || applies(value)\n })\n}\n\n/**\n * The signed-in user, reduced to what slots are given.\n *\n * One place, so two slots cannot disagree about the shape — and deliberately a\n * projection rather than the real user object: the core's `User` is free to grow\n * fields, and handing a plugin the whole thing would make every one of them part\n * of the published slot contract.\n *\n * Returns `null` with no `AuthProvider` above it. The detail card renders inside\n * the catalog panel, which some suites mount outside the provider, and throwing\n * there would make a plugin's presence break unrelated tests.\n */\nexport function usePluginUser(): PluginUser | null {\n const user = useOptionalUser()\n return useMemo(\n // `?? false`, because admin is a capability: absent must read as \"not an\n // admin\", never as undefined leaking into a plugin's own check.\n () => (user ? { email: user.email, isAdmin: user.isAdmin ?? false } : null),\n [user],\n )\n}\n"],"names":[],"mappings":";;;;AAoHO,SAAS,cACd,SACG,OAGM;AACT,QAAM,UAAU,cAAA;AAChB,QAAM,SAAS,MAAM,SAAS;AAC9B,QAAM,QAAQ,MAAM,CAAC;AACrB,MAAI,UAAU,UAAU,OAAW,QAAO;AAC1C,SAAO,QAAQ,KAAK,CAAC,WAAW;;AAC9B,QAAI,GAAC,YAAO,UAAP,mBAAe,OAAO,QAAO;AAClC,UAAM,WAAU,YAAO,cAAP,mBAAmB;AAGnC,WAAO,CAAC,UAAU,CAAC,WAAW,QAAQ,KAAK;AAAA,EAC7C,CAAC;AACH;AAcO,SAAS,gBAAmC;AACjD,QAAM,OAAO,gBAAA;AACb,SAAO;AAAA;AAAA;AAAA,IAGL,MAAO,OAAO,EAAE,OAAO,KAAK,OAAO,SAAS,KAAK,WAAW,MAAA,IAAU;AAAA,IACtE,CAAC,IAAI;AAAA,EAAA;AAET;"}
1
+ {"version":3,"file":"index.js","sources":["../../../../src/modules/extensions/index.ts"],"sourcesContent":["import { useMemo } from 'react'\nimport type { ReactNode } from 'react'\n// Relative, not `~`: this file is on the published entry's graph and therefore\n// in every consumer's program, and `~` is the CORE's alias — a consumer only\n// resolves it if it has mapped it, which the example app has not.\nimport { useOptionalUser } from '../auth/AuthContext'\nimport type {\n ResourceDetailSlotScopes,\n ResourceDetailSlots,\n ResourceDetailWrappers,\n} from './features/resourceDetail'\nimport type { AssertDisjoint, PluginUser } from './types'\n\nimport { useExtensions } from './ExtensionsContext'\n\nexport { ExtensionsContext, useExtensions } from './ExtensionsContext'\nexport { slotFactory, wrapperFactory } from './factories'\nexport { makeSlot, makeWrapper } from './makeSlot'\nexport type { PluginUser, PluginRegistration } from './types'\nexport type { AssertDisjoint } from './types'\nexport {\n ResourceDetailAccessActions,\n ResourceSubResourceRowActions,\n ResourceDetailFeedbackNote,\n ResourceDetailCardSource,\n ResourceDetailProvider,\n} from './features/resourceDetail'\nexport type {\n ResourceDetailSlotScopes,\n ResourceDetailSlots,\n ResourceDetailWrappers,\n} from './features/resourceDetail'\n\n/**\n * Every leaf slot in the core, composed from the per-feature specs.\n *\n * Adding a feature means a new `features/<name>.ts` plus one member here and one\n * line in the guard below. No existing feature file is touched.\n */\nexport type SlotSpec = ResourceDetailSlots\n\n/** Every wrapper slot in the core. */\nexport type WrapperSpec = ResourceDetailWrappers\n\n/** The layout scope of every slot whose layout depends on more than presence. */\nexport type SlotScopeSpec = ResourceDetailSlotScopes\n\n// Guard: with a second feature this becomes\n// `AssertDisjoint<ResourceDetailSlots, ServiceDeskSlots>` and fails with the\n// duplicated id spelled out in the error. With one feature it is trivially true,\n// and is kept so the next feature inherits the check rather than inventing it.\nconst _slotIdsAreDisjoint: AssertDisjoint<SlotSpec, Record<never, never>> = true\nvoid _slotIdsAreDisjoint\n\n/**\n * A plugin's leaf-slot handlers. Each receives exactly its slot's payload.\n *\n * Registering an unknown slot, or reading a field the slot does not pass, is a\n * compile error — which is the whole point of composing `SlotSpec` rather than\n * typing handlers as `(props: unknown)`.\n */\nexport type PluginSlots = {\n [N in keyof SlotSpec]?: (props: SlotSpec[N]) => ReactNode\n}\n\n/** A plugin's wrapper handlers. `children` is injected by the core. */\nexport type PluginWrappers = {\n [N in keyof WrapperSpec]?: (\n props: WrapperSpec[N] & { children: ReactNode },\n ) => ReactNode\n}\n\n/**\n * Which layout scopes a plugin's slot contribution serves. Absent = all of them.\n *\n * Only slots listed in `SlotScopeSpec` take a predicate: those are the slots\n * whose layout the core decides before rendering any contribution.\n */\nexport type PluginAppliesTo = {\n [N in keyof SlotScopeSpec]?: (scope: SlotScopeSpec[N]) => boolean\n}\n\n/**\n * One deployment-specific feature's contribution to the core's UI.\n *\n * Passed to `<App extensions={[...]} />` as an array, not a keyed map: a map\n * holds one value per key, so a second plugin at the same slot would silently\n * replace the first and two wrappers around one subtree would be impossible.\n *\n * The array must be a module-level constant. The core iterates it in render\n * order, so rebuilding it per render would change hook call order inside the\n * plugins and violate the rules of hooks.\n */\nexport interface AcPlugin {\n /** Identity, used in error reports and test assertions. */\n name: string\n slots?: PluginSlots\n wrappers?: PluginWrappers\n appliesTo?: PluginAppliesTo\n}\n\n/**\n * Whether any registered plugin fills this slot.\n *\n * For the cases where rendering the slot is not enough and the core has to\n * change its own layout around it — a table column, say, which would otherwise\n * be an empty column with a header and no cells in every build that registers\n * nothing. Rendering an empty slot costs nothing; reserving space for one is\n * what this is for.\n *\n * Use it ONLY for layout. A slot must never be the only route to something the\n * core itself needs, so branching behaviour on a plugin's presence is a design\n * smell even where branching layout is not.\n *\n * A slot in `SlotScopeSpec` also takes the scope being laid out, and counts only\n * plugins whose `appliesTo` accepts it. An `undefined` scope means there is\n * nothing to decide against, so the slot reads as unfilled.\n */\nexport function useSlotFilled<TSlot extends keyof SlotSpec>(\n slot: TSlot,\n ...scope: TSlot extends keyof SlotScopeSpec\n ? [scope: SlotScopeSpec[TSlot] | undefined]\n : []\n): boolean {\n const plugins = useExtensions()\n const scoped = scope.length > 0\n const value = scope[0]\n if (scoped && value === undefined) return false\n return plugins.some((plugin) => {\n if (!plugin.slots?.[slot]) return false\n const applies = plugin.appliesTo?.[slot] as\n | ((s: typeof value) => boolean)\n | undefined\n return !scoped || !applies || applies(value)\n })\n}\n\n/**\n * The signed-in user, reduced to what slots are given.\n *\n * One place, so two slots cannot disagree about the shape — and deliberately a\n * projection rather than the real user object: the core's `User` is free to grow\n * fields, and handing a plugin the whole thing would make every one of them part\n * of the published slot contract.\n *\n * Returns `null` with no `AuthProvider` above it. The detail card renders inside\n * the catalog panel, which some suites mount outside the provider, and throwing\n * there would make a plugin's presence break unrelated tests.\n */\nexport function usePluginUser(): PluginUser | null {\n const user = useOptionalUser()\n return useMemo(\n // `?? false`, because admin is a capability: absent must read as \"not an\n // admin\", never as undefined leaking into a plugin's own check.\n () => (user ? { email: user.email, isAdmin: user.isAdmin ?? false } : null),\n [user],\n )\n}\n"],"names":[],"mappings":";;;;AAsHO,SAAS,cACd,SACG,OAGM;AACT,QAAM,UAAU,cAAA;AAChB,QAAM,SAAS,MAAM,SAAS;AAC9B,QAAM,QAAQ,MAAM,CAAC;AACrB,MAAI,UAAU,UAAU,OAAW,QAAO;AAC1C,SAAO,QAAQ,KAAK,CAAC,WAAW;;AAC9B,QAAI,GAAC,YAAO,UAAP,mBAAe,OAAO,QAAO;AAClC,UAAM,WAAU,YAAO,cAAP,mBAAmB;AAGnC,WAAO,CAAC,UAAU,CAAC,WAAW,QAAQ,KAAK;AAAA,EAC7C,CAAC;AACH;AAcO,SAAS,gBAAmC;AACjD,QAAM,OAAO,gBAAA;AACb,SAAO;AAAA;AAAA;AAAA,IAGL,MAAO,OAAO,EAAE,OAAO,KAAK,OAAO,SAAS,KAAK,WAAW,MAAA,IAAU;AAAA,IACtE,CAAC,IAAI;AAAA,EAAA;AAET;"}