@igstack/app-catalog-frontend-core 0.13.2 → 0.14.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 (23) hide show
  1. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -0
  2. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +2 -2
  3. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
  4. package/dist/esm/modules/appCatalog/ui/components/Highlight.d.ts +8 -0
  5. package/dist/esm/modules/appCatalog/ui/components/Highlight.js +20 -0
  6. package/dist/esm/modules/appCatalog/ui/components/Highlight.js.map +1 -0
  7. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +33 -19
  8. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
  9. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js +22 -9
  10. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js.map +1 -1
  11. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js +26 -9
  12. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js.map +1 -1
  13. package/dist/index.css +10 -0
  14. package/package.json +4 -4
  15. package/src/__tests__/integration/appCatalog.integration.test.ts +43 -0
  16. package/src/__tests__/integration/appDeepLink.integration.test.ts +13 -1
  17. package/src/__tests__/integration/appFreshness.integration.test.ts +15 -0
  18. package/src/index.css +10 -0
  19. package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +2 -2
  20. package/src/modules/appCatalog/ui/components/Highlight.tsx +26 -0
  21. package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +40 -21
  22. package/src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx +9 -0
  23. package/src/modules/appCatalog/ui/launcher/LauncherHome.tsx +26 -9
@@ -1 +1 @@
1
- {"version":3,"file":"LauncherDetailPanel.js","sources":["../../../../../../src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { AppDetails } from '../grid/AppCatalogGrid'\n\n/**\n * Centered detail card for the launcher (#38, item B). Renders the rich\n * AppDetails (access-hero, sub-resources, tiers) as a large centered modal card\n * over the launcher backdrop, instead of the old grid + resizable split-pane.\n *\n * Escape handling: this component does NOT globally bind Escape. Escape-to-close\n * is owned by AppDetails' own key handling (which first closes an open\n * screenshot gallery, then closes the card), so pressing Esc inside the gallery\n * returns to the card — not all the way to the home page (#38 Esc-stacking bug).\n */\nexport function LauncherDetailPanel({\n app,\n onClose,\n onAppClick,\n}: {\n app: Resource\n onClose: () => void\n onAppClick?: (app: Resource) => void\n}) {\n return (\n <div className=\"fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10\">\n {/* scrim */}\n <button\n type=\"button\"\n aria-label=\"Close details panel\"\n onClick={onClose}\n className=\"fixed inset-0 -z-10 bg-black/40 backdrop-blur-[2px] animate-in fade-in\"\n />\n {/* centered card — wide enough for data tables (sub-resources have 5\n columns incl. \"Access Contacts\"/\"AWS Account\" that wrapped at 760px);\n prose inside AppDetails is width-capped separately so it stays\n readable. Caps at 94vw so it never touches the edges. */}\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={`${app.displayName} details`}\n className=\"relative w-full max-w-[min(1120px,94vw)] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200\"\n >\n <div className=\"max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7\">\n <AppDetails\n app={app}\n onAppClick={onAppClick}\n onClosePanel={onClose}\n />\n </div>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":";;AAaO,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AACF,GAIG;AACD,SACE,qBAAC,OAAA,EAAI,WAAU,yFAEb,UAAA;AAAA,IAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,MAAK;AAAA,QACL,cAAW;AAAA,QACX,SAAS;AAAA,QACT,WAAU;AAAA,MAAA;AAAA,IAAA;AAAA,IAMZ;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,MAAK;AAAA,QACL,cAAW;AAAA,QACX,cAAY,GAAG,IAAI,WAAW;AAAA,QAC9B,WAAU;AAAA,QAEV,UAAA,oBAAC,OAAA,EAAI,WAAU,0DACb,UAAA;AAAA,UAAC;AAAA,UAAA;AAAA,YACC;AAAA,YACA;AAAA,YACA,cAAc;AAAA,UAAA;AAAA,QAAA,EAChB,CACF;AAAA,MAAA;AAAA,IAAA;AAAA,EACF,GACF;AAEJ;"}
1
+ {"version":3,"file":"LauncherDetailPanel.js","sources":["../../../../../../src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { X } from 'lucide-react'\nimport { AppDetails } from '../grid/AppCatalogGrid'\n\n/**\n * Centered detail card for the launcher (#38, item B). Renders the rich\n * AppDetails (access-hero, sub-resources, tiers) as a large centered modal card\n * over the launcher backdrop, instead of the old grid + resizable split-pane.\n *\n * Escape handling: this component does NOT globally bind Escape. Escape-to-close\n * is owned by AppDetails' own key handling (which first closes an open\n * screenshot gallery, then closes the card), so pressing Esc inside the gallery\n * returns to the card — not all the way to the home page (#38 Esc-stacking bug).\n */\nexport function LauncherDetailPanel({\n app,\n onClose,\n onAppClick,\n}: {\n app: Resource\n onClose: () => void\n onAppClick?: (app: Resource) => void\n}) {\n return (\n <div className=\"fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10\">\n {/* scrim */}\n <button\n type=\"button\"\n aria-label=\"Close details panel\"\n onClick={onClose}\n className=\"fixed inset-0 -z-10 bg-black/40 backdrop-blur-[2px] animate-in fade-in\"\n />\n {/* centered card — wide enough for data tables (sub-resources have 5\n columns incl. \"Access Contacts\"/\"AWS Account\" that wrapped at 760px);\n prose inside AppDetails is width-capped separately so it stays\n readable. Caps at 94vw so it never touches the edges. */}\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={`${app.displayName} details`}\n className=\"relative w-full max-w-[min(1120px,94vw)] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200\"\n >\n <button\n type=\"button\"\n aria-label=\"Close\"\n onClick={onClose}\n className=\"absolute top-3 right-3 z-10 rounded-full p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors\"\n >\n <X className=\"size-4\" />\n </button>\n <div className=\"max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7\">\n <AppDetails\n app={app}\n onAppClick={onAppClick}\n onClosePanel={onClose}\n />\n </div>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":";;;AAcO,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AACF,GAIG;AACD,SACE,qBAAC,OAAA,EAAI,WAAU,yFAEb,UAAA;AAAA,IAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,MAAK;AAAA,QACL,cAAW;AAAA,QACX,SAAS;AAAA,QACT,WAAU;AAAA,MAAA;AAAA,IAAA;AAAA,IAMZ;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,MAAK;AAAA,QACL,cAAW;AAAA,QACX,cAAY,GAAG,IAAI,WAAW;AAAA,QAC9B,WAAU;AAAA,QAEV,UAAA;AAAA,UAAA;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,MAAK;AAAA,cACL,cAAW;AAAA,cACX,SAAS;AAAA,cACT,WAAU;AAAA,cAEV,UAAA,oBAAC,GAAA,EAAE,WAAU,SAAA,CAAS;AAAA,YAAA;AAAA,UAAA;AAAA,UAExB,oBAAC,OAAA,EAAI,WAAU,0DACb,UAAA;AAAA,YAAC;AAAA,YAAA;AAAA,cACC;AAAA,cACA;AAAA,cACA,cAAc;AAAA,YAAA;AAAA,UAAA,EAChB,CACF;AAAA,QAAA;AAAA,MAAA;AAAA,IAAA;AAAA,EACF,GACF;AAEJ;"}
@@ -1,5 +1,5 @@
1
1
  import { jsxs, jsx, Fragment } from "react/jsx-runtime";
2
- import { Search, ArrowUpRight } from "lucide-react";
2
+ import { Search, X, ArrowUpRight } from "lucide-react";
3
3
  import { useState, useEffect, useMemo, useRef } from "react";
4
4
  import { cn } from "../../../../lib/utils.js";
5
5
  import { useAppClickHistory } from "../../hooks/useAppClickHistory.js";
@@ -7,6 +7,7 @@ import { markdownToPlainText } from "../../utils/markdownToPlainText.js";
7
7
  import { AttributionFooter } from "./AttributionFooter.js";
8
8
  import { ResourceIcon } from "./ResourceIcon.js";
9
9
  import { searchResources } from "../../utils/searchApps.js";
10
+ import { Highlight } from "../components/Highlight.js";
10
11
  const typeLabel = (t) => {
11
12
  if (!t || t === "application") return null;
12
13
  return t.charAt(0).toUpperCase() + t.slice(1);
@@ -173,10 +174,10 @@ function SearchResultsList({
173
174
  /* @__PURE__ */ jsx(ResourceIcon, { app, size: 40 }),
174
175
  /* @__PURE__ */ jsxs("span", { className: "flex-1 min-w-0", children: [
175
176
  /* @__PURE__ */ jsxs("span", { className: "block text-[14px] font-semibold truncate", children: [
176
- app.displayName,
177
+ /* @__PURE__ */ jsx(Highlight, { text: app.displayName, query: searchValue }),
177
178
  app.abbreviation && app.abbreviation !== app.displayName && /* @__PURE__ */ jsxs("span", { className: "ml-1.5 text-[12px] font-normal text-muted-foreground", children: [
178
179
  "(",
179
- app.abbreviation,
180
+ /* @__PURE__ */ jsx(Highlight, { text: app.abbreviation, query: searchValue }),
180
181
  ")"
181
182
  ] })
182
183
  ] }),
@@ -184,16 +185,19 @@ function SearchResultsList({
184
185
  (() => {
185
186
  const kids = matchedChildrenByParent.get(app.slug);
186
187
  if (!kids || kids.length === 0) return null;
187
- const names = kids.map((k) => k.displayName);
188
- const shown = names.slice(0, 3).join(", ");
189
- const extra = names.length > 3 ? ` +${names.length - 3} more` : "";
188
+ const shown = kids.slice(0, 3);
189
+ const extra = kids.length > 3 ? ` +${kids.length - 3} more` : "";
190
190
  return /* @__PURE__ */ jsxs("span", { className: "mt-0.5 block text-[12px] text-primary truncate", children: [
191
191
  "Matched ",
192
192
  kids.length,
193
193
  " sub-resource",
194
194
  kids.length === 1 ? "" : "s",
195
- ": ",
196
- shown,
195
+ ":",
196
+ " ",
197
+ shown.map((k, idx) => /* @__PURE__ */ jsxs("span", { children: [
198
+ idx > 0 && ", ",
199
+ /* @__PURE__ */ jsx(Highlight, { text: k.displayName, query: searchValue })
200
+ ] }, k.slug)),
197
201
  extra
198
202
  ] });
199
203
  })()
@@ -291,7 +295,20 @@ function LauncherHome({
291
295
  className: "flex-1 bg-transparent border-0 outline-none text-[16px] text-foreground placeholder:text-muted-foreground"
292
296
  }
293
297
  ),
294
- !isSearching && /* @__PURE__ */ jsx("kbd", { className: "hidden sm:inline-flex items-center gap-0.5 text-[11px] text-muted-foreground/60 border border-muted-foreground/20 rounded px-1.5 py-0.5 font-mono shrink-0", children: typeof navigator !== "undefined" && navigator.platform.includes("Mac") ? "⌘K" : "Ctrl+K" })
298
+ isSearching ? /* @__PURE__ */ jsx(
299
+ "button",
300
+ {
301
+ type: "button",
302
+ "aria-label": "Clear search",
303
+ onClick: () => {
304
+ var _a;
305
+ onSearchChange("");
306
+ (_a = searchRef.current) == null ? void 0 : _a.focus();
307
+ },
308
+ className: "shrink-0 rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors",
309
+ children: /* @__PURE__ */ jsx(X, { className: "size-4" })
310
+ }
311
+ ) : /* @__PURE__ */ jsx("kbd", { className: "hidden sm:inline-flex items-center gap-0.5 text-[11px] text-muted-foreground/60 border border-muted-foreground/20 rounded px-1.5 py-0.5 font-mono shrink-0", children: typeof navigator !== "undefined" && navigator.platform.includes("Mac") ? "⌘K" : "Ctrl+K" })
295
312
  ] }) })
296
313
  ] }),
297
314
  isSearching && /* @__PURE__ */ jsx(
@@ -1 +1 @@
1
- {"version":3,"file":"LauncherHome.js","sources":["../../../../../../src/modules/appCatalog/ui/launcher/LauncherHome.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { ArrowUpRight, Search } from 'lucide-react'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport { cn } from '~/lib/utils'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { markdownToPlainText } from '../../utils/markdownToPlainText'\nimport { AttributionFooter } from './AttributionFooter'\nimport { ResourceIcon } from './ResourceIcon'\nimport { searchResources } from '../../utils/searchApps'\n\n/**\n * Adaptive-home discovery spine (issue #38, increment 1) — matches the\n * option-a-launcher prototype:\n * Your apps (frequent) → New this week → Browse all\n * Rendered by AppCatalogPage when there is no active search and no open detail.\n * The hero search box drives the same `searchValue` used elsewhere; typing hands\n * off to the results view (increment 2 refines the morph).\n */\n\n// Type pill text. Plain \"application\" resources are the common case and don't\n// need a badge (the user asked not to show a bare \"App\" badge); only surface a\n// pill for distinctive types (database, cloud, service, …).\nconst typeLabel = (t?: string): string | null => {\n if (!t || t === 'application') return null\n return t.charAt(0).toUpperCase() + t.slice(1)\n}\n\nexport interface LauncherHomeProps {\n /** All root (non-child) resources, already filtered for deprecated per settings. */\n apps: Resource[]\n /**\n * All resources INCLUDING children/sub-resources. Passed to search so a query\n * matching a sub-resource (e.g. an AWS account, an alias/account ID) surfaces\n * its parent — preserving the existing cross-sub-resource search behavior.\n * Falls back to `apps` when omitted.\n */\n allResources?: Resource[]\n searchValue: string\n onSearchChange: (v: string) => void\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n /** Total resource count for the \"Browse all\" label. */\n totalCount: number\n}\n\nfunction LaunchButton({\n app,\n onLaunch,\n className,\n}: {\n app: Resource\n onLaunch: (app: Resource) => void\n className?: string\n}) {\n if (!app.appUrl) return null\n // Show the destination URL on hover (user ask) — the native title tooltip\n // reveals where the ↗ jumps to, e.g. \"Open → console.aws.amazon.com\".\n const prettyUrl = app.appUrl.replace(/^https?:\\/\\//, '')\n return (\n <a\n href={app.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n title={`Open → ${prettyUrl}`}\n aria-label={`Open ${app.displayName} in a new tab (${prettyUrl})`}\n onClick={(e) => {\n e.stopPropagation()\n onLaunch(app)\n }}\n className={cn(\n 'grid place-items-center rounded-full border border-border bg-card text-muted-foreground',\n 'hover:bg-primary hover:text-primary-foreground hover:border-primary transition-colors',\n className,\n )}\n >\n <ArrowUpRight className=\"size-3.5\" />\n </a>\n )\n}\n\nfunction SectionHead({\n title,\n count,\n action,\n}: {\n title: string\n count?: string\n action?: React.ReactNode\n}) {\n return (\n <div className=\"flex items-center gap-2.5 mb-4\">\n <h2 className=\"font-serif font-semibold text-[15px] tracking-tight m-0\">\n {title}\n </h2>\n {count && (\n <span className=\"text-[13px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5\">\n {count}\n </span>\n )}\n {action && <div className=\"ml-auto\">{action}</div>}\n </div>\n )\n}\n\n/** Small row used by \"New this week\" and \"Browse all\". */\nfunction ResourceRow({\n app,\n onAppClick,\n onLaunch,\n showNew = false,\n}: {\n app: Resource\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n showNew?: boolean\n}) {\n return (\n <button\n type=\"button\"\n onClick={() => onAppClick(app)}\n title={`View ${app.displayName}`}\n className={cn(\n 'group flex items-center gap-3.5 w-full text-left',\n 'bg-card border border-border rounded-[var(--radius)] px-3.5 py-3',\n 'hover:border-ring hover:shadow-sm transition-all',\n )}\n >\n <ResourceIcon app={app} size={38} />\n <span className=\"flex-1 min-w-0\">\n <span className=\"block text-[14.5px] font-semibold truncate\">\n {app.displayName}\n </span>\n {app.description && (\n <span className=\"block text-[13px] text-muted-foreground truncate\">\n {markdownToPlainText(app.description)}\n </span>\n )}\n </span>\n {showNew && (\n <span className=\"text-[11px] font-bold text-[#65a443] bg-[#65a443]/12 rounded-full px-2.5 py-0.5\">\n New\n </span>\n )}\n {typeLabel(app.type) && (\n <span className=\"text-[12px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap\">\n {typeLabel(app.type)}\n </span>\n )}\n <LaunchButton app={app} onLaunch={onLaunch} className=\"size-8\" />\n </button>\n )\n}\n\n/**\n * Keyboard-navigable search results list — increment 2 of the launcher redesign.\n * Appears below the hero search box when the user types, replacing the\n * discovery spine. Pressing ↑↓ navigates, ↵ opens detail, Esc clears.\n */\nfunction SearchResultsList({\n apps,\n searchValue,\n onAppClick,\n onLaunch,\n onClear,\n}: {\n apps: Resource[]\n searchValue: string\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n onClear: () => void\n}) {\n const [focusedIndex, setFocusedIndex] = useState(-1)\n\n const results = useMemo(\n () => searchResources(apps, searchValue),\n [apps, searchValue],\n )\n\n // For each result, the child/sub-resources of it that themselves match the\n // query — so a parent surfaced only via its children (e.g. AWS Console via a\n // \"biom\" account) tells the user WHICH sub-resources matched, instead of an\n // unexplained parent. Keyed by parent slug.\n const matchedChildrenByParent = useMemo(() => {\n const q = searchValue.trim().toLowerCase()\n const map = new Map<string, Resource[]>()\n if (!q) return map\n const resultSlugs = new Set(results.map((r) => r.slug))\n for (const r of apps) {\n if (!r.parentSlug || !resultSlugs.has(r.parentSlug)) continue\n const hay = `${r.displayName} ${r.abbreviation ?? ''} ${(\n r.aliases ?? []\n ).join(' ')} ${r.description ?? ''}`.toLowerCase()\n if (!hay.includes(q)) continue\n const list = map.get(r.parentSlug) ?? []\n list.push(r)\n map.set(r.parentSlug, list)\n }\n return map\n }, [apps, results, searchValue])\n\n // #11 deprecated fallback: when EVERY match is a deprecated app (i.e. there\n // are no active matches for the query, only deprecated ones), surface a\n // notice so the user understands why they're seeing archived tools. The\n // deprecated apps are already in `results` because the launcher feeds search\n // the full resource set; this just labels the situation.\n const didDeprecatedFallback = useMemo(\n () => results.length > 0 && results.every((r) => Boolean(r.deprecated)),\n [results],\n )\n\n // Reset focus when results change\n useEffect(() => {\n setFocusedIndex(-1)\n }, [searchValue])\n\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (results.length === 0) return\n if (e.key === 'ArrowDown') {\n e.preventDefault()\n setFocusedIndex((i) => Math.min(i + 1, results.length - 1))\n } else if (e.key === 'ArrowUp') {\n e.preventDefault()\n setFocusedIndex((i) => Math.max(i - 1, 0))\n } else if (e.key === 'Enter') {\n if (focusedIndex >= 0 && results[focusedIndex]) {\n onAppClick(results[focusedIndex])\n }\n } else if (e.key === 'Escape') {\n onClear()\n }\n }\n document.addEventListener('keydown', onKey)\n return () => document.removeEventListener('keydown', onKey)\n }, [results, focusedIndex, onAppClick, onClear])\n\n return (\n <div className=\"max-w-[620px] mx-auto mt-3\">\n {/* Meta line */}\n <div className=\"text-[13px] text-muted-foreground mb-2 px-1\">\n {results.length === 0\n ? `No results for \"${searchValue}\"`\n : `${results.length} result${results.length === 1 ? '' : 's'}`}\n </div>\n\n {/* #11: deprecated-only fallback notice */}\n {didDeprecatedFallback && (\n <div\n role=\"status\"\n className=\"text-[13px] text-muted-foreground mb-2 px-1\"\n >\n No active apps for &quot;{searchValue}&quot; — showing deprecated\n matches.\n </div>\n )}\n\n {/* Results */}\n <div\n className=\"flex flex-col gap-1.5\"\n role=\"listbox\"\n aria-label=\"Search results\"\n >\n {results.map((app, i) => (\n <button\n key={app.slug}\n type=\"button\"\n role=\"option\"\n title={`View ${app.displayName}`}\n aria-selected={focusedIndex === i}\n onMouseEnter={() => setFocusedIndex(i)}\n onClick={() => onAppClick(app)}\n className={cn(\n 'group flex items-center gap-3.5 w-full text-left',\n 'bg-card border rounded-[var(--radius)] px-3.5 py-2.5',\n 'transition-all',\n focusedIndex === i\n ? 'border-ring shadow-sm bg-muted/30'\n : 'border-border hover:border-ring hover:shadow-sm',\n )}\n >\n <ResourceIcon app={app} size={40} />\n <span className=\"flex-1 min-w-0\">\n <span className=\"block text-[14px] font-semibold truncate\">\n {app.displayName}\n {app.abbreviation && app.abbreviation !== app.displayName && (\n <span className=\"ml-1.5 text-[12px] font-normal text-muted-foreground\">\n ({app.abbreviation})\n </span>\n )}\n </span>\n {app.description && (\n <span className=\"block text-[12.5px] text-muted-foreground truncate\">\n {markdownToPlainText(app.description)}\n </span>\n )}\n {(() => {\n const kids = matchedChildrenByParent.get(app.slug)\n if (!kids || kids.length === 0) return null\n const names = kids.map((k) => k.displayName)\n const shown = names.slice(0, 3).join(', ')\n const extra =\n names.length > 3 ? ` +${names.length - 3} more` : ''\n return (\n <span className=\"mt-0.5 block text-[12px] text-primary truncate\">\n Matched {kids.length} sub-resource\n {kids.length === 1 ? '' : 's'}: {shown}\n {extra}\n </span>\n )\n })()}\n </span>\n {typeLabel(app.type) && (\n <span className=\"text-[11.5px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap shrink-0\">\n {typeLabel(app.type)}\n </span>\n )}\n <LaunchButton\n app={app}\n onLaunch={onLaunch}\n className=\"size-7 shrink-0 opacity-0 group-hover:opacity-100\"\n />\n </button>\n ))}\n </div>\n\n {/* Keyboard hint */}\n {results.length > 0 && (\n <p className=\"text-[11.5px] text-muted-foreground/60 text-center mt-3\">\n ↑↓ navigate · ↵ view access · esc clear\n </p>\n )}\n </div>\n )\n}\n\nexport function LauncherHome({\n apps,\n allResources,\n searchValue,\n onSearchChange,\n onAppClick,\n onLaunch,\n totalCount,\n}: LauncherHomeProps) {\n const { getTopApps } = useAppClickHistory()\n const [topSlugs, setTopSlugs] = useState<string[]>([])\n\n useEffect(() => {\n void getTopApps(5).then(setTopSlugs)\n }, [getTopApps])\n\n const bySlug = useMemo(() => {\n const m = new Map<string, Resource>()\n for (const a of apps) m.set(a.slug, a)\n return m\n }, [apps])\n\n // Your apps: frequently-clicked (personalized). Empty on a cold new machine.\n const frequent = useMemo(\n () =>\n topSlugs\n .map((s) => bySlug.get(s))\n .filter((a): a is Resource => Boolean(a))\n .slice(0, 5),\n [topSlugs, bySlug],\n )\n\n // New this week: apps whose sources were checked in the last 7 days, as a\n // proxy for \"recently added/updated\" (frontend Resource has no createdAt yet;\n // see #38). Falls back to empty (section hidden) when no freshness data.\n const fresh = useMemo(() => {\n const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000\n return apps\n .filter((a) => {\n const t = a.freshness?.lastCheckedAt\n return t ? new Date(t).getTime() >= weekAgo : false\n })\n .sort((a, b) => {\n const ta = new Date(a.freshness?.lastCheckedAt ?? 0).getTime()\n const tb = new Date(b.freshness?.lastCheckedAt ?? 0).getTime()\n return tb - ta\n })\n .slice(0, 6)\n }, [apps])\n\n // Browse all: everything not already promoted to \"Your apps\", alpha by name.\n const browse = useMemo(() => {\n const promoted = new Set(frequent.map((a) => a.slug))\n return [...apps]\n .filter((a) => !promoted.has(a.slug))\n .sort((a, b) => a.displayName.localeCompare(b.displayName))\n }, [apps, frequent])\n\n const browseLeft = browse.filter((_, i) => i % 2 === 0)\n const browseRight = browse.filter((_, i) => i % 2 === 1)\n\n const isSearching = searchValue.trim() !== ''\n const searchRef = useRef<HTMLInputElement>(null)\n\n // ⌘K / Ctrl+K focuses the hero search box from anywhere on the page.\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault()\n searchRef.current?.focus()\n }\n }\n document.addEventListener('keydown', onKey)\n return () => document.removeEventListener('keydown', onKey)\n }, [])\n\n return (\n <div className=\"mx-auto w-full max-w-[1000px] pb-24\">\n {/* hero */}\n <div className=\"text-center pt-10 pb-1.5\">\n <h1 className=\"font-serif font-semibold text-[30px] tracking-tight m-0 mb-1.5\">\n What do you need to get into?\n </h1>\n <p className=\"text-muted-foreground m-0 mb-6 text-[15px]\">\n Find any tool, see how to request access, or jump straight in.\n </p>\n <div className=\"max-w-[620px] mx-auto\">\n <div className=\"flex items-center gap-3 bg-card border-[1.5px] border-input rounded-full px-5 h-14 shadow-sm focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/15 transition-colors\">\n <Search className=\"size-5 shrink-0 text-muted-foreground\" />\n <input\n type=\"text\"\n value={searchValue}\n onChange={(e) => onSearchChange(e.target.value)}\n placeholder=\"Search apps, databases, cloud accounts, account IDs…\"\n autoComplete=\"off\"\n autoFocus\n ref={searchRef}\n aria-label=\"Search apps\"\n className=\"flex-1 bg-transparent border-0 outline-none text-[16px] text-foreground placeholder:text-muted-foreground\"\n />\n {!isSearching && (\n <kbd className=\"hidden sm:inline-flex items-center gap-0.5 text-[11px] text-muted-foreground/60 border border-muted-foreground/20 rounded px-1.5 py-0.5 font-mono shrink-0\">\n {typeof navigator !== 'undefined' &&\n navigator.platform.includes('Mac')\n ? '⌘K'\n : 'Ctrl+K'}\n </kbd>\n )}\n </div>\n </div>\n </div>\n\n {/* Search morph: when typing, show compact results list instead of the discovery spine */}\n {isSearching && (\n <SearchResultsList\n apps={allResources ?? apps}\n searchValue={searchValue}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n onClear={() => onSearchChange('')}\n />\n )}\n\n {/* Discovery spine: only shown when not searching */}\n {!isSearching && (\n <>\n {/* Your apps */}\n {frequent.length > 0 && (\n <section className=\"mt-9\">\n <SectionHead title=\"Your apps\" count=\"frequent\" />\n <div className=\"grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3.5\">\n {frequent.map((app) => (\n <button\n type=\"button\"\n key={app.slug}\n onClick={() => onAppClick(app)}\n title={`View ${app.displayName}`}\n className=\"group relative bg-card border border-border rounded-[var(--radius)] px-3.5 pt-[18px] pb-4 text-center shadow-sm hover:-translate-y-0.5 hover:shadow-md transition-all\"\n >\n <LaunchButton\n app={app}\n onLaunch={onLaunch}\n className=\"absolute top-2.5 right-2.5 size-[26px] opacity-0 group-hover:opacity-100\"\n />\n <ResourceIcon\n app={app}\n size={46}\n className=\"mx-auto mb-3\"\n />\n <span className=\"block text-[13.5px] font-bold truncate\">\n {app.displayName}\n </span>\n </button>\n ))}\n </div>\n </section>\n )}\n\n {/* New this week */}\n {fresh.length > 0 && (\n <section className=\"mt-9\">\n <SectionHead\n title=\"New this week\"\n count={`${fresh.length} added`}\n />\n <div className=\"flex flex-col gap-2\">\n {fresh.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n showNew\n />\n ))}\n </div>\n </section>\n )}\n\n {/* Browse all */}\n <section className=\"mt-9\">\n <SectionHead title=\"Browse all\" count={`${totalCount} resources`} />\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-2\">\n <div className=\"flex flex-col gap-2\">\n {browseLeft.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n />\n ))}\n </div>\n <div className=\"flex flex-col gap-2\">\n {browseRight.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n />\n ))}\n </div>\n </div>\n </section>\n\n <AttributionFooter />\n </>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"LauncherHome.js","sources":["../../../../../../src/modules/appCatalog/ui/launcher/LauncherHome.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { ArrowUpRight, Search, X } from 'lucide-react'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport { cn } from '~/lib/utils'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { markdownToPlainText } from '../../utils/markdownToPlainText'\nimport { AttributionFooter } from './AttributionFooter'\nimport { ResourceIcon } from './ResourceIcon'\nimport { searchResources } from '../../utils/searchApps'\nimport { Highlight } from '../components/Highlight'\n\n/**\n * Adaptive-home discovery spine (issue #38, increment 1) — matches the\n * option-a-launcher prototype:\n * Your apps (frequent) → New this week → Browse all\n * Rendered by AppCatalogPage when there is no active search and no open detail.\n * The hero search box drives the same `searchValue` used elsewhere; typing hands\n * off to the results view (increment 2 refines the morph).\n */\n\n// Type pill text. Plain \"application\" resources are the common case and don't\n// need a badge (the user asked not to show a bare \"App\" badge); only surface a\n// pill for distinctive types (database, cloud, service, …).\nconst typeLabel = (t?: string): string | null => {\n if (!t || t === 'application') return null\n return t.charAt(0).toUpperCase() + t.slice(1)\n}\n\nexport interface LauncherHomeProps {\n /** All root (non-child) resources, already filtered for deprecated per settings. */\n apps: Resource[]\n /**\n * All resources INCLUDING children/sub-resources. Passed to search so a query\n * matching a sub-resource (e.g. an AWS account, an alias/account ID) surfaces\n * its parent — preserving the existing cross-sub-resource search behavior.\n * Falls back to `apps` when omitted.\n */\n allResources?: Resource[]\n searchValue: string\n onSearchChange: (v: string) => void\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n /** Total resource count for the \"Browse all\" label. */\n totalCount: number\n}\n\nfunction LaunchButton({\n app,\n onLaunch,\n className,\n}: {\n app: Resource\n onLaunch: (app: Resource) => void\n className?: string\n}) {\n if (!app.appUrl) return null\n // Show the destination URL on hover (user ask) — the native title tooltip\n // reveals where the ↗ jumps to, e.g. \"Open → console.aws.amazon.com\".\n const prettyUrl = app.appUrl.replace(/^https?:\\/\\//, '')\n return (\n <a\n href={app.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n title={`Open → ${prettyUrl}`}\n aria-label={`Open ${app.displayName} in a new tab (${prettyUrl})`}\n onClick={(e) => {\n e.stopPropagation()\n onLaunch(app)\n }}\n className={cn(\n 'grid place-items-center rounded-full border border-border bg-card text-muted-foreground',\n 'hover:bg-primary hover:text-primary-foreground hover:border-primary transition-colors',\n className,\n )}\n >\n <ArrowUpRight className=\"size-3.5\" />\n </a>\n )\n}\n\nfunction SectionHead({\n title,\n count,\n action,\n}: {\n title: string\n count?: string\n action?: React.ReactNode\n}) {\n return (\n <div className=\"flex items-center gap-2.5 mb-4\">\n <h2 className=\"font-serif font-semibold text-[15px] tracking-tight m-0\">\n {title}\n </h2>\n {count && (\n <span className=\"text-[13px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5\">\n {count}\n </span>\n )}\n {action && <div className=\"ml-auto\">{action}</div>}\n </div>\n )\n}\n\n/** Small row used by \"New this week\" and \"Browse all\". */\nfunction ResourceRow({\n app,\n onAppClick,\n onLaunch,\n showNew = false,\n}: {\n app: Resource\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n showNew?: boolean\n}) {\n return (\n <button\n type=\"button\"\n onClick={() => onAppClick(app)}\n title={`View ${app.displayName}`}\n className={cn(\n 'group flex items-center gap-3.5 w-full text-left',\n 'bg-card border border-border rounded-[var(--radius)] px-3.5 py-3',\n 'hover:border-ring hover:shadow-sm transition-all',\n )}\n >\n <ResourceIcon app={app} size={38} />\n <span className=\"flex-1 min-w-0\">\n <span className=\"block text-[14.5px] font-semibold truncate\">\n {app.displayName}\n </span>\n {app.description && (\n <span className=\"block text-[13px] text-muted-foreground truncate\">\n {markdownToPlainText(app.description)}\n </span>\n )}\n </span>\n {showNew && (\n <span className=\"text-[11px] font-bold text-[#65a443] bg-[#65a443]/12 rounded-full px-2.5 py-0.5\">\n New\n </span>\n )}\n {typeLabel(app.type) && (\n <span className=\"text-[12px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap\">\n {typeLabel(app.type)}\n </span>\n )}\n <LaunchButton app={app} onLaunch={onLaunch} className=\"size-8\" />\n </button>\n )\n}\n\n/**\n * Keyboard-navigable search results list — increment 2 of the launcher redesign.\n * Appears below the hero search box when the user types, replacing the\n * discovery spine. Pressing ↑↓ navigates, ↵ opens detail, Esc clears.\n */\nfunction SearchResultsList({\n apps,\n searchValue,\n onAppClick,\n onLaunch,\n onClear,\n}: {\n apps: Resource[]\n searchValue: string\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n onClear: () => void\n}) {\n const [focusedIndex, setFocusedIndex] = useState(-1)\n\n const results = useMemo(\n () => searchResources(apps, searchValue),\n [apps, searchValue],\n )\n\n // For each result, the child/sub-resources of it that themselves match the\n // query — so a parent surfaced only via its children (e.g. AWS Console via a\n // \"biom\" account) tells the user WHICH sub-resources matched, instead of an\n // unexplained parent. Keyed by parent slug.\n const matchedChildrenByParent = useMemo(() => {\n const q = searchValue.trim().toLowerCase()\n const map = new Map<string, Resource[]>()\n if (!q) return map\n const resultSlugs = new Set(results.map((r) => r.slug))\n for (const r of apps) {\n if (!r.parentSlug || !resultSlugs.has(r.parentSlug)) continue\n const hay = `${r.displayName} ${r.abbreviation ?? ''} ${(\n r.aliases ?? []\n ).join(' ')} ${r.description ?? ''}`.toLowerCase()\n if (!hay.includes(q)) continue\n const list = map.get(r.parentSlug) ?? []\n list.push(r)\n map.set(r.parentSlug, list)\n }\n return map\n }, [apps, results, searchValue])\n\n // #11 deprecated fallback: when EVERY match is a deprecated app (i.e. there\n // are no active matches for the query, only deprecated ones), surface a\n // notice so the user understands why they're seeing archived tools. The\n // deprecated apps are already in `results` because the launcher feeds search\n // the full resource set; this just labels the situation.\n const didDeprecatedFallback = useMemo(\n () => results.length > 0 && results.every((r) => Boolean(r.deprecated)),\n [results],\n )\n\n // Reset focus when results change\n useEffect(() => {\n setFocusedIndex(-1)\n }, [searchValue])\n\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (results.length === 0) return\n if (e.key === 'ArrowDown') {\n e.preventDefault()\n setFocusedIndex((i) => Math.min(i + 1, results.length - 1))\n } else if (e.key === 'ArrowUp') {\n e.preventDefault()\n setFocusedIndex((i) => Math.max(i - 1, 0))\n } else if (e.key === 'Enter') {\n if (focusedIndex >= 0 && results[focusedIndex]) {\n onAppClick(results[focusedIndex])\n }\n } else if (e.key === 'Escape') {\n onClear()\n }\n }\n document.addEventListener('keydown', onKey)\n return () => document.removeEventListener('keydown', onKey)\n }, [results, focusedIndex, onAppClick, onClear])\n\n return (\n <div className=\"max-w-[620px] mx-auto mt-3\">\n {/* Meta line */}\n <div className=\"text-[13px] text-muted-foreground mb-2 px-1\">\n {results.length === 0\n ? `No results for \"${searchValue}\"`\n : `${results.length} result${results.length === 1 ? '' : 's'}`}\n </div>\n\n {/* #11: deprecated-only fallback notice */}\n {didDeprecatedFallback && (\n <div\n role=\"status\"\n className=\"text-[13px] text-muted-foreground mb-2 px-1\"\n >\n No active apps for &quot;{searchValue}&quot; — showing deprecated\n matches.\n </div>\n )}\n\n {/* Results */}\n <div\n className=\"flex flex-col gap-1.5\"\n role=\"listbox\"\n aria-label=\"Search results\"\n >\n {results.map((app, i) => (\n <button\n key={app.slug}\n type=\"button\"\n role=\"option\"\n title={`View ${app.displayName}`}\n aria-selected={focusedIndex === i}\n onMouseEnter={() => setFocusedIndex(i)}\n onClick={() => onAppClick(app)}\n className={cn(\n 'group flex items-center gap-3.5 w-full text-left',\n 'bg-card border rounded-[var(--radius)] px-3.5 py-2.5',\n 'transition-all',\n focusedIndex === i\n ? 'border-ring shadow-sm bg-muted/30'\n : 'border-border hover:border-ring hover:shadow-sm',\n )}\n >\n <ResourceIcon app={app} size={40} />\n <span className=\"flex-1 min-w-0\">\n <span className=\"block text-[14px] font-semibold truncate\">\n <Highlight text={app.displayName} query={searchValue} />\n {app.abbreviation && app.abbreviation !== app.displayName && (\n <span className=\"ml-1.5 text-[12px] font-normal text-muted-foreground\">\n (<Highlight text={app.abbreviation} query={searchValue} />)\n </span>\n )}\n </span>\n {app.description && (\n <span className=\"block text-[12.5px] text-muted-foreground truncate\">\n {markdownToPlainText(app.description)}\n </span>\n )}\n {(() => {\n const kids = matchedChildrenByParent.get(app.slug)\n if (!kids || kids.length === 0) return null\n const shown = kids.slice(0, 3)\n const extra = kids.length > 3 ? ` +${kids.length - 3} more` : ''\n return (\n <span className=\"mt-0.5 block text-[12px] text-primary truncate\">\n Matched {kids.length} sub-resource\n {kids.length === 1 ? '' : 's'}:{' '}\n {shown.map((k, idx) => (\n <span key={k.slug}>\n {idx > 0 && ', '}\n <Highlight text={k.displayName} query={searchValue} />\n </span>\n ))}\n {extra}\n </span>\n )\n })()}\n </span>\n {typeLabel(app.type) && (\n <span className=\"text-[11.5px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap shrink-0\">\n {typeLabel(app.type)}\n </span>\n )}\n <LaunchButton\n app={app}\n onLaunch={onLaunch}\n className=\"size-7 shrink-0 opacity-0 group-hover:opacity-100\"\n />\n </button>\n ))}\n </div>\n\n {/* Keyboard hint */}\n {results.length > 0 && (\n <p className=\"text-[11.5px] text-muted-foreground/60 text-center mt-3\">\n ↑↓ navigate · ↵ view access · esc clear\n </p>\n )}\n </div>\n )\n}\n\nexport function LauncherHome({\n apps,\n allResources,\n searchValue,\n onSearchChange,\n onAppClick,\n onLaunch,\n totalCount,\n}: LauncherHomeProps) {\n const { getTopApps } = useAppClickHistory()\n const [topSlugs, setTopSlugs] = useState<string[]>([])\n\n useEffect(() => {\n void getTopApps(5).then(setTopSlugs)\n }, [getTopApps])\n\n const bySlug = useMemo(() => {\n const m = new Map<string, Resource>()\n for (const a of apps) m.set(a.slug, a)\n return m\n }, [apps])\n\n // Your apps: frequently-clicked (personalized). Empty on a cold new machine.\n const frequent = useMemo(\n () =>\n topSlugs\n .map((s) => bySlug.get(s))\n .filter((a): a is Resource => Boolean(a))\n .slice(0, 5),\n [topSlugs, bySlug],\n )\n\n // New this week: apps whose sources were checked in the last 7 days, as a\n // proxy for \"recently added/updated\" (frontend Resource has no createdAt yet;\n // see #38). Falls back to empty (section hidden) when no freshness data.\n const fresh = useMemo(() => {\n const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000\n return apps\n .filter((a) => {\n const t = a.freshness?.lastCheckedAt\n return t ? new Date(t).getTime() >= weekAgo : false\n })\n .sort((a, b) => {\n const ta = new Date(a.freshness?.lastCheckedAt ?? 0).getTime()\n const tb = new Date(b.freshness?.lastCheckedAt ?? 0).getTime()\n return tb - ta\n })\n .slice(0, 6)\n }, [apps])\n\n // Browse all: everything not already promoted to \"Your apps\", alpha by name.\n const browse = useMemo(() => {\n const promoted = new Set(frequent.map((a) => a.slug))\n return [...apps]\n .filter((a) => !promoted.has(a.slug))\n .sort((a, b) => a.displayName.localeCompare(b.displayName))\n }, [apps, frequent])\n\n const browseLeft = browse.filter((_, i) => i % 2 === 0)\n const browseRight = browse.filter((_, i) => i % 2 === 1)\n\n const isSearching = searchValue.trim() !== ''\n const searchRef = useRef<HTMLInputElement>(null)\n\n // ⌘K / Ctrl+K focuses the hero search box from anywhere on the page.\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault()\n searchRef.current?.focus()\n }\n }\n document.addEventListener('keydown', onKey)\n return () => document.removeEventListener('keydown', onKey)\n }, [])\n\n return (\n <div className=\"mx-auto w-full max-w-[1000px] pb-24\">\n {/* hero */}\n <div className=\"text-center pt-10 pb-1.5\">\n <h1 className=\"font-serif font-semibold text-[30px] tracking-tight m-0 mb-1.5\">\n What do you need to get into?\n </h1>\n <p className=\"text-muted-foreground m-0 mb-6 text-[15px]\">\n Find any tool, see how to request access, or jump straight in.\n </p>\n <div className=\"max-w-[620px] mx-auto\">\n <div className=\"flex items-center gap-3 bg-card border-[1.5px] border-input rounded-full px-5 h-14 shadow-sm focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/15 transition-colors\">\n <Search className=\"size-5 shrink-0 text-muted-foreground\" />\n <input\n type=\"text\"\n value={searchValue}\n onChange={(e) => onSearchChange(e.target.value)}\n placeholder=\"Search apps, databases, cloud accounts, account IDs…\"\n autoComplete=\"off\"\n autoFocus\n ref={searchRef}\n aria-label=\"Search apps\"\n className=\"flex-1 bg-transparent border-0 outline-none text-[16px] text-foreground placeholder:text-muted-foreground\"\n />\n {isSearching ? (\n <button\n type=\"button\"\n aria-label=\"Clear search\"\n onClick={() => {\n onSearchChange('')\n searchRef.current?.focus()\n }}\n className=\"shrink-0 rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors\"\n >\n <X className=\"size-4\" />\n </button>\n ) : (\n <kbd className=\"hidden sm:inline-flex items-center gap-0.5 text-[11px] text-muted-foreground/60 border border-muted-foreground/20 rounded px-1.5 py-0.5 font-mono shrink-0\">\n {typeof navigator !== 'undefined' &&\n navigator.platform.includes('Mac')\n ? '⌘K'\n : 'Ctrl+K'}\n </kbd>\n )}\n </div>\n </div>\n </div>\n\n {/* Search morph: when typing, show compact results list instead of the discovery spine */}\n {isSearching && (\n <SearchResultsList\n apps={allResources ?? apps}\n searchValue={searchValue}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n onClear={() => onSearchChange('')}\n />\n )}\n\n {/* Discovery spine: only shown when not searching */}\n {!isSearching && (\n <>\n {/* Your apps */}\n {frequent.length > 0 && (\n <section className=\"mt-9\">\n <SectionHead title=\"Your apps\" count=\"frequent\" />\n <div className=\"grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3.5\">\n {frequent.map((app) => (\n <button\n type=\"button\"\n key={app.slug}\n onClick={() => onAppClick(app)}\n title={`View ${app.displayName}`}\n className=\"group relative bg-card border border-border rounded-[var(--radius)] px-3.5 pt-[18px] pb-4 text-center shadow-sm hover:-translate-y-0.5 hover:shadow-md transition-all\"\n >\n <LaunchButton\n app={app}\n onLaunch={onLaunch}\n className=\"absolute top-2.5 right-2.5 size-[26px] opacity-0 group-hover:opacity-100\"\n />\n <ResourceIcon\n app={app}\n size={46}\n className=\"mx-auto mb-3\"\n />\n <span className=\"block text-[13.5px] font-bold truncate\">\n {app.displayName}\n </span>\n </button>\n ))}\n </div>\n </section>\n )}\n\n {/* New this week */}\n {fresh.length > 0 && (\n <section className=\"mt-9\">\n <SectionHead\n title=\"New this week\"\n count={`${fresh.length} added`}\n />\n <div className=\"flex flex-col gap-2\">\n {fresh.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n showNew\n />\n ))}\n </div>\n </section>\n )}\n\n {/* Browse all */}\n <section className=\"mt-9\">\n <SectionHead title=\"Browse all\" count={`${totalCount} resources`} />\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-2\">\n <div className=\"flex flex-col gap-2\">\n {browseLeft.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n />\n ))}\n </div>\n <div className=\"flex flex-col gap-2\">\n {browseRight.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n />\n ))}\n </div>\n </div>\n </section>\n\n <AttributionFooter />\n </>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
package/dist/index.css CHANGED
@@ -230,3 +230,13 @@
230
230
  transform: scale(1);
231
231
  }
232
232
  }
233
+
234
+ /* Tailwind preflight resets list-style to none. Re-enable for markdown prose. */
235
+ .prose ol {
236
+ list-style-type: decimal;
237
+ padding-left: 1.25rem;
238
+ }
239
+ .prose ul {
240
+ list-style-type: disc;
241
+ padding-left: 1.25rem;
242
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "0.13.2",
3
+ "version": "0.14.0",
4
4
  "description": "Frontend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -134,8 +134,8 @@
134
134
  "vite-plugin-static-copy": "^3.1.4",
135
135
  "vite-plugin-svgr": "^4.2.0",
136
136
  "vitest": "^4.1.2",
137
- "@igstack/app-catalog-backend-core": "0.13.2",
138
- "@igstack/app-catalog-shared-core": "0.13.2"
137
+ "@igstack/app-catalog-shared-core": "0.14.0",
138
+ "@igstack/app-catalog-backend-core": "0.14.0"
139
139
  },
140
140
  "peerDependencies": {
141
141
  "react": "19.1.2",
@@ -144,7 +144,7 @@
144
144
  "vite": "^6.3.5",
145
145
  "vite-plugin-svgr": "^4.2.0"
146
146
  },
147
- "gitHead": "a3c03065868eef8cc3a825628688d86b5b462e92",
147
+ "gitHead": "f7e30b3da50d80d10ed19ddd3be273329495bdcb",
148
148
  "scripts": {
149
149
  "build": "vite build",
150
150
  "build:lenient": "vite build --mode lenient",
@@ -328,4 +328,47 @@ describe('App Catalog Integration', () => {
328
328
  /(?<![\d])0(?![\d]).*clear filters/i,
329
329
  )
330
330
  })
331
+
332
+ it('highlights matched query text in search result app names (#57)', async () => {
333
+ const { ui } = await given(
334
+ magazine.custom(({ backendCfg }) => {
335
+ backendCfg.withApp({ displayName: 'Kubernetes Platform' })
336
+ backendCfg.withApp({ displayName: 'Other Tool' })
337
+ }),
338
+ )
339
+
340
+ await ui.catalog.search('kube')
341
+
342
+ await waitFor(() => {
343
+ const marks = document.querySelectorAll('mark')
344
+ const markTexts = [...marks].map((m) => String(m.textContent))
345
+ expect(markTexts.some((t) => t.toLowerCase().includes('kube'))).toBe(true)
346
+ })
347
+ })
348
+
349
+ it('clear (×) button appears when search has text and clears on click (#54)', async () => {
350
+ const { ui } = await given(magazine.full())
351
+
352
+ // Before typing: no clear button
353
+ expect(
354
+ document.querySelector('[aria-label="Clear search"]'),
355
+ ).not.toBeInTheDocument()
356
+
357
+ // After typing: clear button appears
358
+ await ui.catalog.search('jira')
359
+ await waitFor(() => {
360
+ expect(
361
+ document.querySelector('[aria-label="Clear search"]'),
362
+ ).toBeInTheDocument()
363
+ })
364
+
365
+ // Click × — input clears, button disappears
366
+ fireEvent.click(document.querySelector('[aria-label="Clear search"]')!)
367
+ await waitFor(() => {
368
+ expect(ui.catalog.getSearchInput().value).toBe('')
369
+ expect(
370
+ document.querySelector('[aria-label="Clear search"]'),
371
+ ).not.toBeInTheDocument()
372
+ })
373
+ })
331
374
  })
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { fireEvent, waitFor } from '@testing-library/react'
2
+ import { fireEvent, screen, waitFor } from '@testing-library/react'
3
3
  import '@testing-library/jest-dom/vitest'
4
4
 
5
5
  import { given } from './harness/given'
@@ -45,6 +45,18 @@ describe('App deep-link routing', () => {
45
45
  expect(router.state.location.pathname).toBe('/')
46
46
  })
47
47
 
48
+ it('closes the detail panel when the × close button is clicked (#59)', async () => {
49
+ const { ui, router } = await given(magazine.full(), { initialRoute: '/' })
50
+ await ui.catalog.openApp('Jira')
51
+ await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(true))
52
+ const closeBtn = screen.getByRole('button', { name: /^Close$/i })
53
+ fireEvent.click(closeBtn)
54
+ await waitFor(() => {
55
+ expect(ui.catalog.isDetailPanelOpen()).toBe(false)
56
+ })
57
+ expect(router.state.location.pathname).toBe('/')
58
+ })
59
+
48
60
  it('ignores legacy ?app= query param (no detail opens)', async () => {
49
61
  const { ui } = await given(magazine.full(), { initialRoute: '/?app=jira' })
50
62
  await waitFor(() => {
@@ -45,6 +45,21 @@ describe('App freshness line', () => {
45
45
  expect(screen.getByText(/may be out of date/i)).toBeInTheDocument()
46
46
  })
47
47
 
48
+ it('shows "Added" date before Sources when createdAt is present (#55)', async () => {
49
+ const { ui } = await given(
50
+ magazine.full(({ backendCfg }) => {
51
+ backendCfg.withApp({
52
+ slug: 'dated-app',
53
+ displayName: 'Dated App',
54
+ createdAt: iso(30 * DAY),
55
+ })
56
+ }),
57
+ { initialRoute: '/app/dated-app' },
58
+ )
59
+ await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(true))
60
+ expect(screen.getByText(/Added/i)).toBeInTheDocument()
61
+ })
62
+
48
63
  it('shows no freshness line when the app was never scanned', async () => {
49
64
  const { ui } = await given(
50
65
  magazine.full(({ backendCfg }) => {
package/src/index.css CHANGED
@@ -230,3 +230,13 @@
230
230
  transform: scale(1);
231
231
  }
232
232
  }
233
+
234
+ /* Tailwind preflight resets list-style to none. Re-enable for markdown prose. */
235
+ .prose ol {
236
+ list-style-type: decimal;
237
+ padding-left: 1.25rem;
238
+ }
239
+ .prose ul {
240
+ list-style-type: disc;
241
+ padding-left: 1.25rem;
242
+ }
@@ -240,7 +240,7 @@ export function AccessRequestSection({
240
240
 
241
241
  {/* Comments */}
242
242
  {accessRequest.comments && (
243
- <div className="text-sm text-muted-foreground prose prose-sm max-w-none [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5">
243
+ <div className="text-sm text-muted-foreground prose prose-sm max-w-none">
244
244
  <ReactMarkdown components={{ a: MarkdownLink }}>
245
245
  {accessRequest.comments}
246
246
  </ReactMarkdown>
@@ -326,7 +326,7 @@ export function AccessRequestSection({
326
326
  Post-Approval Instructions
327
327
  </AccordionTrigger>
328
328
  <AccordionContent className="pb-3">
329
- <div className="text-sm text-muted-foreground prose prose-sm max-w-none [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5">
329
+ <div className="text-sm text-muted-foreground prose prose-sm max-w-none">
330
330
  <ReactMarkdown components={{ a: MarkdownLink }}>
331
331
  {accessRequest.postApprovalInstructions}
332
332
  </ReactMarkdown>
@@ -0,0 +1,26 @@
1
+ import { highlightText } from '../../utils/searchApps'
2
+
3
+ /**
4
+ * Highlights all case-insensitive occurrences of `query` in `text`.
5
+ * Renders plain text when query is empty or has no match.
6
+ */
7
+ export function Highlight({ text, query }: { text: string; query: string }) {
8
+ if (!query.trim()) return <>{text}</>
9
+ const segments = highlightText(text, query)
10
+ return (
11
+ <>
12
+ {segments.map((seg, i) =>
13
+ seg.highlight ? (
14
+ <mark
15
+ key={i}
16
+ className="bg-primary/15 text-primary rounded-[2px] px-[1px] font-semibold not-italic"
17
+ >
18
+ {seg.text}
19
+ </mark>
20
+ ) : (
21
+ seg.text
22
+ ),
23
+ )}
24
+ </>
25
+ )
26
+ }
@@ -8,7 +8,15 @@ import {
8
8
  getCoreRowModel,
9
9
  useReactTable,
10
10
  } from '@tanstack/react-table'
11
- import { AppWindow, Clock, ExternalLink, Plus, Trash2, X } from 'lucide-react'
11
+ import {
12
+ AppWindow,
13
+ CalendarPlus,
14
+ Clock,
15
+ ExternalLink,
16
+ Plus,
17
+ Trash2,
18
+ X,
19
+ } from 'lucide-react'
12
20
  import React, { useState } from 'react'
13
21
  import { useHotkeys } from 'react-hotkeys-hook'
14
22
  import { cn } from '~/lib/utils'
@@ -356,26 +364,7 @@ export function AppDetails({
356
364
  <span className="text-muted-foreground">—</span>
357
365
  )}
358
366
  </div>
359
- {/* Freshness date near the header (this is the same signal that
360
- drives "New this week"), so the date is visible in the card
361
- itself, not only in the muted footer. */}
362
- {app.freshness?.lastCheckedAt && (
363
- <div className="mt-1 px-3">
364
- <span
365
- className="inline-flex items-center gap-1 text-xs text-muted-foreground"
366
- title={app.freshness.lastCheckedAt}
367
- >
368
- <Clock className="size-3" />
369
- Updated {formatRelativeTime(app.freshness.lastCheckedAt)}
370
- {app.freshness.isStale && (
371
- <span className="italic opacity-75">
372
- {' '}
373
- · may be out of date
374
- </span>
375
- )}
376
- </span>
377
- </div>
378
- )}
367
+ {/* Updated/Added metadata moved to consolidated section before Sources (#55) */}
379
368
  </div>
380
369
  </div>
381
370
  </div>
@@ -536,6 +525,36 @@ export function AppDetails({
536
525
  </div>
537
526
  )}
538
527
 
528
+ {/* Metadata: Added / Updated */}
529
+ {(app.createdAt || app.freshness?.lastCheckedAt) && (
530
+ <div className="mt-6 flex flex-wrap gap-x-5 gap-y-1">
531
+ {app.createdAt && (
532
+ <span
533
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground"
534
+ title={app.createdAt}
535
+ >
536
+ <CalendarPlus className="size-3" />
537
+ Added {formatRelativeTime(app.createdAt)}
538
+ </span>
539
+ )}
540
+ {app.freshness?.lastCheckedAt && (
541
+ <span
542
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground"
543
+ title={app.freshness.lastCheckedAt}
544
+ >
545
+ <Clock className="size-3" />
546
+ Updated {formatRelativeTime(app.freshness.lastCheckedAt)}
547
+ {app.freshness.isStale && (
548
+ <span className="italic opacity-75">
549
+ {' '}
550
+ · may be out of date
551
+ </span>
552
+ )}
553
+ </span>
554
+ )}
555
+ </div>
556
+ )}
557
+
539
558
  {/* Sources */}
540
559
  <div className="mt-6">
541
560
  <h3 className="mb-2 text-sm font-medium">Sources</h3>
@@ -1,4 +1,5 @@
1
1
  import type { Resource } from '@igstack/app-catalog-backend-core'
2
+ import { X } from 'lucide-react'
2
3
  import { AppDetails } from '../grid/AppCatalogGrid'
3
4
 
4
5
  /**
@@ -39,6 +40,14 @@ export function LauncherDetailPanel({
39
40
  aria-label={`${app.displayName} details`}
40
41
  className="relative w-full max-w-[min(1120px,94vw)] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200"
41
42
  >
43
+ <button
44
+ type="button"
45
+ aria-label="Close"
46
+ onClick={onClose}
47
+ className="absolute top-3 right-3 z-10 rounded-full p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
48
+ >
49
+ <X className="size-4" />
50
+ </button>
42
51
  <div className="max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7">
43
52
  <AppDetails
44
53
  app={app}
@@ -1,5 +1,5 @@
1
1
  import type { Resource } from '@igstack/app-catalog-backend-core'
2
- import { ArrowUpRight, Search } from 'lucide-react'
2
+ import { ArrowUpRight, Search, X } from 'lucide-react'
3
3
  import { useEffect, useMemo, useRef, useState } from 'react'
4
4
  import { cn } from '~/lib/utils'
5
5
  import { useAppClickHistory } from '../../hooks/useAppClickHistory'
@@ -7,6 +7,7 @@ import { markdownToPlainText } from '../../utils/markdownToPlainText'
7
7
  import { AttributionFooter } from './AttributionFooter'
8
8
  import { ResourceIcon } from './ResourceIcon'
9
9
  import { searchResources } from '../../utils/searchApps'
10
+ import { Highlight } from '../components/Highlight'
10
11
 
11
12
  /**
12
13
  * Adaptive-home discovery spine (issue #38, increment 1) — matches the
@@ -281,10 +282,10 @@ function SearchResultsList({
281
282
  <ResourceIcon app={app} size={40} />
282
283
  <span className="flex-1 min-w-0">
283
284
  <span className="block text-[14px] font-semibold truncate">
284
- {app.displayName}
285
+ <Highlight text={app.displayName} query={searchValue} />
285
286
  {app.abbreviation && app.abbreviation !== app.displayName && (
286
287
  <span className="ml-1.5 text-[12px] font-normal text-muted-foreground">
287
- ({app.abbreviation})
288
+ (<Highlight text={app.abbreviation} query={searchValue} />)
288
289
  </span>
289
290
  )}
290
291
  </span>
@@ -296,14 +297,18 @@ function SearchResultsList({
296
297
  {(() => {
297
298
  const kids = matchedChildrenByParent.get(app.slug)
298
299
  if (!kids || kids.length === 0) return null
299
- const names = kids.map((k) => k.displayName)
300
- const shown = names.slice(0, 3).join(', ')
301
- const extra =
302
- names.length > 3 ? ` +${names.length - 3} more` : ''
300
+ const shown = kids.slice(0, 3)
301
+ const extra = kids.length > 3 ? ` +${kids.length - 3} more` : ''
303
302
  return (
304
303
  <span className="mt-0.5 block text-[12px] text-primary truncate">
305
304
  Matched {kids.length} sub-resource
306
- {kids.length === 1 ? '' : 's'}: {shown}
305
+ {kids.length === 1 ? '' : 's'}:{' '}
306
+ {shown.map((k, idx) => (
307
+ <span key={k.slug}>
308
+ {idx > 0 && ', '}
309
+ <Highlight text={k.displayName} query={searchValue} />
310
+ </span>
311
+ ))}
307
312
  {extra}
308
313
  </span>
309
314
  )
@@ -433,7 +438,19 @@ export function LauncherHome({
433
438
  aria-label="Search apps"
434
439
  className="flex-1 bg-transparent border-0 outline-none text-[16px] text-foreground placeholder:text-muted-foreground"
435
440
  />
436
- {!isSearching && (
441
+ {isSearching ? (
442
+ <button
443
+ type="button"
444
+ aria-label="Clear search"
445
+ onClick={() => {
446
+ onSearchChange('')
447
+ searchRef.current?.focus()
448
+ }}
449
+ className="shrink-0 rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
450
+ >
451
+ <X className="size-4" />
452
+ </button>
453
+ ) : (
437
454
  <kbd className="hidden sm:inline-flex items-center gap-0.5 text-[11px] text-muted-foreground/60 border border-muted-foreground/20 rounded px-1.5 py-0.5 font-mono shrink-0">
438
455
  {typeof navigator !== 'undefined' &&
439
456
  navigator.platform.includes('Mac')