@open-mercato/ui 0.6.5-develop.5337.1.534b781eac → 0.6.5

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 (65) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/ai/AiChat.js.map +1 -1
  3. package/dist/ai/records/ActivityCard.js +1 -6
  4. package/dist/ai/records/ActivityCard.js.map +2 -2
  5. package/dist/ai/records/DealCard.js +2 -23
  6. package/dist/ai/records/DealCard.js.map +2 -2
  7. package/dist/ai/records/ProductCard.js +2 -17
  8. package/dist/ai/records/ProductCard.js.map +2 -2
  9. package/dist/ai/useAiChat.js.map +1 -1
  10. package/dist/backend/AppShell.js +15 -8
  11. package/dist/backend/AppShell.js.map +2 -2
  12. package/dist/backend/CrudForm.js.map +1 -1
  13. package/dist/backend/DataTable.js +10 -9
  14. package/dist/backend/DataTable.js.map +2 -2
  15. package/dist/backend/ProfileDropdown.js +62 -33
  16. package/dist/backend/ProfileDropdown.js.map +2 -2
  17. package/dist/backend/charts/KpiCard.js +22 -14
  18. package/dist/backend/charts/KpiCard.js.map +2 -2
  19. package/dist/backend/charts/Sparkline.js +75 -0
  20. package/dist/backend/charts/Sparkline.js.map +7 -0
  21. package/dist/backend/charts/index.js +4 -1
  22. package/dist/backend/charts/index.js.map +2 -2
  23. package/dist/backend/dashboard/DashboardScreen.js +1 -2
  24. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  25. package/dist/backend/injection/InjectionSpot.js +23 -6
  26. package/dist/backend/injection/InjectionSpot.js.map +2 -2
  27. package/dist/backend/injection/useGuardedMutation.js.map +1 -1
  28. package/dist/backend/utils/optimisticLock.js.map +1 -1
  29. package/dist/primitives/avatar.js +2 -2
  30. package/dist/primitives/avatar.js.map +2 -2
  31. package/dist/primitives/pagination.js +3 -3
  32. package/dist/primitives/pagination.js.map +2 -2
  33. package/dist/utils/format.js +27 -0
  34. package/dist/utils/format.js.map +7 -0
  35. package/package.json +9 -6
  36. package/src/ai/AiChat.tsx +1 -1
  37. package/src/ai/__tests__/AiChat.task-plan.test.tsx +1 -1
  38. package/src/ai/records/ActivityCard.tsx +1 -7
  39. package/src/ai/records/DealCard.tsx +2 -26
  40. package/src/ai/records/ProductCard.tsx +2 -19
  41. package/src/ai/useAiChat.ts +1 -1
  42. package/src/backend/AppShell.tsx +18 -8
  43. package/src/backend/CrudForm.tsx +1 -1
  44. package/src/backend/DataTable.tsx +18 -11
  45. package/src/backend/ProfileDropdown.tsx +35 -3
  46. package/src/backend/__tests__/AppShell.test.tsx +61 -1
  47. package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +52 -0
  48. package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +99 -0
  49. package/src/backend/__tests__/ProfileDropdown.test.tsx +59 -0
  50. package/src/backend/__tests__/lazy-heavy-libraries.test.ts +8 -0
  51. package/src/backend/charts/KpiCard.tsx +22 -12
  52. package/src/backend/charts/Sparkline.tsx +87 -0
  53. package/src/backend/charts/index.ts +2 -1
  54. package/src/backend/dashboard/DashboardScreen.tsx +1 -2
  55. package/src/backend/injection/InjectionSpot.tsx +27 -7
  56. package/src/backend/injection/__tests__/InjectionSpot.remount.test.tsx +140 -0
  57. package/src/backend/injection/useGuardedMutation.ts +1 -1
  58. package/src/backend/utils/optimisticLock.ts +1 -1
  59. package/src/primitives/__tests__/alert.test.tsx +16 -0
  60. package/src/primitives/__tests__/avatar.test.tsx +36 -1
  61. package/src/primitives/__tests__/pagination.test.tsx +31 -0
  62. package/src/primitives/avatar.tsx +8 -2
  63. package/src/primitives/pagination.tsx +3 -3
  64. package/src/utils/__tests__/format.test.ts +58 -0
  65. package/src/utils/format.ts +40 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/primitives/pagination.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport {\n ChevronLeft,\n ChevronRight,\n ChevronsLeft,\n ChevronsRight,\n} from 'lucide-react'\nimport { cva, type VariantProps } from 'class-variance-authority'\n\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n CompactSelectTrigger,\n Select,\n SelectContent,\n SelectItem,\n SelectValue,\n} from './compact-select'\n\n/**\n * Page navigation primitive per Figma `Pagination Group [1.1]` (DS Open\n * Mercato componentSet `199985:4135`).\n *\n * Layout (Figma `Basic` variant):\n *\n * [Left] \"Page 2 of 16\"\n * [Center] \u23EE \u25C0 [1][2]\u2026[N-1][N] \u25B6 \u23ED\n * [Right] \"7 / page\" CompactSelect\n *\n * Figma's three boolean variant props are mapped 1:1 to React props:\n *\n * \uD83E\uDD47 First / Last \u2192 `showFirstLast` (default true)\n * \u23ED\uFE0F Next / Previous \u2192 `showPrevNext` (default true)\n * \uD83E\uDDEA Advanced \u2192 `showInfo` + `showPageSize` (default both true)\n *\n * The numeric page list uses the same ellipsis algorithm Material /\n * MUI / shadcn ship: `boundaryCount` pages at each end (default 1),\n * `siblingCount` pages on either side of the current page (default 1),\n * collapse the rest into `\u2026` placeholders.\n *\n * ```tsx\n * const [page, setPage] = React.useState(1)\n * const [pageSize, setPageSize] = React.useState(25)\n * <Pagination\n * page={page}\n * pageSize={pageSize}\n * total={items.length}\n * onPageChange={setPage}\n * onPageSizeChange={setPageSize}\n * />\n *\n * // Compact (no first/last, no page-size select)\n * <Pagination\n * page={page}\n * pageSize={20}\n * total={120}\n * onPageChange={setPage}\n * showFirstLast={false}\n * showPageSize={false}\n * />\n * ```\n */\n\n/**\n * Build a stable list of pages + ellipsis placeholders that fits a\n * pagination row of `boundaryCount + 2 + siblingCount * 2 + 1` cells.\n * Returns `number` entries (1-indexed) and `'ellipsis-left'` /\n * `'ellipsis-right'` placeholders that the renderer paints as `\u2026`.\n *\n * Exported for tests + consumer reuse (e.g. a server-side rendered\n * pagination indicator that needs the same shape).\n */\nexport function buildPaginationItems(\n page: number,\n totalPages: number,\n siblingCount: number,\n boundaryCount: number,\n): Array<number | 'ellipsis-left' | 'ellipsis-right'> {\n if (totalPages <= 0) return []\n // When everything fits, just list every page.\n const totalSlots = boundaryCount * 2 + siblingCount * 2 + 3\n if (totalPages <= totalSlots) {\n return Array.from({ length: totalPages }, (_, i) => i + 1)\n }\n const startPages = Array.from({ length: boundaryCount }, (_, i) => i + 1)\n const endPages = Array.from(\n { length: boundaryCount },\n (_, i) => totalPages - boundaryCount + 1 + i,\n )\n const siblingStart = Math.max(\n Math.min(page - siblingCount, totalPages - boundaryCount - siblingCount * 2 - 1),\n boundaryCount + 2,\n )\n const siblingEnd = Math.min(\n Math.max(page + siblingCount, boundaryCount + siblingCount * 2 + 2),\n endPages.length > 0 ? endPages[0] - 2 : totalPages - 1,\n )\n const middle: Array<number> = []\n for (let i = siblingStart; i <= siblingEnd; i += 1) middle.push(i)\n\n // Bridge between the start boundary and the sibling window. The\n // ellipsis is only worth showing when \u22652 page numbers fall between\n // them; if exactly 1 page is missing, render that single number\n // instead of \"\u2026\" (cleaner UX \u2014 same width, no information loss).\n const result: Array<number | 'ellipsis-left' | 'ellipsis-right'> = []\n for (const p of startPages) result.push(p)\n if (siblingStart > boundaryCount + 2) {\n result.push('ellipsis-left')\n } else if (siblingStart === boundaryCount + 2) {\n result.push(boundaryCount + 1)\n }\n for (const p of middle) result.push(p)\n const firstEnd = endPages[0] ?? totalPages\n if (siblingEnd < firstEnd - 2) {\n result.push('ellipsis-right')\n } else if (siblingEnd === firstEnd - 2) {\n result.push(firstEnd - 1)\n }\n for (const p of endPages) result.push(p)\n return result\n}\n\nconst cellVariants = cva(\n 'inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-medium outline-none transition-colors tabular-nums ' +\n 'focus-visible:shadow-focus ' +\n 'disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n selected: {\n true: 'bg-muted text-foreground',\n false: 'bg-background text-foreground hover:bg-muted/40',\n },\n },\n defaultVariants: { selected: false },\n },\n)\n\nconst navButtonVariants = cva(\n 'inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors ' +\n 'hover:bg-muted/40 hover:text-foreground ' +\n 'focus-visible:shadow-focus ' +\n 'disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground',\n)\n\nexport type PaginationProps = React.HTMLAttributes<HTMLDivElement> & {\n /** Current 1-indexed page. */\n page: number\n /** Items per page. */\n pageSize: number\n /** Total item count. Used to derive `Math.ceil(total / pageSize)` pages. */\n total: number\n /** Called when the user changes page. */\n onPageChange: (next: number) => void\n /** Called when the user changes page size. Optional \u2014 when omitted,\n * the \"X / page\" select is hidden. */\n onPageSizeChange?: (next: number) => void\n /** Page size options for the select. Default `[10, 25, 50, 100]`. */\n pageSizeOptions?: readonly number[]\n /** Show the left \"Page X of Y\" indicator. Default `true`. */\n showInfo?: boolean\n /** Show the right \"X / page\" select. Default `true` when\n * `onPageSizeChange` is provided; ignored otherwise. */\n showPageSize?: boolean\n /** Show \u23EE / \u23ED first / last buttons. Default `true`. */\n showFirstLast?: boolean\n /** Show \u25C0 / \u25B6 prev / next buttons. Default `true`. */\n showPrevNext?: boolean\n /** Pages on either side of the current page in the page list. Default `1`. */\n siblingCount?: number\n /** Pages pinned at each end of the page list. Default `1`. */\n boundaryCount?: number\n /** Block all interactions. Default `false`. */\n disabled?: boolean\n /** ARIA label for the navigation landmark. Default `\"Pagination\"`. */\n 'aria-label'?: string\n /** Format the \"Page X of Y\" label. */\n formatPageInfo?: (page: number, totalPages: number) => string\n /** Format the \"X / page\" label. */\n formatPageSizeLabel?: (pageSize: number) => string\n}\n\nexport const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(\n (\n {\n className,\n page,\n pageSize,\n total,\n onPageChange,\n onPageSizeChange,\n pageSizeOptions = [10, 25, 50, 100],\n showInfo = true,\n showPageSize: showPageSizeProp,\n showFirstLast = true,\n showPrevNext = true,\n siblingCount = 1,\n boundaryCount = 1,\n disabled = false,\n formatPageInfo,\n formatPageSizeLabel,\n ...props\n },\n ref,\n ) => {\n const t = useT()\n const resolvedFormatPageInfo =\n formatPageInfo ??\n ((p: number, total: number) =>\n t('ui.pagination.info.pageOf', 'Page {page} of {total}', { page: p, total }))\n const resolvedFormatPageSizeLabel =\n formatPageSizeLabel ??\n ((size: number) =>\n t('ui.pagination.itemsPerPage.label', '{size} / page', { size }))\n const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)))\n const safePage = Math.min(Math.max(1, page), totalPages)\n const items = React.useMemo(\n () => buildPaginationItems(safePage, totalPages, siblingCount, boundaryCount),\n [safePage, totalPages, siblingCount, boundaryCount],\n )\n const showPageSize = showPageSizeProp ?? Boolean(onPageSizeChange)\n\n const goTo = React.useCallback(\n (next: number) => {\n if (disabled) return\n const bounded = Math.min(Math.max(1, next), totalPages)\n if (bounded !== safePage) onPageChange(bounded)\n },\n [disabled, onPageChange, safePage, totalPages],\n )\n\n return (\n <nav\n ref={ref}\n data-slot=\"pagination\"\n aria-label={props['aria-label'] ?? t('ui.pagination.landmark.ariaLabel', 'Pagination')}\n className={cn('flex w-full items-center justify-between gap-6', className)}\n {...props}\n >\n {showInfo ? (\n <div\n data-slot=\"pagination-info\"\n className=\"shrink-0 text-sm text-muted-foreground tabular-nums\"\n >\n {resolvedFormatPageInfo(safePage, totalPages)}\n </div>\n ) : (\n <div />\n )}\n\n <div\n data-slot=\"pagination-controls\"\n className=\"inline-flex items-center gap-2\"\n >\n {showFirstLast ? (\n <button\n type=\"button\"\n data-slot=\"pagination-first\"\n aria-label={t('ui.pagination.first.ariaLabel', 'First page')}\n disabled={disabled || safePage <= 1}\n onClick={() => goTo(1)}\n className={cn(navButtonVariants())}\n >\n <ChevronsLeft aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n {showPrevNext ? (\n <button\n type=\"button\"\n data-slot=\"pagination-prev\"\n aria-label={t('ui.pagination.previous.ariaLabel', 'Previous page')}\n disabled={disabled || safePage <= 1}\n onClick={() => goTo(safePage - 1)}\n className={cn(navButtonVariants())}\n >\n <ChevronLeft aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n\n <ol\n data-slot=\"pagination-pages\"\n className=\"inline-flex items-center gap-2 list-none\"\n >\n {items.map((entry, index) => {\n if (entry === 'ellipsis-left' || entry === 'ellipsis-right') {\n return (\n <li\n key={`${entry}-${index}`}\n data-slot=\"pagination-ellipsis\"\n aria-hidden=\"true\"\n className=\"inline-flex size-8 items-center justify-center text-sm text-muted-foreground\"\n >\n \u2026\n </li>\n )\n }\n const selected = entry === safePage\n return (\n <li key={entry}>\n <button\n type=\"button\"\n data-slot=\"pagination-page\"\n data-state={selected ? 'on' : 'off'}\n aria-current={selected ? 'page' : undefined}\n aria-label={\n selected\n ? t('ui.pagination.page.currentAriaLabel', 'Page {page}, current page', { page: entry })\n : t('ui.pagination.page.goToAriaLabel', 'Go to page {page}', { page: entry })\n }\n disabled={disabled}\n onClick={() => goTo(entry)}\n className={cn(cellVariants({ selected }))}\n >\n {entry}\n </button>\n </li>\n )\n })}\n </ol>\n\n {showPrevNext ? (\n <button\n type=\"button\"\n data-slot=\"pagination-next\"\n aria-label={t('ui.pagination.next.ariaLabel', 'Next page')}\n disabled={disabled || safePage >= totalPages}\n onClick={() => goTo(safePage + 1)}\n className={cn(navButtonVariants())}\n >\n <ChevronRight aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n {showFirstLast ? (\n <button\n type=\"button\"\n data-slot=\"pagination-last\"\n aria-label={t('ui.pagination.last.ariaLabel', 'Last page')}\n disabled={disabled || safePage >= totalPages}\n onClick={() => goTo(totalPages)}\n className={cn(navButtonVariants())}\n >\n <ChevronsRight aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n </div>\n\n {showPageSize && onPageSizeChange ? (\n <div data-slot=\"pagination-page-size\" className=\"shrink-0\">\n <Select\n value={String(pageSize)}\n onValueChange={(next) => onPageSizeChange(Number(next))}\n disabled={disabled}\n >\n <CompactSelectTrigger aria-label={t('ui.pagination.itemsPerPage.ariaLabel', 'Items per page')}>\n <SelectValue />\n </CompactSelectTrigger>\n <SelectContent>\n {pageSizeOptions.map((option) => (\n <SelectItem key={option} value={String(option)}>\n {resolvedFormatPageSizeLabel(option)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n ) : (\n <div />\n )}\n </nav>\n )\n },\n)\nPagination.displayName = 'Pagination'\n\nexport { cellVariants as paginationCellVariants, navButtonVariants as paginationNavVariants }\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport {\n ChevronLeft,\n ChevronRight,\n ChevronsLeft,\n ChevronsRight,\n} from 'lucide-react'\nimport { cva, type VariantProps } from 'class-variance-authority'\n\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n CompactSelectTrigger,\n Select,\n SelectContent,\n SelectItem,\n SelectValue,\n} from './compact-select'\n\n/**\n * Page navigation primitive per Figma `Pagination Group [1.1]` (DS Open\n * Mercato componentSet `199985:4135`).\n *\n * Layout (Figma `Basic` variant):\n *\n * [Left] \"Page 2 of 16\"\n * [Center] \u23EE \u25C0 [1][2]\u2026[N-1][N] \u25B6 \u23ED\n * [Right] \"7 / page\" CompactSelect\n *\n * Figma's three boolean variant props are mapped 1:1 to React props:\n *\n * \uD83E\uDD47 First / Last \u2192 `showFirstLast` (default true)\n * \u23ED\uFE0F Next / Previous \u2192 `showPrevNext` (default true)\n * \uD83E\uDDEA Advanced \u2192 `showInfo` + `showPageSize` (default both true)\n *\n * The numeric page list uses the same ellipsis algorithm Material /\n * MUI / shadcn ship: `boundaryCount` pages at each end (default 1),\n * `siblingCount` pages on either side of the current page (default 1),\n * collapse the rest into `\u2026` placeholders.\n *\n * ```tsx\n * const [page, setPage] = React.useState(1)\n * const [pageSize, setPageSize] = React.useState(25)\n * <Pagination\n * page={page}\n * pageSize={pageSize}\n * total={items.length}\n * onPageChange={setPage}\n * onPageSizeChange={setPageSize}\n * />\n *\n * // Compact (no first/last, no page-size select)\n * <Pagination\n * page={page}\n * pageSize={20}\n * total={120}\n * onPageChange={setPage}\n * showFirstLast={false}\n * showPageSize={false}\n * />\n * ```\n */\n\n/**\n * Build a stable list of pages + ellipsis placeholders that fits a\n * pagination row of `boundaryCount + 2 + siblingCount * 2 + 1` cells.\n * Returns `number` entries (1-indexed) and `'ellipsis-left'` /\n * `'ellipsis-right'` placeholders that the renderer paints as `\u2026`.\n *\n * Exported for tests + consumer reuse (e.g. a server-side rendered\n * pagination indicator that needs the same shape).\n */\nexport function buildPaginationItems(\n page: number,\n totalPages: number,\n siblingCount: number,\n boundaryCount: number,\n): Array<number | 'ellipsis-left' | 'ellipsis-right'> {\n if (totalPages <= 0) return []\n // When everything fits, just list every page.\n const totalSlots = boundaryCount * 2 + siblingCount * 2 + 3\n if (totalPages <= totalSlots) {\n return Array.from({ length: totalPages }, (_, i) => i + 1)\n }\n const startPages = Array.from({ length: boundaryCount }, (_, i) => i + 1)\n const endPages = Array.from(\n { length: boundaryCount },\n (_, i) => totalPages - boundaryCount + 1 + i,\n )\n const siblingStart = Math.max(\n Math.min(page - siblingCount, totalPages - boundaryCount - siblingCount * 2 - 1),\n boundaryCount + 2,\n )\n const siblingEnd = Math.min(\n Math.max(page + siblingCount, boundaryCount + siblingCount * 2 + 2),\n endPages.length > 0 ? endPages[0] - 2 : totalPages - 1,\n )\n const middle: Array<number> = []\n for (let i = siblingStart; i <= siblingEnd; i += 1) middle.push(i)\n\n // Bridge between the start boundary and the sibling window. The\n // ellipsis is only worth showing when \u22652 page numbers fall between\n // them; if exactly 1 page is missing, render that single number\n // instead of \"\u2026\" (cleaner UX \u2014 same width, no information loss).\n const result: Array<number | 'ellipsis-left' | 'ellipsis-right'> = []\n for (const p of startPages) result.push(p)\n if (siblingStart > boundaryCount + 2) {\n result.push('ellipsis-left')\n } else if (siblingStart === boundaryCount + 2) {\n result.push(boundaryCount + 1)\n }\n for (const p of middle) result.push(p)\n const firstEnd = endPages[0] ?? totalPages\n if (siblingEnd < firstEnd - 2) {\n result.push('ellipsis-right')\n } else if (siblingEnd === firstEnd - 2) {\n result.push(firstEnd - 1)\n }\n for (const p of endPages) result.push(p)\n return result\n}\n\nconst cellVariants = cva(\n 'inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-medium outline-none transition-colors tabular-nums ' +\n 'focus-visible:shadow-focus ' +\n 'disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n selected: {\n true: 'bg-muted text-foreground',\n false: 'bg-background text-foreground hover:bg-muted/40',\n },\n },\n defaultVariants: { selected: false },\n },\n)\n\nconst navButtonVariants = cva(\n 'inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors ' +\n 'hover:bg-muted/40 hover:text-foreground ' +\n 'focus-visible:shadow-focus ' +\n 'disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground',\n)\n\nexport type PaginationProps = React.HTMLAttributes<HTMLDivElement> & {\n /** Current 1-indexed page. */\n page: number\n /** Items per page. */\n pageSize: number\n /** Total item count. Used to derive `Math.ceil(total / pageSize)` pages. */\n total: number\n /** Called when the user changes page. */\n onPageChange: (next: number) => void\n /** Called when the user changes page size. Optional \u2014 when omitted,\n * the \"X / page\" select is hidden. */\n onPageSizeChange?: (next: number) => void\n /** Page size options for the select. Default `[10, 25, 50, 100]`. */\n pageSizeOptions?: readonly number[]\n /** Show the left \"Page X of Y\" indicator. Default `true`. */\n showInfo?: boolean\n /** Show the right \"X / page\" select. Default `true` when\n * `onPageSizeChange` is provided; ignored otherwise. */\n showPageSize?: boolean\n /** Show \u23EE / \u23ED first / last buttons. Default `true`. */\n showFirstLast?: boolean\n /** Show \u25C0 / \u25B6 prev / next buttons. Default `true`. */\n showPrevNext?: boolean\n /** Pages on either side of the current page in the page list. Default `1`. */\n siblingCount?: number\n /** Pages pinned at each end of the page list. Default `1`. */\n boundaryCount?: number\n /** Block all interactions. Default `false`. */\n disabled?: boolean\n /** ARIA label for the navigation landmark. Default `\"Pagination\"`. */\n 'aria-label'?: string\n /** Format the \"Page X of Y\" label. */\n formatPageInfo?: (page: number, totalPages: number) => string\n /** Format the \"X / page\" label. */\n formatPageSizeLabel?: (pageSize: number) => string\n}\n\nexport const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(\n (\n {\n className,\n page,\n pageSize,\n total,\n onPageChange,\n onPageSizeChange,\n pageSizeOptions = [10, 25, 50, 100],\n showInfo = true,\n showPageSize: showPageSizeProp,\n showFirstLast = true,\n showPrevNext = true,\n siblingCount = 1,\n boundaryCount = 1,\n disabled = false,\n formatPageInfo,\n formatPageSizeLabel,\n ...props\n },\n ref,\n ) => {\n const t = useT()\n const resolvedFormatPageInfo =\n formatPageInfo ??\n ((p: number, total: number) =>\n t('ui.pagination.info.pageOf', 'Page {page} of {total}', { page: p, total }))\n const resolvedFormatPageSizeLabel =\n formatPageSizeLabel ??\n ((size: number) =>\n t('ui.pagination.itemsPerPage.label', '{size} / page', { size }))\n const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)))\n const safePage = Math.min(Math.max(1, page), totalPages)\n const items = React.useMemo(\n () => buildPaginationItems(safePage, totalPages, siblingCount, boundaryCount),\n [safePage, totalPages, siblingCount, boundaryCount],\n )\n const showPageSize = showPageSizeProp ?? Boolean(onPageSizeChange)\n\n const goTo = React.useCallback(\n (next: number) => {\n if (disabled) return\n const bounded = Math.min(Math.max(1, next), totalPages)\n if (bounded !== safePage) onPageChange(bounded)\n },\n [disabled, onPageChange, safePage, totalPages],\n )\n\n return (\n <nav\n ref={ref}\n data-slot=\"pagination\"\n aria-label={props['aria-label'] ?? t('ui.pagination.landmark.ariaLabel', 'Pagination')}\n className={cn('flex w-full flex-wrap items-center justify-between gap-x-6 gap-y-2', className)}\n {...props}\n >\n {showInfo ? (\n <div\n data-slot=\"pagination-info\"\n className=\"shrink-0 text-sm text-muted-foreground tabular-nums\"\n >\n {resolvedFormatPageInfo(safePage, totalPages)}\n </div>\n ) : (\n <div />\n )}\n\n <div\n data-slot=\"pagination-controls\"\n className=\"flex flex-wrap items-center justify-center gap-2\"\n >\n {showFirstLast ? (\n <button\n type=\"button\"\n data-slot=\"pagination-first\"\n aria-label={t('ui.pagination.first.ariaLabel', 'First page')}\n disabled={disabled || safePage <= 1}\n onClick={() => goTo(1)}\n className={cn(navButtonVariants())}\n >\n <ChevronsLeft aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n {showPrevNext ? (\n <button\n type=\"button\"\n data-slot=\"pagination-prev\"\n aria-label={t('ui.pagination.previous.ariaLabel', 'Previous page')}\n disabled={disabled || safePage <= 1}\n onClick={() => goTo(safePage - 1)}\n className={cn(navButtonVariants())}\n >\n <ChevronLeft aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n\n <ol\n data-slot=\"pagination-pages\"\n className=\"flex flex-wrap items-center justify-center gap-2 list-none\"\n >\n {items.map((entry, index) => {\n if (entry === 'ellipsis-left' || entry === 'ellipsis-right') {\n return (\n <li\n key={`${entry}-${index}`}\n data-slot=\"pagination-ellipsis\"\n aria-hidden=\"true\"\n className=\"inline-flex size-8 items-center justify-center text-sm text-muted-foreground\"\n >\n \u2026\n </li>\n )\n }\n const selected = entry === safePage\n return (\n <li key={entry}>\n <button\n type=\"button\"\n data-slot=\"pagination-page\"\n data-state={selected ? 'on' : 'off'}\n aria-current={selected ? 'page' : undefined}\n aria-label={\n selected\n ? t('ui.pagination.page.currentAriaLabel', 'Page {page}, current page', { page: entry })\n : t('ui.pagination.page.goToAriaLabel', 'Go to page {page}', { page: entry })\n }\n disabled={disabled}\n onClick={() => goTo(entry)}\n className={cn(cellVariants({ selected }))}\n >\n {entry}\n </button>\n </li>\n )\n })}\n </ol>\n\n {showPrevNext ? (\n <button\n type=\"button\"\n data-slot=\"pagination-next\"\n aria-label={t('ui.pagination.next.ariaLabel', 'Next page')}\n disabled={disabled || safePage >= totalPages}\n onClick={() => goTo(safePage + 1)}\n className={cn(navButtonVariants())}\n >\n <ChevronRight aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n {showFirstLast ? (\n <button\n type=\"button\"\n data-slot=\"pagination-last\"\n aria-label={t('ui.pagination.last.ariaLabel', 'Last page')}\n disabled={disabled || safePage >= totalPages}\n onClick={() => goTo(totalPages)}\n className={cn(navButtonVariants())}\n >\n <ChevronsRight aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n </div>\n\n {showPageSize && onPageSizeChange ? (\n <div data-slot=\"pagination-page-size\" className=\"shrink-0\">\n <Select\n value={String(pageSize)}\n onValueChange={(next) => onPageSizeChange(Number(next))}\n disabled={disabled}\n >\n <CompactSelectTrigger aria-label={t('ui.pagination.itemsPerPage.ariaLabel', 'Items per page')}>\n <SelectValue />\n </CompactSelectTrigger>\n <SelectContent>\n {pageSizeOptions.map((option) => (\n <SelectItem key={option} value={String(option)}>\n {resolvedFormatPageSizeLabel(option)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n ) : (\n <div />\n )}\n </nav>\n )\n },\n)\nPagination.displayName = 'Pagination'\n\nexport { cellVariants as paginationCellVariants, navButtonVariants as paginationNavVariants }\n"],
5
+ "mappings": "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",
6
6
  "names": ["total"]
7
7
  }
@@ -0,0 +1,27 @@
1
+ function formatCurrency(value, currency) {
2
+ if (value === null || value === void 0 || value === "") return null;
3
+ const numeric = typeof value === "number" ? value : Number(value);
4
+ if (!Number.isFinite(numeric)) {
5
+ return typeof value === "string" ? value : null;
6
+ }
7
+ const code = currency && currency.length === 3 ? currency.toUpperCase() : void 0;
8
+ try {
9
+ if (code) {
10
+ return new Intl.NumberFormat(void 0, { style: "currency", currency: code }).format(numeric);
11
+ }
12
+ } catch {
13
+ }
14
+ const formatted = new Intl.NumberFormat().format(numeric);
15
+ return code ? `${formatted} ${code}` : formatted;
16
+ }
17
+ function formatDate(value) {
18
+ if (!value) return null;
19
+ const date = new Date(value);
20
+ if (Number.isNaN(date.getTime())) return value;
21
+ return date.toLocaleDateString(void 0, { year: "numeric", month: "short", day: "numeric" });
22
+ }
23
+ export {
24
+ formatCurrency,
25
+ formatDate
26
+ };
27
+ //# sourceMappingURL=format.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/utils/format.ts"],
4
+ "sourcesContent": ["/**\n * Format a monetary value with an optional ISO-4217 currency code.\n *\n * Returns `null` for empty input, the original string for non-numeric input,\n * and falls back to a plain number format (optionally suffixed with the code)\n * when the currency code is missing or rejected by `Intl.NumberFormat`.\n */\nexport function formatCurrency(\n value: string | number | null | undefined,\n currency?: string | null,\n): string | null {\n if (value === null || value === undefined || value === '') return null\n const numeric = typeof value === 'number' ? value : Number(value)\n if (!Number.isFinite(numeric)) {\n return typeof value === 'string' ? value : null\n }\n const code = currency && currency.length === 3 ? currency.toUpperCase() : undefined\n try {\n if (code) {\n return new Intl.NumberFormat(undefined, { style: 'currency', currency: code }).format(numeric)\n }\n } catch {\n // fall through to plain number formatting\n }\n const formatted = new Intl.NumberFormat().format(numeric)\n return code ? `${formatted} ${code}` : formatted\n}\n\n/**\n * Format an ISO date string as a localized short date (e.g. `Jun 9, 2026`).\n *\n * Returns `null` for empty input and echoes the original value back when it is\n * not a parseable date.\n */\nexport function formatDate(value: string | null | undefined): string | null {\n if (!value) return null\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return value\n return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })\n}\n"],
5
+ "mappings": "AAOO,SAAS,eACd,OACA,UACe;AACf,MAAI,UAAU,QAAQ,UAAU,UAAa,UAAU,GAAI,QAAO;AAClE,QAAM,UAAU,OAAO,UAAU,WAAW,QAAQ,OAAO,KAAK;AAChE,MAAI,CAAC,OAAO,SAAS,OAAO,GAAG;AAC7B,WAAO,OAAO,UAAU,WAAW,QAAQ;AAAA,EAC7C;AACA,QAAM,OAAO,YAAY,SAAS,WAAW,IAAI,SAAS,YAAY,IAAI;AAC1E,MAAI;AACF,QAAI,MAAM;AACR,aAAO,IAAI,KAAK,aAAa,QAAW,EAAE,OAAO,YAAY,UAAU,KAAK,CAAC,EAAE,OAAO,OAAO;AAAA,IAC/F;AAAA,EACF,QAAQ;AAAA,EAER;AACA,QAAM,YAAY,IAAI,KAAK,aAAa,EAAE,OAAO,OAAO;AACxD,SAAO,OAAO,GAAG,SAAS,IAAI,IAAI,KAAK;AACzC;AAQO,SAAS,WAAW,OAAiD;AAC1E,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,OAAO,IAAI,KAAK,KAAK;AAC3B,MAAI,OAAO,MAAM,KAAK,QAAQ,CAAC,EAAG,QAAO;AACzC,SAAO,KAAK,mBAAmB,QAAW,EAAE,MAAM,WAAW,OAAO,SAAS,KAAK,UAAU,CAAC;AAC/F;",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.5-develop.5337.1.534b781eac",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -74,6 +74,10 @@
74
74
  "types": "./src/backend/forms/index.ts",
75
75
  "default": "./dist/backend/forms/index.js"
76
76
  },
77
+ "./backend/markdown": {
78
+ "types": "./src/backend/markdown/index.ts",
79
+ "default": "./dist/backend/markdown/index.js"
80
+ },
77
81
  "./backend/messages": {
78
82
  "types": "./src/backend/messages/index.ts",
79
83
  "default": "./dist/backend/messages/index.js"
@@ -131,7 +135,7 @@
131
135
  "@dnd-kit/core": "^6.3.1",
132
136
  "@dnd-kit/sortable": "^10.0.0",
133
137
  "@dnd-kit/utilities": "^3.2.2",
134
- "@mdxeditor/editor": "^4.0.2",
138
+ "@mdxeditor/editor": "^4.0.3",
135
139
  "@radix-ui/react-accordion": "^1.2.13",
136
140
  "@radix-ui/react-dialog": "^1.1.16",
137
141
  "@radix-ui/react-popover": "^1.1.16",
@@ -150,13 +154,13 @@
150
154
  "remark-gfm": "^4.0.1"
151
155
  },
152
156
  "peerDependencies": {
153
- "@open-mercato/shared": "0.6.5-develop.5337.1.534b781eac",
157
+ "@open-mercato/shared": "0.6.5",
154
158
  "react": ">=18.0.0",
155
159
  "react-dom": ">=18.0.0",
156
160
  "react-is": ">=18.0.0"
157
161
  },
158
162
  "devDependencies": {
159
- "@open-mercato/shared": "0.6.5-develop.5337.1.534b781eac",
163
+ "@open-mercato/shared": "0.6.5",
160
164
  "@testing-library/dom": "^10.4.1",
161
165
  "@testing-library/jest-dom": "^6.9.1",
162
166
  "@testing-library/react": "^16.3.1",
@@ -177,6 +181,5 @@
177
181
  "type": "git",
178
182
  "url": "https://github.com/open-mercato/open-mercato",
179
183
  "directory": "packages/ui"
180
- },
181
- "stableVersion": "0.6.4"
184
+ }
182
185
  }
package/src/ai/AiChat.tsx CHANGED
@@ -432,7 +432,7 @@ function formatToolCaption(call: AiChatToolCallSnapshot): string {
432
432
 
433
433
  /**
434
434
  * Visible agent task plan rendered above raw tool-call rows. Phase 1
435
- * implementation of spec `.ai/specs/2026-05-13-ai-chat-visible-task-plan.md`:
435
+ * implementation of spec `.ai/specs/implemented/2026-05-13-ai-chat-visible-task-plan.md`:
436
436
  * compact, fixed-height rows with icon + label + status badge so the
437
437
  * panel does not jump while streaming. Source of truth is the
438
438
  * client-local `taskPlan` array on the assistant message.
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * Acceptance tests for the visible agent task plan
7
- * (spec `.ai/specs/2026-05-13-ai-chat-visible-task-plan.md`).
7
+ * (spec `.ai/specs/implemented/2026-05-13-ai-chat-visible-task-plan.md`).
8
8
  *
9
9
  * Asserts that:
10
10
  * - `useAiChat` merges `data-agent-task-plan` snapshots and
@@ -15,13 +15,7 @@ import type { LucideIcon } from 'lucide-react'
15
15
  import { useT } from '@open-mercato/shared/lib/i18n/context'
16
16
  import { KeyValueList, RecordCardShell, TagRow, statusToTagVariant } from './RecordCardShell'
17
17
  import type { ActivityRecordPayload } from './types'
18
-
19
- function formatDate(value: string | null | undefined): string | null {
20
- if (!value) return null
21
- const d = new Date(value)
22
- if (Number.isNaN(d.getTime())) return value
23
- return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
24
- }
18
+ import { formatDate } from '../../utils/format'
25
19
 
26
20
  function pickActivityIcon(type: string | null | undefined): LucideIcon {
27
21
  if (!type) return ActivityIcon
@@ -5,31 +5,7 @@ import { Briefcase, Building2, CalendarDays, CircleDollarSign, User } from 'luci
5
5
  import { useT } from '@open-mercato/shared/lib/i18n/context'
6
6
  import { KeyValueList, RecordCardShell, TagRow, statusToTagVariant } from './RecordCardShell'
7
7
  import type { DealRecordPayload } from './types'
8
-
9
- function formatAmount(amount: string | number | null | undefined, currency?: string | null): string | null {
10
- if (amount === null || amount === undefined || amount === '') return null
11
- const value = typeof amount === 'number' ? amount : Number(amount)
12
- if (!Number.isFinite(value)) {
13
- return typeof amount === 'string' ? amount : null
14
- }
15
- const code = currency && currency.length === 3 ? currency.toUpperCase() : undefined
16
- try {
17
- if (code) {
18
- return new Intl.NumberFormat(undefined, { style: 'currency', currency: code }).format(value)
19
- }
20
- } catch {
21
- // fall through to fallback
22
- }
23
- const formatted = new Intl.NumberFormat().format(value)
24
- return code ? `${formatted} ${code}` : formatted
25
- }
26
-
27
- function formatDate(value: string | null | undefined): string | null {
28
- if (!value) return null
29
- const d = new Date(value)
30
- if (Number.isNaN(d.getTime())) return value
31
- return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
32
- }
8
+ import { formatCurrency, formatDate } from '../../utils/format'
33
9
 
34
10
  export interface DealCardProps extends DealRecordPayload {}
35
11
 
@@ -39,7 +15,7 @@ export function DealCard(props: DealCardProps) {
39
15
  ? { label: props.status, variant: statusToTagVariant(props.status) }
40
16
  : null
41
17
  const stage = props.stage && props.stage !== props.status ? props.stage : null
42
- const amount = formatAmount(props.amount, props.currency)
18
+ const amount = formatCurrency(props.amount, props.currency)
43
19
  const closeDate = formatDate(props.closeDate)
44
20
 
45
21
  const items = [
@@ -5,24 +5,7 @@ import { Package, Tag as TagIcon } from 'lucide-react'
5
5
  import { useT } from '@open-mercato/shared/lib/i18n/context'
6
6
  import { KeyValueList, RecordCardShell, TagRow, statusToTagVariant } from './RecordCardShell'
7
7
  import type { ProductRecordPayload } from './types'
8
-
9
- function formatPrice(price: string | number | null | undefined, currency?: string | null): string | null {
10
- if (price === null || price === undefined || price === '') return null
11
- const value = typeof price === 'number' ? price : Number(price)
12
- if (!Number.isFinite(value)) {
13
- return typeof price === 'string' ? price : null
14
- }
15
- const code = currency && currency.length === 3 ? currency.toUpperCase() : undefined
16
- try {
17
- if (code) {
18
- return new Intl.NumberFormat(undefined, { style: 'currency', currency: code }).format(value)
19
- }
20
- } catch {
21
- // fall through
22
- }
23
- const formatted = new Intl.NumberFormat().format(value)
24
- return code ? `${formatted} ${code}` : formatted
25
- }
8
+ import { formatCurrency } from '../../utils/format'
26
9
 
27
10
  export interface ProductCardProps extends ProductRecordPayload {}
28
11
 
@@ -31,7 +14,7 @@ export function ProductCard(props: ProductCardProps) {
31
14
  const status = props.status
32
15
  ? { label: props.status, variant: statusToTagVariant(props.status) }
33
16
  : null
34
- const price = formatPrice(props.price, props.currency)
17
+ const price = formatCurrency(props.price, props.currency)
35
18
 
36
19
  const leading = props.imageUrl ? (
37
20
  <div className="relative size-12 overflow-hidden rounded-md border border-border bg-muted">
@@ -44,7 +44,7 @@ export interface AiChatMessageUiPart {
44
44
 
45
45
  /**
46
46
  * Snapshot of a single task in the visible agent task plan
47
- * (spec `.ai/specs/2026-05-13-ai-chat-visible-task-plan.md`). Tasks are
47
+ * (spec `.ai/specs/implemented/2026-05-13-ai-chat-visible-task-plan.md`). Tasks are
48
48
  * streamed as additive `data-agent-task-plan` / `data-agent-task-update`
49
49
  * SSE chunks and rendered above raw tool-call rows in `<AiChat>`.
50
50
  *
@@ -150,6 +150,11 @@ function resolveInjectedMenuLabel(
150
150
  return item.label ?? item.id
151
151
  }
152
152
 
153
+ function shouldBypassLogoOptimization(src?: string | null): boolean {
154
+ const value = src ?? ''
155
+ return /^https?:\/\//.test(value) || /^\/api\/attachments\/(?:image|file)\//.test(value)
156
+ }
157
+
153
158
  function mergeSidebarItemsWithInjected(
154
159
  items: SidebarItem[],
155
160
  injectedItems: InjectionMenuItem[],
@@ -444,6 +449,11 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
444
449
  const { items: topbarInjectedMenuItems } = useInjectedMenuItems('menu:topbar:actions')
445
450
  useEventBridge() // SSE DOM Event Bridge — singleton SSE connection for real-time server events
446
451
  const resolvedProductName = productName ?? t('appShell.productName')
452
+ const resolvedLogo = chromePayload?.brand?.logo?.src ? chromePayload.brand.logo : logo
453
+ const resolvedBrandName = chromePayload?.brand?.logo?.src
454
+ ? chromePayload.brand.name ?? resolvedProductName
455
+ : resolvedProductName
456
+ const resolvedLogoBypassesOptimization = shouldBypassLogoOptimization(resolvedLogo?.src)
447
457
  const [mobileOpen, setMobileOpen] = React.useState(false)
448
458
  // When the mobile drawer opens on a settings/profile route, it follows the
449
459
  // section sidebar by default. Set to 'main' to force-show the main nav even
@@ -700,8 +710,8 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
700
710
  className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
701
711
  aria-label={t('appShell.goToDashboard')}
702
712
  >
703
- <Image src={logo?.src ?? "/open-mercato.svg"} alt={logo?.alt ?? resolvedProductName} width={40} height={40} className="rounded-full shrink-0" />
704
- {!compact && <span className="truncate text-sm font-medium text-foreground">{resolvedProductName}</span>}
713
+ <Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={40} height={40} className="rounded-full shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
714
+ {!compact && <span className="truncate text-sm font-medium text-foreground">{resolvedBrandName}</span>}
705
715
  </Link>
706
716
  </div>
707
717
  )}
@@ -833,8 +843,8 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
833
843
  className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
834
844
  aria-label={t('appShell.goToDashboard')}
835
845
  >
836
- <Image src={logo?.src ?? "/open-mercato.svg"} alt={logo?.alt ?? resolvedProductName} width={40} height={40} className="rounded-full shrink-0" />
837
- {!compact && <span className="truncate text-sm font-medium text-foreground">{resolvedProductName}</span>}
846
+ <Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={40} height={40} className="rounded-full shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
847
+ {!compact && <span className="truncate text-sm font-medium text-foreground">{resolvedBrandName}</span>}
838
848
  </Link>
839
849
  </div>
840
850
  ) : null}
@@ -899,8 +909,8 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
899
909
  className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
900
910
  aria-label={t('appShell.goToDashboard')}
901
911
  >
902
- <Image src={logo?.src ?? "/open-mercato.svg"} alt={logo?.alt ?? resolvedProductName} width={40} height={40} className="rounded-full shrink-0" />
903
- {!compact && <span className="truncate text-sm font-medium text-foreground">{resolvedProductName}</span>}
912
+ <Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={40} height={40} className="rounded-full shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
913
+ {!compact && <span className="truncate text-sm font-medium text-foreground">{resolvedBrandName}</span>}
904
914
  </Link>
905
915
  </div>
906
916
  )}
@@ -1392,8 +1402,8 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1392
1402
  <aside className="absolute left-0 top-0 flex h-full w-[280px] max-w-[85vw] flex-col bg-background border-r shadow-lg overflow-hidden">
1393
1403
  <div className="shrink-0 flex items-center justify-between gap-2 border-b px-4 py-3">
1394
1404
  <Link href="/backend" className="flex items-center gap-2 min-w-0 text-sm font-semibold" onClick={() => setMobileOpen(false)} aria-label={t('appShell.goToDashboard')}>
1395
- <Image src={logo?.src ?? "/open-mercato.svg"} alt={logo?.alt ?? resolvedProductName} width={28} height={28} className="rounded shrink-0" />
1396
- <span className="truncate">{resolvedProductName}</span>
1405
+ <Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={28} height={28} className="rounded shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
1406
+ <span className="truncate">{resolvedBrandName}</span>
1397
1407
  </Link>
1398
1408
  <IconButton variant="ghost" size="sm" onClick={() => setMobileOpen(false)} aria-label={t('appShell.closeMenu')}>
1399
1409
  <X className="size-4" />
@@ -310,7 +310,7 @@ export type CrudFormProps<TValues extends Record<string, unknown>> = {
310
310
  // When true, shows Delete button whenever onDelete is provided, even without an id
311
311
  deleteVisible?: boolean
312
312
  /**
313
- * OSS opt-in optimistic locking (spec: .ai/specs/2026-05-25-oss-optimistic-locking.md).
313
+ * OSS opt-in optimistic locking (spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md).
314
314
  *
315
315
  * When set to a non-empty ISO-8601 string (typically `record.updatedAt`
316
316
  * from the API response), the form auto-injects the
@@ -264,6 +264,8 @@ export type DataTableProps<T> = {
264
264
  extensionTableId?: string
265
265
  stickyFirstColumn?: boolean
266
266
  stickyActionsColumn?: boolean
267
+ /** Horizontal alignment of the row-actions (kebab) column header + cell. Defaults to 'right'. */
268
+ actionsColumnAlign?: 'right' | 'center'
267
269
  virtualized?: boolean
268
270
  virtualizedMaxHeight?: number | string
269
271
  virtualizedOverscan?: number
@@ -272,7 +274,7 @@ export type DataTableProps<T> = {
272
274
  * or the legacy flat `AdvancedFilterState` shape as a backward-compatibility
273
275
  * bridge. The bridge is provided for one minor version; legacy callers SHOULD
274
276
  * migrate to the tree shape — see the spec
275
- * `.ai/specs/2026-05-10-crm-list-filter-redesign.md` "Migration & Backward
277
+ * `.ai/specs/implemented/2026-05-10-crm-list-filter-redesign.md` "Migration & Backward
276
278
  * Compatibility" section and `RELEASE_NOTES.md`.
277
279
  *
278
280
  * When the legacy flat shape is detected, DataTable converts it to a tree via
@@ -365,10 +367,14 @@ const EMPTY_FILTER_VALUES: FilterValues = Object.freeze({}) as FilterValues
365
367
  // sticky right-0 → shadow falls to the LEFT (use `before:` + `-left-2` + `to-l`)
366
368
  // sticky left-0 → shadow falls to the RIGHT (use `after:` + `-right-2` + `to-r`)
367
369
  // `foreground/8` matches the `--shadow-md` token opacity (8%) and is theme-aware.
370
+ // Column pinning (and these shadows) is md-and-up only: below `md` the pinned
371
+ // first column + actions column can be wider than the whole viewport, which
372
+ // leaves the scrollable middle columns no visible window at all — narrow
373
+ // screens fall back to plain horizontal scroll so every column stays reachable.
368
374
  const STICKY_RIGHT_SHADOW_CLASS =
369
- 'before:absolute before:inset-y-0 before:-left-2 before:w-2 before:bg-gradient-to-l before:from-foreground/8 before:to-transparent before:pointer-events-none'
375
+ 'md:before:absolute md:before:inset-y-0 md:before:-left-2 md:before:w-2 md:before:bg-gradient-to-l md:before:from-foreground/8 md:before:to-transparent md:before:pointer-events-none'
370
376
  const STICKY_LEFT_SHADOW_CLASS =
371
- 'after:absolute after:inset-y-0 after:-right-2 after:w-2 after:bg-gradient-to-r after:from-foreground/8 after:to-transparent after:pointer-events-none'
377
+ 'md:after:absolute md:after:inset-y-0 md:after:-right-2 md:after:w-2 md:after:bg-gradient-to-r md:after:from-foreground/8 md:after:to-transparent md:after:pointer-events-none'
372
378
 
373
379
  type BulkActionExecuteResult = {
374
380
  ok: boolean
@@ -1000,6 +1006,7 @@ export function DataTable<T>({
1000
1006
  extensionTableId: extensionTableIdProp,
1001
1007
  stickyFirstColumn = false,
1002
1008
  stickyActionsColumn = false,
1009
+ actionsColumnAlign = 'right',
1003
1010
  virtualized = false,
1004
1011
  virtualizedMaxHeight,
1005
1012
  virtualizedOverscan = 10,
@@ -1023,7 +1030,7 @@ export function DataTable<T>({
1023
1030
  // (`triggerRef`, `externalPopover`, `onApplyTree`, `onTriggerClick`) are
1024
1031
  // undefined for legacy callers — they were added in this PR and didn't exist
1025
1032
  // in the legacy contract.
1026
- // See spec `.ai/specs/2026-05-10-crm-list-filter-redesign.md` ("Migration &
1033
+ // See spec `.ai/specs/implemented/2026-05-10-crm-list-filter-redesign.md` ("Migration &
1027
1034
  // Backward Compatibility") and BACKWARD_COMPATIBILITY.md §3.
1028
1035
  type AdvancedFilterNormalized = {
1029
1036
  fields?: AdvancedFilterFieldDef[]
@@ -2705,12 +2712,12 @@ export function DataTable<T>({
2705
2712
  const columnMeta = (header.column.columnDef as any)?.meta
2706
2713
  const priority = resolvePriority(header.column)
2707
2714
  const isFirstDataColumn = headerIndex === 0
2708
- const stickyClass = stickyFirstColumn && isFirstDataColumn ? ` sticky left-0 z-10 bg-background ${STICKY_LEFT_SHADOW_CLASS}` : ''
2715
+ const stickyClass = stickyFirstColumn && isFirstDataColumn ? ` md:sticky md:left-0 md:z-10 md:bg-background ${STICKY_LEFT_SHADOW_CLASS}` : ''
2709
2716
  const headerCellContent = header.isPlaceholder ? null : (
2710
2717
  <Button
2711
2718
  variant="ghost"
2712
2719
  type="button"
2713
- className={`h-auto p-0 font-medium ${sortable && header.column.getCanSort?.() ? 'cursor-pointer select-none' : ''}`}
2720
+ className={`h-auto p-0 has-[>svg]:px-0 font-medium ${sortable && header.column.getCanSort?.() ? 'cursor-pointer select-none' : ''}`}
2714
2721
  onClick={() => sortable && header.column.toggleSorting?.(header.column.getIsSorted() === 'asc')}
2715
2722
  >
2716
2723
  {flexRender(header.column.columnDef.header, header.getContext())}
@@ -2737,8 +2744,8 @@ export function DataTable<T>({
2737
2744
  {rowActions || injectedRowActions.length > 0 ? (
2738
2745
  <TableHead
2739
2746
  className={cn(
2740
- 'w-0 text-right',
2741
- stickyActionsColumn && `sticky right-0 z-20 bg-background ${STICKY_RIGHT_SHADOW_CLASS}`,
2747
+ actionsColumnAlign === 'center' ? 'w-0 text-center' : 'w-0 text-right',
2748
+ stickyActionsColumn && `md:sticky md:right-0 md:z-20 md:bg-background ${STICKY_RIGHT_SHADOW_CLASS}`,
2742
2749
  )}
2743
2750
  >
2744
2751
  {t('ui.dataTable.actionsColumn', 'Actions')}
@@ -2860,7 +2867,7 @@ export function DataTable<T>({
2860
2867
  ) : content
2861
2868
 
2862
2869
  return (
2863
- <TableCell key={cell.id} className={responsiveClass(priority, columnMeta?.hidden) + (isStickyCell ? ` sticky left-0 z-10 bg-background ${STICKY_LEFT_SHADOW_CLASS}` : '')}>
2870
+ <TableCell key={cell.id} className={responsiveClass(priority, columnMeta?.hidden) + (isStickyCell ? ` md:sticky md:left-0 md:z-10 md:bg-background ${STICKY_LEFT_SHADOW_CLASS}` : '')}>
2864
2871
  {wrappedContent}
2865
2872
  </TableCell>
2866
2873
  )
@@ -2868,8 +2875,8 @@ export function DataTable<T>({
2868
2875
  {rowActions || injectedRowActions.length > 0 ? (
2869
2876
  <TableCell
2870
2877
  className={cn(
2871
- 'text-right whitespace-nowrap',
2872
- stickyActionsColumn && `sticky right-0 z-10 bg-background ${STICKY_RIGHT_SHADOW_CLASS}`,
2878
+ actionsColumnAlign === 'center' ? 'text-center whitespace-nowrap' : 'text-right whitespace-nowrap',
2879
+ stickyActionsColumn && `md:sticky md:right-0 md:z-10 md:bg-background ${STICKY_RIGHT_SHADOW_CLASS}`,
2873
2880
  )}
2874
2881
  data-actions-cell
2875
2882
  >
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
  import * as React from 'react'
3
+ import { createPortal } from 'react-dom'
3
4
  import Link from 'next/link'
4
5
  import { User, LogOut, Bell, Moon, Sun, Globe, Key, Check, ChevronRight } from 'lucide-react'
5
6
  import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'
@@ -21,6 +22,8 @@ export type ProfileDropdownProps = {
21
22
  notificationsHref?: string
22
23
  }
23
24
 
25
+ const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect
26
+
24
27
  const localeLabels: Record<Locale, string> = {
25
28
  en: 'English',
26
29
  de: 'Deutsch',
@@ -42,12 +45,39 @@ export function ProfileDropdown({
42
45
  const [mounted, setMounted] = React.useState(false)
43
46
  const buttonRef = React.useRef<HTMLButtonElement>(null)
44
47
  const menuRef = React.useRef<HTMLDivElement>(null)
48
+ const [menuPosition, setMenuPosition] = React.useState<{ top: number; right: number } | null>(null)
45
49
  const { items: injectedItems } = useInjectedMenuItems('menu:topbar:profile-dropdown')
46
50
 
47
51
  React.useEffect(() => {
48
52
  setMounted(true)
49
53
  }, [])
50
54
 
55
+ // Position the portaled menu under the trigger. Rendering the menu in a body
56
+ // portal keeps it out of the sticky header's backdrop-blur stacking context so
57
+ // it always paints above page content such as the DataTable sticky Actions column.
58
+ useIsomorphicLayoutEffect(() => {
59
+ if (!open) {
60
+ setMenuPosition(null)
61
+ return
62
+ }
63
+ const updatePosition = () => {
64
+ const trigger = buttonRef.current
65
+ if (!trigger) return
66
+ const rect = trigger.getBoundingClientRect()
67
+ setMenuPosition({
68
+ top: rect.bottom + 8,
69
+ right: Math.max(0, window.innerWidth - rect.right),
70
+ })
71
+ }
72
+ updatePosition()
73
+ window.addEventListener('resize', updatePosition)
74
+ window.addEventListener('scroll', updatePosition, true)
75
+ return () => {
76
+ window.removeEventListener('resize', updatePosition)
77
+ window.removeEventListener('scroll', updatePosition, true)
78
+ }
79
+ }, [open])
80
+
51
81
  const isDark = resolvedTheme === 'dark'
52
82
 
53
83
  // Close on click outside
@@ -336,10 +366,11 @@ export function ProfileDropdown({
336
366
  <User className="size-4" />
337
367
  </IconButton>
338
368
 
339
- {open && (
369
+ {open && menuPosition && typeof document !== 'undefined' && createPortal(
340
370
  <div
341
371
  ref={menuRef}
342
- className="absolute right-0 top-full z-popover mt-2 w-64 overflow-hidden rounded-lg border bg-popover p-0 shadow-lg"
372
+ className="fixed z-popover w-64 overflow-hidden rounded-lg border bg-popover p-0 shadow-lg"
373
+ style={{ top: menuPosition.top, right: menuPosition.right }}
343
374
  role="menu"
344
375
  data-testid="profile-dropdown"
345
376
  >
@@ -384,7 +415,8 @@ export function ProfileDropdown({
384
415
  context={injectionContext}
385
416
  />
386
417
  </div>
387
- </div>
418
+ </div>,
419
+ document.body,
388
420
  )}
389
421
  </div>
390
422
  )