diff --git a/.changeset/12610-i18n-kernel-note-plain-text.md b/.changeset/12610-i18n-kernel-note-plain-text.md new file mode 100644 index 0000000000..48c828eea6 --- /dev/null +++ b/.changeset/12610-i18n-kernel-note-plain-text.md @@ -0,0 +1,7 @@ +--- +'@object-ui/i18n': patch +--- + +**`marketplace.installedAdditiveNote` is plain text in all ten packs** (objectui#12610). Each value dropped its leading `Note:` markup (in that pack's language); the sentence after it is unchanged. The console draws the key as the tooltip of Installed Apps' Uninstall, which renders text, not HTML. The key's name does not change. + +**Clause-②: yes.** The value is part of the published `en` pack and its nine siblings. A consumer that rendered this key as HTML now renders the same sentence without the bold "Note:" prefix; one that rendered it as text no longer shows literal tags. diff --git a/.changeset/12610-installed-apps-kernel-note-tooltip.md b/.changeset/12610-installed-apps-kernel-note-tooltip.md new file mode 100644 index 0000000000..c217538b59 --- /dev/null +++ b/.changeset/12610-installed-apps-kernel-note-tooltip.md @@ -0,0 +1,5 @@ +--- +'@object-ui/app-shell': patch +--- + +**Installed Apps' kernel note is the tooltip of the Uninstall it qualifies** (objectui#12610). The "kernel API is additive only" note was a paragraph at the foot of the list, shown under the empty state too. It now opens as a tooltip on hover or focus of a loaded local row's Uninstall, and is that button's accessible description. A not-loaded row's Uninstall carries no note, since that package left nothing in the running kernel (objectui#11645), and a cloud-bound list, which offers no Uninstall, carries none either. The note renders as text; the widget no longer passes it through `dangerouslySetInnerHTML`. diff --git a/.changeset/12610-settings-action-row-one-label.md b/.changeset/12610-settings-action-row-one-label.md new file mode 100644 index 0000000000..18dde4574c --- /dev/null +++ b/.changeset/12610-settings-action-row-one-label.md @@ -0,0 +1,5 @@ +--- +'@object-ui/console': patch +--- + +**A settings action row draws its label once, on the button** (objectui#12610). The settings renderer's `action_button` row printed the manifest's `label` as the row label and again on the button, so Mail Delivery read "Send test email" twice and AI & Embedder repeated "Test connection", "Reset to environment defaults" and "Test embedder". The button now carries the label alone. The row's left side holds the specifier's `description` when the manifest declares one, as one line of muted text, and nothing otherwise. Settings keys, values and save behaviour do not change. diff --git a/apps/console/src/pages/settings/SettingsField.tsx b/apps/console/src/pages/settings/SettingsField.tsx index 56027835b4..dcb14d10b0 100644 --- a/apps/console/src/pages/settings/SettingsField.tsx +++ b/apps/console/src/pages/settings/SettingsField.tsx @@ -313,14 +313,16 @@ export function SettingsField(props: SettingsFieldProps) { const actionLabel = labels ? labels.actionLabel(actionId, literalLabel) : literalLabel; + // One label per action row (objectui#12610): the button carries it. The + // row's left side holds the manifest's one-line `description` when it + // declares one, and nothing otherwise — the label is never drawn twice. return ( -
-
-
{actionLabel}
- {spec.description ? ( -

{spec.description}

- ) : null} -
+
+ {spec.description ? ( +

{spec.description}

+ ) : null} + ); + if (entry.notLoaded) return button; + return ( + + + {button} + +

{t('marketplace.installedAdditiveNote')}

+
+
+
+ ); + }; + if (!isAdmin) return ; return ( @@ -184,17 +222,7 @@ export function InstalledList() {
{source === 'local' && ( @@ -212,10 +240,11 @@ export function InstalledList() {
)} -

+ {/* The Uninstall tooltip only shows on hover or focus; this is what a + screen reader announces as the button's description. */} + {source === 'local' && items.some((entry) => !entry.notLoaded) && ( + {t('marketplace.installedAdditiveNote')} + )} ); } diff --git a/packages/app-shell/src/console/marketplace/__tests__/InstalledListWidget.kernelNoteTooltip-12610.test.tsx b/packages/app-shell/src/console/marketplace/__tests__/InstalledListWidget.kernelNoteTooltip-12610.test.tsx new file mode 100644 index 0000000000..a54e45983e --- /dev/null +++ b/packages/app-shell/src/console/marketplace/__tests__/InstalledListWidget.kernelNoteTooltip-12610.test.tsx @@ -0,0 +1,171 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Installed Apps' kernel note rides the Uninstall it qualifies, as a tooltip, + * instead of a paragraph under the list (objectui#12610). + * + * The settings walk measured the "kernel API is additive only" paragraph under + * the EMPTY state, where there is nothing to uninstall. The note says what + * Uninstall does to a loaded package (the running kernel keeps it until + * restart), so it belongs on that action, and only where it applies: + * + * - the empty state carries no note; + * - a loaded local row's Uninstall is described by the note and opens a + * tooltip with it, as plain text — no markup is drawn literally; + * - a not-loaded row's Uninstall carries no note: that package left nothing + * in the running kernel (objectui#11645); + * - every pack's value is plain text, since a tooltip renders it as text. + * + * The wire is stubbed `fetch` against the REAL `marketplaceApi`, as the + * not-loaded file beside this one does; text is the PACK's value for its key, + * through the real `I18nProvider`, never a sentence copied into this file. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nProvider } from '@object-ui/i18n'; +import { builtInLocales, en, zh } from '@object-ui/i18n/locales'; + +vi.mock('react-router-dom', () => { + const navigate = () => {}; + const params = {}; + return { useNavigate: () => navigate, useParams: () => params }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), +})); + +import { InstalledList } from '../InstalledListWidget'; + +type Pack = typeof en; +const EN = { defaultLanguage: 'en', detectBrowserLanguage: false } as const; +const ZH = { defaultLanguage: 'zh', detectBrowserLanguage: false } as const; + +const LOADED = { + packageId: 'com.example.todo', + versionId: 'ver_todo_1', + manifestId: 'com.example.todo', + version: '1.0.0', + installedAt: '2026-10-05T09:00:00.000Z', + withSampleData: true, + installedBy: 'admin@objectos.ai', +}; + +const REFUSED = { + packageId: 'com.example.crm', + versionId: 'ver_crm_3', + manifestId: 'com.example.crm', + version: '3.2.0', + installedAt: '2026-10-05T09:00:00.000Z', + notLoaded: { code: 'OS_PROTOCOL_INCOMPATIBLE', requiredRange: '^16' }, + installedBy: 'admin@objectos.ai', +}; + +const answer = (status: number, body: unknown) => ({ + ok: status >= 200 && status < 300, + status, + statusText: '', + json: async () => body, +}); + +/** Not cloud-bound: the widget lists the local install cache, `entries`. */ +function serveLocal(entries: Array>) { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + const path = new URL(String(input), 'http://console.test').pathname; + if (path === '/api/v1/cloud-connection/installed') return answer(404, {}); + if (path === '/api/v1/marketplace/install-local') { + return answer(200, { success: true, data: { items: entries, total: entries.length } }); + } + return answer(404, {}); + }), + ); +} + +function renderList(config: typeof EN | typeof ZH) { + return render( + + + , + ); +} + +const TITLE = { selector: 'div.font-semibold' } as const; + +async function card(manifestId: string): Promise { + const title = await screen.findByText(manifestId, TITLE); + const el = title.closest('div.rounded-lg'); + if (!el) throw new Error(`no card around ${manifestId}`); + return el as HTMLElement; +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe.each([ + ['en', EN, en as Pack], + ['zh', ZH, zh as unknown as Pack], +])('InstalledList — the kernel note is the Uninstall tooltip (objectui#12610) [%s]', (_lang, config, pack) => { + const note = pack.marketplace.installedAdditiveNote; + + it('the empty state carries no kernel note', async () => { + serveLocal([]); + renderList(config); + expect(await screen.findByText(pack.marketplace.installedEmpty)).toBeInTheDocument(); + expect(document.body.textContent).not.toContain(note); + }); + + it('a loaded row\'s Uninstall is described by the note, and hovering it opens a tooltip with it', async () => { + const user = userEvent.setup(); + serveLocal([LOADED]); + renderList(config); + + const row = await card('com.example.todo'); + const uninstall = within(row).getByRole('button', { name: pack.marketplace.action.uninstall }); + expect(uninstall).toHaveAccessibleDescription(note); + // The note is not a paragraph on the page any more: the only copy outside + // the tooltip is the one the button's description points at. + const described = document.getElementById(uninstall.getAttribute('aria-describedby') as string); + expect(described).toHaveClass('sr-only'); + + await user.hover(uninstall); + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent(note); + // Plain text: no tag drawn literally, no element injected from the value. + expect(tooltip.textContent).not.toMatch(/<\/?[a-z]/i); + expect(tooltip.querySelector('strong')).toBeNull(); + }); + + it('a not-loaded row\'s Uninstall carries no note — that package left nothing in the kernel', async () => { + serveLocal([REFUSED]); + renderList(config); + + const row = await card('com.example.crm'); + const uninstall = within(row).getByRole('button', { name: pack.marketplace.action.uninstall }); + expect(uninstall).not.toHaveAttribute('aria-describedby'); + expect(document.body.textContent).not.toContain(note); + }); +}); + +describe('every pack\'s kernel note is plain text (objectui#12610)', () => { + const packs = Object.entries(builtInLocales) as Array<[string, Pack]>; + + it('the walk below reads all ten packs', () => { + expect(packs.map(([lang]) => lang).sort()).toEqual( + ['ar', 'de', 'en', 'es', 'fr', 'ja', 'ko', 'pt', 'ru', 'zh'], + ); + }); + + it.each(packs)('%s: `marketplace.installedAdditiveNote` carries no markup', (_lang, pack) => { + const note = pack.marketplace.installedAdditiveNote; + expect(note.length).toBeGreaterThan(20); + expect(note).not.toMatch(/<\/?[a-z][^>]*>/i); + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 794ac2ed10..0aed75db29 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -3757,7 +3757,7 @@ const ar = { installedSubtitle: "حزم السوق المثبتة حالياً في نواة هذا الوقت التشغيل.", installedEmpty: "لا توجد تطبيقات سوق مثبتة في هذا الوقت التشغيل بعد.", browseLink: "استعراض السوق →", - installedAdditiveNote: "ملاحظة: واجهة برمجة النواة إضافية فقط — إلغاء التثبيت يزيل المانيفست من القرص لكي لا يتم تحميل الحزمة عند التشغيل التالي، لكن النواة الجارية تبقي التطبيق مسجلاً حتى إعادة التشغيل.", + installedAdditiveNote: "واجهة برمجة النواة إضافية فقط — إلغاء التثبيت يزيل المانيفست من القرص لكي لا يتم تحميل الحزمة عند التشغيل التالي، لكن النواة الجارية تبقي التطبيق مسجلاً حتى إعادة التشغيل.", installedAt: "تم التثبيت {{when}}", installedBy: "بواسطة {{user}}", installedPackageId: "الحزمة", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 3956dac602..333527dc3a 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -3482,7 +3482,7 @@ const de = { installedSubtitle: "Marktplatz-Pakete, die derzeit im Kernel dieses Laufzeitsystems installiert sind.", installedEmpty: "Noch keine Marktplatz-Apps in diesem Laufzeitsystem installiert.", browseLink: "Marktplatz durchsuchen →", - installedAdditiveNote: "Hinweis: Die Kernel-API ist nur additiv — Deinstallation entfernt das Manifest auf der Festplatte, damit das Paket beim nächsten Start nicht geladen wird, aber der laufende Kernel hält die App bis zum Neustart registriert.", + installedAdditiveNote: "Die Kernel-API ist nur additiv — Deinstallation entfernt das Manifest auf der Festplatte, damit das Paket beim nächsten Start nicht geladen wird, aber der laufende Kernel hält die App bis zum Neustart registriert.", installedAt: "Installiert {{when}}", installedBy: "von {{user}}", installedPackageId: "Paket", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index cd8052d7f3..d83595834f 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -4126,7 +4126,7 @@ const en = { installedSubtitle: 'Marketplace packages currently installed into this runtime\'s kernel. Cached manifests live in .objectstack/installed-packages/ and survive restarts.', installedEmpty: 'No marketplace apps installed in this runtime yet.', browseLink: 'Browse the marketplace →', - installedAdditiveNote: 'Note: The kernel API is additive only — uninstall removes the on-disk manifest so the package won\'t load on next boot, but the running kernel keeps the app registered until you restart the runtime.', + installedAdditiveNote: 'The kernel API is additive only — uninstall removes the on-disk manifest so the package won\'t load on next boot, but the running kernel keeps the app registered until you restart the runtime.', installedAt: 'Installed {{when}}', installedBy: 'by {{user}}', installedPackageId: 'package', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 949e421657..1fe0e64c0c 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -3549,7 +3549,7 @@ const es = { installedSubtitle: "Paquetes del marketplace instalados en el kernel de este runtime.", installedEmpty: "Aún no hay aplicaciones del marketplace instaladas en este runtime.", browseLink: "Explorar marketplace →", - installedAdditiveNote: "Nota: La API del kernel es solo aditiva — desinstalar elimina el manifiesto del disco para que el paquete no se cargue al próximo inicio, pero el kernel en ejecución mantiene la aplicación registrada hasta reiniciar.", + installedAdditiveNote: "La API del kernel es solo aditiva — desinstalar elimina el manifiesto del disco para que el paquete no se cargue al próximo inicio, pero el kernel en ejecución mantiene la aplicación registrada hasta reiniciar.", installedAt: "Instalado {{when}}", installedBy: "por {{user}}", installedPackageId: "Paquete", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 080f300eb6..bf0d0b5fa1 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -3546,7 +3546,7 @@ const fr = { installedSubtitle: "Paquets du marketplace actuellement installés dans le noyau de ce runtime.", installedEmpty: "Aucune application du marketplace installée dans ce runtime pour l'instant.", browseLink: "Parcourir le marketplace →", - installedAdditiveNote: "Remarque : L'API du noyau est uniquement additive — la désinstallation supprime le manifeste du disque pour que le paquet ne se charge pas au prochain démarrage, mais le noyau en cours d'exécution maintient l'application enregistrée jusqu'au redémarrage.", + installedAdditiveNote: "L'API du noyau est uniquement additive — la désinstallation supprime le manifeste du disque pour que le paquet ne se charge pas au prochain démarrage, mais le noyau en cours d'exécution maintient l'application enregistrée jusqu'au redémarrage.", installedAt: "Installé {{when}}", installedBy: "par {{user}}", installedPackageId: "Paquet", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index e668abb214..926e5a6ec6 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -3484,7 +3484,7 @@ const ja = { installedSubtitle: "このランタイムのカーネルにインストールされているマーケットプレイスパッケージ。", installedEmpty: "このランタイムにはまだマーケットプレイスアプリがインストールされていません。", browseLink: "マーケットプレイスを参照 →", - installedAdditiveNote: "注意:カーネルAPIは追加のみです。アンインストールするとディスク上のマニフェストが削除され、次回起動時にパッケージは読み込まれませんが、実行中のカーネルはランタイムを再起動するまでアプリを登録したままにします。", + installedAdditiveNote: "カーネルAPIは追加のみです。アンインストールするとディスク上のマニフェストが削除され、次回起動時にパッケージは読み込まれませんが、実行中のカーネルはランタイムを再起動するまでアプリを登録したままにします。", installedAt: "{{when}} にインストール", installedBy: "{{user}} による", installedPackageId: "パッケージ", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 7706bc5691..ff5942ad10 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -3481,7 +3481,7 @@ const ko = { installedSubtitle: "이 런타임의 커널에 현재 설치된 마켓플레이스 패키지.", installedEmpty: "아직 이 런타임에 마켓플레이스 앱이 설치되지 않았습니다.", browseLink: "마켓플레이스 탐색 →", - installedAdditiveNote: "참고: 커널 API는 추가 전용입니다 — 제거하면 디스크에서 매니페스트가 삭제되어 다음 시작 시 패키지가 로드되지 않지만, 실행 중인 커널은 재시작될 때까지 앱을 등록된 상태로 유지합니다.", + installedAdditiveNote: "커널 API는 추가 전용입니다 — 제거하면 디스크에서 매니페스트가 삭제되어 다음 시작 시 패키지가 로드되지 않지만, 실행 중인 커널은 재시작될 때까지 앱을 등록된 상태로 유지합니다.", installedAt: "{{when}} 설치됨", installedBy: "{{user}}에 의해", installedPackageId: "패키지", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 066f10ab96..a52278b6a6 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -3544,7 +3544,7 @@ const pt = { installedSubtitle: "Pacotes do marketplace atualmente instalados no kernel deste runtime.", installedEmpty: "Nenhum aplicativo do marketplace instalado neste runtime ainda.", browseLink: "Explorar marketplace →", - installedAdditiveNote: "Nota: A API do kernel é apenas aditiva — desinstalar remove o manifesto do disco para que o pacote não seja carregado na próxima inicialização, mas o kernel em execução mantém o aplicativo registrado até reiniciar.", + installedAdditiveNote: "A API do kernel é apenas aditiva — desinstalar remove o manifesto do disco para que o pacote não seja carregado na próxima inicialização, mas o kernel em execução mantém o aplicativo registrado até reiniciar.", installedAt: "Instalado {{when}}", installedBy: "por {{user}}", installedPackageId: "Pacote", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 69512e0bcf..0c383bf251 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -3632,7 +3632,7 @@ const ru = { installedSubtitle: "Пакеты маркетплейса, установленные в ядре этого рантайма.", installedEmpty: "Пока нет установленных приложений.", browseLink: "Маркетплейс →", - installedAdditiveNote: "Примечание: API ядра только аддитивный — удаление убирает манифест с диска, чтобы пакет не загружался при следующем запуске, но работающее ядро держит приложение зарегистрированным до перезапуска.", + installedAdditiveNote: "API ядра только аддитивный — удаление убирает манифест с диска, чтобы пакет не загружался при следующем запуске, но работающее ядро держит приложение зарегистрированным до перезапуска.", installedAt: "Установлено {{when}}", installedBy: "пользователем {{user}}", installedPackageId: "Пакет", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 43391a79d2..5a0db399d2 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -3607,7 +3607,7 @@ const zh = { installedSubtitle: '当前已安装到本运行时内核的应用市场软件包。缓存的清单文件位于 .objectstack/installed-packages/,重启后仍然保留。', installedEmpty: '本运行时尚未安装任何应用市场应用。', browseLink: '去应用市场看看 →', - installedAdditiveNote: '说明:内核 API 仅支持新增 — 卸载会移除磁盘上的清单,下次启动时不再加载;但运行中的内核仍会保留该应用,直到下次重启运行时才会真正卸载。', + installedAdditiveNote: '内核 API 仅支持新增 — 卸载会移除磁盘上的清单,下次启动时不再加载;但运行中的内核仍会保留该应用,直到下次重启运行时才会真正卸载。', installedAt: '安装于 {{when}}', installedBy: '由 {{user}}', installedPackageId: '包',