diff --git a/.changeset/28566-studio-landing-customization-counts.md b/.changeset/28566-studio-landing-customization-counts.md
new file mode 100644
index 00000000000..317564d8255
--- /dev/null
+++ b/.changeset/28566-studio-landing-customization-counts.md
@@ -0,0 +1,18 @@
+---
+'@object-ui/app-shell': patch
+'@object-ui/i18n': patch
+---
+
+feat(app-shell): each read-only base's card on the Studio landing says "n customized, m switched off"
+
+Clause-②: yes (widening)
+Metadata-protocol: none
+
+Before an upgrade, or just to check, an administrator wants to see which installed apps they changed and what they switched off. Studio's landing now says it on each read-only base's card, for example "4 customized, 2 switched off" (zh 「已定制 4 项,已停用 2 项」).
+
+- **The numbers are the server's.** Each card reads `GET /api/v1/packages/:id/customizations` once. "Customized" is the served overrides plus the served extensions of the base's `.custom` pair, and "switched off" is the served switched-off count. The answer is judged by `GetPackageCustomizationsResponseSchema`. An answer that schema refuses counts as a failed read.
+- **Zero only when the server answered.** While the read is pending, the line shows no number. A failed read shows a muted "Couldn't read customizations", with the server's sentence as its title, never "0".
+- **Links.** When the package list holds the base's pair, "n customized" opens the pair in Studio (`/studio/.custom/data`). "m switched off" opens the base's Automations pillar.
+- **No line on a writable package or on a pair**, and a pair id is never sent: the route refuses one.
+- **The landing paints first.** The counts arrive after it.
+- **New strings.** `@object-ui/i18n`'s studio namespace gains seven `engine.studio.landing.customizations.*` keys in `en` and `zh`. The other languages fall back to `en`, as the whole namespace does. They interpolate `customized`, `switchedOff` and `pair`.
diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.accessibleNames-12510.test.tsx b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.accessibleNames-12510.test.tsx
index 180d49334dc..596e4427c46 100644
--- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.accessibleNames-12510.test.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.accessibleNames-12510.test.tsx
@@ -27,6 +27,11 @@ vi.mock('./packages-io', async (importOriginal) => {
// The create dialog is closed throughout; its own names are pinned beside it.
vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null }));
+// A read-only base's customization read (ADR-0146 D3) is not under test here:
+// it stays pending, so no request leaves the file.
+vi.mock('./packageCustomizations-io', () => ({
+ fetchPackageCustomizationCounts: vi.fn(() => new Promise(() => {})),
+}));
import { BuilderLanding } from './BuilderLanding';
import { RecentItemsProvider } from '../../context/RecentItemsProvider';
diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.customizationCounts-28566.test.tsx b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.customizationCounts-28566.test.tsx
new file mode 100644
index 00000000000..a2f8b4045df
--- /dev/null
+++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.customizationCounts-28566.test.tsx
@@ -0,0 +1,211 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * ADR-0146 D3 — each read-only base's Studio landing card says what this
+ * deployment changed on it, "n customized, m switched off", from the server's
+ * per-package customization read (`GET /api/v1/packages/:id/customizations`).
+ *
+ * The real `BuilderLanding` and the real reader (`packageCustomizations-io`)
+ * run under a real router; only the package list and the wire are stubbed.
+ * The stubbed answers are shaped by the route's declaration,
+ * `GetPackageCustomizationsResponseSchema`, inside the dispatcher envelope.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import * as React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
+import { t, tFormat } from '../metadata-admin/i18n';
+import { resetRuntimeConfigForTesting } from '../../runtime-config';
+
+const packages = vi.hoisted(() => ({ list: [] as Array> }));
+
+vi.mock('./packages-io', async (importOriginal) => {
+ const mod = await importOriginal();
+ return { ...mod, fetchPackages: vi.fn(async () => packages.list) };
+});
+
+// The create dialog is closed throughout; its form stack is not under test.
+vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null }));
+
+import { BuilderLanding } from './BuilderLanding';
+
+const HOTCRM = { id: 'hotcrm', name: 'HotCRM', writable: false, namespace: 'hotcrm' };
+const HOTCRM_PAIR = { id: 'hotcrm.custom', name: 'HotCRM customizations', writable: true, namespace: 'hotcrm_custom' };
+const SHOWCASE = { id: 'com.example.showcase', name: 'Showcase', writable: false, namespace: 'showcase' };
+const WRITABLE = { id: 'com.acme.app', name: 'Acme', writable: true, namespace: 'acme' };
+/** A pair served read-only (code-shipped): still a pair, so still no line. */
+const READONLY_PAIR = { id: 'com.example.showcase.custom', name: 'Showcase pair', writable: false, namespace: 'showcase_custom' };
+
+const CUSTOMIZATIONS_RE = /^\/api\/v1\/packages\/([^/]+)\/customizations$/;
+
+/** A served answer for `id`: `overrides` overrides, `extensions` field members, `switchedOff` flows. */
+function served(id: string, overrides: number, extensions: number, switchedOff: number) {
+ const data = {
+ packageId: id,
+ overrides: Array.from({ length: overrides }, (_, i) => ({ type: 'view', name: `view_${i}` })),
+ extensions: Array.from({ length: extensions }, (_, i) => ({ object: 'account', kind: 'field' as const, name: `field_${i}` })),
+ switchedOff: Array.from({ length: switchedOff }, (_, i) => ({ type: 'flow', name: `flow_${i}` })),
+ counts: { overrides, extensions, switchedOff },
+ };
+ return { status: 200, body: { success: true, data } };
+}
+
+type Answer = { status: number; body: unknown } | 'never';
+
+/**
+ * Stub the wire: each customization request is answered from `answers` by
+ * base id; every request URL is recorded.
+ */
+function stubWire(answers: Record) {
+ const requested: string[] = [];
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ requested.push(url);
+ const m = CUSTOMIZATIONS_RE.exec(url);
+ const answer = m ? answers[decodeURIComponent(m[1])] : undefined;
+ if (answer === 'never') return new Promise(() => {});
+ if (!answer) throw new Error(`unexpected request ${url}`);
+ return {
+ ok: answer.status >= 200 && answer.status < 300,
+ status: answer.status,
+ json: async () => answer.body,
+ text: async () => JSON.stringify(answer.body),
+ } as Response;
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ return { requested };
+}
+
+const customizationRequests = (requested: string[]) => requested.filter((u) => CUSTOMIZATIONS_RE.test(u));
+
+function LocationProbe() {
+ const { pathname } = useLocation();
+ return
{pathname}
;
+}
+
+function renderLanding() {
+ return render(
+
+
+
+ } />
+ } />
+
+ ,
+ );
+}
+
+/** The card a package renders in: the element holding both its name and its links. */
+function cardOf(name: string): HTMLElement {
+ const card = screen.getByText(name).closest('.rounded-lg.border');
+ if (!(card instanceof HTMLElement)) throw new Error(`no card found for ${name}`);
+ return card;
+}
+
+const customizedText = (n: number) => tFormat('engine.studio.landing.customizations.customized', 'en-US', { customized: n });
+const switchedOffText = (n: number) => tFormat('engine.studio.landing.customizations.switchedOff', 'en-US', { switchedOff: n });
+
+afterEach(() => {
+ cleanup();
+ packages.list = [];
+ vi.unstubAllGlobals();
+ resetRuntimeConfigForTesting();
+});
+
+describe('Studio landing: a read-only base says "n customized, m switched off" (ADR-0146 D3)', () => {
+ it('reads 3 overrides + 1 extension and 2 switched off as "4 customized, 2 switched off", linking the pair and the Automations pillar', async () => {
+ packages.list = [HOTCRM_PAIR, HOTCRM];
+ const { requested } = stubWire({ hotcrm: served('hotcrm', 3, 1, 2) });
+ renderLanding();
+
+ const line = await within(await waitFor(() => cardOf('HotCRM'))).findByTestId('studio-landing-customizations');
+ await waitFor(() => expect(line).toHaveAttribute('data-state', 'read'));
+ expect(line).toHaveTextContent('4 customized, 2 switched off');
+
+ const pairLink = within(line).getByTestId('studio-landing-customizations-pair');
+ expect(pairLink).toHaveTextContent(customizedText(4));
+ expect(pairLink).toHaveAttribute('href', '/studio/hotcrm.custom/data');
+ const offLink = within(line).getByTestId('studio-landing-customizations-switched-off');
+ expect(offLink).toHaveTextContent(switchedOffText(2));
+ expect(offLink).toHaveAttribute('href', '/studio/hotcrm/automations');
+
+ fireEvent.click(pairLink);
+ await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/studio/hotcrm.custom/data'));
+ // One read, for the base; never for its pair.
+ expect(customizationRequests(requested)).toEqual(['/api/v1/packages/hotcrm/customizations']);
+ });
+
+ it('reads a base with no pair as "0 customized, 0 switched off", with no pair link', async () => {
+ packages.list = [SHOWCASE];
+ stubWire({ 'com.example.showcase': served('com.example.showcase', 0, 0, 0) });
+ renderLanding();
+
+ const line = await within(await waitFor(() => cardOf('Showcase'))).findByTestId('studio-landing-customizations');
+ await waitFor(() => expect(line).toHaveAttribute('data-state', 'read'));
+ expect(line).toHaveTextContent('0 customized, 0 switched off');
+ expect(within(line).queryByTestId('studio-landing-customizations-pair')).not.toBeInTheDocument();
+ expect(within(line).getByText(customizedText(0))).not.toHaveAttribute('href');
+ expect(within(line).getByTestId('studio-landing-customizations-switched-off')).toHaveAttribute(
+ 'href',
+ '/studio/com.example.showcase/automations',
+ );
+ });
+
+ it('shows no number on a failed read: a muted "couldn’t read" with the server’s sentence as its title', async () => {
+ packages.list = [SHOWCASE];
+ const sentence = 'Metadata service not available';
+ stubWire({ 'com.example.showcase': { status: 503, body: { success: false, error: { message: sentence } } } });
+ renderLanding();
+
+ const line = await within(await waitFor(() => cardOf('Showcase'))).findByTestId('studio-landing-customizations');
+ await waitFor(() => expect(line).toHaveAttribute('data-state', 'failed'));
+ expect(line).toHaveTextContent(t('engine.studio.landing.customizations.failed', 'en-US'));
+ expect(line).toHaveAttribute('title', expect.stringContaining(sentence));
+ expect(line.textContent).not.toMatch(/\d/);
+ });
+
+ it('shows no number when the answer is one the route’s declaration refuses', async () => {
+ packages.list = [SHOWCASE];
+ // `counts` missing: off-contract, so no count is read out of it.
+ stubWire({
+ 'com.example.showcase': { status: 200, body: { success: true, data: { packageId: 'com.example.showcase', overrides: [] } } },
+ });
+ renderLanding();
+
+ const line = await within(await waitFor(() => cardOf('Showcase'))).findByTestId('studio-landing-customizations');
+ await waitFor(() => expect(line).toHaveAttribute('data-state', 'failed'));
+ expect(line.textContent).not.toMatch(/\d/);
+ });
+
+ it('draws no line and sends no request for a writable package or a pair', async () => {
+ packages.list = [WRITABLE, HOTCRM_PAIR, READONLY_PAIR, SHOWCASE];
+ const { requested } = stubWire({ 'com.example.showcase': served('com.example.showcase', 1, 0, 0) });
+ renderLanding();
+
+ await screen.findByText('Showcase pair');
+ await waitFor(() =>
+ expect(within(cardOf('Showcase')).getByTestId('studio-landing-customizations')).toHaveAttribute('data-state', 'read'),
+ );
+ expect(within(cardOf('Acme')).queryByTestId('studio-landing-customizations')).not.toBeInTheDocument();
+ expect(within(cardOf('HotCRM customizations')).queryByTestId('studio-landing-customizations')).not.toBeInTheDocument();
+ expect(within(cardOf('Showcase pair')).queryByTestId('studio-landing-customizations')).not.toBeInTheDocument();
+ expect(customizationRequests(requested)).toEqual(['/api/v1/packages/com.example.showcase/customizations']);
+ });
+
+ it('paints the landing before the counts arrive, with no number on the line', async () => {
+ packages.list = [HOTCRM, SHOWCASE];
+ const { requested } = stubWire({ hotcrm: 'never', 'com.example.showcase': 'never' });
+ renderLanding();
+
+ await screen.findByText('HotCRM');
+ expect(screen.getByText('Showcase')).toBeInTheDocument();
+ await waitFor(() => expect(customizationRequests(requested)).toHaveLength(2));
+ for (const name of ['HotCRM', 'Showcase']) {
+ const line = within(cardOf(name)).getByTestId('studio-landing-customizations');
+ expect(line).toHaveAttribute('data-state', 'loading');
+ expect(line.textContent).not.toMatch(/\d/);
+ }
+ });
+});
diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.orgScope-11553.test.tsx b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.orgScope-11553.test.tsx
index 6d147f5bafc..932bc997fa8 100644
--- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.orgScope-11553.test.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.orgScope-11553.test.tsx
@@ -25,6 +25,11 @@ vi.mock('./packages-io', async (importOriginal) => {
// The create dialog is closed throughout; its form stack is not under test.
vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null }));
+// A read-only base's customization read (ADR-0146 D3) is not under test here:
+// it stays pending, so no request leaves the file.
+vi.mock('./packageCustomizations-io', () => ({
+ fetchPackageCustomizationCounts: vi.fn(() => new Promise(() => {})),
+}));
import { BuilderLanding } from './BuilderLanding';
diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.recentPackages-11863.test.tsx b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.recentPackages-11863.test.tsx
index a93d15752ab..966d57cd31e 100644
--- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.recentPackages-11863.test.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.recentPackages-11863.test.tsx
@@ -46,6 +46,11 @@ vi.mock('./packages-io', async (importOriginal) => {
// The create dialog is closed throughout; its form stack is not under test.
vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null }));
+// A read-only base's customization read (ADR-0146 D3) is not under test here:
+// it stays pending, so no request leaves the file.
+vi.mock('./packageCustomizations-io', () => ({
+ fetchPackageCustomizationCounts: vi.fn(() => new Promise(() => {})),
+}));
import { BuilderLanding } from './BuilderLanding';
import { RecentItemsProvider } from '../../context/RecentItemsProvider';
diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.setupExit-12513.test.tsx b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.setupExit-12513.test.tsx
index 207191a1056..2106228cd9d 100644
--- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.setupExit-12513.test.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.setupExit-12513.test.tsx
@@ -21,6 +21,11 @@ vi.mock('./packages-io', async (importOriginal) => {
// The create dialog is closed throughout; its form stack is not under test.
vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null }));
+// A read-only base's customization read (ADR-0146 D3) is not under test here:
+// it stays pending, so no request leaves the file.
+vi.mock('./packageCustomizations-io', () => ({
+ fetchPackageCustomizationCounts: vi.fn(() => new Promise(() => {})),
+}));
import { BuilderLanding, packagedAutomationPath } from './BuilderLanding';
diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.tsx b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.tsx
index 332846e337c..449d9c9a3af 100644
--- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.tsx
@@ -15,7 +15,9 @@
* code and installed packages are listed last: they open for browsing, and each
* card points at the routes that customize one (objectui#11808). Writability is
* the server's verdict read by packages-io (`PkgEntry.writable`, ADR-0070 D2),
- * and the server's write gate stays the authority.
+ * and the server's write gate stays the authority. Each read-only base's card
+ * says what this deployment changed on it, "n customized, m switched off"
+ * (ADR-0146 D3), from the server's per-package customization read.
*/
import * as React from 'react';
@@ -28,6 +30,8 @@ import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n.js';
import { PackageFormDialog } from '../metadata-admin/PackageFormDialog.js';
import { isMarketplaceEnabled } from '../../runtime-config.js';
import { fetchPackages, duplicatePackage, isSpecPackageId, type PkgEntry } from './packages-io.js';
+import { fetchPackageCustomizationCounts, type PackageCustomizationCounts } from './packageCustomizations-io.js';
+import { customizationPackageId, isCustomizationPackage } from '@objectstack/spec/kernel';
import { PackageIdInput } from './PackageIdInput.js';
import { studioOrgScopePath } from './studioScope.js';
import { STUDIO_NEW_PARAM, STUDIO_NEW_PACKAGE_VALUE } from './studioNewPackage.js';
@@ -64,6 +68,18 @@ export const packagedAutomationPath = () =>
/** objectui#11863 — how many recent packages the landing lists: one row of cards. */
const RECENT_PACKAGES_SHOWN = 3;
+/**
+ * ADR-0146 D3 — one read-only base's customization line, by the state of its
+ * read. No entry means the read has not answered: the line shows no number.
+ */
+type CustomizationLine =
+ | { kind: 'read'; counts: PackageCustomizationCounts }
+ | { kind: 'failed'; message: string };
+
+/** A package's Studio address on one pillar, the way `open` below spells it. */
+const studioPillarPath = (packageId: string, pillar: 'data' | 'automations') =>
+ `/studio/${encodeURIComponent(packageId)}/${pillar}`;
+
export function BuilderLanding(): React.ReactElement {
const navigate = useNavigate();
const locale = useMetadataLocale();
@@ -123,6 +139,100 @@ export function BuilderLanding(): React.ReactElement {
const writable = pkgs?.filter((p) => p.writable) ?? [];
const readonly = pkgs?.filter((p) => !p.writable) ?? [];
+ // ADR-0146 D3 — each read-only BASE's card carries "n customized, m switched
+ // off", the server's counts (`GET /packages/:id/customizations`), one read
+ // per card drawn. A pair (`.custom`) draws no line and is never sent:
+ // the route refuses its id. The page does not wait for these reads. The
+ // effect is keyed on the joined ids, the primitive it reads (ui/AGENTS.md #10).
+ const customizationTargetKey = readonly
+ .filter((p) => !isCustomizationPackage(p.id))
+ .map((p) => p.id)
+ .join('\n');
+ const [customizationLines, setCustomizationLines] = React.useState>({});
+ React.useEffect(() => {
+ if (!customizationTargetKey) return;
+ let cancelled = false;
+ for (const id of customizationTargetKey.split('\n')) {
+ fetchPackageCustomizationCounts(id).then(
+ (counts) => {
+ if (!cancelled) setCustomizationLines((prev) => ({ ...prev, [id]: { kind: 'read', counts } }));
+ },
+ (e: unknown) => {
+ const message = e instanceof Error ? e.message : String(e);
+ if (!cancelled) setCustomizationLines((prev) => ({ ...prev, [id]: { kind: 'failed', message } }));
+ },
+ );
+ }
+ return () => {
+ cancelled = true;
+ };
+ }, [customizationTargetKey]);
+ // The pair is linked only when the package list holds it: a base with no
+ // pair has nothing to open, and the read never creates one.
+ const listedIds = new Set(pkgs?.map((p) => p.id) ?? []);
+ const listedPairOf = (baseId: string): string | null => {
+ const pair = customizationPackageId(baseId);
+ return pair.ok && listedIds.has(pair.value) ? pair.value : null;
+ };
+
+ const renderCustomizationLine = (baseId: string): React.ReactNode => {
+ if (isCustomizationPackage(baseId)) return null;
+ const line = customizationLines[baseId];
+ const lineClass = 'text-[10px] leading-4 text-muted-foreground';
+ if (!line) {
+ return (
+
+ );
+ };
+
// Duplicate (ADR-0070 D4) clones a writable BASE into a new writable package,
// the "duplicate base" gesture, and drops the user straight into the copy's
// builder. It is offered on writable bases only. A read-only code or
@@ -384,12 +494,13 @@ export function BuilderLanding(): React.ReactElement {
{t('engine.studio.pkg.readonly', locale)}
-
+
+ {renderCustomizationLine(p.id)}
{/* objectui#12513 — the link leaves Studio for Setup: its
name says where it goes, the arrow that it goes out. */}
diff --git a/ui/packages/app-shell/src/views/studio-design/packageCustomizations-io.ts b/ui/packages/app-shell/src/views/studio-design/packageCustomizations-io.ts
new file mode 100644
index 00000000000..c7714e1860c
--- /dev/null
+++ b/ui/packages/app-shell/src/views/studio-design/packageCustomizations-io.ts
@@ -0,0 +1,71 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The per-package customization read the Studio landing draws on each
+ * read-only base's card (ADR-0146 D3: one line per package, "n customized,
+ * m switched off").
+ *
+ * The numbers are the server's: `GET /api/v1/packages/:id/customizations`
+ * (ADR-0146 D8 item 4) answers, for one installed base, the items its paired
+ * `.custom` package overrides, the members that pair adds to the base's
+ * objects and the base's items switched off in this deployment, each with its
+ * count. This reader computes no count of its own: "customized" is the served
+ * `counts.overrides` plus the served `counts.extensions`, the two kinds of
+ * change the pair carries, and "switched off" is the served
+ * `counts.switchedOff`.
+ *
+ * The answer is judged by the route's own declaration,
+ * `GetPackageCustomizationsResponseSchema` (strict at every level): an answer
+ * it refuses is a failed read, never a count read out of an off-contract body.
+ * A failure carries the server's sentence by the rule every package reader in
+ * this folder uses (`readEnvelopeFailureText`), else `HTTP `.
+ *
+ * ⛔ A pair id is never sent: the route refuses one by its shape
+ * (`400 VALIDATION_ERROR` naming its base), so this reader refuses it first,
+ * without a request.
+ */
+
+import { GetPackageCustomizationsResponseSchema } from '@objectstack/spec/api';
+import { isCustomizationPackage } from '@objectstack/spec/kernel';
+import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js';
+
+/** What the landing draws for one base: two served numbers. */
+export interface PackageCustomizationCounts {
+ /** Served overrides plus served extensions of the base's pair. */
+ customized: number;
+ /** Served switched-off items of the base. */
+ switchedOff: number;
+}
+
+/** The route of the read, for one base package id. */
+export const packageCustomizationsUrl = (baseId: string) =>
+ `/api/v1/packages/${encodeURIComponent(baseId)}/customizations`;
+
+/**
+ * Read one installed base's customization counts.
+ *
+ * Rejects with the server's sentence on a failed read, and with a sentence of
+ * its own on an answer the route's declaration refuses; resolves only with
+ * counts the server served.
+ */
+export async function fetchPackageCustomizationCounts(baseId: string): Promise {
+ if (isCustomizationPackage(baseId)) {
+ throw new Error(`Package '${baseId}' is a customization package: customizations are read for its base.`);
+ }
+ const res = await fetch(packageCustomizationsUrl(baseId), {
+ credentials: 'include',
+ headers: { Accept: 'application/json' },
+ cache: 'no-store',
+ });
+ const payload = (await res.json().catch(() => null)) as Record | null;
+ if (!res.ok) {
+ throw new Error(readEnvelopeFailureText(payload) ?? `HTTP ${res.status}`);
+ }
+ // The dispatcher envelope `{ success, data }`; the declaration is the `data`.
+ const parsed = GetPackageCustomizationsResponseSchema.safeParse(payload?.data);
+ if (!parsed.success) {
+ throw new Error(`The customization read for '${baseId}' answered a body its declaration refuses.`);
+ }
+ const { counts } = parsed.data;
+ return { customized: counts.overrides + counts.extensions, switchedOff: counts.switchedOff };
+}
diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts
index e72cc677d74..b939c3f4617 100644
--- a/ui/packages/i18n/src/namespaces/studio/en.ts
+++ b/ui/packages/i18n/src/namespaces/studio/en.ts
@@ -3183,6 +3183,15 @@ const STUDIO_EN: Record = {
'engine.studio.landing.overlay': 'Open in Setup',
'engine.studio.landing.overlayTitle': 'Open this package’s metadata: each type shows whether it accepts an environment overlay',
'engine.studio.landing.marketplace': 'Or install a template app from the marketplace',
+ // ADR-0146 D3 — a read-only base's card says what this deployment changed on
+ // it, from the server's per-package customization read.
+ 'engine.studio.landing.customizations.customized': '{customized} customized',
+ 'engine.studio.landing.customizations.separator': ', ',
+ 'engine.studio.landing.customizations.switchedOff': '{switchedOff} switched off',
+ 'engine.studio.landing.customizations.loading': 'Reading customizations…',
+ 'engine.studio.landing.customizations.failed': 'Couldn’t read customizations',
+ 'engine.studio.landing.customizations.pairTitle': 'Open the customization package {pair} in Studio',
+ 'engine.studio.landing.customizations.switchedOffTitle': 'Open this package’s Automations, where its items are switched on and off',
// objectui#11553 — the package-less scope: the organization's own flows.
'engine.studio.landing.orgHeading': 'Not in a package',
'engine.studio.org.name': 'Organization flows',
diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts
index 12143029805..a18dce80ae2 100644
--- a/ui/packages/i18n/src/namespaces/studio/zh.ts
+++ b/ui/packages/i18n/src/namespaces/studio/zh.ts
@@ -2825,6 +2825,13 @@ const STUDIO_ZH: Record = {
'engine.studio.landing.overlay': '在设置中打开',
'engine.studio.landing.overlayTitle': '打开该软件包的元数据:每种类型都会标明是否接受环境覆盖',
'engine.studio.landing.marketplace': '或从应用市场安装一个模板应用',
+ 'engine.studio.landing.customizations.customized': '已定制 {customized} 项',
+ 'engine.studio.landing.customizations.separator': ',',
+ 'engine.studio.landing.customizations.switchedOff': '已停用 {switchedOff} 项',
+ 'engine.studio.landing.customizations.loading': '正在读取定制…',
+ 'engine.studio.landing.customizations.failed': '无法读取定制',
+ 'engine.studio.landing.customizations.pairTitle': '在 Studio 中打开定制包 {pair}',
+ 'engine.studio.landing.customizations.switchedOffTitle': '打开该软件包的自动化,在那里启用或停用其中的项',
// objectui#11553 — the package-less scope: the organization's own flows.
'engine.studio.landing.orgHeading': '不属于软件包',
'engine.studio.org.name': '组织流程',