@nospt/plugin-dev-ai-hub 1.1.2 → 1.2.0-rc1

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 (70) hide show
  1. package/README.md +93 -2
  2. package/config.schema.json +15 -0
  3. package/dist/api/DevAiHubResourceClient.esm.js +42 -2
  4. package/dist/api/DevAiHubResourceClient.esm.js.map +1 -1
  5. package/dist/components/AiResourcesPage/AiResourcesPage.esm.js +98 -20
  6. package/dist/components/AiResourcesPage/AiResourcesPage.esm.js.map +1 -1
  7. package/dist/components/AiResourcesPage/AiResourcesPage.module.css.esm.js +2 -2
  8. package/dist/components/AiResourcesPage/AppLaunchScope.esm.js +9 -0
  9. package/dist/components/AiResourcesPage/AppLaunchScope.esm.js.map +1 -0
  10. package/dist/components/AiResourcesPage/ResourceCard.esm.js +13 -4
  11. package/dist/components/AiResourcesPage/ResourceCard.esm.js.map +1 -1
  12. package/dist/components/AiResourcesPage/ResourceCard.module.css.esm.js +2 -2
  13. package/dist/components/AiResourcesPage/ResourceDetailPanel.esm.js +55 -9
  14. package/dist/components/AiResourcesPage/ResourceDetailPanel.esm.js.map +1 -1
  15. package/dist/components/AiResourcesPage/ResourceFilters.esm.js +57 -3
  16. package/dist/components/AiResourcesPage/ResourceFilters.esm.js.map +1 -1
  17. package/dist/components/AiResourcesPage/ResourceInstallDialog.esm.js +6 -3
  18. package/dist/components/AiResourcesPage/ResourceInstallDialog.esm.js.map +1 -1
  19. package/dist/components/AiResourcesPage/SortControl.esm.js +139 -0
  20. package/dist/components/AiResourcesPage/SortControl.esm.js.map +1 -0
  21. package/dist/components/AiResourcesPage/SortControl.module.css.esm.js +8 -0
  22. package/dist/components/AiResourcesPage/SortControl.module.css.esm.js.map +1 -0
  23. package/dist/components/AiResourcesPage/ownerLabel.esm.js +6 -0
  24. package/dist/components/AiResourcesPage/ownerLabel.esm.js.map +1 -0
  25. package/dist/components/AiResourcesPage/typeMeta.esm.js +1 -12
  26. package/dist/components/AiResourcesPage/typeMeta.esm.js.map +1 -1
  27. package/dist/components/Backoffice/Backoffice.module.css.esm.js +8 -0
  28. package/dist/components/Backoffice/Backoffice.module.css.esm.js.map +1 -0
  29. package/dist/components/Backoffice/BackofficeCuration.esm.js +191 -0
  30. package/dist/components/Backoffice/BackofficeCuration.esm.js.map +1 -0
  31. package/dist/components/Backoffice/BackofficeExclusion.esm.js +185 -0
  32. package/dist/components/Backoffice/BackofficeExclusion.esm.js.map +1 -0
  33. package/dist/components/Backoffice/BackofficeGate.esm.js +25 -0
  34. package/dist/components/Backoffice/BackofficeGate.esm.js.map +1 -0
  35. package/dist/components/Backoffice/BackofficeHeaderAction.esm.js +43 -0
  36. package/dist/components/Backoffice/BackofficeHeaderAction.esm.js.map +1 -0
  37. package/dist/components/Backoffice/BackofficeOverview.esm.js +517 -0
  38. package/dist/components/Backoffice/BackofficeOverview.esm.js.map +1 -0
  39. package/dist/components/Backoffice/BackofficeSection.esm.js +37 -0
  40. package/dist/components/Backoffice/BackofficeSection.esm.js.map +1 -0
  41. package/dist/components/Backoffice/TypeDistributionBar.esm.js +44 -0
  42. package/dist/components/Backoffice/TypeDistributionBar.esm.js.map +1 -0
  43. package/dist/components/Backoffice/rowEntrance.esm.js +11 -0
  44. package/dist/components/Backoffice/rowEntrance.esm.js.map +1 -0
  45. package/dist/components/Backoffice/useCurationAccess.esm.js +12 -0
  46. package/dist/components/Backoffice/useCurationAccess.esm.js.map +1 -0
  47. package/dist/components/RecommendedBadge/RecommendedBadgeIcon.esm.js +25 -0
  48. package/dist/components/RecommendedBadge/RecommendedBadgeIcon.esm.js.map +1 -0
  49. package/dist/components/RecommendedBadge/recommended-badge.svg +9 -0
  50. package/dist/components/ToolIcon/ToolIcon.esm.js +41 -6
  51. package/dist/components/ToolIcon/ToolIcon.esm.js.map +1 -1
  52. package/dist/hooks/useDefaultSortMode.esm.js +37 -0
  53. package/dist/hooks/useDefaultSortMode.esm.js.map +1 -0
  54. package/dist/hooks/useFrameworkLabel.esm.js +42 -0
  55. package/dist/hooks/useFrameworkLabel.esm.js.map +1 -0
  56. package/dist/hooks/useResources.esm.js +25 -14
  57. package/dist/hooks/useResources.esm.js.map +1 -1
  58. package/dist/hooks/useTelemetryCounts.esm.js +16 -7
  59. package/dist/hooks/useTelemetryCounts.esm.js.map +1 -1
  60. package/dist/hooks/useTelemetrySummary.esm.js +46 -0
  61. package/dist/hooks/useTelemetrySummary.esm.js.map +1 -0
  62. package/dist/index.d.ts +8 -1
  63. package/dist/index.esm.js +1 -1
  64. package/dist/plugin.esm.js +90 -3
  65. package/dist/plugin.esm.js.map +1 -1
  66. package/dist/routes.esm.js +2 -1
  67. package/dist/routes.esm.js.map +1 -1
  68. package/dist/theme/typeTokens.module.css.esm.js +5 -0
  69. package/dist/theme/typeTokens.module.css.esm.js.map +1 -0
  70. package/package.json +9 -4
package/README.md CHANGED
@@ -23,7 +23,7 @@ Six kinds of resource, each with its own install path:
23
23
 
24
24
  ## What a user does with it
25
25
 
26
- 1. **Browses** — type tiles double as filters; search covers name, description and tags; further filters narrow by AI tool and tag.
26
+ 1. **Browses** — recommended resources are pinned in their own section at the top; type tiles double as filters; search covers name, description and tags; further filters narrow by AI tool and tag.
27
27
  2. **Opens a resource** — a drawer shows the metadata plus the body, resolved on demand from wherever it lives in Git. Markdown is rendered; an `mcp-config` is shown as copyable JSON.
28
28
  3. **Installs it** — the dialog gives per-framework install paths, a copy button, a download where the body *is* the artifact, and one-click launchers where the tool has a URL handler.
29
29
 
@@ -50,6 +50,49 @@ export const app = createApp({
50
50
 
51
51
  The sidebar item and the `/dev-ai-hub` route register themselves.
52
52
 
53
+ ## The backoffice
54
+
55
+ Administrative capabilities live on a separate page at `/dev-ai-hub/backoffice`, gated by the permission `dev-ai-hub.backoffice.read`. Users without it see no entry point in the plugin header, and a direct link renders the app's not-found page.
56
+
57
+ To restrict it you need the permission framework enabled:
58
+
59
+ ```yaml
60
+ permission:
61
+ enabled: true
62
+ ```
63
+
64
+ **Until you do, the backoffice is visible to every signed-in user** — with the framework disabled, Backstage's permission client answers ALLOW to everything, and the plugin follows that rather than second-guessing your configuration.
65
+
66
+ With the framework on, grant `dev-ai-hub.backoffice.read` the same way you grant any other permission. Using the RBAC plugin, it appears in the permission picker once the backend plugin is installed. Using a hand-written policy:
67
+
68
+ ```typescript
69
+ if (isPermission(request.permission, devAiHubBackofficeReadPermission)) {
70
+ return user?.identity.ownershipEntityRefs.includes('group:default/ai-platform')
71
+ ? { result: AuthorizeResult.ALLOW }
72
+ : { result: AuthorizeResult.DENY };
73
+ }
74
+ ```
75
+
76
+ The permission ref is exported from `@nospt/plugin-dev-ai-hub-common`.
77
+
78
+ Note that this hides the surface; it is not a security boundary. Reads are protected because the underlying endpoint already enforces per-user catalog visibility; the one write path the backoffice has (see below) is checked independently.
79
+
80
+ ### Recommending resources
81
+
82
+ Recommended resources are badged and pinned in a **Recommended** section at the top of the browse page, following the active filters and sort.
83
+
84
+ An author can recommend a resource from its own definition by tagging it `recommended` in `metadata.tags` (ADR-0020). That recommendation belongs to the author: the backoffice shows it locked on, so removing the tag is the only way to un-recommend it. The backend also refuses to add a backoffice recommendation to a tagged resource (`409 Conflict`), and the backoffice offers to clear one made before the tag was added, so no curation row can outlive the tag.
85
+
86
+ The **Recommendations** section lets a curator mark any other resource "Recommended" — a plugin opinion, stored in the plugin's own `curation` table and merged onto `ResourceSummary` at read time, never written into the catalog entity (ADR-0017). Writing it requires the separate permission `dev-ai-hub.backoffice.curate` (exported as `devAiHubBackofficeCuratePermission`); a user with only `dev-ai-hub.backoffice.read` sees the section but its controls are disabled.
87
+
88
+ ### Excluding resources
89
+
90
+ The **Excluded** section is the mirror of Recommendations: excluding a resource takes it off the hub entirely (ADR-0019). It disappears from browsing, search, the filters and the stat tiles — the backend drops it from `GET /resources`, so it is genuinely absent from the response rather than skipped by the UI. Excluding is reversible and loses nothing: the recommendation, the attribution and the telemetry all survive, and un-excluding restores the resource as it was.
91
+
92
+ Exclusion uses the same `dev-ai-hub.backoffice.curate` permission as recommending, and the **Excluded** page is the only surface that can see excluded resources — it asks for them with `?includeExcluded=true`, which costs `dev-ai-hub.backoffice.read`.
93
+
94
+ This is curation, not access control. The `AiResource` entity stays in the catalog and remains readable through Backstage's own catalog UI and API; the plugin's body endpoints are deliberately left alone rather than implying a boundary they cannot enforce.
95
+
53
96
  ### You also need
54
97
 
55
98
  - **[`@nospt/plugin-dev-ai-hub-backend`](https://www.npmjs.com/package/@nospt/plugin-dev-ai-hub-backend)** — the page has nothing to show without it.
@@ -67,10 +110,56 @@ devAiHub:
67
110
  # an overlay, so this never reflows the page; it is capped at the viewport
68
111
  # width and floored at a sane minimum. Defaults to 615 when unset.
69
112
  detailPanelWidth: 615
113
+ # Optional. Image for the "Recommended" mark — the badge on resource cards,
114
+ # the browse page's Recommended section heading, and the Admin Panel
115
+ # curation list. Any browser-loadable source — an absolute URL, an
116
+ # app-served static path, or a data URI. Defaults to the bundled
117
+ # medal-and-ribbon mark when unset. The heading's text stays amber to
118
+ # match that default, so pick an image that sits well beside it.
119
+ recommendedBadgeIcon: https://example.com/recommended.svg
120
+ # Optional. Default sort applied to the browse page on load, before any
121
+ # explicit user selection: `usage`, `newest`, or `name`. Users can still
122
+ # change it from the page's sort dropdown. Defaults to `usage` when unset.
123
+ defaultSort: usage
124
+ # Optional. Default sort direction on initial load: `asc` or `desc`.
125
+ # Defaults to the selected mode's natural direction when unset.
126
+ defaultSortDirection: desc
70
127
  ```
71
128
 
72
129
  The framework allow-list (`devAiHub.frameworks.allowed`) is enforced by the
73
- backend plugin — see its README.
130
+ backend plugin — see its README. `devAiHub.frameworks.custom` is read by both:
131
+ the backend gates the served payload on it, while this plugin reads each
132
+ entry's `displayName` and `icon` to label and illustrate the token in badges,
133
+ the "Works with" section and the AI Tool filter (ADR-0018). An entry without
134
+ an `icon` falls back to a neutral glyph; an icon renders through an `<img>`
135
+ and is not theme-inverted, so supply one that reads on both themes.
136
+
137
+ ### Sorting
138
+
139
+ The browse page's sort dropdown (top-right of the filters row) offers three
140
+ modes: **Most popular** (`usage` — installs plus a weighted view count,
141
+ `installs + views * 0.8`), **Newest** (`newest`), and **Name**. Recommended resources need no sort
142
+ mode of their own: they are pinned in their own section above the grid.
143
+ Most popular and Newest tie on Name (A-Z), so the list is always
144
+ deterministic, even when every resource has identical or zero counts.
145
+
146
+ **Newest** orders by when the plugin first saw each resource (ADR-0021) — the
147
+ catalog records no creation date. Resources that already existed when you
148
+ upgraded have no date and rank as the oldest, so right after upgrading Newest
149
+ reads like Name order; it becomes useful as new resources are added. A
150
+ resource that is renamed or moved to another namespace counts as new.
151
+
152
+ Each mode opens in its natural direction (Most popular and Newest
153
+ highest-first, Name A-Z) unless overridden by
154
+ `devAiHub.ui.defaultSortDirection`; a toggle next to the dropdown flips the
155
+ current mode's direction. The sort applies for the current view only — it is
156
+ not persisted, and reloading the page always starts from
157
+ `devAiHub.ui.defaultSort` (or `usage` if unset).
158
+
159
+ The **Most installed**, **Most downloaded**, **Most viewed** and
160
+ **Recommended first** modes were removed. A `defaultSort` still set to
161
+ `installs`, `downloads`, `views` or `recommended` logs an error and falls back
162
+ to `usage`.
74
163
 
75
164
  ## Troubleshooting
76
165
 
@@ -80,6 +169,8 @@ backend plugin — see its README.
80
169
  | "Could not load resources" | The backend plugin is not registered, or the request is unauthenticated |
81
170
  | Components render unstyled | `@backstage/ui/css/styles.css` is not imported in `packages/app/src/index.tsx` — apps scaffolded by a current `create-app` already do this |
82
171
  | A resource is listed but has no install actions | It publishes no `backstage.io/source-location`, so it is browsable but not installable |
172
+ | No "Admin Panel" button in the plugin header | The signed-in user has not been granted `dev-ai-hub.backoffice.read` |
173
+ | Everyone can open the backoffice | `permission.enabled` is false or unset, so the permission framework allows everything |
83
174
 
84
175
  ## License
85
176
 
@@ -12,6 +12,21 @@
12
12
  "type": "number",
13
13
  "description": "Width of the resource detail drawer, in pixels. The drawer is an overlay, so this never reflows the page; it is capped at the viewport width and floored at a sane minimum. Defaults to 615 when unset.",
14
14
  "visibility": "frontend"
15
+ },
16
+ "recommendedBadgeIcon": {
17
+ "type": "string",
18
+ "description": "Image used for the \"Recommended\" mark — the badge on resource cards, the browse page's Recommended section heading, and the Admin Panel curation list. Any browser-loadable source works — an absolute URL, an app-served static path, or a data URI. Defaults to the bundled medal-and-ribbon mark when unset.",
19
+ "visibility": "frontend"
20
+ },
21
+ "defaultSort": {
22
+ "type": "string",
23
+ "description": "Default sort applied to the browse page on load, before any explicit user selection (issue #102): `usage` (installs plus a weighted view count — the \"most popular\" composite), `newest` (when the plugin first saw the resource, ADR-0021), or `name`. Users can still change the sort from the page's sort dropdown; this only controls the initial order. An unrecognised value — including the removed `installs`, `downloads`, `views` and `recommended` modes — is rejected with a logged error and the page falls back to `usage`. Defaults to `usage` when unset.",
24
+ "visibility": "frontend"
25
+ },
26
+ "defaultSortDirection": {
27
+ "type": "string",
28
+ "description": "Default direction applied with `defaultSort` on initial load: `asc` or `desc`. Users can still toggle direction from the page control; this only sets the initial direction. Defaults to the selected mode's natural direction when unset. An unrecognised value is rejected with a logged error and the page falls back to that natural direction.",
29
+ "visibility": "frontend"
15
30
  }
16
31
  },
17
32
  "description": "Presentation settings for the Dev AI Hub frontend."
@@ -18,9 +18,10 @@ class DevAiHubResourceClient {
18
18
  }
19
19
  discoveryApi;
20
20
  fetchApi;
21
- async getResources() {
21
+ async getResources(options) {
22
22
  const base = await this.discoveryApi.getBaseUrl("dev-ai-hub");
23
- const response = await this.fetchApi.fetch(`${base}/resources`);
23
+ const query = options?.includeExcluded ? "?includeExcluded=true" : "";
24
+ const response = await this.fetchApi.fetch(`${base}/resources${query}`);
24
25
  if (!response.ok) {
25
26
  const text = await response.text();
26
27
  throw new Error(`Dev AI Hub API error ${response.status}: ${text}`);
@@ -91,6 +92,45 @@ class DevAiHubResourceClient {
91
92
  }
92
93
  return await response.json();
93
94
  }
95
+ async getTelemetrySummary() {
96
+ const base = await this.discoveryApi.getBaseUrl("dev-ai-hub");
97
+ const response = await this.fetchApi.fetch(`${base}/telemetry`);
98
+ if (!response.ok) {
99
+ throw new Error(`Dev AI Hub API error ${response.status}`);
100
+ }
101
+ const body = await response.json();
102
+ return body.counts;
103
+ }
104
+ async setRecommendation(entityRef, input) {
105
+ const base = await this.discoveryApi.getBaseUrl("dev-ai-hub");
106
+ const response = await this.fetchApi.fetch(
107
+ `${base}/curation/${encodeURIComponent(entityRef)}/recommendation`,
108
+ {
109
+ method: "PUT",
110
+ headers: { "Content-Type": "application/json" },
111
+ body: JSON.stringify(input)
112
+ }
113
+ );
114
+ if (!response.ok) {
115
+ const text = await response.text();
116
+ throw new Error(`Dev AI Hub API error ${response.status}: ${text}`);
117
+ }
118
+ }
119
+ async setExclusion(entityRef, input) {
120
+ const base = await this.discoveryApi.getBaseUrl("dev-ai-hub");
121
+ const response = await this.fetchApi.fetch(
122
+ `${base}/curation/${encodeURIComponent(entityRef)}/exclusion`,
123
+ {
124
+ method: "PUT",
125
+ headers: { "Content-Type": "application/json" },
126
+ body: JSON.stringify(input)
127
+ }
128
+ );
129
+ if (!response.ok) {
130
+ const text = await response.text();
131
+ throw new Error(`Dev AI Hub API error ${response.status}: ${text}`);
132
+ }
133
+ }
94
134
  }
95
135
 
96
136
  export { DevAiHubResourceClient, ResourceBodyError, devAiHubResourceApiRef };
@@ -1 +1 @@
1
- {"version":3,"file":"DevAiHubResourceClient.esm.js","sources":["../../src/api/DevAiHubResourceClient.ts"],"sourcesContent":["import {\n createApiRef,\n DiscoveryApi,\n FetchApi,\n} from '@backstage/core-plugin-api';\nimport type {\n ResourceListResponse,\n ResourceSummary,\n TelemetryAction,\n TelemetryCounts,\n} from '@nospt/plugin-dev-ai-hub-common';\n\n/**\n * v2 API client: consumes the catalog-backed `/resources` endpoint and knows\n * only the flat `ResourceSummary` contract (architecture.md).\n */\nexport const devAiHubResourceApiRef = createApiRef<DevAiHubResourceApi>({\n id: 'plugin.dev-ai-hub.resources',\n});\n\n/** The resolved body of a resource, as served by `GET /entity/:ref/raw`. */\nexport interface ResourceBody {\n content: string;\n contentType: string;\n}\n\n/** API error carrying the HTTP status so the UI can distinguish 404 from 502. */\nexport class ResourceBodyError extends Error {\n constructor(message: string, readonly status: number) {\n super(message);\n this.name = 'ResourceBodyError';\n }\n}\n\nexport interface DevAiHubResourceApi {\n getResources(): Promise<ResourceSummary[]>;\n /** Fetch the viewable body (a directory body's entry file). */\n getEntityBody(entityRef: string): Promise<ResourceBody>;\n /** Backend URL of the body; `download: true` yields the artifact form. */\n getEntityBodyUrl(\n entityRef: string,\n options?: { download?: boolean },\n ): Promise<string>;\n /** Trigger a browser download of the artifact (file, or zip if multi-file). */\n downloadEntityBody(entityRef: string): Promise<void>;\n /** Record a telemetry event (ADR-0007). Fire-and-forget from callers. */\n track(ref: string, action: TelemetryAction, tool?: string): Promise<void>;\n /** Raw per-action lifetime counts for a resource (ADR-0007). */\n getInstallCount(ref: string): Promise<TelemetryCounts>;\n}\n\nexport class DevAiHubResourceClient implements DevAiHubResourceApi {\n constructor(\n private readonly discoveryApi: DiscoveryApi,\n private readonly fetchApi: FetchApi,\n ) {}\n\n async getResources(): Promise<ResourceSummary[]> {\n const base = await this.discoveryApi.getBaseUrl('dev-ai-hub');\n const response = await this.fetchApi.fetch(`${base}/resources`);\n if (!response.ok) {\n const text = await response.text();\n throw new Error(`Dev AI Hub API error ${response.status}: ${text}`);\n }\n const body = (await response.json()) as ResourceListResponse;\n return body.items;\n }\n\n async getEntityBodyUrl(\n entityRef: string,\n options?: { download?: boolean },\n ): Promise<string> {\n const base = await this.discoveryApi.getBaseUrl('dev-ai-hub');\n const url = `${base}/entity/${encodeURIComponent(entityRef)}/raw`;\n return options?.download ? `${url}?download=true` : url;\n }\n\n async getEntityBody(entityRef: string): Promise<ResourceBody> {\n const response = await this.fetchApi.fetch(\n await this.getEntityBodyUrl(entityRef),\n );\n if (!response.ok) {\n throw new ResourceBodyError(\n `Failed to fetch body: ${response.status}`,\n response.status,\n );\n }\n return {\n content: await response.text(),\n contentType: response.headers.get('content-type') ?? 'text/plain',\n };\n }\n\n async downloadEntityBody(entityRef: string): Promise<void> {\n // The endpoint is auth-gated (ADR-0005), so a plain <a href> cannot carry\n // the token — fetch the artifact and hand the browser a blob instead.\n const response = await this.fetchApi.fetch(\n await this.getEntityBodyUrl(entityRef, { download: true }),\n );\n if (!response.ok) {\n throw new ResourceBodyError(\n `Failed to download body: ${response.status}`,\n response.status,\n );\n }\n const disposition = response.headers.get('content-disposition') ?? '';\n const filename =\n /filename=\"([^\"]+)\"/.exec(disposition)?.[1] ??\n `${entityRef.split('/').pop() ?? 'resource'}.md`;\n\n const blobUrl = URL.createObjectURL(await response.blob());\n try {\n const anchor = document.createElement('a');\n anchor.href = blobUrl;\n anchor.download = filename;\n anchor.click();\n } finally {\n URL.revokeObjectURL(blobUrl);\n }\n }\n\n async track(\n ref: string,\n action: TelemetryAction,\n tool?: string,\n ): Promise<void> {\n // Fire-and-forget (ADR-0007): a telemetry failure must never surface as\n // a user-facing error or block the copy/download/install it's tracking.\n try {\n const base = await this.discoveryApi.getBaseUrl('dev-ai-hub');\n await this.fetchApi.fetch(`${base}/telemetry`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ ref, action, tool }),\n });\n } catch {\n // ignored — see above\n }\n }\n\n async getInstallCount(ref: string): Promise<TelemetryCounts> {\n const base = await this.discoveryApi.getBaseUrl('dev-ai-hub');\n const response = await this.fetchApi.fetch(\n `${base}/telemetry/${encodeURIComponent(ref)}`,\n );\n if (!response.ok) {\n throw new Error(`Dev AI Hub API error ${response.status}`);\n }\n return (await response.json()) as TelemetryCounts;\n }\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"DevAiHubResourceClient.esm.js","sources":["../../src/api/DevAiHubResourceClient.ts"],"sourcesContent":["import {\n createApiRef,\n DiscoveryApi,\n FetchApi,\n} from '@backstage/core-plugin-api';\nimport type {\n ExclusionInput,\n RecommendationInput,\n ResourceListResponse,\n ResourceSummary,\n TelemetryAction,\n TelemetryCounts,\n TelemetrySummary,\n} from '@nospt/plugin-dev-ai-hub-common';\n\n/**\n * v2 API client: consumes the catalog-backed `/resources` endpoint and knows\n * only the flat `ResourceSummary` contract (architecture.md).\n */\nexport const devAiHubResourceApiRef = createApiRef<DevAiHubResourceApi>({\n id: 'plugin.dev-ai-hub.resources',\n});\n\n/** The resolved body of a resource, as served by `GET /entity/:ref/raw`. */\nexport interface ResourceBody {\n content: string;\n contentType: string;\n}\n\n/** API error carrying the HTTP status so the UI can distinguish 404 from 502. */\nexport class ResourceBodyError extends Error {\n constructor(message: string, readonly status: number) {\n super(message);\n this.name = 'ResourceBodyError';\n }\n}\n\nexport interface DevAiHubResourceApi {\n /**\n * The resources this caller can see. Excluded resources are absent unless\n * `includeExcluded` is set (ADR-0019), which the backend only honours for\n * a caller holding `dev-ai-hub.backoffice.read`.\n */\n getResources(options?: {\n includeExcluded?: boolean;\n }): Promise<ResourceSummary[]>;\n /** Fetch the viewable body (a directory body's entry file). */\n getEntityBody(entityRef: string): Promise<ResourceBody>;\n /** Backend URL of the body; `download: true` yields the artifact form. */\n getEntityBodyUrl(\n entityRef: string,\n options?: { download?: boolean },\n ): Promise<string>;\n /** Trigger a browser download of the artifact (file, or zip if multi-file). */\n downloadEntityBody(entityRef: string): Promise<void>;\n /** Record a telemetry event (ADR-0007). Fire-and-forget from callers. */\n track(ref: string, action: TelemetryAction, tool?: string): Promise<void>;\n /** Raw per-action lifetime counts for a resource (ADR-0007). */\n getInstallCount(ref: string): Promise<TelemetryCounts>;\n /**\n * Catalog-wide lifetime totals, keyed by entity ref (#83 backoffice\n * overview). Powers the \"Usage\" section; per-card counts still use\n * `getInstallCount`, which fetches one resource at a time.\n */\n getTelemetrySummary(): Promise<TelemetrySummary>;\n /**\n * Recommend or un-recommend a resource (#68, ADR-0017). Requires\n * `dev-ai-hub.backoffice.curate`; the backend enforces this — a caller\n * without it gets a 403, not a silent no-op.\n */\n setRecommendation(\n entityRef: string,\n input: RecommendationInput,\n ): Promise<void>;\n /**\n * Exclude a resource from the hub, or restore it (#68, ADR-0019). Shares\n * the `dev-ai-hub.backoffice.curate` permission with `setRecommendation` —\n * curation is granted as one capability, not per verb.\n */\n setExclusion(entityRef: string, input: ExclusionInput): Promise<void>;\n}\n\nexport class DevAiHubResourceClient implements DevAiHubResourceApi {\n constructor(\n private readonly discoveryApi: DiscoveryApi,\n private readonly fetchApi: FetchApi,\n ) {}\n\n async getResources(options?: {\n includeExcluded?: boolean;\n }): Promise<ResourceSummary[]> {\n const base = await this.discoveryApi.getBaseUrl('dev-ai-hub');\n const query = options?.includeExcluded ? '?includeExcluded=true' : '';\n const response = await this.fetchApi.fetch(`${base}/resources${query}`);\n if (!response.ok) {\n const text = await response.text();\n throw new Error(`Dev AI Hub API error ${response.status}: ${text}`);\n }\n const body = (await response.json()) as ResourceListResponse;\n return body.items;\n }\n\n async getEntityBodyUrl(\n entityRef: string,\n options?: { download?: boolean },\n ): Promise<string> {\n const base = await this.discoveryApi.getBaseUrl('dev-ai-hub');\n const url = `${base}/entity/${encodeURIComponent(entityRef)}/raw`;\n return options?.download ? `${url}?download=true` : url;\n }\n\n async getEntityBody(entityRef: string): Promise<ResourceBody> {\n const response = await this.fetchApi.fetch(\n await this.getEntityBodyUrl(entityRef),\n );\n if (!response.ok) {\n throw new ResourceBodyError(\n `Failed to fetch body: ${response.status}`,\n response.status,\n );\n }\n return {\n content: await response.text(),\n contentType: response.headers.get('content-type') ?? 'text/plain',\n };\n }\n\n async downloadEntityBody(entityRef: string): Promise<void> {\n // The endpoint is auth-gated (ADR-0005), so a plain <a href> cannot carry\n // the token — fetch the artifact and hand the browser a blob instead.\n const response = await this.fetchApi.fetch(\n await this.getEntityBodyUrl(entityRef, { download: true }),\n );\n if (!response.ok) {\n throw new ResourceBodyError(\n `Failed to download body: ${response.status}`,\n response.status,\n );\n }\n const disposition = response.headers.get('content-disposition') ?? '';\n const filename =\n /filename=\"([^\"]+)\"/.exec(disposition)?.[1] ??\n `${entityRef.split('/').pop() ?? 'resource'}.md`;\n\n const blobUrl = URL.createObjectURL(await response.blob());\n try {\n const anchor = document.createElement('a');\n anchor.href = blobUrl;\n anchor.download = filename;\n anchor.click();\n } finally {\n URL.revokeObjectURL(blobUrl);\n }\n }\n\n async track(\n ref: string,\n action: TelemetryAction,\n tool?: string,\n ): Promise<void> {\n // Fire-and-forget (ADR-0007): a telemetry failure must never surface as\n // a user-facing error or block the copy/download/install it's tracking.\n try {\n const base = await this.discoveryApi.getBaseUrl('dev-ai-hub');\n await this.fetchApi.fetch(`${base}/telemetry`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ ref, action, tool }),\n });\n } catch {\n // ignored — see above\n }\n }\n\n async getInstallCount(ref: string): Promise<TelemetryCounts> {\n const base = await this.discoveryApi.getBaseUrl('dev-ai-hub');\n const response = await this.fetchApi.fetch(\n `${base}/telemetry/${encodeURIComponent(ref)}`,\n );\n if (!response.ok) {\n throw new Error(`Dev AI Hub API error ${response.status}`);\n }\n return (await response.json()) as TelemetryCounts;\n }\n\n async getTelemetrySummary(): Promise<TelemetrySummary> {\n const base = await this.discoveryApi.getBaseUrl('dev-ai-hub');\n const response = await this.fetchApi.fetch(`${base}/telemetry`);\n if (!response.ok) {\n throw new Error(`Dev AI Hub API error ${response.status}`);\n }\n const body = (await response.json()) as { counts: TelemetrySummary };\n return body.counts;\n }\n\n async setRecommendation(\n entityRef: string,\n input: RecommendationInput,\n ): Promise<void> {\n const base = await this.discoveryApi.getBaseUrl('dev-ai-hub');\n const response = await this.fetchApi.fetch(\n `${base}/curation/${encodeURIComponent(entityRef)}/recommendation`,\n {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(input),\n },\n );\n if (!response.ok) {\n const text = await response.text();\n throw new Error(`Dev AI Hub API error ${response.status}: ${text}`);\n }\n }\n\n async setExclusion(entityRef: string, input: ExclusionInput): Promise<void> {\n const base = await this.discoveryApi.getBaseUrl('dev-ai-hub');\n const response = await this.fetchApi.fetch(\n `${base}/curation/${encodeURIComponent(entityRef)}/exclusion`,\n {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(input),\n },\n );\n if (!response.ok) {\n const text = await response.text();\n throw new Error(`Dev AI Hub API error ${response.status}: ${text}`);\n }\n }\n}\n"],"names":[],"mappings":"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"}
@@ -2,12 +2,15 @@ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
2
2
  import { useState, useEffect, useMemo } from 'react';
3
3
  import { useSearchParams } from 'react-router-dom';
4
4
  import { Skeleton, Text, Box, Flex, TablePagination } from '@backstage/ui';
5
- import { RESOURCE_TYPES } from '@nospt/plugin-dev-ai-hub-common';
5
+ import { RESOURCE_TYPES, sortResources } from '@nospt/plugin-dev-ai-hub-common';
6
6
  import { useResources } from '../../hooks/useResources.esm.js';
7
+ import { useTelemetrySummary } from '../../hooks/useTelemetrySummary.esm.js';
8
+ import { useDefaultSortMode, useDefaultSortDirection } from '../../hooks/useDefaultSortMode.esm.js';
7
9
  import { StatTiles } from './StatTiles.esm.js';
8
10
  import { ResourceCard } from './ResourceCard.esm.js';
9
11
  import { ResourceFilters } from './ResourceFilters.esm.js';
10
12
  import { ResourceDetailPanel } from './ResourceDetailPanel.esm.js';
13
+ import { RecommendedBadgeIcon } from '../RecommendedBadge/RecommendedBadgeIcon.esm.js';
11
14
  import styles from './AiResourcesPage.module.css.esm.js';
12
15
 
13
16
  const PAGE_SIZE = 24;
@@ -16,6 +19,13 @@ const DEFAULT_FILTERS = {
16
19
  framework: void 0,
17
20
  tags: []
18
21
  };
22
+ const METRIC_SORT_MODES = ["usage"];
23
+ const ZERO_TELEMETRY_COUNTS = {
24
+ install: 0,
25
+ copy: 0,
26
+ download: 0,
27
+ view: 0
28
+ };
19
29
  function matchesSearch(r, query) {
20
30
  const q = query.trim().toLowerCase();
21
31
  if (!q) return true;
@@ -23,11 +33,20 @@ function matchesSearch(r, query) {
23
33
  }
24
34
  function AiResourcesPage() {
25
35
  const { items, error, loading } = useResources();
36
+ const {
37
+ summary: telemetrySummary,
38
+ error: telemetryError,
39
+ loading: telemetryLoading
40
+ } = useTelemetrySummary();
41
+ const defaultSortMode = useDefaultSortMode();
42
+ const defaultSortDirection = useDefaultSortDirection(defaultSortMode);
26
43
  const [typeFilter, setTypeFilter] = useState(
27
44
  void 0
28
45
  );
29
46
  const [filters, setFilters] = useState(DEFAULT_FILTERS);
30
47
  const [page, setPage] = useState(1);
48
+ const [sortMode, setSortMode] = useState(defaultSortMode);
49
+ const [sortDirection, setSortDirection] = useState(defaultSortDirection);
31
50
  const [playEntrance, setPlayEntrance] = useState(true);
32
51
  useEffect(() => {
33
52
  if (loading || !playEntrance) return void 0;
@@ -46,7 +65,7 @@ function AiResourcesPage() {
46
65
  n.delete("resource");
47
66
  return n;
48
67
  });
49
- const counts = useMemo(() => {
68
+ const typeCounts = useMemo(() => {
50
69
  const c = Object.fromEntries(RESOURCE_TYPES.map((t) => [t, 0]));
51
70
  (items ?? []).forEach((r) => {
52
71
  c[r.type] += 1;
@@ -61,14 +80,34 @@ function AiResourcesPage() {
61
80
  () => Array.from(new Set((items ?? []).flatMap((r) => r.tags))).sort(),
62
81
  [items]
63
82
  );
64
- const visible = useMemo(
83
+ const telemetryCountsMap = useMemo(
84
+ () => new Map(Object.entries(telemetrySummary ?? {})),
85
+ [telemetrySummary]
86
+ );
87
+ const filtered = useMemo(
65
88
  () => (items ?? []).filter(
66
89
  (r) => (!typeFilter || r.type === typeFilter) && (!filters.framework || r.frameworks.includes(filters.framework)) && (filters.tags.length === 0 || filters.tags.every((t) => r.tags.includes(t))) && matchesSearch(r, filters.search)
67
90
  ),
68
91
  [items, typeFilter, filters]
69
92
  );
70
- const totalPages = Math.ceil(visible.length / PAGE_SIZE);
71
- const pageItems = visible.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
93
+ const telemetrySortUnavailable = telemetryError !== void 0 && METRIC_SORT_MODES.includes(sortMode);
94
+ const effectiveSortMode = telemetrySortUnavailable ? "name" : sortMode;
95
+ const visible = useMemo(
96
+ () => sortResources(
97
+ filtered,
98
+ effectiveSortMode,
99
+ sortDirection,
100
+ telemetryCountsMap
101
+ ),
102
+ [filtered, effectiveSortMode, sortDirection, telemetryCountsMap]
103
+ );
104
+ const recommended = useMemo(
105
+ () => visible.filter((r) => r.recommended),
106
+ [visible]
107
+ );
108
+ const others = useMemo(() => visible.filter((r) => !r.recommended), [visible]);
109
+ const totalPages = Math.ceil(others.length / PAGE_SIZE);
110
+ const pageItems = others.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
72
111
  const handleTileToggle = (type) => {
73
112
  setTypeFilter((current) => current === type ? void 0 : type);
74
113
  setPage(1);
@@ -77,12 +116,29 @@ function AiResourcesPage() {
77
116
  setFilters(next);
78
117
  setPage(1);
79
118
  };
119
+ const handleSortChange = (mode, direction) => {
120
+ setSortMode(mode);
121
+ setSortDirection(direction);
122
+ setPage(1);
123
+ };
124
+ const renderGrid = (resources, indexOffset) => /* @__PURE__ */ jsx("div", { className: styles.grid, children: resources.map((r, i) => /* @__PURE__ */ jsx(
125
+ ResourceCard,
126
+ {
127
+ resource: r,
128
+ onView: openDetail,
129
+ className: playEntrance ? styles.cardEntrance : void 0,
130
+ style: playEntrance ? { "--card-index": indexOffset + i } : void 0,
131
+ seedCounts: telemetryError ? void 0 : telemetryCountsMap.get(r.entityRef) ?? ZERO_TELEMETRY_COUNTS
132
+ },
133
+ r.entityRef
134
+ )) });
80
135
  const selectedResource = selectedRef ? (items ?? []).find((r) => r.entityRef === selectedRef) : void 0;
136
+ const showLoading = loading || telemetryLoading;
81
137
  return /* @__PURE__ */ jsxs("div", { className: styles.pageRoot, children: [
82
138
  /* @__PURE__ */ jsx(
83
139
  StatTiles,
84
140
  {
85
- counts,
141
+ counts: typeCounts,
86
142
  activeType: typeFilter,
87
143
  onToggle: handleTileToggle
88
144
  }
@@ -93,10 +149,15 @@ function AiResourcesPage() {
93
149
  value: filters,
94
150
  onChange: handleFiltersChange,
95
151
  availableFrameworks,
96
- availableTags
152
+ availableTags,
153
+ sort: {
154
+ mode: sortMode,
155
+ direction: sortDirection,
156
+ onChange: handleSortChange
157
+ }
97
158
  }
98
159
  ),
99
- loading && /* @__PURE__ */ jsx("div", { className: styles.grid, children: Array.from({ length: 8 }).map((_, i) => /* @__PURE__ */ jsx(
160
+ showLoading && /* @__PURE__ */ jsx("div", { className: styles.grid, children: Array.from({ length: 8 }).map((_, i) => /* @__PURE__ */ jsx(
100
161
  Skeleton,
101
162
  {
102
163
  style: { height: 170, borderRadius: "var(--bui-radius-2)" }
@@ -107,29 +168,46 @@ function AiResourcesPage() {
107
168
  /* @__PURE__ */ jsx(Text, { variant: "title-small", color: "secondary", weight: "bold", as: "p", children: "Could not load resources" }),
108
169
  /* @__PURE__ */ jsx(Text, { variant: "body-small", color: "secondary", as: "p", children: error.message })
109
170
  ] }),
110
- !loading && !error && visible.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
171
+ !showLoading && !error && telemetrySortUnavailable && /* @__PURE__ */ jsxs("div", { className: styles.emptyState, children: [
172
+ /* @__PURE__ */ jsx(Text, { variant: "title-small", color: "secondary", weight: "bold", as: "p", children: "Telemetry is temporarily unavailable" }),
173
+ /* @__PURE__ */ jsx(Text, { variant: "body-small", color: "secondary", as: "p", children: "Showing Name order until telemetry metrics are available again." })
174
+ ] }),
175
+ !showLoading && !error && visible.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
111
176
  /* @__PURE__ */ jsx(Box, { mb: "4", children: /* @__PURE__ */ jsxs(Text, { variant: "body-x-small", color: "secondary", children: [
112
177
  visible.length,
113
178
  " resource",
114
179
  visible.length !== 1 ? "s" : "",
115
180
  " found"
116
181
  ] }) }),
117
- /* @__PURE__ */ jsx("div", { className: styles.grid, children: pageItems.map((r, i) => /* @__PURE__ */ jsx(
118
- ResourceCard,
182
+ recommended.length > 0 && /* @__PURE__ */ jsxs(
183
+ "section",
119
184
  {
120
- resource: r,
121
- onView: openDetail,
122
- className: playEntrance ? styles.cardEntrance : void 0,
123
- style: playEntrance ? { "--card-index": i } : void 0
124
- },
125
- r.entityRef
126
- )) }),
185
+ className: styles.recommendedSection,
186
+ "aria-labelledby": "devaihub-recommended-heading",
187
+ children: [
188
+ /* @__PURE__ */ jsxs(
189
+ "h2",
190
+ {
191
+ id: "devaihub-recommended-heading",
192
+ className: styles.recommendedHeading,
193
+ children: [
194
+ /* @__PURE__ */ jsx(RecommendedBadgeIcon, { size: 16 }),
195
+ "Recommended",
196
+ /* @__PURE__ */ jsx("span", { className: styles.recommendedCount, children: recommended.length })
197
+ ]
198
+ }
199
+ ),
200
+ renderGrid(recommended, 0)
201
+ ]
202
+ }
203
+ ),
204
+ others.length > 0 && renderGrid(pageItems, recommended.length),
127
205
  totalPages > 1 && /* @__PURE__ */ jsx(Flex, { className: styles.paginationRow, children: /* @__PURE__ */ jsx(
128
206
  TablePagination,
129
207
  {
130
208
  pageSize: PAGE_SIZE,
131
209
  offset: (page - 1) * PAGE_SIZE,
132
- totalCount: visible.length,
210
+ totalCount: others.length,
133
211
  hasNextPage: page < totalPages,
134
212
  hasPreviousPage: page > 1,
135
213
  onNextPage: () => setPage((p) => p + 1),
@@ -137,7 +215,7 @@ function AiResourcesPage() {
137
215
  }
138
216
  ) })
139
217
  ] }),
140
- !loading && !error && visible.length === 0 && /* @__PURE__ */ jsxs("div", { className: styles.emptyState, children: [
218
+ !showLoading && !error && visible.length === 0 && /* @__PURE__ */ jsxs("div", { className: styles.emptyState, children: [
141
219
  /* @__PURE__ */ jsx("div", { className: styles.emptyEmoji, children: "\u{1F916}" }),
142
220
  /* @__PURE__ */ jsx(Text, { variant: "title-small", color: "secondary", weight: "bold", as: "p", children: "No AI resources found" }),
143
221
  /* @__PURE__ */ jsx(Text, { variant: "body-small", color: "secondary", as: "p", children: items && items.length > 0 ? "No resources match the current filters." : "Register AiResource entities in the catalog to see them here." })
@@ -1 +1 @@
1
- {"version":3,"file":"AiResourcesPage.esm.js","sources":["../../../src/components/AiResourcesPage/AiResourcesPage.tsx"],"sourcesContent":["import { useEffect, useMemo, useState } from 'react';\nimport { useSearchParams } from 'react-router-dom';\nimport { Box, Flex, Skeleton, TablePagination, Text } from '@backstage/ui';\nimport {\n RESOURCE_TYPES,\n type ResourceSummary,\n type ResourceType,\n} from '@nospt/plugin-dev-ai-hub-common';\nimport { useResources } from '../../hooks/useResources';\nimport { StatTiles } from './StatTiles';\nimport { ResourceCard } from './ResourceCard';\nimport { ResourceFilters, type ResourceFiltersValue } from './ResourceFilters';\nimport { ResourceDetailPanel } from './ResourceDetailPanel';\nimport styles from './AiResourcesPage.module.css';\n\nconst PAGE_SIZE = 24;\n\nconst DEFAULT_FILTERS: ResourceFiltersValue = {\n search: '',\n framework: undefined,\n tags: [],\n};\n\nfunction matchesSearch(r: ResourceSummary, query: string): boolean {\n const q = query.trim().toLowerCase();\n if (!q) return true;\n return (\n r.name.toLowerCase().includes(q) ||\n (r.title ?? '').toLowerCase().includes(q) ||\n (r.description ?? '').toLowerCase().includes(q) ||\n r.tags.some(t => t.toLowerCase().includes(q))\n );\n}\n\nexport function AiResourcesPage() {\n const { items, error, loading } = useResources();\n const [typeFilter, setTypeFilter] = useState<ResourceType | undefined>(\n undefined,\n );\n const [filters, setFilters] = useState<ResourceFiltersValue>(DEFAULT_FILTERS);\n const [page, setPage] = useState(1);\n\n // Staggered card entrance plays once, on the initial load only — re-playing\n // it on every filter/search keystroke would be noisy rather than delightful\n // (\"add some flowers\").\n const [playEntrance, setPlayEntrance] = useState(true);\n useEffect(() => {\n if (loading || !playEntrance) return undefined;\n const timer = setTimeout(() => setPlayEntrance(false), 900);\n return () => clearTimeout(timer);\n }, [loading, playEntrance]);\n\n const [searchParams, setSearchParams] = useSearchParams();\n const selectedRef = searchParams.get('resource');\n\n const openDetail = (entityRef: string) =>\n setSearchParams(p => {\n const n = new URLSearchParams(p);\n n.set('resource', entityRef);\n return n;\n });\n\n const closeDetail = () =>\n setSearchParams(p => {\n const n = new URLSearchParams(p);\n n.delete('resource');\n return n;\n });\n\n const counts = useMemo(() => {\n const c = Object.fromEntries(RESOURCE_TYPES.map(t => [t, 0])) as Record<\n ResourceType,\n number\n >;\n (items ?? []).forEach(r => {\n c[r.type] += 1;\n });\n return c;\n }, [items]);\n\n const availableFrameworks = useMemo(\n () => Array.from(new Set((items ?? []).flatMap(r => r.frameworks))).sort(),\n [items],\n );\n\n const availableTags = useMemo(\n () => Array.from(new Set((items ?? []).flatMap(r => r.tags))).sort(),\n [items],\n );\n\n const visible = useMemo(\n () =>\n (items ?? []).filter(\n r =>\n (!typeFilter || r.type === typeFilter) &&\n (!filters.framework || r.frameworks.includes(filters.framework)) &&\n (filters.tags.length === 0 ||\n filters.tags.every(t => r.tags.includes(t))) &&\n matchesSearch(r, filters.search),\n ),\n [items, typeFilter, filters],\n );\n\n const totalPages = Math.ceil(visible.length / PAGE_SIZE);\n const pageItems = visible.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);\n\n const handleTileToggle = (type: ResourceType) => {\n setTypeFilter(current => (current === type ? undefined : type));\n setPage(1);\n };\n\n const handleFiltersChange = (next: ResourceFiltersValue) => {\n setFilters(next);\n setPage(1);\n };\n\n const selectedResource = selectedRef\n ? (items ?? []).find(r => r.entityRef === selectedRef)\n : undefined;\n\n return (\n <div className={styles.pageRoot}>\n <StatTiles\n counts={counts}\n activeType={typeFilter}\n onToggle={handleTileToggle}\n />\n\n <ResourceFilters\n value={filters}\n onChange={handleFiltersChange}\n availableFrameworks={availableFrameworks}\n availableTags={availableTags}\n />\n\n {loading && (\n <div className={styles.grid}>\n {Array.from({ length: 8 }).map((_, i) => (\n <Skeleton\n key={i}\n style={{ height: 170, borderRadius: 'var(--bui-radius-2)' }}\n />\n ))}\n </div>\n )}\n\n {error && (\n <div className={styles.emptyState}>\n <Text variant=\"title-small\" color=\"secondary\" weight=\"bold\" as=\"p\">\n Could not load resources\n </Text>\n <Text variant=\"body-small\" color=\"secondary\" as=\"p\">\n {error.message}\n </Text>\n </div>\n )}\n\n {!loading && !error && visible.length > 0 && (\n <>\n <Box mb=\"4\">\n <Text variant=\"body-x-small\" color=\"secondary\">\n {visible.length} resource{visible.length !== 1 ? 's' : ''} found\n </Text>\n </Box>\n <div className={styles.grid}>\n {pageItems.map((r, i) => (\n <ResourceCard\n key={r.entityRef}\n resource={r}\n onView={openDetail}\n className={playEntrance ? styles.cardEntrance : undefined}\n style={\n playEntrance\n ? ({ '--card-index': i } as React.CSSProperties)\n : undefined\n }\n />\n ))}\n </div>\n {totalPages > 1 && (\n <Flex className={styles.paginationRow}>\n <TablePagination\n pageSize={PAGE_SIZE}\n offset={(page - 1) * PAGE_SIZE}\n totalCount={visible.length}\n hasNextPage={page < totalPages}\n hasPreviousPage={page > 1}\n onNextPage={() => setPage(p => p + 1)}\n onPreviousPage={() => setPage(p => p - 1)}\n />\n </Flex>\n )}\n </>\n )}\n\n {!loading && !error && visible.length === 0 && (\n <div className={styles.emptyState}>\n <div className={styles.emptyEmoji}>🤖</div>\n <Text variant=\"title-small\" color=\"secondary\" weight=\"bold\" as=\"p\">\n No AI resources found\n </Text>\n <Text variant=\"body-small\" color=\"secondary\" as=\"p\">\n {items && items.length > 0\n ? 'No resources match the current filters.'\n : 'Register AiResource entities in the catalog to see them here.'}\n </Text>\n </div>\n )}\n\n <ResourceDetailPanel\n resource={selectedResource}\n onClose={closeDetail}\n items={items ?? []}\n onOpen={openDetail}\n />\n </div>\n );\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"AiResourcesPage.esm.js","sources":["../../../src/components/AiResourcesPage/AiResourcesPage.tsx"],"sourcesContent":["import { useEffect, useMemo, useState } from 'react';\nimport { useSearchParams } from 'react-router-dom';\nimport { Box, Flex, Skeleton, TablePagination, Text } from '@backstage/ui';\nimport {\n RESOURCE_TYPES,\n sortResources,\n type ResourceSummary,\n type ResourceType,\n type SortDirection,\n type SortMode,\n type TelemetryCounts,\n} from '@nospt/plugin-dev-ai-hub-common';\nimport { useResources } from '../../hooks/useResources';\nimport { useTelemetrySummary } from '../../hooks/useTelemetrySummary';\nimport {\n useDefaultSortDirection,\n useDefaultSortMode,\n} from '../../hooks/useDefaultSortMode';\nimport { StatTiles } from './StatTiles';\nimport { ResourceCard } from './ResourceCard';\nimport { ResourceFilters, type ResourceFiltersValue } from './ResourceFilters';\nimport { ResourceDetailPanel } from './ResourceDetailPanel';\nimport { RecommendedBadgeIcon } from '../RecommendedBadge';\nimport styles from './AiResourcesPage.module.css';\n\nconst PAGE_SIZE = 24;\n\nconst DEFAULT_FILTERS: ResourceFiltersValue = {\n search: '',\n framework: undefined,\n tags: [],\n};\nconst METRIC_SORT_MODES: readonly SortMode[] = ['usage'];\n\n/** A stable \"no telemetry yet\" value, so a resource absent from the summary\n * map (per `TelemetrySummary`'s contract — no events means no entry) is\n * treated as all-zero rather than \"not seeded\", letting `ResourceCard` skip\n * its own fetch for the common case of a low/no-usage resource. Kept as one\n * shared object so its identity never changes across renders. */\nconst ZERO_TELEMETRY_COUNTS: TelemetryCounts = {\n install: 0,\n copy: 0,\n download: 0,\n view: 0,\n};\n\nfunction matchesSearch(r: ResourceSummary, query: string): boolean {\n const q = query.trim().toLowerCase();\n if (!q) return true;\n return (\n r.name.toLowerCase().includes(q) ||\n (r.title ?? '').toLowerCase().includes(q) ||\n (r.description ?? '').toLowerCase().includes(q) ||\n r.tags.some(t => t.toLowerCase().includes(q))\n );\n}\n\nexport function AiResourcesPage() {\n const { items, error, loading } = useResources();\n const {\n summary: telemetrySummary,\n error: telemetryError,\n loading: telemetryLoading,\n } = useTelemetrySummary();\n const defaultSortMode = useDefaultSortMode();\n const defaultSortDirection = useDefaultSortDirection(defaultSortMode);\n\n const [typeFilter, setTypeFilter] = useState<ResourceType | undefined>(\n undefined,\n );\n const [filters, setFilters] = useState<ResourceFiltersValue>(DEFAULT_FILTERS);\n const [page, setPage] = useState(1);\n\n // Initialised from the configured default (issue #102); not persisted —\n // a reload always starts from the configured default again.\n const [sortMode, setSortMode] = useState<SortMode>(defaultSortMode);\n const [sortDirection, setSortDirection] =\n useState<SortDirection>(defaultSortDirection);\n\n // Staggered card entrance plays once, on the initial load only — re-playing\n // it on every filter/search keystroke would be noisy rather than delightful\n // (\"add some flowers\").\n const [playEntrance, setPlayEntrance] = useState(true);\n useEffect(() => {\n if (loading || !playEntrance) return undefined;\n const timer = setTimeout(() => setPlayEntrance(false), 900);\n return () => clearTimeout(timer);\n }, [loading, playEntrance]);\n\n const [searchParams, setSearchParams] = useSearchParams();\n const selectedRef = searchParams.get('resource');\n\n const openDetail = (entityRef: string) =>\n setSearchParams(p => {\n const n = new URLSearchParams(p);\n n.set('resource', entityRef);\n return n;\n });\n\n const closeDetail = () =>\n setSearchParams(p => {\n const n = new URLSearchParams(p);\n n.delete('resource');\n return n;\n });\n\n const typeCounts = useMemo(() => {\n const c = Object.fromEntries(RESOURCE_TYPES.map(t => [t, 0])) as Record<\n ResourceType,\n number\n >;\n (items ?? []).forEach(r => {\n c[r.type] += 1;\n });\n return c;\n }, [items]);\n\n const availableFrameworks = useMemo(\n () => Array.from(new Set((items ?? []).flatMap(r => r.frameworks))).sort(),\n [items],\n );\n\n const availableTags = useMemo(\n () => Array.from(new Set((items ?? []).flatMap(r => r.tags))).sort(),\n [items],\n );\n\n // The shared page-level telemetry fetch (issue #102): built once from the\n // catalog-wide summary rather than per-card, so usage/installs/views sort\n // the whole list and every card's badge reads from the very same numbers.\n // A resource missing from `telemetrySummary` (no recorded events) maps to\n // `ZERO_TELEMETRY_COUNTS` rather than being absent from this map — see\n // `sortResources`, whose tie-breaker then settles the all-zero case\n // alphabetically instead of leaving it in arbitrary fetch order.\n const telemetryCountsMap = useMemo(\n () => new Map(Object.entries(telemetrySummary ?? {})),\n [telemetrySummary],\n );\n\n const filtered = useMemo(\n () =>\n (items ?? []).filter(\n r =>\n (!typeFilter || r.type === typeFilter) &&\n (!filters.framework || r.frameworks.includes(filters.framework)) &&\n (filters.tags.length === 0 ||\n filters.tags.every(t => r.tags.includes(t))) &&\n matchesSearch(r, filters.search),\n ),\n [items, typeFilter, filters],\n );\n\n const telemetrySortUnavailable =\n telemetryError !== undefined && METRIC_SORT_MODES.includes(sortMode);\n const effectiveSortMode: SortMode = telemetrySortUnavailable\n ? 'name'\n : sortMode;\n\n const visible = useMemo(\n () =>\n sortResources(\n filtered,\n effectiveSortMode,\n sortDirection,\n telemetryCountsMap,\n ),\n [filtered, effectiveSortMode, sortDirection, telemetryCountsMap],\n );\n\n // Recommended resources (ADR-0017) that survive the current filters are\n // pulled out into their own section above the grid, in the same sort order,\n // and are not paginated — they are the curated few meant to be seen first.\n // Pagination covers only the rest.\n const recommended = useMemo(\n () => visible.filter(r => r.recommended),\n [visible],\n );\n const others = useMemo(() => visible.filter(r => !r.recommended), [visible]);\n\n const totalPages = Math.ceil(others.length / PAGE_SIZE);\n const pageItems = others.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);\n\n const handleTileToggle = (type: ResourceType) => {\n setTypeFilter(current => (current === type ? undefined : type));\n setPage(1);\n };\n\n const handleFiltersChange = (next: ResourceFiltersValue) => {\n setFilters(next);\n setPage(1);\n };\n\n const handleSortChange = (mode: SortMode, direction: SortDirection) => {\n setSortMode(mode);\n setSortDirection(direction);\n setPage(1);\n };\n\n // `indexOffset` keeps the staggered entrance flowing from the Recommended\n // grid into the one below instead of both starting at card 0 together.\n const renderGrid = (resources: ResourceSummary[], indexOffset: number) => (\n <div className={styles.grid}>\n {resources.map((r, i) => (\n <ResourceCard\n key={r.entityRef}\n resource={r}\n onView={openDetail}\n className={playEntrance ? styles.cardEntrance : undefined}\n style={\n playEntrance\n ? ({ '--card-index': indexOffset + i } as React.CSSProperties)\n : undefined\n }\n seedCounts={\n telemetryError\n ? undefined\n : telemetryCountsMap.get(r.entityRef) ?? ZERO_TELEMETRY_COUNTS\n }\n />\n ))}\n </div>\n );\n\n const selectedResource = selectedRef\n ? (items ?? []).find(r => r.entityRef === selectedRef)\n : undefined;\n\n // Wait for both the resource list and the telemetry summary before the\n // first paint, so the grid never has to visibly reshuffle from\n // fetch-order into usage order. A summary fetch failure stops the wait —\n // `telemetryCountsMap` is then simply empty, which `sortResources` treats\n // as all-zero and settles via its Name tie-breaker, so the page still\n // renders a stable, if not usage-ranked, order rather than staying stuck.\n const showLoading = loading || telemetryLoading;\n\n return (\n <div className={styles.pageRoot}>\n <StatTiles\n counts={typeCounts}\n activeType={typeFilter}\n onToggle={handleTileToggle}\n />\n\n <ResourceFilters\n value={filters}\n onChange={handleFiltersChange}\n availableFrameworks={availableFrameworks}\n availableTags={availableTags}\n sort={{\n mode: sortMode,\n direction: sortDirection,\n onChange: handleSortChange,\n }}\n />\n\n {showLoading && (\n <div className={styles.grid}>\n {Array.from({ length: 8 }).map((_, i) => (\n <Skeleton\n key={i}\n style={{ height: 170, borderRadius: 'var(--bui-radius-2)' }}\n />\n ))}\n </div>\n )}\n\n {error && (\n <div className={styles.emptyState}>\n <Text variant=\"title-small\" color=\"secondary\" weight=\"bold\" as=\"p\">\n Could not load resources\n </Text>\n <Text variant=\"body-small\" color=\"secondary\" as=\"p\">\n {error.message}\n </Text>\n </div>\n )}\n\n {!showLoading && !error && telemetrySortUnavailable && (\n <div className={styles.emptyState}>\n <Text variant=\"title-small\" color=\"secondary\" weight=\"bold\" as=\"p\">\n Telemetry is temporarily unavailable\n </Text>\n <Text variant=\"body-small\" color=\"secondary\" as=\"p\">\n Showing Name order until telemetry metrics are available again.\n </Text>\n </div>\n )}\n\n {!showLoading && !error && visible.length > 0 && (\n <>\n <Box mb=\"4\">\n <Text variant=\"body-x-small\" color=\"secondary\">\n {visible.length} resource{visible.length !== 1 ? 's' : ''} found\n </Text>\n </Box>\n {recommended.length > 0 && (\n <section\n className={styles.recommendedSection}\n aria-labelledby=\"devaihub-recommended-heading\"\n >\n <h2\n id=\"devaihub-recommended-heading\"\n className={styles.recommendedHeading}\n >\n <RecommendedBadgeIcon size={16} />\n Recommended\n <span className={styles.recommendedCount}>\n {recommended.length}\n </span>\n </h2>\n {renderGrid(recommended, 0)}\n </section>\n )}\n {others.length > 0 && renderGrid(pageItems, recommended.length)}\n {totalPages > 1 && (\n <Flex className={styles.paginationRow}>\n <TablePagination\n pageSize={PAGE_SIZE}\n offset={(page - 1) * PAGE_SIZE}\n totalCount={others.length}\n hasNextPage={page < totalPages}\n hasPreviousPage={page > 1}\n onNextPage={() => setPage(p => p + 1)}\n onPreviousPage={() => setPage(p => p - 1)}\n />\n </Flex>\n )}\n </>\n )}\n\n {!showLoading && !error && visible.length === 0 && (\n <div className={styles.emptyState}>\n <div className={styles.emptyEmoji}>🤖</div>\n <Text variant=\"title-small\" color=\"secondary\" weight=\"bold\" as=\"p\">\n No AI resources found\n </Text>\n <Text variant=\"body-small\" color=\"secondary\" as=\"p\">\n {items && items.length > 0\n ? 'No resources match the current filters.'\n : 'Register AiResource entities in the catalog to see them here.'}\n </Text>\n </div>\n )}\n\n <ResourceDetailPanel\n resource={selectedResource}\n onClose={closeDetail}\n items={items ?? []}\n onOpen={openDetail}\n />\n </div>\n );\n}\n"],"names":[],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  import styleInject from '../../node_modules_dist/style-inject/dist/style-inject.es.esm.js';
2
2
 
3
- var css_248z = "/*\n * Plugin-owned colour tokens for the six ResourceType roles (ADR-0008,\n * ADR-0010, amended 2026-08-03).\n *\n * Source of truth is the NOS *digital* brand palette — `brand` in\n * nosportugal/backstage `packages/app/src/themes/palette.ts`, the same one\n * that app's MUI themes and BUI variable overrides derive from. Hue and\n * saturation come from there and never change; only lightness moves, and only\n * far enough to clear 3:1 against the card background of the theme the value\n * is used in (`--bui-bg-neutral-1`: #ededeb in nos-light, #252830 in\n * nos-dark). Several values are the brand hex untouched, because they already\n * cleared it.\n *\n * marketplace is the exception: it keeps NOS Coral (#f26b43, from the\n * presentation brand reference) rather than taking brand.red, which that\n * palette binds to `error` — a semantic colour used for identity is the\n * misuse ADR-0008 exists to avoid.\n *\n * This is the ONLY place raw hex may appear for type identity; components\n * reference the custom properties. Chrome accents are NOT here — they use\n * BUI's own tokens, so they track the host theme instead of competing.\n */\n:root,\n[data-theme-mode='light'] {\n /* green / pink / yellow / blue / turquoise darkened onto the off-white card */\n --devaihub-type-skill: #609112;\n --devaihub-type-agent: #e042b2;\n --devaihub-type-hook: #9b8100;\n --devaihub-type-plugin: #4f60d2;\n --devaihub-type-mcp-config: #1d9381;\n --devaihub-type-marketplace: #ef4716;\n\n --devaihub-type-skill-bg: rgba(96, 145, 18, 0.14);\n --devaihub-type-agent-bg: rgba(224, 66, 178, 0.14);\n --devaihub-type-hook-bg: rgba(155, 129, 0, 0.14);\n --devaihub-type-plugin-bg: rgba(79, 96, 210, 0.14);\n --devaihub-type-mcp-config-bg: rgba(29, 147, 129, 0.14);\n --devaihub-type-marketplace-bg: rgba(239, 71, 22, 0.14);\n}\n\n[data-theme-mode='dark'] {\n /* The brand hexes as published — all but blue already clear 3:1 on #252830 */\n --devaihub-type-skill: #6ea514;\n --devaihub-type-agent: #eb84cd;\n --devaihub-type-hook: #fcd200;\n --devaihub-type-plugin: #5d6dd6;\n --devaihub-type-mcp-config: #4bdbc5;\n --devaihub-type-marketplace: #f26b43;\n\n --devaihub-type-skill-bg: rgba(110, 165, 20, 0.2);\n --devaihub-type-agent-bg: rgba(235, 132, 205, 0.2);\n --devaihub-type-hook-bg: rgba(252, 210, 0, 0.2);\n --devaihub-type-plugin-bg: rgba(93, 109, 214, 0.2);\n --devaihub-type-mcp-config-bg: rgba(75, 219, 197, 0.2);\n --devaihub-type-marketplace-bg: rgba(242, 107, 67, 0.2);\n}\n\n/*\n * Stat-tile fills. The tiles are solid blocks of the type's colour, so unlike\n * the accents above they take the NOS brand hex **as published, identically in\n * both themes** — a filled tile carries its own contrast and has no page\n * behind it to react to.\n *\n * This is deliberately not `--devaihub-type-*`. Those values are tuned for\n * legibility *against* a card surface, which in light mode means darkened —\n * filling a tile with the light-mode accent produces the olive/dull row this\n * palette exists to avoid. Same hues, different job, so different tokens.\n *\n * White on these measures 5.34:1 (blue) down to 1.46:1 (yellow) and 1.72:1\n * (turquoise): three of six are below WCAG AA. That is a known trade, made\n * with the alternatives rendered — see ADR-0008. It is contained to this row;\n * every type accent on the cards is AA in both themes.\n */\n:root {\n --devaihub-tile-fg: #ffffff;\n\n /*\n * A uniform ink veil composited over the fill — appearance, not contrast.\n * At 8% the published hex is still plainly itself (yellow #fcd200 renders\n * as #e8c100) while the row loses the glare it had as six blocks of\n * full-strength brand colour, which read hardest against the light theme's\n * off-white page.\n *\n * It buys almost no legibility and is not claimed to: white on yellow goes\n * 1.46:1 -> 1.74:1, against a 3:1 floor. The trade recorded in ADR-0008 is\n * unchanged. Reaching that floor everywhere needs ~30%, and AA ~45%, by\n * which point yellow is #8c7500 and no longer a NOS colour — the outcome\n * that ADR records as rejected twice on sight.\n *\n * The veil is a separate token rather than baked into the hexes below so\n * the fills stay the brand values as published, and so the strength is one\n * number to revisit.\n */\n --devaihub-tile-veil: rgba(0, 0, 0, 0.08);\n\n --devaihub-tile-skill: #6ea514;\n --devaihub-tile-agent: #eb84cd;\n --devaihub-tile-hook: #fcd200;\n --devaihub-tile-plugin: #4f60d2;\n --devaihub-tile-mcp-config: #4bdbc5;\n --devaihub-tile-marketplace: #f26b43;\n}\n\n@layer components {\n .AiResourcesPage_pageRoot__da942a1d83 {\n padding: var(--bui-space-6);\n }\n\n .AiResourcesPage_grid__da942a1d83 {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));\n gap: var(--bui-space-4);\n }\n\n .AiResourcesPage_paginationRow__da942a1d83 {\n justify-content: center;\n margin-top: var(--bui-space-5);\n }\n\n /* Staggered entrance for the initial card grid render only (see\n AiResourcesPage.tsx's playEntrance) — --card-index is set per-card. */\n .AiResourcesPage_cardEntrance__da942a1d83 {\n animation: AiResourcesPage_card-entrance__da942a1d83 320ms cubic-bezier(0.16, 1, 0.3, 1) both;\n animation-delay: calc(var(--card-index, 0) * 40ms);\n }\n\n @keyframes AiResourcesPage_card-entrance__da942a1d83 {\n from {\n opacity: 0;\n transform: translateY(8px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .AiResourcesPage_cardEntrance__da942a1d83 {\n animation: none;\n }\n }\n\n .AiResourcesPage_emptyState__da942a1d83 {\n text-align: center;\n padding: var(--bui-space-9) 0;\n }\n\n .AiResourcesPage_emptyEmoji__da942a1d83 {\n font-size: 40px;\n margin-bottom: var(--bui-space-2);\n animation: AiResourcesPage_empty-emoji-bounce__da942a1d83 700ms cubic-bezier(0.34, 1.56, 0.64, 1);\n }\n\n @keyframes AiResourcesPage_empty-emoji-bounce__da942a1d83 {\n 0% {\n opacity: 0;\n transform: scale(0.4) translateY(-6px);\n }\n 60% {\n opacity: 1;\n transform: scale(1.08) translateY(0);\n }\n 100% {\n transform: scale(1) translateY(0);\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .AiResourcesPage_emptyEmoji__da942a1d83 {\n animation: none;\n }\n }\n}\n";
4
- var styles = {"pageRoot":"AiResourcesPage_pageRoot__da942a1d83","grid":"AiResourcesPage_grid__da942a1d83","paginationRow":"AiResourcesPage_paginationRow__da942a1d83","cardEntrance":"AiResourcesPage_cardEntrance__da942a1d83","emptyState":"AiResourcesPage_emptyState__da942a1d83","emptyEmoji":"AiResourcesPage_emptyEmoji__da942a1d83"};
3
+ var css_248z = "/*\n * The six ResourceType identity colours (`--devaihub-type-*`, ADR-0008) live\n * in `../../theme/typeTokens.module.css`, imported once from `plugin.tsx` so\n * they exist regardless of which page loads first — see that file's header\n * comment. Do not redeclare them here.\n */\n\n/*\n * Stat-tile fills. The tiles are solid blocks of the type's colour, so unlike\n * the accents above they take the NOS brand hex **as published, identically in\n * both themes** — a filled tile carries its own contrast and has no page\n * behind it to react to.\n *\n * This is deliberately not `--devaihub-type-*`. Those values are tuned for\n * legibility *against* a card surface, which in light mode means darkened —\n * filling a tile with the light-mode accent produces the olive/dull row this\n * palette exists to avoid. Same hues, different job, so different tokens.\n *\n * White on these measures 5.34:1 (blue) down to 1.46:1 (yellow) and 1.72:1\n * (turquoise): three of six are below WCAG AA. That is a known trade, made\n * with the alternatives rendered — see ADR-0008. It is contained to this row;\n * every type accent on the cards is AA in both themes.\n */\n:root {\n --devaihub-tile-fg: #ffffff;\n\n /*\n * A uniform ink veil composited over the fill — appearance, not contrast.\n * At 8% the published hex is still plainly itself (yellow #fcd200 renders\n * as #e8c100) while the row loses the glare it had as six blocks of\n * full-strength brand colour, which read hardest against the light theme's\n * off-white page.\n *\n * It buys almost no legibility and is not claimed to: white on yellow goes\n * 1.46:1 -> 1.74:1, against a 3:1 floor. The trade recorded in ADR-0008 is\n * unchanged. Reaching that floor everywhere needs ~30%, and AA ~45%, by\n * which point yellow is #8c7500 and no longer a NOS colour — the outcome\n * that ADR records as rejected twice on sight.\n *\n * The veil is a separate token rather than baked into the hexes below so\n * the fills stay the brand values as published, and so the strength is one\n * number to revisit.\n */\n --devaihub-tile-veil: rgba(0, 0, 0, 0.08);\n\n --devaihub-tile-skill: #6ea514;\n --devaihub-tile-agent: #eb84cd;\n --devaihub-tile-hook: #fcd200;\n --devaihub-tile-plugin: #4f60d2;\n --devaihub-tile-mcp-config: #4bdbc5;\n --devaihub-tile-marketplace: #f26b43;\n}\n\n@layer components {\n .AiResourcesPage_pageRoot__8ec180751b {\n padding: var(--bui-space-6);\n }\n\n .AiResourcesPage_grid__8ec180751b {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));\n gap: var(--bui-space-4);\n }\n\n /* The curated few (ADR-0017/0020), lifted above the paginated grid and\n closed off from the rest by the same hairline BUI's PluginHeader draws\n under the page tabs (`--bui-plugin-header-tabs-border-bottom`). */\n .AiResourcesPage_recommendedSection__8ec180751b {\n padding-bottom: var(--bui-space-6);\n margin-bottom: var(--bui-space-6);\n border-bottom: 1px solid var(--bui-border-1);\n }\n\n /* Uppercase, tracked eyebrow — the typography the card type label and the\n backoffice subsections already use — tinted with the recommended badge's\n warm colour so the heading and the badges on its cards read as one mark. */\n .AiResourcesPage_recommendedHeading__8ec180751b {\n display: flex;\n align-items: center;\n gap: var(--bui-space-2);\n margin: 0 0 var(--bui-space-4);\n font-size: 0.75rem;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n line-height: 1.3;\n color: var(--bui-fg-warning, #92400e);\n }\n\n /* Same pill as the card's recommended badge. */\n .AiResourcesPage_recommendedCount__8ec180751b {\n background: var(--bui-bg-warning, #fff4e5);\n border-radius: var(--bui-radius-full, 999px);\n padding: 1px var(--bui-space-2);\n font-size: 0.6875rem;\n letter-spacing: normal;\n }\n\n .AiResourcesPage_paginationRow__8ec180751b {\n justify-content: center;\n margin-top: var(--bui-space-5);\n }\n\n /* Staggered entrance for the initial card grid render only (see\n AiResourcesPage.tsx's playEntrance) — --card-index is set per-card. */\n .AiResourcesPage_cardEntrance__8ec180751b {\n animation: AiResourcesPage_card-entrance__8ec180751b 320ms cubic-bezier(0.16, 1, 0.3, 1) both;\n animation-delay: calc(var(--card-index, 0) * 40ms);\n }\n\n @keyframes AiResourcesPage_card-entrance__8ec180751b {\n from {\n opacity: 0;\n transform: translateY(8px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .AiResourcesPage_cardEntrance__8ec180751b {\n animation: none;\n }\n }\n\n .AiResourcesPage_emptyState__8ec180751b {\n text-align: center;\n padding: var(--bui-space-9) 0;\n }\n\n .AiResourcesPage_emptyEmoji__8ec180751b {\n font-size: 40px;\n margin-bottom: var(--bui-space-2);\n animation: AiResourcesPage_empty-emoji-bounce__8ec180751b 700ms cubic-bezier(0.34, 1.56, 0.64, 1);\n }\n\n @keyframes AiResourcesPage_empty-emoji-bounce__8ec180751b {\n 0% {\n opacity: 0;\n transform: scale(0.4) translateY(-6px);\n }\n 60% {\n opacity: 1;\n transform: scale(1.08) translateY(0);\n }\n 100% {\n transform: scale(1) translateY(0);\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .AiResourcesPage_emptyEmoji__8ec180751b {\n animation: none;\n }\n }\n}\n";
4
+ var styles = {"pageRoot":"AiResourcesPage_pageRoot__8ec180751b","grid":"AiResourcesPage_grid__8ec180751b","recommendedSection":"AiResourcesPage_recommendedSection__8ec180751b","recommendedHeading":"AiResourcesPage_recommendedHeading__8ec180751b","recommendedCount":"AiResourcesPage_recommendedCount__8ec180751b","paginationRow":"AiResourcesPage_paginationRow__8ec180751b","cardEntrance":"AiResourcesPage_cardEntrance__8ec180751b","emptyState":"AiResourcesPage_emptyState__8ec180751b","emptyEmoji":"AiResourcesPage_emptyEmoji__8ec180751b"};
5
5
  styleInject(css_248z);
6
6
 
7
7
  export { styles as default };
@@ -0,0 +1,9 @@
1
+ import { jsx } from 'react/jsx-runtime';
2
+ import { UNSAFE_LocationContext } from 'react-router-dom';
3
+
4
+ function AppLaunchScope({ children }) {
5
+ return /* @__PURE__ */ jsx(UNSAFE_LocationContext.Provider, { value: null, children });
6
+ }
7
+
8
+ export { AppLaunchScope };
9
+ //# sourceMappingURL=AppLaunchScope.esm.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AppLaunchScope.esm.js","sources":["../../../src/components/AiResourcesPage/AppLaunchScope.tsx"],"sourcesContent":["import type { ReactNode } from 'react';\nimport { UNSAFE_LocationContext as LocationContext } from 'react-router-dom';\n\n/**\n * Scope for buttons that launch a local app through a custom-scheme deep link\n * (`vscode:`, `cursor://`, `claude-cli://`).\n *\n * `@backstage/ui` link components resolve every `href` through React Router\n * unless it is `http(s)://`, `//`, `mailto:` or `tel:`, and since\n * `@remix-run/router` 1.23.4 the router treats `vscode:…` as a route-relative\n * path — the button then points at `https://<backstage>/vscode:…`. BUI only\n * resolves when `useInRouterContext()` is true, so hiding the location\n * context here makes it pass the href through untouched; a custom-scheme\n * anchor is never same-origin, so React Aria leaves the click to the browser,\n * which hands it to the OS protocol handler.\n *\n * Nothing inside may rely on routing — wrap app launchers only.\n */\nexport function AppLaunchScope({ children }: { children: ReactNode }) {\n return (\n <LocationContext.Provider value={null as never}>\n {children}\n </LocationContext.Provider>\n );\n}\n"],"names":["LocationContext"],"mappings":";;;AAkBO,SAAS,cAAA,CAAe,EAAE,QAAA,EAAS,EAA4B;AACpE,EAAA,2BACGA,sBAAA,CAAgB,QAAA,EAAhB,EAAyB,KAAA,EAAO,MAC9B,QAAA,EACH,CAAA;AAEJ;;;;"}