Skip to content

Commit 24d3e65

Browse files
feat(app-shell,types): cloud:plan-status marks the organization's current plan card (objectui#10919) (#10990)
Fixes #10919 Clause-②: yes ## What this does The Cloud pricing page is static metadata, so it cannot tell which of its plan cards the organization is already on (objectstack-ai/cloud#2434 item 5). This PR adds the SDUI widget `cloud:plan-status`. A page places one node on each plan card and names that card's plan code in `properties.plan`. The widget reads the org-scoped `GET /cloud/environment-entitlements` summary and renders a localized "Current plan" badge when the summary's `plan` equals `properties.plan`. It renders nothing in every other state: another plan, loading, a failed request, a body that is not the `{ success, data }` envelope, or a node that names no plan. It never guesses a plan. **The snippet cloud places** in each `planCard(...)` column of `packages/service-tenant/src/pages/pricing.page.ts`, for the cloud#2434 item 5 follow-up: ```ts { id: `plan_${opts.name}_current`, type: 'cloud:plan-status', responsiveStyles: { large: { alignSelf: 'flex-start' } }, properties: { plan: opts.name } }, ``` The card names `free`, `team` and `enterprise` are the summary's plan codes for those cards. The endpoint can also report `solo`, `business`, `starter`, `pro` and `custom`, and an organization on one of those gets no card marked, because the page lists no such card. ## Routing: the triage answer `5867937466`, quoted > **Lane: `domain:ui`.** > - The deliverable is the `cloud:*` widget (`packages/app-shell`). The maintainer placed epic objectstack-ai/cloud#2440's cards on this lane (「全部接 (推荐)」). > - **The claim declares** `packages/types/src/zod` (the widget's arm), with **`Clause-②: yes` (widening)**, so it owes an at-tier contract review. > - **Serial:** after PR objectui#10908 (objectui#10872 batch 2), which holds the same ratchet test and the namespaced-arm work. > > **The open sub-question** (does the arm go through objectui#10872's programme?): no. > - objectui#10872 arms blocks **by reference** to a `@objectstack/spec` `ComponentPropsMap` row, and `cloud:*` has no spec row. ⛔ Don't invent one. > - Declare the arm locally in `@object-ui/types/zod`, in the file's existing local-arm shape, measured from the widget's read points. > - If PR objectui#10908 changes the arm mechanism, rebase onto it. ## What the accept set gains (Clause-②: yes) `AnyComponentSchema` gains exactly one `type` literal, `cloud:plan-status`, through one new arm, `CloudPlanStatusSchema`, exported by name from `@object-ui/types/zod`. So do `safeValidateSchema` and `objectui validate`, which judge with it. A document with that type is accepted only when all of these hold: - `properties` is present and is exactly `{ plan }`. The bag is strict: any other key is `unrecognized_keys` at `properties`. - `plan` is a non-empty string: `invalid_type` or `too_small` at `properties.plan` otherwise. It is not an enum, because the plan catalog belongs to the control plane. - There is no `body` and no `children`. Both are refused by name (`invalid_type`), because the widget reads neither channel. - Every other node key is judged as on every arm: `BaseSchema`'s declared keys by their types; an undeclared key passes the tolerant face and is refused by the strict face (`StrictAnyComponentSchema`). Nothing that parsed before is refused now. `NAMESPACED_REFUSED_AT_TYPE` stays **397** and `REFUSED_AT_TYPE` stays 73: the one new registered key lands with its arm. ## What changed - **`packages/app-shell/src/console/home/CloudPlanStatus.tsx`** (new): the widget and its registration. It reads the plan through the existing `useEnvironmentEntitlements` hook, which is not edited. The hook's `authFetch` comes from `useState`, not `useMemo`, because the hook keys its fetch effect on it (AGENTS.md #10). The node's `className` reaches the badge, so the page's `responsiveStyles` scope class does too. - **`packages/types/src/zod/cloud.zod.ts`** (new): `CloudPlanStatusSchema`, `BaseSchema` plus the `cloud:plan-status` literal, a required strict `properties: { plan }` bag, and the two content-channel refusals. The refusal text carries the objectui#10928 parser-tier clause: the registration declares no `children` input, so `validateTree` warns `not-a-container` for this node. - **`packages/types/src/zod/index.zod.ts`**: the barrel export, the import, and the one union line. `origin/main` is merged in (`19fc41a`), per the claim's note on the draft PR objectui#10962. - **`packages/types/src/zod/README.md`**: a "Cloud Widgets" group lists the arm. - **`packages/cli/src/utils/known-schema-types.ts`**: regenerated by `node scripts/regenerate-known-schema-types.mjs`. It gains the one line `cloud:plan-status`. - **`packages/i18n/src/locales/*.ts`**: `cloudPlanStatus.current` ("Current plan") in all ten packs. - **Ledgers and pins**: one `EXCLUSIONS` row in `zod-mirror-parity.test.ts` (no TS declaration in `@object-ui/types` restates the node), `cloud-plan-status-arm-10919.test.ts`, `CloudPlanStatus.test.tsx`, and one ratchet row naming `cloud:plan-status` as armed. - **`.changeset/10919-cloud-plan-status.md`**: app-shell and types `minor`, i18n and cli `patch`. ## PM assumptions, measured: three falsified, two confirmed 1. **Falsified: the props shape.** The suggested node was `{ type, plan }`. The measured node is `{ type, properties: { plan } }`. Cloud's pages are typed `Page` from `@objectstack/spec/ui`, and `PageComponentSchema` is a `strictObject` whose only props channel is `properties`, so a top-level `plan` is refused by the spec parse before objectui sees it. `cloud:onboarding-next` is authored the same way on the welcome page, and it reads `properties.*`. The widget reads `properties.plan` and nothing else, and the arm declares exactly that. 2. **Falsified: the registration spelling.** The precedent `register('cloud:onboarding-next', …, { namespace: 'app-shell' })` makes TWO registry keys, `app-shell:cloud:onboarding-next` and the fallback `cloud:onboarding-next`, and the namespaced ratchet counts both. Following it would have needed the arm to accept a second literal nobody authors. So the widget registers as `register('plan-status', …, { namespace: 'cloud', skipFallback: true })`: one key, one arm literal, and one generated line. 3. **Falsified: the i18n key name.** It is `cloudPlanStatus.current`, not `cloud.planStatus.current`, following the sibling blocks `cloudOnboarding.*` and `aiModelStatus.*`. 4. **Confirmed: the hook reads the plan, org-scoped.** `useEnvironmentEntitlements` sends `?organizationId=` from `useAuth().activeOrganization.id`, as the precedent does, and the endpoint resolves an absent parameter to the session's active organization. Its row-derived fallback carries no plan, so the widget marks a card only on `source: 'summary'`. 5. **Confirmed: `plan` values.** The endpoint's `plan` is cloud's `planKey(...)` over `PLAN_CODES` (`free`, `solo`, `team`, `business`, `starter`, `pro`, `enterprise`, `custom`). ObjectUI names none of them. ## Surface supplement: two files outside the claim's declared surface `packages/app-shell/src/index.ts` (one side-effect import line) and `packages/app-shell/package.json` (two `sideEffects` entries). A registration cannot ship without both. Without the barrel import, the console never evaluates the module. Without the array entries, `scripts/check-side-effects-array.mjs` goes red, because every registering module in the entry graph must be named there, and a bundler would be free to drop the registration. Both edits have the same shape as `CloudOnboardingNext`'s. **Overlap**, read on the open PR list (REST `pulls/N/files`, 2026-09-28): none of the ten non-release open PRs touches either file. The changesets release PR rewrites only the `version` field. **Seat:** please add these two paths to the claim's file surface. ## Verification, at `19fc41a` (the merge of `origin/main` `42687ba`) - **Suites** (`pnpm exec vitest run --maxWorkers=2`, through the verify lock): `packages/types/`, `packages/cli/`, `packages/i18n/`, plus a declared narrowing of `packages/app-shell/` (below). Result: `Test Files 407 passed (407)`, `Tests 8074 passed | 13 skipped`. - **The new pins, verbose**: `cloud-plan-status-arm-10919.test.ts` 12/12; `CloudPlanStatus.test.tsx` 10/10; the ratchet file 15/15, including the new `cloud:plan-status` row. - **type-check**, after `turbo run build --filter='@object-ui/app-shell^...'` (28/28): `@object-ui/types`, `@object-ui/i18n`, `@object-ui/cli` and `@object-ui/app-shell` all exit 0. `--listFilesOnly` shows each package's test program includes the new test files. - **eslint**: the 19 touched `.ts`/`.tsx` files, `--format json`, 19 files, 0 errors and 0 warnings. The narrowing is a measurement: the packages lint under the root `eslint.config.js` alone (no package-level config), and that config enables no type-aware linting (0 hits for `projectService`, `parserOptions`, `TypeChecked` or `tsconfigRootDir`), so this diff cannot move the verdict on any untouched file. - **Gates, all exit 0**: `regenerate-known-schema-types.mjs --check`, `check:side-effects-array`, `check:i18n-keys`, `check:i18n-drift`, `check:i18n-dead-keys` (report), `check:registry-bare-names`, `check:doc-types`, `check:prompt-keys`, `check:unreferenced-sources`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:test-path-roots`, `check:new-line-citations` (0 new), `check:control-bytes`, `check:handler-key-reads`, `check:changeset-claims` (report), `check:pending-changeset-literals`, `changeset:check`, `check-changeset-presence` (4 released packages, 1 changeset), `check-changeset-overwrite`, `check-type-check-coverage`, `check:phantom-deps`, `check:esm-specifiers`, `check-lint-coverage`, and `check-governed-queue-guard --test` over the 22 paths: NOT GOVERNED. - **Reverse verification**, run at `5c4a1d2` (the merge changed neither mutated file). Each leg was committed first, mutated through `ablation-replace.mjs` with an EXIT/INT/TERM restore trap, then restored with `git checkout HEAD --`: - **Ablate the arm** (drop the union line in `index.zod.ts`). The ratchet goes red: "refuses 398 registered namespaced key(s) at `type`; the pin is 397", and the Refused list names `cloud:plan-status`. The arm pins go red too (14 failed). Restored: blob `6fa23ee31c7c` equals HEAD, and `git diff HEAD` is empty. - **Ablate the match** (`entitlements.plan === plan` becomes `true`). The widget goes red on "renders nothing on a card whose plan is not the organization's" and "compares the plan code verbatim". Restored: blob `08d10662137c` equals HEAD, and `git diff HEAD` is empty. - Both subjects resolve to SOURCE (`vitest.config.mts` aliases `@object-ui/types/zod` to `src`), so no `dist` rebuild was part of either leg. **NOT MEASURED**, each with its reason: - `packages/app-shell/` full suite (856 files): at `--maxWorkers=2` it exceeds the foreground cap (exit 124 at 560 s, and exit 124 on shard 1/6 at 270 s). The run was narrowed to the 40 files that read what this diff touches: the two widget directories (`console/home/__tests__`, `environment/__tests__`), `src/__tests__/`, and the 13 tests that read `package.json`, `src/index.ts` or the barrel. Result: 40/40 files, 575 tests. CI runs the whole suite. - `check:sdui-registration-pins`, `check:eager-closure` and `check:eager-locale-catalogues` need a built console (PREREQUISITE NOT MET, exit 2). CI's `performance-budget.yml` runs them on `packages/**`. - `check:readme-exports` (exit 1) and `check:doc-examples` (exit 2) could not judge the READMEs of packages not built here (app-shell, cli, plugin-*). Among what was judged: 0 wrong-path and 0 fabricated. - No browser run against a live control plane. ## Acceptance notes (not filed; no carrier) - Each node reads the summary itself, so three cards make three GETs of the same summary. The hook has no shared cache, and none was added. - The Free card's CTA ("Get started") still shows to an organization on Free. This PR marks the card and leaves the CTA alone. Whether the widget should also stand in for the CTA is an open question in the report. - `useEnvironmentEntitlements`' docblock still describes an environment-list-only hook ("Only fetch when this is the environment list"), and it now has a second caller. Per the claim, the hook is not edited. - The precedent registrations' double-prefixed keys (`app-shell:cloud:onboarding-next`, `app-shell:cloud:ai-model-status` and their siblings) stay in the refused set. That family is objectui#10872's population. --- _Generated by [Claude Code](https://claude.ai/code/session_015AUunPkX7UTkCH9e7AdZo1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6c57c77 commit 24d3e65

22 files changed

Lines changed: 569 additions & 0 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@object-ui/app-shell': minor
3+
'@object-ui/types': minor
4+
'@object-ui/i18n': patch
5+
'@object-ui/cli': patch
6+
---
7+
8+
New SDUI widget `cloud:plan-status`: a "Current plan" badge for one plan card on the Cloud pricing page, shown when that card's plan is the organization's plan (objectui#10919).
9+
10+
**Clause-②: yes** — the accept set of `AnyComponentSchema`, and so of `safeValidateSchema` and `objectui validate`, widens by one `type` literal, `cloud:plan-status`, and `@object-ui/types/zod` exports one new schema, `CloudPlanStatusSchema`. Nothing that parsed before is refused now.
11+
12+
**Why.** The pricing page is static metadata, and nothing in a page's expression scope carries the organization's plan, so the page could not tell which of its cards the organization is already on. The plan is available only from the org-scoped `GET /cloud/environment-entitlements` summary.
13+
14+
**What changed, in observable terms.**
15+
16+
- `@object-ui/app-shell` registers `cloud:plan-status`. A page places one node on each plan card and names that card's plan code in `properties.plan`: `{ "type": "cloud:plan-status", "properties": { "plan": "free" } }`. The widget reads the summary through the hook the environment list already uses, and renders the badge when the summary's `plan` equals `properties.plan`. The comparison is exact, so `Free` does not match `free`.
17+
- The widget renders nothing on every other card, while the summary loads, when the request fails, and when the body is not the `{ success, data }` envelope. It never guesses a plan.
18+
- The node's `className` and `responsiveStyles` reach the badge. Each node reads the summary itself, so a page with three cards makes three requests.
19+
- The widget is registered under one key, `cloud:plan-status`. There is no bare `plan-status` fallback and no `app-shell:`-prefixed twin.
20+
- `@object-ui/types/zod` exports `CloudPlanStatusSchema`, a member of `AnyComponentSchema`. `properties` is required and must be exactly `{ plan }`, with `plan` a non-empty string. A missing bag, a missing or empty `plan`, and any other key in the bag are each refused at that path.
21+
- `body` and `children` are refused by name on this node, because the widget reads neither.
22+
- `plan` is not an enum: the plan catalog belongs to the control plane, and ObjectUI does not list its codes.
23+
- `@object-ui/i18n` adds `cloudPlanStatus.current` ("Current plan") to all ten locale packs.
24+
- `@object-ui/cli`: `objectui check` knows `cloud:plan-status` as a registered type.

‎packages/app-shell/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"./dist/console/connect/ConnectAgentWidget.js",
1010
"./dist/console/diagnostics/CloudAiModelStatus.js",
1111
"./dist/console/home/CloudOnboardingNext.js",
12+
"./dist/console/home/CloudPlanStatus.js",
1213
"./dist/console/marketplace/InstalledListWidget.js",
1314
"./dist/services/builtinComponents.js",
1415
"./dist/views/global-notifications-renderer.js",
@@ -24,6 +25,7 @@
2425
"./src/console/connect/ConnectAgentWidget.tsx",
2526
"./src/console/diagnostics/CloudAiModelStatus.tsx",
2627
"./src/console/home/CloudOnboardingNext.tsx",
28+
"./src/console/home/CloudPlanStatus.tsx",
2729
"./src/console/marketplace/InstalledListWidget.tsx",
2830
"./src/services/builtinComponents.tsx",
2931
"./src/views/global-notifications-renderer.tsx",
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* CloudPlanStatus — the "current plan" marker for one plan card on the Cloud
5+
* control plane's pricing page, registered as the SDUI widget
6+
* `cloud:plan-status` (objectui#10919).
7+
*
8+
* The pricing page is static metadata: nothing in its expression scope carries
9+
* the organization's plan, so a page cannot tell which of its cards is the one
10+
* the organization is already on. The plan is only available from the
11+
* org-scoped `GET /cloud/environment-entitlements` summary (`data.plan`). This
12+
* widget reads that summary and answers one question for the card it is placed
13+
* on: is this card's plan the organization's plan?
14+
*
15+
* • the summary names this card's plan → a localized "Current plan" badge;
16+
* • the summary names another plan → nothing;
17+
* • loading, failed, or no summary → nothing. The plan is never guessed:
18+
* only the authoritative summary (`source: 'summary'`) can mark a card, and
19+
* the row-derived fallback carries no plan at all.
20+
*
21+
* ## One node per card, and the page names the card
22+
*
23+
* The plan code a card stands for is the page's, not this widget's: the plan
24+
* catalog belongs to the control plane, so the page passes its own code in
25+
* `properties.plan` (the `properties` bag is the spelling every page component
26+
* is authored in) and the widget compares it VERBATIM with `data.plan`. No
27+
* code is listed, normalized or defaulted here.
28+
*
29+
* ## The summary is read through `useEnvironmentEntitlements`
30+
*
31+
* The same hook the environment list uses — one reading of the endpoint and its
32+
* strict `{ success, data }` envelope, not a third copy of the fetch. It is
33+
* enabled unconditionally here because this widget exists only to read it.
34+
*/
35+
36+
import { useState } from 'react';
37+
import { Badge, cn } from '@object-ui/components';
38+
import { createAuthenticatedFetch } from '@object-ui/auth';
39+
import { ComponentRegistry } from '@object-ui/core';
40+
import { useObjectTranslation } from '@object-ui/i18n';
41+
import { useAdapter } from '../../providers/AdapterProvider.js';
42+
import { useEnvironmentEntitlements } from '../../environment/useEnvironmentEntitlements.js';
43+
44+
export interface CloudPlanStatusProps {
45+
properties?: {
46+
/**
47+
* The plan code of the card this node sits on, exactly as the control
48+
* plane's entitlements summary spells it (for example `free`).
49+
*/
50+
plan?: string;
51+
};
52+
/** Carries the scope class of the node's `responsiveStyles`. */
53+
className?: string;
54+
}
55+
56+
export function CloudPlanStatus({ properties, className }: CloudPlanStatusProps) {
57+
const { t } = useObjectTranslation();
58+
const dataSource = useAdapter();
59+
// `useState`, not `useMemo`: the hook keys its fetch effect on this function,
60+
// so its identity has to be one React guarantees to keep (AGENTS.md #10).
61+
const [authFetch] = useState(() => createAuthenticatedFetch());
62+
const entitlements = useEnvironmentEntitlements({
63+
enabled: true,
64+
dataSource,
65+
authFetch,
66+
apiBase: (import.meta.env.VITE_SERVER_URL as string | undefined) || '',
67+
});
68+
69+
const plan = properties?.plan;
70+
const isCurrent =
71+
typeof plan === 'string'
72+
&& plan !== ''
73+
&& entitlements?.source === 'summary'
74+
&& entitlements.plan === plan;
75+
if (!isCurrent) return null;
76+
77+
return (
78+
<Badge variant="secondary" className={cn(className)} data-plan-status="current">
79+
{t('cloudPlanStatus.current')}
80+
</Badge>
81+
);
82+
}
83+
84+
// SDUI registration — the Cloud pricing page places one node per plan card.
85+
// Registered under the `cloud` namespace with no bare fallback, so the one key
86+
// is `cloud:plan-status`: the spelling the page authors, and the one literal
87+
// its `@object-ui/types` arm claims (`CloudPlanStatusSchema`).
88+
ComponentRegistry.register('plan-status', (props: CloudPlanStatusProps) => (
89+
<CloudPlanStatus {...props} />
90+
), {
91+
namespace: 'cloud',
92+
skipFallback: true,
93+
label: 'Cloud Plan Status',
94+
category: 'plugin',
95+
inputs: [],
96+
});
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* CloudPlanStatus (`cloud:plan-status`, objectui#10919) marks ONE plan card on
5+
* the Cloud pricing page: a "Current plan" badge when the organization's plan,
6+
* read from the authoritative entitlements summary, equals the card's
7+
* `properties.plan` — and nothing in every other state, so a plan is never
8+
* guessed.
9+
*/
10+
11+
import { describe, it, expect, vi, afterEach } from 'vitest';
12+
import { render, screen, waitFor, cleanup } from '@testing-library/react';
13+
import React from 'react';
14+
15+
let fetchImpl: (url: string, init?: RequestInit) => Promise<unknown>;
16+
const fetchedUrls: string[] = [];
17+
18+
vi.mock('@object-ui/auth', async (importOriginal) => ({
19+
...(await importOriginal<Record<string, unknown>>()),
20+
useAuth: () => ({ activeOrganization: { id: 'org_1' } }),
21+
createAuthenticatedFetch: () => (url: string, init?: RequestInit) => {
22+
fetchedUrls.push(url);
23+
return fetchImpl(url, init);
24+
},
25+
}));
26+
27+
import { I18nProvider } from '@object-ui/i18n';
28+
import { ComponentRegistry } from '@object-ui/core';
29+
import { AdapterCtx, SchemaRenderer } from '@object-ui/react';
30+
import { CloudPlanStatus } from '../CloudPlanStatus';
31+
32+
/**
33+
* The badge text resolves from the locale packs, so a real i18n context is
34+
* needed — without one `t()` returns the raw key and a text assertion would
35+
* pass against nothing. The adapter is the row-derived fallback's data source;
36+
* its rows name a production env, so the fallback resolves `derived` for real
37+
* rather than failing.
38+
*/
39+
const adapter = { find: vi.fn().mockResolvedValue([{ id: 'env_1', environment_type: 'production' }]) };
40+
41+
const renderInShell = (ui: React.ReactElement) =>
42+
render(
43+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
44+
<AdapterCtx.Provider value={adapter as never}>{ui}</AdapterCtx.Provider>
45+
</I18nProvider>,
46+
);
47+
48+
/** The control plane's `{ success, data }` envelope around a summary naming `plan`. */
49+
function summary(plan: string) {
50+
return {
51+
ok: true,
52+
status: 200,
53+
json: async () => ({ success: true, data: { plan, hasProductionEnv: true } }),
54+
};
55+
}
56+
57+
afterEach(() => {
58+
cleanup();
59+
fetchedUrls.length = 0;
60+
adapter.find.mockClear();
61+
});
62+
63+
describe('CloudPlanStatus (objectui#10919)', () => {
64+
it('marks the card whose plan is the organization\'s plan', async () => {
65+
fetchImpl = async () => summary('free');
66+
const { container } = renderInShell(<CloudPlanStatus properties={{ plan: 'free' }} />);
67+
68+
expect(await screen.findByText('Current plan')).toBeTruthy();
69+
expect(container.querySelector('[data-plan-status="current"]')).toBeTruthy();
70+
});
71+
72+
it('reads the org-scoped summary endpoint', async () => {
73+
fetchImpl = async () => summary('free');
74+
renderInShell(<CloudPlanStatus properties={{ plan: 'free' }} />);
75+
76+
await screen.findByText('Current plan');
77+
expect(fetchedUrls).toEqual(['/api/v1/cloud/environment-entitlements?organizationId=org_1']);
78+
});
79+
80+
it('renders nothing on a card whose plan is not the organization\'s', async () => {
81+
fetchImpl = async () => summary('team');
82+
const { container } = renderInShell(<CloudPlanStatus properties={{ plan: 'free' }} />);
83+
84+
// Wait until the summary was actually read, so the empty render is a
85+
// verdict on it and not the loading state.
86+
await waitFor(() => expect(fetchedUrls).toHaveLength(1));
87+
await new Promise((r) => setTimeout(r, 0));
88+
expect(container.innerHTML).toBe('');
89+
});
90+
91+
it('compares the plan code verbatim — no case folding', async () => {
92+
fetchImpl = async () => summary('free');
93+
const { container } = renderInShell(<CloudPlanStatus properties={{ plan: 'Free' }} />);
94+
95+
await waitFor(() => expect(fetchedUrls).toHaveLength(1));
96+
await new Promise((r) => setTimeout(r, 0));
97+
expect(container.innerHTML).toBe('');
98+
});
99+
100+
it('renders nothing while the summary is loading', () => {
101+
fetchImpl = () => new Promise(() => {});
102+
const { container } = renderInShell(<CloudPlanStatus properties={{ plan: 'free' }} />);
103+
104+
expect(container.innerHTML).toBe('');
105+
});
106+
107+
it('renders nothing when the summary fails — the row-derived fallback carries no plan', async () => {
108+
fetchImpl = async () => ({ ok: false, status: 500, json: async () => null });
109+
const { container } = renderInShell(<CloudPlanStatus properties={{ plan: 'free' }} />);
110+
111+
// The hook fell through to the rows, which resolve (source `derived`) but
112+
// name no plan — so the card is left unmarked instead of guessed.
113+
await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1));
114+
await new Promise((r) => setTimeout(r, 0));
115+
expect(container.innerHTML).toBe('');
116+
});
117+
118+
it('renders nothing for a bare (un-enveloped) body that names the plan', async () => {
119+
// A bare body is a producer contract violation (cloud#1046): it yields no
120+
// summary, so even a `plan` that matches must not mark the card.
121+
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ plan: 'free' }) });
122+
const { container } = renderInShell(<CloudPlanStatus properties={{ plan: 'free' }} />);
123+
124+
await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1));
125+
await new Promise((r) => setTimeout(r, 0));
126+
expect(container.innerHTML).toBe('');
127+
});
128+
129+
it('renders nothing when the node names no plan', async () => {
130+
fetchImpl = async () => summary('free');
131+
const { container } = renderInShell(<CloudPlanStatus />);
132+
133+
await waitFor(() => expect(fetchedUrls).toHaveLength(1));
134+
await new Promise((r) => setTimeout(r, 0));
135+
expect(container.innerHTML).toBe('');
136+
});
137+
});
138+
139+
describe('`cloud:plan-status` registration (objectui#10919)', () => {
140+
it('is registered under the one key the page authors, with no second spelling', () => {
141+
expect(ComponentRegistry.has('cloud:plan-status')).toBe(true);
142+
// `skipFallback: true` — no bare `plan-status` fallback …
143+
expect(ComponentRegistry.has('plan-status')).toBe(false);
144+
// … and no `app-shell:`-prefixed twin.
145+
expect(ComponentRegistry.has('app-shell:cloud:plan-status')).toBe(false);
146+
});
147+
148+
it('renders from a page node through SchemaRenderer, reading `properties.plan`', async () => {
149+
fetchImpl = async () => summary('team');
150+
const { container } = renderInShell(
151+
<SchemaRenderer
152+
schema={{
153+
type: 'cloud:plan-status',
154+
id: 'plan_team_current',
155+
responsiveStyles: { large: { alignSelf: 'flex-start' } },
156+
properties: { plan: 'team' },
157+
} as never}
158+
/>,
159+
);
160+
161+
expect(await screen.findByText('Current plan')).toBeTruthy();
162+
// The node's `responsiveStyles` scope class reaches the badge through
163+
// `className`, so the page can place it inside its card.
164+
const scope = container.querySelector('style[data-os-scope]')?.getAttribute('data-os-scope');
165+
expect(scope).toBeTruthy();
166+
const badge = container.querySelector('[data-plan-status="current"]');
167+
expect(badge?.classList.contains(scope as string)).toBe(true);
168+
});
169+
});

‎packages/app-shell/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,8 @@ import './console/marketplace/InstalledListWidget.js';
313313
import './console/connect/ConnectAgentWidget.js';
314314
// SDUI widget for the Cloud Welcome page's state-aware onboarding next-step.
315315
import './console/home/CloudOnboardingNext.js';
316+
// SDUI widget for the Cloud pricing page's "current plan" marker (objectui#10919).
317+
import './console/home/CloudPlanStatus.js';
316318
// SDUI widget: read-only admin diagnostic for the env's effective AI model
317319
// (cloud#797) — fetches GET /api/v1/ai/effective-model.
318320
import './console/diagnostics/CloudAiModelStatus.js';

‎packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,14 @@ describe('registered NAMESPACED component types refused at `type` — a ratchet
196196
expect(refusedAtType(key), key).toBe(false);
197197
}
198198
});
199+
200+
it('counts `cloud:plan-status` armed — it registered WITH its arm (objectui#10919)', () => {
201+
// One registry key (`skipFallback: true`, so no bare `plan-status`), armed in
202+
// `@object-ui/types/zod` in the same change, so the pin above did not move.
203+
expect(NAMESPACED_KEYS).toContain('cloud:plan-status');
204+
expect(BARE_KEYS).not.toContain('plan-status');
205+
expect(refusedAtType('cloud:plan-status')).toBe(false);
206+
});
199207
});
200208

201209
/* ── End to end: the README document through `objectui validate` ─────────── */

‎packages/cli/src/utils/known-schema-types.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [
8686
'cloud-connection:panel',
8787
'cloud:ai-model-status',
8888
'cloud:onboarding-next',
89+
'cloud:plan-status',
8990
'code',
9091
'code-editor',
9192
'collapsible',

‎packages/i18n/src/locales/ar.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2952,6 +2952,9 @@ const ar = {
29522952
openProduction: "فتح بيئة الإنتاج",
29532953
manageEnvironments: "إدارة البيئات",
29542954
},
2955+
cloudPlanStatus: {
2956+
current: "الخطة الحالية",
2957+
},
29552958
ai: {
29562959
nlQuery: {
29572960
placeholder: "اطرح سؤالًا حول بياناتك…",

‎packages/i18n/src/locales/de.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2922,6 +2922,9 @@ const de = {
29222922
openProduction: "Produktion öffnen",
29232923
manageEnvironments: "Umgebungen verwalten",
29242924
},
2925+
cloudPlanStatus: {
2926+
current: "Aktueller Tarif",
2927+
},
29252928
ai: {
29262929
nlQuery: {
29272930
placeholder: "Stellen Sie eine Frage zu Ihren Daten…",

‎packages/i18n/src/locales/en.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3397,6 +3397,10 @@ const en = {
33973397
openProduction: 'Open Production',
33983398
manageEnvironments: 'Manage environments',
33993399
},
3400+
// `cloud:plan-status` — the Cloud pricing page's current-plan marker (objectui#10919).
3401+
cloudPlanStatus: {
3402+
current: 'Current plan',
3403+
},
34003404
// `@object-ui/plugin-ai` — the `nl-query`, `ai-form-assist` and
34013405
// `ai-recommendations` components (objectui#10232). The `*One` rows are this
34023406
// repo's two-key plural convention (see `search.itemsAvailableOne`): the

0 commit comments

Comments
 (0)