From 239f4002013f8660347e9bd51758111e998924fe Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 6 May 2026 11:05:26 -0400 Subject: [PATCH 01/16] feat(payload): add versionInSettingsMenu config + dependencies on sanitized admin --- packages/payload/src/config/types.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 8bbfcbf8eb3..779155f0de2 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -1137,6 +1137,13 @@ export type Config = { } /** The slug of a Collection that you want to be used to log in to the Admin dashboard. */ user?: string + /** + * When false, the built-in Payload version item is not auto-injected into + * `admin.components.settingsMenu`. Users can still add `@payloadcms/ui#PayloadVersionMenuItem` + * manually to control placement. + * @default true + */ + versionInSettingsMenu?: boolean } /** @@ -1582,6 +1589,12 @@ export type Config = { */ export type SanitizedConfig = { admin: { + /** + * Map of resolved Payload-related package versions (name → semver), computed + * at config sanitization. Always contains a `payload` entry. Missing packages + * are omitted. Used by the built-in version menu item. + */ + packageVersions: Record timezones: SanitizedTimezoneConfig } & DeepRequired blocks?: FlattenedBlock[] From 557343f42e64f11a0226140433b8113504a19ca9 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 6 May 2026 11:22:12 -0400 Subject: [PATCH 02/16] feat(payload): compute admin.packageVersions and auto-inject version menu item --- packages/payload/src/config/sanitize.ts | 10 +++++ .../src/config/sanitizeDependencies.spec.ts | 29 +++++++++++++ .../src/config/sanitizeDependencies.ts | 42 +++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 packages/payload/src/config/sanitizeDependencies.spec.ts create mode 100644 packages/payload/src/config/sanitizeDependencies.ts diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index e65799c6e6d..3a66b5209b1 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -39,6 +39,7 @@ import { validateTimezones } from '../utilities/validateTimezones.js' import { getSchedulePublishTask } from '../versions/schedule/job.js' import { addDefaultsToConfig } from './defaults.js' import { addOrderableEndpoint, addOrderableFieldsAndHook } from './orderable/index.js' +import { sanitizeDependencies } from './sanitizeDependencies.js' const sanitizeAdminConfig = (configToSanitize: Config): Partial => { const sanitizedConfig = { ...configToSanitize } @@ -118,6 +119,15 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise = sanitizeAdminConfig(configWithDefaults) + config.admin!.packageVersions = await sanitizeDependencies() + + if (config.admin!.versionInSettingsMenu !== false) { + const adminComponents = config.admin!.components ?? {} + config.admin!.components = adminComponents + const existing = adminComponents.settingsMenu ?? [] + adminComponents.settingsMenu = [...existing, '@payloadcms/ui#PayloadVersionMenuItem'] + } + if (!config.endpoints) { config.endpoints = [] } diff --git a/packages/payload/src/config/sanitizeDependencies.spec.ts b/packages/payload/src/config/sanitizeDependencies.spec.ts new file mode 100644 index 00000000000..62dfd357f04 --- /dev/null +++ b/packages/payload/src/config/sanitizeDependencies.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest' + +import { sanitizeDependencies } from './sanitizeDependencies.js' + +describe('sanitizeDependencies', () => { + it('returns a record always containing a payload entry', async () => { + const deps = await sanitizeDependencies() + expect(deps.payload).toMatch(/^\d+\.\d+\.\d+/) + }) + + it('returns alphabetically sorted keys', async () => { + const deps = await sanitizeDependencies() + const keys = Object.keys(deps) + const sorted = [...keys].sort((a, b) => a.localeCompare(b)) + expect(keys).toEqual(sorted) + }) + + it('falls back to a payload-only map when getDependencies throws', async () => { + vi.doMock('../utilities/dependencies/getDependencies.js', () => ({ + getDependencies: () => Promise.reject(new Error('boom')), + })) + vi.resetModules() + const { sanitizeDependencies: freshSanitize } = await import('./sanitizeDependencies.js') + const deps = await freshSanitize() + expect(Object.keys(deps)).toEqual(['payload']) + expect(deps.payload).toMatch(/^\d+\.\d+\.\d+/) + vi.doUnmock('../utilities/dependencies/getDependencies.js') + }) +}) diff --git a/packages/payload/src/config/sanitizeDependencies.ts b/packages/payload/src/config/sanitizeDependencies.ts new file mode 100644 index 00000000000..e79f32dbe20 --- /dev/null +++ b/packages/payload/src/config/sanitizeDependencies.ts @@ -0,0 +1,42 @@ +import { promises as fs } from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +import { getDependencies } from '../utilities/dependencies/getDependencies.js' +import { PAYLOAD_PACKAGE_LIST } from '../versions/payloadPackageList.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) +const payloadPkgRoot = path.resolve(dirname, '../../') + +const readPayloadFallbackVersion = async (): Promise => { + try { + const pkgJsonPath = path.join(payloadPkgRoot, 'package.json') + const raw = await fs.readFile(pkgJsonPath, 'utf8') + const parsed = JSON.parse(raw) as { version?: string } + return parsed.version ?? '0.0.0' + } catch { + return '0.0.0' + } +} + +/** + * Scan the user's project for installed Payload-related packages and return + * an alphabetically-sorted map of name → version. Always contains `payload`. + * Never throws — falls back to a single-entry map if the scan fails. + */ +export const sanitizeDependencies = async (): Promise> => { + try { + const result = await getDependencies(process.cwd(), ['payload', ...PAYLOAD_PACKAGE_LIST]) + const entries = [...result.resolved.entries()] + .map(([name, { version }]) => [name, version] as const) + .sort(([a], [b]) => a.localeCompare(b)) + const map = Object.fromEntries(entries) + if (!map.payload) { + map.payload = await readPayloadFallbackVersion() + } + return map + } catch { + return { payload: await readPayloadFallbackVersion() } + } +} From 3ca3d5c1266fc85e16788b6211dcfeea12cf4323 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 6 May 2026 12:01:42 -0400 Subject: [PATCH 03/16] feat(ui): add formatForClipboard helper for payload version export --- .../formatForClipboard.spec.ts | 22 +++++++++++++++++++ .../formatForClipboard.ts | 12 ++++++++++ 2 files changed, 34 insertions(+) create mode 100644 packages/ui/src/elements/PayloadVersionMenuItem/formatForClipboard.spec.ts create mode 100644 packages/ui/src/elements/PayloadVersionMenuItem/formatForClipboard.ts diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/formatForClipboard.spec.ts b/packages/ui/src/elements/PayloadVersionMenuItem/formatForClipboard.spec.ts new file mode 100644 index 00000000000..1a6bdcab3b1 --- /dev/null +++ b/packages/ui/src/elements/PayloadVersionMenuItem/formatForClipboard.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { formatForClipboard } from './formatForClipboard.js' + +describe('formatForClipboard', () => { + it('puts payload first, rest alphabetical', () => { + expect( + formatForClipboard({ + '@payloadcms/ui': '3.0.0', + '@payloadcms/db-mongodb': '3.0.0', + payload: '3.0.0', + }), + ).toBe('payload: 3.0.0\n@payloadcms/db-mongodb: 3.0.0\n@payloadcms/ui: 3.0.0') + }) + + it('returns empty string when payload missing', () => { + expect(formatForClipboard({})).toBe('') + }) + + it('handles a payload-only map', () => { + expect(formatForClipboard({ payload: '3.1.2' })).toBe('payload: 3.1.2') + }) +}) diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/formatForClipboard.ts b/packages/ui/src/elements/PayloadVersionMenuItem/formatForClipboard.ts new file mode 100644 index 00000000000..c8bd1afb534 --- /dev/null +++ b/packages/ui/src/elements/PayloadVersionMenuItem/formatForClipboard.ts @@ -0,0 +1,12 @@ +export const formatForClipboard = (deps: Record): string => { + if (!deps.payload) { + return '' + } + + const rest = Object.keys(deps) + .filter((k) => k !== 'payload') + .sort((a, b) => a.localeCompare(b)) + + const lines = [`payload: ${deps.payload}`, ...rest.map((k) => `${k}: ${deps[k]}`)] + return lines.join('\n') +} From 1f10bb7e15f90326d1f2df1297732fa1c44fba3c Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 6 May 2026 12:09:39 -0400 Subject: [PATCH 04/16] feat(ui): add PayloadVersionMenuItem with version drawer and copy button --- .../PayloadVersionMenuItem/CopyButton.tsx | 33 +++++++++++++++++ .../PayloadVersionModalTrigger.tsx | 36 +++++++++++++++++++ .../PayloadVersionMenuItem/VersionList.tsx | 25 +++++++++++++ .../PayloadVersionMenuItem/index.scss | 30 ++++++++++++++++ .../elements/PayloadVersionMenuItem/index.tsx | 11 ++++++ packages/ui/src/exports/client/index.ts | 1 + packages/ui/src/exports/rsc/index.ts | 1 + 7 files changed, 137 insertions(+) create mode 100644 packages/ui/src/elements/PayloadVersionMenuItem/CopyButton.tsx create mode 100644 packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx create mode 100644 packages/ui/src/elements/PayloadVersionMenuItem/VersionList.tsx create mode 100644 packages/ui/src/elements/PayloadVersionMenuItem/index.scss create mode 100644 packages/ui/src/elements/PayloadVersionMenuItem/index.tsx diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/CopyButton.tsx b/packages/ui/src/elements/PayloadVersionMenuItem/CopyButton.tsx new file mode 100644 index 00000000000..e1d71e8d355 --- /dev/null +++ b/packages/ui/src/elements/PayloadVersionMenuItem/CopyButton.tsx @@ -0,0 +1,33 @@ +'use client' + +import React, { useState } from 'react' + +// eslint-disable-next-line payload/no-imports-from-exports-dir -- Client component imports from same package's client bundle +import { Button, useTranslation } from '../../exports/client/index.js' + +export const CopyButton: React.FC<{ text: string }> = ({ text }) => { + const { t } = useTranslation() + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + // clipboard unavailable; silently no-op + } + } + + return ( + + ) +} diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx b/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx new file mode 100644 index 00000000000..65cea749280 --- /dev/null +++ b/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx @@ -0,0 +1,36 @@ +'use client' + +import React from 'react' + +// eslint-disable-next-line payload/no-imports-from-exports-dir -- Client component imports from same package's client bundle +import { Drawer, PopupList, useModal } from '../../exports/client/index.js' +import { CopyButton } from './CopyButton.js' +import { formatForClipboard } from './formatForClipboard.js' +import { VersionList } from './VersionList.js' +import './index.scss' + +const drawerSlug = 'payload-version-info' + +export const PayloadVersionModalTrigger: React.FC<{ + versions: Record +}> = ({ versions }) => { + const { openModal } = useModal() + const payloadVersion = versions.payload ?? '0.0.0' + + return ( + + openModal(drawerSlug)} + > + {`Payload v${payloadVersion}`} + + +
+ + +
+
+
+ ) +} diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/VersionList.tsx b/packages/ui/src/elements/PayloadVersionMenuItem/VersionList.tsx new file mode 100644 index 00000000000..5a2612af18c --- /dev/null +++ b/packages/ui/src/elements/PayloadVersionMenuItem/VersionList.tsx @@ -0,0 +1,25 @@ +'use client' + +import React from 'react' + +export const VersionList: React.FC<{ + versions: Record +}> = ({ versions }) => { + const ordered = [ + ['payload', versions.payload], + ...Object.entries(versions) + .filter(([k]) => k !== 'payload') + .sort(([a], [b]) => a.localeCompare(b)), + ].filter(([, v]) => Boolean(v)) as Array<[string, string]> + + return ( +
+ {ordered.map(([name, version]) => ( +
+
{name}
+
{version}
+
+ ))} +
+ ) +} diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/index.scss b/packages/ui/src/elements/PayloadVersionMenuItem/index.scss new file mode 100644 index 00000000000..f01ab258f13 --- /dev/null +++ b/packages/ui/src/elements/PayloadVersionMenuItem/index.scss @@ -0,0 +1,30 @@ +.payload-version-menu-item__content { + padding: var(--base); + display: flex; + flex-direction: column; + gap: var(--base); +} + +.payload-version-menu-item__list { + margin: 0; + display: flex; + flex-direction: column; + gap: calc(var(--base) / 2); +} + +.payload-version-menu-item__row { + display: flex; + justify-content: space-between; + gap: var(--base); + font-family: var(--font-mono); + font-size: 0.875rem; +} + +.payload-version-menu-item__row dt { + color: var(--theme-elevation-800); +} + +.payload-version-menu-item__row dd { + margin: 0; + color: var(--theme-elevation-500); +} diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/index.tsx b/packages/ui/src/elements/PayloadVersionMenuItem/index.tsx new file mode 100644 index 00000000000..4ca7565a254 --- /dev/null +++ b/packages/ui/src/elements/PayloadVersionMenuItem/index.tsx @@ -0,0 +1,11 @@ +import type { ServerProps } from 'payload' + +import React from 'react' + +// eslint-disable-next-line payload/no-imports-from-exports-dir -- Server component must reference exports dir for proper client boundary +import { PayloadVersionModalTrigger } from '../../exports/client/index.js' + +export const PayloadVersionMenuItem: React.FC = ({ payload }) => { + const versions = payload?.config?.admin?.packageVersions ?? { payload: '0.0.0' } + return +} diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index 82ae9c29569..d27ccb158e9 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -489,4 +489,5 @@ export type { } from '../../forms/fieldSchemasToFormState/serverFunctions/renderFieldServerFn.js' export { useLivePreviewContext } from '../../providers/LivePreview/context.js' +export { PayloadVersionModalTrigger } from '../../elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.js' export { LivePreviewWindow } from '../../elements/LivePreview/Window/index.js' diff --git a/packages/ui/src/exports/rsc/index.ts b/packages/ui/src/exports/rsc/index.ts index 01d92bd74ac..fd18de82b8e 100644 --- a/packages/ui/src/exports/rsc/index.ts +++ b/packages/ui/src/exports/rsc/index.ts @@ -8,6 +8,7 @@ export { getHTMLDiffComponents, unescapeDiffHTML, } from '../../elements/HTMLDiff/index.js' +export { PayloadVersionMenuItem } from '../../elements/PayloadVersionMenuItem/index.js' export { _internal_renderFieldHandler } from '../../forms/fieldSchemasToFormState/serverFunctions/renderFieldServerFn.js' export { File } from '../../graphics/File/index.js' export { CheckIcon } from '../../icons/Check/index.js' From 20dd5b3d06fab784038fc16dc957617f28d042cb Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 6 May 2026 12:14:50 -0400 Subject: [PATCH 05/16] test(admin): cover version menu auto-injection and opt-out --- .../payload/src/config/versionMenu.spec.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 packages/payload/src/config/versionMenu.spec.ts diff --git a/packages/payload/src/config/versionMenu.spec.ts b/packages/payload/src/config/versionMenu.spec.ts new file mode 100644 index 00000000000..d09e27a8d78 --- /dev/null +++ b/packages/payload/src/config/versionMenu.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' + +import type { Config } from './types.js' + +import { sanitizeConfig } from './sanitize.js' + +const VERSION_MENU_ITEM = '@payloadcms/ui#PayloadVersionMenuItem' + +/** Minimal config shape — db/editor are not used during the sanitization paths we test */ +// @ts-expect-error +const minimalConfig: Config = { + collections: [], +} + +describe('version menu auto-injection', () => { + it('populates packageVersions.payload with a semver string', async () => { + const sanitized = await sanitizeConfig(minimalConfig) + + expect(sanitized.admin.packageVersions.payload).toMatch(/^\d+\.\d+\.\d+/) + }) + + it('appends the menu item after user-defined settingsMenu entries', async () => { + const item1 = '/components/Item1.tsx#Item1' + const item2 = '/components/Item2.tsx#Item2' + + // @ts-expect-error + const config: Config = { + collections: [], + admin: { + components: { + settingsMenu: [item1, item2], + }, + }, + } + + const sanitized = await sanitizeConfig(config) + const menu = sanitized.admin.components!.settingsMenu! + + expect(menu.slice(0, 2)).toEqual([item1, item2]) + expect(menu[menu.length - 1]).toBe(VERSION_MENU_ITEM) + }) + + it('does NOT inject the menu item when versionInSettingsMenu is false', async () => { + // @ts-expect-error + const config: Config = { + collections: [], + admin: { + versionInSettingsMenu: false, + components: { + settingsMenu: ['/components/Item1.tsx#Item1'], + }, + }, + } + + const sanitized = await sanitizeConfig(config) + const menu = sanitized.admin.components?.settingsMenu ?? [] + + expect(menu).not.toContain(VERSION_MENU_ITEM) + }) + + it('injects the menu item when no settingsMenu is defined', async () => { + // @ts-expect-error + const config: Config = { + collections: [], + admin: { + components: {}, + }, + } + + const sanitized = await sanitizeConfig(config) + const menu = sanitized.admin.components!.settingsMenu! + + expect(menu).toContain(VERSION_MENU_ITEM) + }) +}) From e427ebe1c46373baeef5ccfaea7d9b0b342e85b1 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 6 May 2026 12:32:18 -0400 Subject: [PATCH 06/16] test(admin): e2e for payload version drawer and clipboard copy --- test/admin/e2e/general/e2e.spec.ts | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/admin/e2e/general/e2e.spec.ts b/test/admin/e2e/general/e2e.spec.ts index 159772eaf46..c4c83c111c5 100644 --- a/test/admin/e2e/general/e2e.spec.ts +++ b/test/admin/e2e/general/e2e.spec.ts @@ -1154,6 +1154,37 @@ describe('General', () => { }) }) }) + + describe('Payload version menu item', () => { + test('should show Payload version button, open drawer, and copy version to clipboard', async ({}, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + await page.goto(postsUrl.admin) + await openNav(page) + + const gearButton = page.locator('.nav__controls .popup#settings-menu .popup-button') + await gearButton.click() + + const versionButton = page.locator( + '[data-popup-id="settings-menu"] .popup-button-list__button', + { + hasText: /^Payload v\d+\.\d+/, + }, + ) + await expect(versionButton).toBeVisible() + await versionButton.click() + + const drawer = page.locator('#payload-version-info') + await expect(drawer).toBeVisible() + + await expect(drawer.locator('dt', { hasText: 'payload' }).first()).toBeVisible() + + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await drawer.getByRole('button', { name: /^Copy/ }).click() + + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) + expect(clipboardText).toMatch(/^payload: \d+\.\d+\.\d+/) + }) + }) }) async function createPost(overrides?: Partial): Promise { From f8f49cf0163e8f9f83fa16a2c55213d7680d4865 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 6 May 2026 13:18:00 -0400 Subject: [PATCH 07/16] fix(payload): use @payloadcms/ui/rsc subpath for PayloadVersionMenuItem injection Server-only components must be referenced via the /rsc subpath, matching the existing pattern in injectHierarchyButton.ts. The bare @payloadcms/ui entry resolves to the client bundle, where PayloadVersionMenuItem is not exported, causing 'getFromImportMap: PayloadComponent not found' at runtime. --- packages/payload/src/config/sanitize.ts | 2 +- packages/payload/src/config/types.ts | 2 +- packages/payload/src/config/versionMenu.spec.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index 3a66b5209b1..a0c064692f1 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -125,7 +125,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise Date: Wed, 6 May 2026 13:28:19 -0400 Subject: [PATCH 08/16] feat(ui): use Modal instead of Drawer for PayloadVersionMenuItem --- .../PayloadVersionModalTrigger.tsx | 39 ++++--- .../PayloadVersionMenuItem/index.scss | 102 +++++++++++++----- 2 files changed, 103 insertions(+), 38 deletions(-) diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx b/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx index 65cea749280..34cf4188398 100644 --- a/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx +++ b/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx @@ -3,34 +3,47 @@ import React from 'react' // eslint-disable-next-line payload/no-imports-from-exports-dir -- Client component imports from same package's client bundle -import { Drawer, PopupList, useModal } from '../../exports/client/index.js' +import { Modal, PopupList, useModal } from '../../exports/client/index.js' import { CopyButton } from './CopyButton.js' import { formatForClipboard } from './formatForClipboard.js' import { VersionList } from './VersionList.js' import './index.scss' -const drawerSlug = 'payload-version-info' +const baseClass = 'payload-version-menu-item' +const modalSlug = 'payload-version-info' export const PayloadVersionModalTrigger: React.FC<{ versions: Record }> = ({ versions }) => { - const { openModal } = useModal() + const { closeModal, isModalOpen, openModal } = useModal() const payloadVersion = versions.payload ?? '0.0.0' return ( - openModal(drawerSlug)} - > + openModal(modalSlug)}> {`Payload v${payloadVersion}`} - -
- - -
-
+ {isModalOpen(modalSlug) && ( + +
+
+

Payload Version Info

+ +
+ +
+ +
+
+
+ )}
) } diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/index.scss b/packages/ui/src/elements/PayloadVersionMenuItem/index.scss index f01ab258f13..4d018cb051e 100644 --- a/packages/ui/src/elements/PayloadVersionMenuItem/index.scss +++ b/packages/ui/src/elements/PayloadVersionMenuItem/index.scss @@ -1,30 +1,82 @@ -.payload-version-menu-item__content { - padding: var(--base); - display: flex; - flex-direction: column; - gap: var(--base); -} +@import '../../scss/styles.scss'; -.payload-version-menu-item__list { - margin: 0; - display: flex; - flex-direction: column; - gap: calc(var(--base) / 2); -} +@layer payload-default { + .payload-version-menu-item { + @include blur-bg; + display: flex; + align-items: center; + justify-content: center; + height: 100%; -.payload-version-menu-item__row { - display: flex; - justify-content: space-between; - gap: var(--base); - font-family: var(--font-mono); - font-size: 0.875rem; -} + &__wrapper { + z-index: 1; + position: relative; + display: flex; + flex-direction: column; + gap: base(0.8); + padding: base(2); + max-width: base(36); + min-width: base(20); + background: var(--theme-bg); + border: 1px solid var(--theme-elevation-150); + border-radius: var(--style-radius-m); + } -.payload-version-menu-item__row dt { - color: var(--theme-elevation-800); -} + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: base(0.5); + + h2 { + margin: 0; + font-size: 1.125rem; + } + } + + &__close { + background: transparent; + border: 0; + cursor: pointer; + font-size: 1.5rem; + line-height: 1; + padding: 0; + color: var(--theme-elevation-500); + + &:hover { + color: var(--theme-elevation-800); + } + } + + &__list { + margin: 0; + display: flex; + flex-direction: column; + gap: calc(var(--base) / 4); + max-height: 60vh; + overflow-y: auto; + } + + &__row { + display: flex; + justify-content: space-between; + gap: var(--base); + font-family: var(--font-mono); + font-size: 0.8125rem; + + dt { + color: var(--theme-elevation-800); + } + + dd { + margin: 0; + color: var(--theme-elevation-500); + } + } -.payload-version-menu-item__row dd { - margin: 0; - color: var(--theme-elevation-500); + &__controls { + display: flex; + justify-content: flex-end; + } + } } From f0661918642f9b35c6fa1ebc06b1acf91b81f237 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 6 May 2026 13:29:35 -0400 Subject: [PATCH 09/16] style(ui): lighter menu-item text and remove version list scroll --- packages/ui/src/elements/PayloadVersionMenuItem/index.scss | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/index.scss b/packages/ui/src/elements/PayloadVersionMenuItem/index.scss index 4d018cb051e..4130395c8b9 100644 --- a/packages/ui/src/elements/PayloadVersionMenuItem/index.scss +++ b/packages/ui/src/elements/PayloadVersionMenuItem/index.scss @@ -53,8 +53,10 @@ display: flex; flex-direction: column; gap: calc(var(--base) / 4); - max-height: 60vh; - overflow-y: auto; + } + + &__button { + color: var(--theme-elevation-500); } &__row { From 56fb4d8c1d59b658bf07f9bc5666d16ac5e8e228 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 6 May 2026 13:31:38 -0400 Subject: [PATCH 10/16] refactor(ui): use existing CopyToClipboard component for version export --- .../PayloadVersionMenuItem/CopyButton.tsx | 33 ------------------- .../PayloadVersionModalTrigger.tsx | 5 ++- 2 files changed, 2 insertions(+), 36 deletions(-) delete mode 100644 packages/ui/src/elements/PayloadVersionMenuItem/CopyButton.tsx diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/CopyButton.tsx b/packages/ui/src/elements/PayloadVersionMenuItem/CopyButton.tsx deleted file mode 100644 index e1d71e8d355..00000000000 --- a/packages/ui/src/elements/PayloadVersionMenuItem/CopyButton.tsx +++ /dev/null @@ -1,33 +0,0 @@ -'use client' - -import React, { useState } from 'react' - -// eslint-disable-next-line payload/no-imports-from-exports-dir -- Client component imports from same package's client bundle -import { Button, useTranslation } from '../../exports/client/index.js' - -export const CopyButton: React.FC<{ text: string }> = ({ text }) => { - const { t } = useTranslation() - const [copied, setCopied] = useState(false) - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(text) - setCopied(true) - setTimeout(() => setCopied(false), 1500) - } catch { - // clipboard unavailable; silently no-op - } - } - - return ( - - ) -} diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx b/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx index 34cf4188398..c64edbb7464 100644 --- a/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx +++ b/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx @@ -3,8 +3,7 @@ import React from 'react' // eslint-disable-next-line payload/no-imports-from-exports-dir -- Client component imports from same package's client bundle -import { Modal, PopupList, useModal } from '../../exports/client/index.js' -import { CopyButton } from './CopyButton.js' +import { CopyToClipboard, Modal, PopupList, useModal } from '../../exports/client/index.js' import { formatForClipboard } from './formatForClipboard.js' import { VersionList } from './VersionList.js' import './index.scss' @@ -39,7 +38,7 @@ export const PayloadVersionModalTrigger: React.FC<{
- +
From f916efb9639d091fe2fea25ca727b0141821b601 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 6 May 2026 13:32:59 -0400 Subject: [PATCH 11/16] style(ui): inline copy button with version info header --- .../PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx | 4 +--- packages/ui/src/elements/PayloadVersionMenuItem/index.scss | 6 +----- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx b/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx index c64edbb7464..7c52e213d62 100644 --- a/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx +++ b/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx @@ -27,6 +27,7 @@ export const PayloadVersionModalTrigger: React.FC<{

Payload Version Info

+
-
- -
)} diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/index.scss b/packages/ui/src/elements/PayloadVersionMenuItem/index.scss index 4130395c8b9..ae083cd9271 100644 --- a/packages/ui/src/elements/PayloadVersionMenuItem/index.scss +++ b/packages/ui/src/elements/PayloadVersionMenuItem/index.scss @@ -25,11 +25,11 @@ &__header { display: flex; align-items: center; - justify-content: space-between; gap: base(0.5); h2 { margin: 0; + margin-right: auto; font-size: 1.125rem; } } @@ -76,9 +76,5 @@ } } - &__controls { - display: flex; - justify-content: flex-end; - } } } From 792c2b593722c5e20b38cbc85b07180d4684abbc Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 6 May 2026 13:35:41 -0400 Subject: [PATCH 12/16] refactor(ui): migrate PayloadVersionMenuItem styles to vanilla CSS with v4 tokens --- .../PayloadVersionModalTrigger.tsx | 2 +- .../{index.scss => index.css} | 40 +++++++++---------- 2 files changed, 19 insertions(+), 23 deletions(-) rename packages/ui/src/elements/PayloadVersionMenuItem/{index.scss => index.css} (59%) diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx b/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx index 7c52e213d62..70728a6ab93 100644 --- a/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx +++ b/packages/ui/src/elements/PayloadVersionMenuItem/PayloadVersionModalTrigger.tsx @@ -6,7 +6,7 @@ import React from 'react' import { CopyToClipboard, Modal, PopupList, useModal } from '../../exports/client/index.js' import { formatForClipboard } from './formatForClipboard.js' import { VersionList } from './VersionList.js' -import './index.scss' +import './index.css' const baseClass = 'payload-version-menu-item' const modalSlug = 'payload-version-info' diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/index.scss b/packages/ui/src/elements/PayloadVersionMenuItem/index.css similarity index 59% rename from packages/ui/src/elements/PayloadVersionMenuItem/index.scss rename to packages/ui/src/elements/PayloadVersionMenuItem/index.css index ae083cd9271..5ced24c37f3 100644 --- a/packages/ui/src/elements/PayloadVersionMenuItem/index.scss +++ b/packages/ui/src/elements/PayloadVersionMenuItem/index.css @@ -1,8 +1,5 @@ -@import '../../scss/styles.scss'; - @layer payload-default { .payload-version-menu-item { - @include blur-bg; display: flex; align-items: center; justify-content: center; @@ -13,19 +10,19 @@ position: relative; display: flex; flex-direction: column; - gap: base(0.8); - padding: base(2); - max-width: base(36); - min-width: base(20); - background: var(--theme-bg); - border: 1px solid var(--theme-elevation-150); - border-radius: var(--style-radius-m); + gap: var(--spacer-2-5); + padding: var(--spacer-5); + max-width: 36rem; + min-width: 20rem; + background: var(--bg-default-default); + border: 1px solid var(--border-default-default); + border-radius: var(--radius-medium); } &__header { display: flex; align-items: center; - gap: base(0.5); + gap: var(--spacer-2); h2 { margin: 0; @@ -41,10 +38,10 @@ font-size: 1.5rem; line-height: 1; padding: 0; - color: var(--theme-elevation-500); + color: var(--text-default-secondary); &:hover { - color: var(--theme-elevation-800); + color: var(--text-default-default); } } @@ -52,29 +49,28 @@ margin: 0; display: flex; flex-direction: column; - gap: calc(var(--base) / 4); - } - - &__button { - color: var(--theme-elevation-500); + gap: var(--spacer-1); } &__row { display: flex; justify-content: space-between; - gap: var(--base); - font-family: var(--font-mono); + gap: var(--spacer-3); + font-family: var(--font-family-mono); font-size: 0.8125rem; dt { - color: var(--theme-elevation-800); + color: var(--text-default-default); } dd { margin: 0; - color: var(--theme-elevation-500); + color: var(--text-default-secondary); } } + &__button { + color: var(--text-default-secondary); + } } } From 2dc85a03cf4aac6bc96d565d5d2e95173faff219 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 6 May 2026 13:37:16 -0400 Subject: [PATCH 13/16] fix(ui): use top-level BEM selectors instead of unsupported native CSS nesting concat --- .../elements/PayloadVersionMenuItem/index.css | 116 +++++++++--------- 1 file changed, 58 insertions(+), 58 deletions(-) diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/index.css b/packages/ui/src/elements/PayloadVersionMenuItem/index.css index 5ced24c37f3..82f59b7d1d6 100644 --- a/packages/ui/src/elements/PayloadVersionMenuItem/index.css +++ b/packages/ui/src/elements/PayloadVersionMenuItem/index.css @@ -4,73 +4,73 @@ align-items: center; justify-content: center; height: 100%; + } - &__wrapper { - z-index: 1; - position: relative; - display: flex; - flex-direction: column; - gap: var(--spacer-2-5); - padding: var(--spacer-5); - max-width: 36rem; - min-width: 20rem; - background: var(--bg-default-default); - border: 1px solid var(--border-default-default); - border-radius: var(--radius-medium); - } + .payload-version-menu-item__wrapper { + z-index: 1; + position: relative; + display: flex; + flex-direction: column; + gap: var(--spacer-2-5); + padding: var(--spacer-5); + max-width: 36rem; + min-width: 20rem; + background: var(--bg-default-default); + border: 1px solid var(--border-default-default); + border-radius: var(--radius-medium); + } - &__header { - display: flex; - align-items: center; - gap: var(--spacer-2); + .payload-version-menu-item__header { + display: flex; + align-items: center; + gap: var(--spacer-2); + } - h2 { - margin: 0; - margin-right: auto; - font-size: 1.125rem; - } - } + .payload-version-menu-item__header h2 { + margin: 0; + margin-right: auto; + font-size: 1.125rem; + } - &__close { - background: transparent; - border: 0; - cursor: pointer; - font-size: 1.5rem; - line-height: 1; - padding: 0; - color: var(--text-default-secondary); + .payload-version-menu-item__close { + background: transparent; + border: 0; + cursor: pointer; + font-size: 1.5rem; + line-height: 1; + padding: 0; + color: var(--text-default-secondary); + } - &:hover { - color: var(--text-default-default); - } - } + .payload-version-menu-item__close:hover { + color: var(--text-default-default); + } - &__list { - margin: 0; - display: flex; - flex-direction: column; - gap: var(--spacer-1); - } + .payload-version-menu-item__list { + margin: 0; + display: flex; + flex-direction: column; + gap: var(--spacer-1); + } - &__row { - display: flex; - justify-content: space-between; - gap: var(--spacer-3); - font-family: var(--font-family-mono); - font-size: 0.8125rem; + .payload-version-menu-item__row { + display: flex; + justify-content: space-between; + gap: var(--spacer-3); + font-family: var(--font-family-mono); + font-size: 0.8125rem; + } - dt { - color: var(--text-default-default); - } + .payload-version-menu-item__row dt { + color: var(--text-default-default); + } - dd { - margin: 0; - color: var(--text-default-secondary); - } - } + .payload-version-menu-item__row dd { + margin: 0; + color: var(--text-default-secondary); + } - &__button { - color: var(--text-default-secondary); - } + .payload-version-menu-item__button { + color: var(--text-default-secondary); } } From d26bad5b36fc1cd768b3ff65d2e3615ccfb6d4c2 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 6 May 2026 13:38:26 -0400 Subject: [PATCH 14/16] style(ui): unbold package name/version rows --- packages/ui/src/elements/PayloadVersionMenuItem/index.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/elements/PayloadVersionMenuItem/index.css b/packages/ui/src/elements/PayloadVersionMenuItem/index.css index 82f59b7d1d6..9e59b858b23 100644 --- a/packages/ui/src/elements/PayloadVersionMenuItem/index.css +++ b/packages/ui/src/elements/PayloadVersionMenuItem/index.css @@ -59,6 +59,7 @@ gap: var(--spacer-3); font-family: var(--font-family-mono); font-size: 0.8125rem; + font-weight: normal; } .payload-version-menu-item__row dt { From 458ab5ccc5a26d00b5e35ace780ffb99b4ad9719 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 6 May 2026 13:53:50 -0400 Subject: [PATCH 15/16] feat(payload): strip subpath probes from displayed package names --- .../src/config/sanitizeDependencies.ts | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/payload/src/config/sanitizeDependencies.ts b/packages/payload/src/config/sanitizeDependencies.ts index e79f32dbe20..0e2c5788aa6 100644 --- a/packages/payload/src/config/sanitizeDependencies.ts +++ b/packages/payload/src/config/sanitizeDependencies.ts @@ -20,6 +20,20 @@ const readPayloadFallbackVersion = async (): Promise => { } } +/** + * Strip any subpath after the npm package name. For `@payloadcms/next/utilities`, + * the package name is `@payloadcms/next`. For `lodash/get`, it's `lodash`. + * The list passed to `getDependencies` includes subpath probes (e.g. to verify + * a specific export resolves), but for display we only want the bare package. + */ +const stripSubpath = (specifier: string): string => { + if (specifier.startsWith('@')) { + const [scope, name] = specifier.split('/') + return name ? `${scope}/${name}` : specifier + } + return specifier.split('/')[0] ?? specifier +} + /** * Scan the user's project for installed Payload-related packages and return * an alphabetically-sorted map of name → version. Always contains `payload`. @@ -28,14 +42,19 @@ const readPayloadFallbackVersion = async (): Promise => { export const sanitizeDependencies = async (): Promise> => { try { const result = await getDependencies(process.cwd(), ['payload', ...PAYLOAD_PACKAGE_LIST]) - const entries = [...result.resolved.entries()] - .map(([name, { version }]) => [name, version] as const) - .sort(([a], [b]) => a.localeCompare(b)) - const map = Object.fromEntries(entries) - if (!map.payload) { - map.payload = await readPayloadFallbackVersion() + const map: Record = {} + for (const [specifier, { version }] of result.resolved) { + const name = stripSubpath(specifier) + // If the same package is probed via multiple specifiers, keep the first hit. + if (!map[name]) { + map[name] = version + } + } + const sorted = Object.fromEntries(Object.entries(map).sort(([a], [b]) => a.localeCompare(b))) + if (!sorted.payload) { + sorted.payload = await readPayloadFallbackVersion() } - return map + return sorted } catch { return { payload: await readPayloadFallbackVersion() } } From a292d48b5db43dd7a4bbd93bf8ce8a88225a2284 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 6 May 2026 14:03:06 -0400 Subject: [PATCH 16/16] docs: add learnings on /rsc subpath, vanilla CSS, and translation key parity --- CLAUDE.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 13b754ed746..bdd446e9a28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -233,6 +233,7 @@ Generate types for a test directory: `pnpm run dev:generate-types `. Dev mode (`pnpm dev`) doesn't catch these issues. + +### Styling + +New components prefer vanilla CSS (`index.css`) over SCSS, using v4 design tokens. Existing SCSS files are migrated as touched. + +- Wrap rules in `@layer payload-default { ... }` (matches Tooltip, PopupButtonList, and other v4 components). +- Use v4 tokens: `--bg-default-default`, `--text-default-secondary`, `--spacer-N`, `--radius-medium`, `--font-family-mono`, etc. Do not hardcode colors or pixels. +- **Native CSS nesting does not support BEM concatenation.** `&__wrapper` only works in SCSS. In vanilla CSS, write each BEM child as a top-level rule within the layer: + +```css +/* BAD - native CSS nesting cannot concat suffixes */ +.thing { + &__row { ... } +} + +/* GOOD - separate top-level rules */ +.thing { ... } +.thing__row { ... } +``` + +- `&` is still valid for pseudo-classes/states (`&:hover`, `&.is-open`) and descendants (`& .child`).