diff --git a/.changeset/11659-console-record-ux.md b/.changeset/11659-console-record-ux.md index fea85d89fe..0bb09a39ef 100644 --- a/.changeset/11659-console-record-ux.md +++ b/.changeset/11659-console-record-ux.md @@ -6,7 +6,7 @@ Console, record page and Studio copy from the 2026-10-05 cloud acceptance run (objectui#11659). -**The cloud home's primary button reads "Open workspace" (zh 「进入工作区」), not "Open Production".** A new customer has exactly one environment, and "production" is control-plane vocabulary. The `cloud:onboarding-next` widget's ready-state button now resolves `cloudOnboarding.openWorkspace`, which replaces `cloudOnboarding.openProduction` in all ten language packs, and the hint under it (`cloudOnboarding.hintReady`) says "workspace" instead of "production environment". The button still navigates to the page's `openProductionUrl`; the page-metadata contract is unchanged. +**The cloud home's primary button reads "Open workspace" (zh 「进入工作区」), not "Open Production".** A new customer has exactly one environment, and "production" is control-plane vocabulary. The `cloud:onboarding-next` widget's ready-state button now resolves `cloudOnboarding.openWorkspace`, which replaces `cloudOnboarding.openProduction` in all ten language packs, and the hint under it (`cloudOnboarding.hintReady`) says "workspace" instead of "production environment". The button still navigates to the page's `openProductionUrl`; the page-metadata contract is unchanged. The same release then retires the `cloud:onboarding-next` widget and every `cloudOnboarding.*` key (objectui#12954), so neither the button nor those keys ship. **The create-workspace dialog asks for the name only.** `CreateWorkspaceDialog` no longer shows the "URL slug" field: the customer never sees the slug take effect at this step. The slug is still generated from the name, by the same rule as before, and sent with the create call; the owner can change it later in organization settings. Because the slug is no longer the user's to fix in the dialog, a slug collision (`ORGANIZATION_ALREADY_EXISTS` or `ORGANIZATION_SLUG_ALREADY_TAKEN`) is retried with a short random suffix, up to three attempts in all; any other refusal is shown as before. The `workspace.slugLabel` and `workspace.slugHint` pack keys are left in place, now unread. diff --git a/.changeset/11930-workspace-timezone-notice.md b/.changeset/11930-workspace-timezone-notice.md index f23f475cae..75a06cf38e 100644 --- a/.changeset/11930-workspace-timezone-notice.md +++ b/.changeset/11930-workspace-timezone-notice.md @@ -18,7 +18,7 @@ New SDUI widget `cloud:workspace-timezone-notice`: one line on the Cloud welcome **What changed, in observable terms.** -- A page places the node with no props: `{ "type": "cloud:workspace-timezone-notice" }`. The widget reads the summary through the hook the environment list and `cloud:plan-status` already use, and when the summary carries `workspaceTimezoneSeed` it renders one muted line naming that zone, verbatim. In English: "The workspace timezone was set to Asia/Shanghai from your browser when the workspace was created. You can change it in Settings → Localization." +- A page places the node with no props: `{ "type": "cloud:workspace-timezone-notice" }`. The widget reads the summary through the hook the environment list already uses, and when the summary carries `workspaceTimezoneSeed` it renders one muted line naming that zone, verbatim. In English: "The environment timezone was set to Asia/Shanghai from your browser when the environment was created. You can change it in Settings → Localization." - It renders nothing when the summary carries no seed (workspaces created before the seed existed, and control planes that do not send it yet), while the summary loads, when the request fails or rejects, and when the body is not the `{ success, data }` envelope. A failed request does not throw. - The line is text only: no link and no dismissal state. - The node's `className` and `responsiveStyles` reach the line. diff --git a/.changeset/12954-retire-cloud-onboarding-plan-status.md b/.changeset/12954-retire-cloud-onboarding-plan-status.md new file mode 100644 index 0000000000..f92c86208c --- /dev/null +++ b/.changeset/12954-retire-cloud-onboarding-plan-status.md @@ -0,0 +1,19 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/types': minor +'@object-ui/i18n': minor +'@object-ui/cli': minor +--- + +Two Cloud SDUI widgets are retired: `cloud:onboarding-next`, the Welcome page's next-step block, and `cloud:plan-status`, the pricing page's current-plan marker (objectui#12954). The Cloud control plane was their one consumer, and objectstack-ai/cloud#3010 replaced both: the home's next step is declared `element:button` nodes over `GET /api/v1/cloud/home`, and the pricing page marks the current plan with a `visibleWhen` on the same summary. At retirement, hotcrm `main` (`3b05ae7`) and objectstack `main` (`0b44840dfd`) registered and authored neither type. They are retired at once, with no deprecation alias. This is breaking for a page or host that still names one of them; it is marked `minor` under this repository's version policy. + +- **SDUI widget `cloud:onboarding-next`**, also registered as `app-shell:cloud:onboarding-next`. FROM: `@object-ui/app-shell` registered both keys. TO: neither is registered. A page that still names one draws the unknown-component panel, and `objectui check` no longer counts either key among the known component types. +- **SDUI widget `cloud:plan-status`**. FROM: `@object-ui/app-shell` registered it. TO: not registered, with the same two consequences. +- **Schema `CloudPlanStatusSchema`** in `@object-ui/types`: the TypeScript interface from the package entry, and the zod arm from `@object-ui/types/zod`, which was also a member of `AnyComponentSchema`. FROM: exported, and a `cloud:plan-status` node validated against the arm. TO: there is no such export, so importing it fails to compile. `AnyComponentSchema` keeps a refusal arm for the literal, which is not exported, so `objectui validate` refuses a `cloud:plan-status` node BY NAME at `type`, and the message names the remedy: a `visibleWhen` on the page's summary. `CloudWorkspaceTimezoneNoticeSchema` stays. +- **Locale keys `cloudOnboarding.*`** (`hintCreate`, `hintReady`, `createEnvironment`, `openWorkspace`, `manageEnvironments`) **and `cloudPlanStatus.current`** leave all ten built-in packs of `@object-ui/i18n`. + +The two widget modules also leave the `sideEffects` array of `@object-ui/app-shell`. `useEnvironmentEntitlements` stays: the environment list and `cloud:workspace-timezone-notice` still read it. + +Fix for a page that still names either widget: compose it from generic blocks, as objectstack-ai/cloud#3010 did — declared `element:button` nodes over the home summary in place of `cloud:onboarding-next`, and a `visibleWhen` on the same summary in place of `cloud:plan-status`. + +Clause-②: no (narrowing) diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index b80427deb9..c85039ee20 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -9,8 +9,6 @@ "./dist/console/cloud-connection/CloudConnectionPanel.js", "./dist/console/connect/ConnectAgentWidget.js", "./dist/console/diagnostics/CloudAiModelStatus.js", - "./dist/console/home/CloudOnboardingNext.js", - "./dist/console/home/CloudPlanStatus.js", "./dist/console/home/CloudWorkspaceTimezoneNotice.js", "./dist/services/builtinComponents.js", "./dist/views/global-notifications-renderer.js", @@ -29,8 +27,6 @@ "./src/console/cloud-connection/CloudConnectionPanel.tsx", "./src/console/connect/ConnectAgentWidget.tsx", "./src/console/diagnostics/CloudAiModelStatus.tsx", - "./src/console/home/CloudOnboardingNext.tsx", - "./src/console/home/CloudPlanStatus.tsx", "./src/console/home/CloudWorkspaceTimezoneNotice.tsx", "./src/services/builtinComponents.tsx", "./src/views/global-notifications-renderer.tsx", diff --git a/packages/app-shell/src/__tests__/navRunAction.privateConventionRetired.test.ts b/packages/app-shell/src/__tests__/navRunAction.privateConventionRetired.test.ts index f2a4e27555..f7dd3ee29a 100644 --- a/packages/app-shell/src/__tests__/navRunAction.privateConventionRetired.test.ts +++ b/packages/app-shell/src/__tests__/navRunAction.privateConventionRetired.test.ts @@ -70,10 +70,11 @@ const stripComments = (src: string): string => * Every file that used to hand-write the param name, plus the two that consume * it today. If a future file needs the name, it imports the constant — so this * list is allowed to grow only with files that DON'T contain the literal. + * `CloudOnboardingNext.tsx`, the producer half of the retired convention, left + * the list when the widget itself retired (objectui#12954). */ const SOURCES_THAT_MUST_NOT_SPELL_IT = [ 'packages/app-shell/src/environment/EnvironmentListToolbar.tsx', - 'packages/app-shell/src/console/home/CloudOnboardingNext.tsx', 'packages/app-shell/src/views/ObjectView.tsx', 'packages/app-shell/src/hooks/useNavRunAction.ts', ]; @@ -100,16 +101,6 @@ describe('the private `?runAction=` convention is gone from the source (#5216)', expect(src).not.toContain('URLSearchParams'); expect(src).toContain('useNavRunAction'); }); - - it('the welcome CTA no longer concatenates its own deep link', () => { - const src = read('packages/app-shell/src/console/home/CloudOnboardingNext.tsx'); - - expect(src).toContain('navRunActionHref'); - // The exact template that used to build it. Comments are stripped for the - // same reason as above: the comment there NAMES the retired string while - // explaining what replaced it, which is documentation, not a producer. - expect(stripComments(src)).not.toContain('?runAction=create_environment'); - }); }); describe('the param name now has exactly one definition (#5216)', () => { diff --git a/packages/app-shell/src/__tests__/product-specific.allowlist.json b/packages/app-shell/src/__tests__/product-specific.allowlist.json index 8c10754e4d..f72781eeb8 100644 --- a/packages/app-shell/src/__tests__/product-specific.allowlist.json +++ b/packages/app-shell/src/__tests__/product-specific.allowlist.json @@ -3,18 +3,13 @@ "registrations": [ "app-shell:cloud-connection:panel", "app-shell:cloud:ai-model-status", - "app-shell:cloud:onboarding-next", "cloud-connection:panel", "cloud:ai-model-status", - "cloud:onboarding-next", - "cloud:plan-status", "cloud:workspace-timezone-notice" ], "files": [ "packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx", "packages/app-shell/src/console/diagnostics/CloudAiModelStatus.tsx", - "packages/app-shell/src/console/home/CloudOnboardingNext.tsx", - "packages/app-shell/src/console/home/CloudPlanStatus.tsx", "packages/app-shell/src/console/home/CloudWorkspaceTimezoneNotice.tsx", "packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx", "packages/app-shell/src/console/organizations/OrganizationsLayout.tsx", @@ -86,16 +81,6 @@ "waiting.polling", "waiting.popupOpened" ], - "cloudOnboarding": [ - "createEnvironment", - "hintCreate", - "hintReady", - "manageEnvironments", - "openWorkspace" - ], - "cloudPlanStatus": [ - "current" - ], "cloudWorkspaceTimezoneNotice": [ "seeded" ], diff --git a/packages/app-shell/src/console/blocks/__tests__/productBlocks-12197.test.tsx b/packages/app-shell/src/console/blocks/__tests__/productBlocks-12197.test.tsx index 8373690368..304b18ffa7 100644 --- a/packages/app-shell/src/console/blocks/__tests__/productBlocks-12197.test.tsx +++ b/packages/app-shell/src/console/blocks/__tests__/productBlocks-12197.test.tsx @@ -70,11 +70,6 @@ describe('product-page blocks are registered under product-neutral names (object for (const input of config?.inputs ?? []) expect(input.description, `${type}.${input.name}`).toBeTruthy(); } }); - - it('keeps `cloud:onboarding-next` registered (control)', async () => { - await import('../../home/CloudOnboardingNext'); - expect(ComponentRegistry.get('app-shell:cloud:onboarding-next')).toBeTruthy(); - }); }); describe('hero', () => { diff --git a/packages/app-shell/src/console/diagnostics/CloudAiModelStatus.tsx b/packages/app-shell/src/console/diagnostics/CloudAiModelStatus.tsx index ba1bbc1380..25b1bcecbd 100644 --- a/packages/app-shell/src/console/diagnostics/CloudAiModelStatus.tsx +++ b/packages/app-shell/src/console/diagnostics/CloudAiModelStatus.tsx @@ -13,7 +13,7 @@ * panel never re-computes the model (a second compute source is exactly the * drift trap the boot-convergence guard fixed). The component owns only the * fetch + render; the endpoint path comes from page metadata `properties`, so - * the Cloud/Setup page owns its URL. Same pattern as `cloud:onboarding-next`. + * the Cloud/Setup page owns its URL. */ import { useEffect, useMemo, useState } from 'react'; diff --git a/packages/app-shell/src/console/home/CloudOnboardingNext.tsx b/packages/app-shell/src/console/home/CloudOnboardingNext.tsx deleted file mode 100644 index 65bff6de04..0000000000 --- a/packages/app-shell/src/console/home/CloudOnboardingNext.tsx +++ /dev/null @@ -1,203 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * CloudOnboardingNext — the state-aware "next step" block for the Cloud - * control-plane Welcome page, registered as the SDUI widget - * `cloud:onboarding-next`. - * - * The Welcome page is otherwise static SDUI, but the single most useful thing - * it can show — "what do I do next?" — depends on live state the metadata can't - * carry: does the caller's org already have its production environment? New - * signups are auto-provisioned one, so a static "Step 1: create an environment" - * is wrong for most first-time users. This widget resolves that one signal from - * the same `/cloud/environment-entitlements` endpoint the environment list uses - * and renders the right primary action: - * - * • has production env → "Open" (the doorway into the env, where - * building happens) + a "Manage environments" link. - * The label names the place in the customer's words, - * not the operator's: a new customer has exactly one - * environment, and "production" is control-plane - * vocabulary (objectui#11659). The destination is - * still the org's production environment. - * • no production env → "Create your environment" (the real first step). - * • loading → a neutral skeleton (no CTA flashes / layout jump). - * • unknown / error → degrade to BOTH actions, so the button always works. - * - * Routes and the "Open" endpoint come from the page metadata - * (`properties`) so the Cloud app owns its URLs; this component owns only the - * state logic. The signal is read through `useEnvironmentEntitlements`, the - * resource the pricing page's plan cards and the workspace-timezone notice share, - * so this block adds no request of its own (objectui#12808). Only the - * authoritative summary resolves it: the hook's row-derived fallback reads as - * `unknown` here, exactly as a failed summary always has. - */ - -import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Button, Skeleton } from '@object-ui/components'; -import { Rocket, Plus, Settings2 } from 'lucide-react'; -import { createAuthenticatedFetch } from '@object-ui/auth'; -import { ComponentRegistry } from '@object-ui/core'; -import { useObjectTranslation } from '@object-ui/i18n'; -import { navRunActionHref } from '../../hooks/useNavRunAction.js'; -import { useAdapter } from '../../providers/AdapterProvider.js'; -import { useEnvironmentEntitlements } from '../../environment/useEnvironmentEntitlements.js'; - -interface CloudOnboardingNextProps { - properties?: { - /** Backend SSO-open endpoint (full-page nav that 302s into the prod env). */ - openProductionUrl?: string; - /** SPA route to the environments list (create / open / manage). */ - environmentsRoute?: string; - /** - * Name of the action the "Create your environment" CTA deep-links into on - * that list — the same slot an object nav entry declares as `runAction`. - * Defaults to `create_environment`, which is the name the Cloud app's - * transitional pin test guards, so this is safe to ship ahead of the page - * metadata that will supply it (#5216). - */ - createAction?: string; - /** - * Optional backend pre-warm endpoint. When set and the caller already has - * a production env, the widget fires a best-effort GET the moment it knows - * the env exists — nudging a possibly-asleep container awake WHILE the user - * reads the hint, so the later "Open" click lands in an - * already-waking env instead of paying the full cold-start. No-op when - * unset (older page metadata), so this is safe to ship ahead of the page. - */ - warmUrl?: string; - }; -} - -type Resolved = - | { phase: 'loading' } - | { phase: 'ready'; hasProductionEnv: boolean } - | { phase: 'unknown' }; - -const DEFAULT_OPEN_PRODUCTION_URL = '/api/v1/cloud/environments/production/sso-open'; -const DEFAULT_ENVIRONMENTS_ROUTE = '/apps/cloud_control/sys_environment'; - -/** - * The environment list's create action. Cloud-owned name, guarded by cloud's - * transitional pin test (cloud#1048) — kept as the default so page metadata - * that predates `properties.createAction` still deep-links correctly. - */ -const DEFAULT_CREATE_ACTION = 'create_environment'; - -/** - * Resolve `hasProductionEnv` from the org-scoped entitlements summary. Returns - * `unknown` on any failure so the caller degrades gracefully rather than - * blocking the user behind a wrong "create" CTA. - */ -function useProductionEnvState(warmUrl?: string): Resolved { - const dataSource = useAdapter(); - // `useState`, not `useMemo`: the warm effect below keys on this function, so - // its identity has to be one React guarantees to keep (AGENTS.md #10). - const [authFetch] = useState(() => createAuthenticatedFetch()); - const apiBase = ((import.meta.env.VITE_SERVER_URL as string | undefined) || '').replace(/\/+$/, ''); - const entitlements = useEnvironmentEntitlements({ enabled: true, dataSource, authFetch, apiBase }); - - // Strict: only the authoritative summary resolves the signal (cloud#1046's - // `{ success, data }` envelope, read by the hook). A row-derived or failed - // answer is `unknown`, which degrades to both actions and always works. - const state: Resolved = entitlements === null - ? { phase: 'loading' } - : entitlements.source === 'summary' - ? { phase: 'ready', hasProductionEnv: entitlements.hasProductionEnv } - : { phase: 'unknown' }; - - // Pre-warm the prod env the instant we know it exists — fire-and-forget, - // best-effort, once per resolve. The user is now reading the hint and will - // click "Open" seconds later; by then the container is already - // waking. The sso-open click warms again, so a failed warm here costs - // nothing. Only when the page passes a warmUrl (else no-op). Keyed on the - // answer object itself, so each resolution warms once. - useEffect(() => { - if (entitlements?.source !== 'summary' || !entitlements.hasProductionEnv || !warmUrl) return; - void authFetch(`${apiBase}${warmUrl}`, { method: 'GET', credentials: 'include' }) - .catch(() => { /* pre-warm is best-effort */ }); - }, [entitlements, warmUrl, authFetch, apiBase]); - - return state; -} - -/** Full-page nav to the backend SSO endpoint so the browser follows its 302. */ -function openProduction(url: string) { - window.location.href = url; -} - -export function CloudOnboardingNext({ properties }: CloudOnboardingNextProps) { - const { t } = useObjectTranslation(); - const navigate = useNavigate(); - const state = useProductionEnvState(properties?.warmUrl); - const openUrl = properties?.openProductionUrl || DEFAULT_OPEN_PRODUCTION_URL; - const envsRoute = properties?.environmentsRoute || DEFAULT_ENVIRONMENTS_ROUTE; - const createAction = properties?.createAction || DEFAULT_CREATE_ACTION; - - // Loading — a neutral skeleton sized like the button row, so the hero doesn't - // jump when the real CTA lands. - if (state.phase === 'loading') { - return ( -
- - -
- ); - } - - const hint = - state.phase === 'ready' && !state.hasProductionEnv - ? t('cloudOnboarding.hintCreate') - : t('cloudOnboarding.hintReady'); - - // No production env yet → the real first step is "create", not "open". - const showCreatePrimary = state.phase === 'ready' && !state.hasProductionEnv; - - return ( -
-
- {showCreatePrimary ? ( - // Deep-link straight INTO the create dialog (#844): the environments - // list consumes the declared `runAction` slot and auto-opens the named - // action once entitlements resolve — "Create your environment" used to - // be a plain navigation that left the user hunting for a second create - // button on the list page. - // - // [#5216] Both halves are declared now. The action NAME comes from page - // metadata (`properties.createAction`, same bag that already owns - // `environmentsRoute` — the Cloud app owns its URLs and now its deep - // link too), and the ENCODING comes from `navRunActionHref`, which - // spells the param through `NAV_RUN_ACTION_PARAM`. What this retires is - // the hand-concatenated `?runAction=create_environment` literal: a - // private convention whose only definition was this template string - // and a matching literal in the toolbar. - - ) : ( - - )} - -
-

{hint}

-
- ); -} - -// SDUI registration — the Cloud Welcome page references this widget by type. -ComponentRegistry.register('cloud:onboarding-next', (props: CloudOnboardingNextProps) => ( - -), { - namespace: 'app-shell', - label: 'Cloud Onboarding Next-Step', - category: 'plugin', - inputs: [], -}); diff --git a/packages/app-shell/src/console/home/CloudPlanStatus.tsx b/packages/app-shell/src/console/home/CloudPlanStatus.tsx deleted file mode 100644 index 4755b6066b..0000000000 --- a/packages/app-shell/src/console/home/CloudPlanStatus.tsx +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * CloudPlanStatus — the "current plan" marker for one plan card on the Cloud - * control plane's pricing page, registered as the SDUI widget - * `cloud:plan-status` (objectui#10919). - * - * The pricing page is static metadata: nothing in its expression scope carries - * the organization's plan, so a page cannot tell which of its cards is the one - * the organization is already on. The plan is only available from the - * org-scoped `GET /cloud/environment-entitlements` summary (`data.plan`). This - * widget reads that summary and answers one question for the card it is placed - * on: is this card's plan the organization's plan? - * - * • the summary names this card's plan → a localized "Current plan" badge; - * • the summary names another plan → nothing; - * • loading, failed, or no summary → nothing. The plan is never guessed: - * only the authoritative summary (`source: 'summary'`) can mark a card, and - * the row-derived fallback carries no plan at all. - * - * ## One node per card, and the page names the card - * - * The plan code a card stands for is the page's, not this widget's: the plan - * catalog belongs to the control plane, so the page passes its own code in - * `properties.plan` (the `properties` bag is the spelling every page component - * is authored in) and the widget compares it VERBATIM with `data.plan`. No - * code is listed, normalized or defaulted here. - * - * ## The summary is read through `useEnvironmentEntitlements` - * - * The same hook the environment list uses — one reading of the endpoint and its - * strict `{ success, data }` envelope, not a third copy of the fetch. It is - * enabled unconditionally here because this widget exists only to read it. - */ - -import { useState } from 'react'; -import { Badge, cn } from '@object-ui/components'; -import { createAuthenticatedFetch } from '@object-ui/auth'; -import { ComponentRegistry } from '@object-ui/core'; -import { useObjectTranslation } from '@object-ui/i18n'; -import { useAdapter } from '../../providers/AdapterProvider.js'; -import { useEnvironmentEntitlements } from '../../environment/useEnvironmentEntitlements.js'; - -export interface CloudPlanStatusProps { - properties?: { - /** - * The plan code of the card this node sits on, exactly as the control - * plane's entitlements summary spells it (for example `free`). - */ - plan?: string; - }; - /** Carries the scope class of the node's `responsiveStyles`. */ - className?: string; -} - -export function CloudPlanStatus({ properties, className }: CloudPlanStatusProps) { - const { t } = useObjectTranslation(); - const dataSource = useAdapter(); - // `useState`, not `useMemo`: one function per mount, with an identity React - // guarantees to keep (AGENTS.md #10). The hook uses it only when this reader - // is the one that starts the shared resolution (objectui#12808). - const [authFetch] = useState(() => createAuthenticatedFetch()); - const entitlements = useEnvironmentEntitlements({ - enabled: true, - dataSource, - authFetch, - apiBase: (import.meta.env.VITE_SERVER_URL as string | undefined) || '', - }); - - const plan = properties?.plan; - const isCurrent = - typeof plan === 'string' - && plan !== '' - && entitlements?.source === 'summary' - && entitlements.plan === plan; - if (!isCurrent) return null; - - return ( - - {t('cloudPlanStatus.current')} - - ); -} - -// SDUI registration — the Cloud pricing page places one node per plan card. -// Registered under the `cloud` namespace with no bare fallback, so the one key -// is `cloud:plan-status`: the spelling the page authors, and the one literal -// its `@object-ui/types` arm claims (`CloudPlanStatusSchema`). -ComponentRegistry.register('plan-status', (props: CloudPlanStatusProps) => ( - -), { - namespace: 'cloud', - skipFallback: true, - label: 'Cloud Plan Status', - category: 'plugin', - inputs: [], -}); diff --git a/packages/app-shell/src/console/home/CloudWorkspaceTimezoneNotice.tsx b/packages/app-shell/src/console/home/CloudWorkspaceTimezoneNotice.tsx index 86024606e9..eb67788eeb 100644 --- a/packages/app-shell/src/console/home/CloudWorkspaceTimezoneNotice.tsx +++ b/packages/app-shell/src/console/home/CloudWorkspaceTimezoneNotice.tsx @@ -27,9 +27,8 @@ * * ## The summary is read through `useEnvironmentEntitlements` * - * The same hook `cloud:plan-status` and the environment list use: one reading of - * the endpoint and its strict `{ success, data }` envelope, not another copy of - * the fetch. + * The same hook the environment list uses: one reading of the endpoint and its + * strict `{ success, data }` envelope, not another copy of the fetch. */ import { useState } from 'react'; diff --git a/packages/app-shell/src/console/home/__tests__/CloudOnboardingNext.test.tsx b/packages/app-shell/src/console/home/__tests__/CloudOnboardingNext.test.tsx deleted file mode 100644 index e7a6887f26..0000000000 --- a/packages/app-shell/src/console/home/__tests__/CloudOnboardingNext.test.tsx +++ /dev/null @@ -1,215 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * CloudOnboardingNext resolves `hasProductionEnv` from the entitlements summary - * and shows the right primary next-step: "Create your environment" when the org - * has none, "Open" once it does, and a graceful both-actions fallback - * when the signal can't be resolved. - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import React from 'react'; - -const navigateMock = vi.fn(); -let fetchImpl: (url: string, init?: any) => Promise; - -vi.mock('react-router-dom', () => ({ - useNavigate: () => navigateMock, -})); -vi.mock('@object-ui/auth', async (importOriginal) => ({ - ...(await importOriginal>()), - useAuth: () => ({ activeOrganization: { id: 'org_1' } }), - createAuthenticatedFetch: () => (url: string, init?: any) => fetchImpl(url, init), -})); - -import { I18nProvider } from '@object-ui/i18n'; -import { CloudOnboardingNext } from '../CloudOnboardingNext'; - -/** - * The CTA labels resolve from the locale packs (objectui#2871), so these - * renders need a real i18n context — without one `t()` returns the raw key and - * these assertions would pass against nothing. Pinned to `en` with browser - * detection off so the expectations stay deterministic. - */ -const renderOnboarding = (ui: React.ReactElement, language: 'en' | 'zh' = 'en') => - render( - - {ui} - , - ); - -function summary(hasProductionEnv: boolean) { - return { - ok: true, - status: 200, - json: async () => ({ data: { hasProductionEnv } }), - }; -} - -const PROPS = { - properties: { - openProductionUrl: '/api/v1/cloud/environments/production/sso-open', - environmentsRoute: '/apps/cloud_control/sys_environment', - }, -}; - -describe('CloudOnboardingNext', () => { - beforeEach(() => { - navigateMock.mockReset(); - }); - - it('shows "Create your environment" when the org has no production env', async () => { - fetchImpl = async () => summary(false); - renderOnboarding(); - - const create = await screen.findByText('Create your environment'); - expect(create).toBeTruthy(); - expect(screen.queryByText('Open')).toBeNull(); - - fireEvent.click(create); - // Deep-links into the create dialog (#844): the environments list consumes - // `runAction=create_environment` and auto-opens its create action. - expect(navigateMock).toHaveBeenCalledWith( - '/apps/cloud_control/sys_environment?runAction=create_environment', - ); - }); - - it('shows "Open" once the org has a production env', async () => { - fetchImpl = async () => summary(true); - renderOnboarding(); - - expect(await screen.findByText('Open')).toBeTruthy(); - expect(screen.queryByText('Create your environment')).toBeNull(); - }); - - it('degrades to the Open actions when the signal cannot be resolved', async () => { - fetchImpl = async () => ({ ok: false, status: 500, json: async () => null }); - renderOnboarding(); - - // Unknown state is fail-safe: it must NEVER strand a real user behind a - // wrong "create" CTA, so it shows Open + Manage environments. - expect(await screen.findByText('Open')).toBeTruthy(); - expect(screen.getByText('Manage environments')).toBeTruthy(); - expect(screen.queryByText('Create your environment')).toBeNull(); - }); - - // Strict envelope pins (#3352): the control plane wraps success bodies as - // `{ success, data }`, so a BARE body is a producer contract violation and - // must NOT produce a usable summary. It resolves to `unknown` (both actions - // work) instead of being read field-by-field off the wrong shape. - it('does NOT read a bare (un-enveloped) body as a usable summary', async () => { - fetchImpl = async () => ({ - ok: true, - status: 200, - json: async () => ({ hasProductionEnv: true }), - }); - const { container } = renderOnboarding(); - - await waitFor(() => - expect(container.querySelector('[data-onboarding="unknown"]')).toBeTruthy(), - ); - expect(container.querySelector('[data-onboarding="ready"]')).toBeNull(); - }); - - it('never strands a user behind a wrong "create" CTA when the envelope is missing', async () => { - // The silent-degradation case the strict read exists to kill: an - // un-enveloped body whose `hasProductionEnv` reads as absent used to - // resolve to `ready:false` and show "Create your environment" to an org - // that already HAS a production env. - fetchImpl = async () => ({ - ok: true, - status: 200, - json: async () => ({ hasProductionEnv: false }), - }); - const { container } = renderOnboarding(); - - expect(await screen.findByText('Open')).toBeTruthy(); - expect(screen.queryByText('Create your environment')).toBeNull(); - expect(container.querySelector('[data-onboarding="unknown"]')).toBeTruthy(); - }); - - // objectui#11659 ruling 1: the cloud home's primary button names the place in - // the customer's words. A new customer has exactly one environment, and - // "production" is operator vocabulary, so neither the button nor the hint - // under it says it. The destination is unchanged: the same sso-open URL. - describe('the ready state opens the environment, and never says production (objectui#11659, objectui#12851)', () => { - it('zh: the primary button reads 打开 and the block never says 生产环境', async () => { - fetchImpl = async () => summary(true); - const { container } = renderOnboarding(, 'zh'); - - const open = await screen.findByRole('button', { name: '打开' }); - expect(container.querySelector('[data-onboarding="ready"]')).toBeTruthy(); - expect(container.textContent).not.toContain('生产环境'); - - // Destination unchanged: the button still navigates to the page's sso-open URL. - const assign = vi.fn(); - const original = window.location; - Object.defineProperty(window, 'location', { - configurable: true, - value: { ...original, set href(v: string) { assign(v); }, get href() { return original.href; } }, - }); - try { - fireEvent.click(open); - } finally { - Object.defineProperty(window, 'location', { configurable: true, value: original }); - } - expect(assign).toHaveBeenCalledWith(PROPS.properties.openProductionUrl); - }); - - it('en: the primary button reads "Open" and the block never says production', async () => { - fetchImpl = async () => summary(true); - const { container } = renderOnboarding(); - - expect(await screen.findByRole('button', { name: 'Open' })).toBeTruthy(); - expect(container.textContent ?? '').not.toMatch(/production/i); - }); - }); - - // objectui#12851 (ruling on objectstack-ai/cloud#2939, letter A): the running - // instance is 「环境」, and the hint under the buttons says in one line what an - // environment is. 「工作区」 is retired from this block in both states. - describe('the hint says what an environment is (objectui#12851)', () => { - it('zh: the create-state hint defines 环境 and never says 工作区', async () => { - fetchImpl = async () => summary(false); - const { container } = renderOnboarding(, 'zh'); - - expect(await screen.findByText('环境是运行你的应用的独立实例,有自己的网址、数据库和套餐。')).toBeTruthy(); - expect(container.textContent).not.toContain('工作区'); - }); - - it('zh: the ready-state hint says the environment is ready, beside the 打开 button', async () => { - fetchImpl = async () => summary(true); - const { container } = renderOnboarding(, 'zh'); - - expect(await screen.findByRole('button', { name: '打开' })).toBeTruthy(); - expect(screen.getByText('你的环境已就绪——它是运行你的应用的独立实例,有自己的网址、数据库和套餐。')).toBeTruthy(); - expect(container.textContent).not.toContain('工作区'); - }); - - it('en (control): the ready-state hint names the environment and never says workspace', async () => { - fetchImpl = async () => summary(true); - const { container } = renderOnboarding(); - - expect(await screen.findByRole('button', { name: 'Open' })).toBeTruthy(); - expect( - screen.getByText('Your environment is ready — a private instance that runs your apps, with its own URL, database, and plan.'), - ).toBeTruthy(); - expect(container.textContent ?? '').not.toMatch(/workspace/i); - }); - }); - - it('renders a non-CTA skeleton while the signal is still loading', async () => { - let resolveFetch: (v: any) => void = () => {}; - fetchImpl = () => new Promise((r) => { resolveFetch = r; }); - const { container } = renderOnboarding(); - - // Before the fetch resolves: no CTA text, just the skeleton placeholder. - expect(screen.queryByText('Open')).toBeNull(); - expect(screen.queryByText('Create your environment')).toBeNull(); - expect(container.querySelector('[data-onboarding="loading"]')).toBeTruthy(); - - resolveFetch(summary(true)); - await waitFor(() => expect(screen.getByText('Open')).toBeTruthy()); - }); -}); diff --git a/packages/app-shell/src/console/home/__tests__/CloudPlanStatus.test.tsx b/packages/app-shell/src/console/home/__tests__/CloudPlanStatus.test.tsx deleted file mode 100644 index 4961cf9cb7..0000000000 --- a/packages/app-shell/src/console/home/__tests__/CloudPlanStatus.test.tsx +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * CloudPlanStatus (`cloud:plan-status`, objectui#10919) marks ONE plan card on - * the Cloud pricing page: a "Current plan" badge when the organization's plan, - * read from the authoritative entitlements summary, equals the card's - * `properties.plan` — and nothing in every other state, so a plan is never - * guessed. - */ - -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, screen, waitFor, cleanup } from '@testing-library/react'; -import React from 'react'; - -let fetchImpl: (url: string, init?: RequestInit) => Promise; -const fetchedUrls: string[] = []; - -vi.mock('@object-ui/auth', async (importOriginal) => ({ - ...(await importOriginal>()), - useAuth: () => ({ activeOrganization: { id: 'org_1' } }), - createAuthenticatedFetch: () => (url: string, init?: RequestInit) => { - fetchedUrls.push(url); - return fetchImpl(url, init); - }, -})); - -import { I18nProvider } from '@object-ui/i18n'; -import { ComponentRegistry } from '@object-ui/core'; -import { AdapterCtx, SchemaRenderer } from '@object-ui/react'; -import { CloudPlanStatus } from '../CloudPlanStatus'; - -/** - * The badge text resolves from the locale packs, so a real i18n context is - * needed — without one `t()` returns the raw key and a text assertion would - * pass against nothing. The adapter is the row-derived fallback's data source; - * its rows name a production env, so the fallback resolves `derived` for real - * rather than failing. - */ -const adapter = { find: vi.fn().mockResolvedValue([{ id: 'env_1', environment_type: 'production' }]) }; - -const renderInShell = (ui: React.ReactElement) => - render( - - {ui} - , - ); - -/** The control plane's `{ success, data }` envelope around a summary naming `plan`. */ -function summary(plan: string) { - return { - ok: true, - status: 200, - json: async () => ({ success: true, data: { plan, hasProductionEnv: true } }), - }; -} - -afterEach(() => { - cleanup(); - fetchedUrls.length = 0; - adapter.find.mockClear(); -}); - -describe('CloudPlanStatus (objectui#10919)', () => { - it('marks the card whose plan is the organization\'s plan', async () => { - fetchImpl = async () => summary('free'); - const { container } = renderInShell(); - - expect(await screen.findByText('Current plan')).toBeTruthy(); - expect(container.querySelector('[data-plan-status="current"]')).toBeTruthy(); - }); - - it('reads the org-scoped summary endpoint', async () => { - fetchImpl = async () => summary('free'); - renderInShell(); - - await screen.findByText('Current plan'); - expect(fetchedUrls).toEqual(['/api/v1/cloud/environment-entitlements?organizationId=org_1']); - }); - - it('renders nothing on a card whose plan is not the organization\'s', async () => { - fetchImpl = async () => summary('team'); - const { container } = renderInShell(); - - // Wait until the summary was actually read, so the empty render is a - // verdict on it and not the loading state. - await waitFor(() => expect(fetchedUrls).toHaveLength(1)); - await new Promise((r) => setTimeout(r, 0)); - expect(container.innerHTML).toBe(''); - }); - - it('compares the plan code verbatim — no case folding', async () => { - fetchImpl = async () => summary('free'); - const { container } = renderInShell(); - - await waitFor(() => expect(fetchedUrls).toHaveLength(1)); - await new Promise((r) => setTimeout(r, 0)); - expect(container.innerHTML).toBe(''); - }); - - it('renders nothing while the summary is loading', () => { - fetchImpl = () => new Promise(() => {}); - const { container } = renderInShell(); - - expect(container.innerHTML).toBe(''); - }); - - it('renders nothing when the summary fails — the row-derived fallback carries no plan', async () => { - fetchImpl = async () => ({ ok: false, status: 500, json: async () => null }); - const { container } = renderInShell(); - - // The hook fell through to the rows, which resolve (source `derived`) but - // name no plan — so the card is left unmarked instead of guessed. - await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1)); - await new Promise((r) => setTimeout(r, 0)); - expect(container.innerHTML).toBe(''); - }); - - it('renders nothing for a bare (un-enveloped) body that names the plan', async () => { - // A bare body is a producer contract violation (cloud#1046): it yields no - // summary, so even a `plan` that matches must not mark the card. - fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ plan: 'free' }) }); - const { container } = renderInShell(); - - await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1)); - await new Promise((r) => setTimeout(r, 0)); - expect(container.innerHTML).toBe(''); - }); - - it('renders nothing when the node names no plan', async () => { - fetchImpl = async () => summary('free'); - const { container } = renderInShell(); - - await waitFor(() => expect(fetchedUrls).toHaveLength(1)); - await new Promise((r) => setTimeout(r, 0)); - expect(container.innerHTML).toBe(''); - }); -}); - -describe('`cloud:plan-status` registration (objectui#10919)', () => { - it('is registered under the one key the page authors, with no second spelling', () => { - expect(ComponentRegistry.has('cloud:plan-status')).toBe(true); - // `skipFallback: true` — no bare `plan-status` fallback … - expect(ComponentRegistry.has('plan-status')).toBe(false); - // … and no `app-shell:`-prefixed twin. - expect(ComponentRegistry.has('app-shell:cloud:plan-status')).toBe(false); - }); - - it('renders from a page node through SchemaRenderer, reading `properties.plan`', async () => { - fetchImpl = async () => summary('team'); - const { container } = renderInShell( - , - ); - - expect(await screen.findByText('Current plan')).toBeTruthy(); - // The node's `responsiveStyles` scope class reaches the badge through - // `className`, so the page can place it inside its card. - const scope = container.querySelector('style[data-os-scope]')?.getAttribute('data-os-scope'); - expect(scope).toBeTruthy(); - const badge = container.querySelector('[data-plan-status="current"]'); - expect(badge?.classList.contains(scope as string)).toBe(true); - }); -}); diff --git a/packages/app-shell/src/console/home/__tests__/HomePage.approvalsTarget.test.tsx b/packages/app-shell/src/console/home/__tests__/HomePage.approvalsTarget.test.tsx index 56e1eaf9e8..5408e479bf 100644 --- a/packages/app-shell/src/console/home/__tests__/HomePage.approvalsTarget.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/HomePage.approvalsTarget.test.tsx @@ -35,8 +35,7 @@ * its module boundary; `HomeRail` — which owns the card and its testid — stays * real, because "the click reaches the right URL" is not assertable through a * stubbed button. `useNavigate` is mocked rather than driven through a - * `MemoryRouter` so the assertion reads the emitted target directly, matching - * `CloudOnboardingNext.test.tsx` in this same directory. + * `MemoryRouter` so the assertion reads the emitted target directly. */ import '@testing-library/jest-dom/vitest'; diff --git a/packages/app-shell/src/console/marketplace/marketplaceApi.ts b/packages/app-shell/src/console/marketplace/marketplaceApi.ts index 8ce3f29a68..dd8f1abfff 100644 --- a/packages/app-shell/src/console/marketplace/marketplaceApi.ts +++ b/packages/app-shell/src/console/marketplace/marketplaceApi.ts @@ -514,9 +514,9 @@ export async function listInstallableOrgIds(): Promise> { * default on), landing the user in the Cloud app whose nav carries Billing. * * ⛔ Do NOT re-add a path here "temporarily". The durable fix is a - * control-plane-DECLARED URL — the pattern this repo already uses for the one - * cloud route it does navigate to, `CloudOnboardingNext`'s `environmentsRoute`, - * which arrives from cloud page metadata. Until the control plane publishes an + * control-plane-DECLARED URL, one that arrives from cloud page metadata — the + * way the since-retired `cloud:onboarding-next` widget received its + * `environmentsRoute` (objectui#12954). Until the control plane publishes an * equivalent for the tenant SPA (reported alongside this fix), the origin is * the most specific target we are entitled to name. * diff --git a/packages/app-shell/src/environment/EnvironmentListToolbar.tsx b/packages/app-shell/src/environment/EnvironmentListToolbar.tsx index 5e8a50d2bf..0ed00243ad 100644 --- a/packages/app-shell/src/environment/EnvironmentListToolbar.tsx +++ b/packages/app-shell/src/environment/EnvironmentListToolbar.tsx @@ -35,10 +35,8 @@ * though it was reported against the Chinese console, where the two texts are * owned by different packages and the swap read as an inconsistency rather than * a load. The maintainer's ruling on cloud#1049 was option A — present nothing - * rather than a label we are about to overwrite. Same shape as the adjacent - * `cloud:onboarding-next` welcome CTA (`console/home/CloudOnboardingNext.tsx`): - * loading → neutral skeleton sized like the button, unknown/error → degrade to a - * working button. Option B (a neutral label for every state) was rejected: it is + * rather than a label we are about to overwrite: loading → neutral skeleton + * sized like the button, unknown/error → degrade to a working button. Option B (a neutral label for every state) was rejected: it is * semantically wrong for an org that already owns its production environment. * * ## Who owns this button's text (the "two sources" question) @@ -229,8 +227,7 @@ export function EnvironmentListToolbar({ actions, entitlements }: Props) { {hasCreateAction && ( // Sized like the `size="sm"` bar button it stands in for (h-9, - // rounded-md), mirroring CloudOnboardingNext's loading placeholder. - // The width is an approximation of the resolved labels — the point is + // rounded-md). The width is an approximation of the resolved labels — the point is // that the header keeps its shape, not a pixel-exact match. )} diff --git a/packages/app-shell/src/environment/__tests__/useEnvironmentEntitlements.oneRequest-12808.test.tsx b/packages/app-shell/src/environment/__tests__/useEnvironmentEntitlements.oneRequest-12808.test.tsx index 5f004fddd1..3619235713 100644 --- a/packages/app-shell/src/environment/__tests__/useEnvironmentEntitlements.oneRequest-12808.test.tsx +++ b/packages/app-shell/src/environment/__tests__/useEnvironmentEntitlements.oneRequest-12808.test.tsx @@ -4,14 +4,17 @@ * objectui#12808 — one `GET /cloud/environment-entitlements` per organisation, * however many readers are mounted. * - * The Cloud pricing page mounts three `cloud:plan-status` nodes (one per plan - * card) and the Welcome page mounts `cloud:onboarding-next` beside + * The Cloud pricing page mounted three `cloud:plan-status` nodes (one per plan + * card) and the Welcome page mounted `cloud:onboarding-next` beside * `cloud:workspace-timezone-notice`. Each used to issue its own GET, and Chrome * serialises identical in-flight GETs, so the requests cost one round trip each, * in series. Every reader now shares one organisation-keyed resource. * - * The count is taken at the one seam every reader's request goes through - * (`createAuthenticatedFetch`), with the five real widgets mounted together. + * `cloud:plan-status` and `cloud:onboarding-next` have since retired + * (objectui#12954), so the page below mounts the readers that remain: the real + * `cloud:workspace-timezone-notice` widget twice, and twice a bare hook reader + * that reads the summary the way the environment list does. The count is taken + * at the one seam every reader's request goes through (`createAuthenticatedFetch`). */ import { describe, it, expect, vi, afterEach } from 'vitest'; @@ -21,7 +24,6 @@ import React from 'react'; let orgId = 'org_1'; const entitlementGets: string[] = []; -vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() })); vi.mock('@object-ui/auth', async (importOriginal) => ({ ...(await importOriginal>()), useAuth: () => ({ activeOrganization: { id: orgId } }), @@ -36,36 +38,43 @@ vi.mock('@object-ui/auth', async (importOriginal) => ({ data: { plan: org === 'org_2' ? 'pro' : 'free', hasProductionEnv: true, - workspaceTimezoneSeed: 'Asia/Shanghai', + workspaceTimezoneSeed: org === 'org_2' ? 'Europe/Berlin' : 'Asia/Shanghai', }, }), }; }, })); +import { createAuthenticatedFetch } from '@object-ui/auth'; import { I18nProvider } from '@object-ui/i18n'; import { AdapterCtx } from '@object-ui/react'; -import { CloudPlanStatus } from '../../console/home/CloudPlanStatus'; -import { CloudOnboardingNext } from '../../console/home/CloudOnboardingNext'; import { CloudWorkspaceTimezoneNotice } from '../../console/home/CloudWorkspaceTimezoneNotice'; import { useEnvironmentEntitlements } from '../useEnvironmentEntitlements'; const adapter = { find: vi.fn().mockResolvedValue([]) }; +/** A bare reader: the hook as the environment list reads it, printing the plan it is answered. */ +function PlanReader() { + const [authFetch] = React.useState(() => createAuthenticatedFetch()); + const entitlements = useEnvironmentEntitlements({ enabled: true, dataSource: adapter, authFetch, apiBase: '' }); + return ; +} + function Page() { return ( - - - - + + + ); } +const planReaders = (plan: string) => document.querySelectorAll(`[data-plan-reader="${plan}"]`); + /** Let any request a mount would still start get started and settled. */ const flush = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); }); @@ -76,12 +85,11 @@ afterEach(() => { }); describe('one entitlements request per organisation (objectui#12808)', () => { - it('five readers mounted together issue one GET, and every reader gets the answer', async () => { + it('four readers mounted together issue one GET, and every reader gets the answer', async () => { render(); - expect(await screen.findByText('Current plan')).toBeTruthy(); - expect(await screen.findByText(/Asia\/Shanghai/)).toBeTruthy(); - await waitFor(() => expect(document.querySelector('[data-onboarding="ready"]')).toBeTruthy()); + expect(await screen.findAllByText(/Asia\/Shanghai/)).toHaveLength(2); + await waitFor(() => expect(planReaders('free')).toHaveLength(2)); await flush(); expect(entitlementGets).toEqual(['/api/v1/cloud/environment-entitlements?organizationId=org_1']); @@ -89,16 +97,16 @@ describe('one entitlements request per organisation (objectui#12808)', () => { it('CONTROL: an organisation change issues exactly one more', async () => { const view = render(); - await screen.findByText('Current plan'); + await screen.findAllByText(/Asia\/Shanghai/); await flush(); expect(entitlementGets).toHaveLength(1); orgId = 'org_2'; view.rerender(); - // The `pro` card is the one marked now: every reader moved to org_2's answer. - await waitFor(() => - expect(document.querySelectorAll('[data-plan-status="current"]')).toHaveLength(1), - ); + // Every reader moved to org_2's answer: both notices name its seed, both + // bare readers its plan. + expect(await screen.findAllByText(/Europe\/Berlin/)).toHaveLength(2); + await waitFor(() => expect(planReaders('pro')).toHaveLength(2)); await flush(); expect(entitlementGets).toEqual([ @@ -129,13 +137,13 @@ describe('one entitlements request per organisation (objectui#12808)', () => { it('CONTROL: the answer does not outlive its readers — a later mount asks again', async () => { const first = render(); - await screen.findByText('Current plan'); + await screen.findAllByText(/Asia\/Shanghai/); await flush(); first.unmount(); await flush(); render(); - await screen.findByText('Current plan'); + await screen.findAllByText(/Asia\/Shanghai/); await flush(); expect(entitlementGets).toHaveLength(2); diff --git a/packages/app-shell/src/environment/useEnvironmentEntitlements.ts b/packages/app-shell/src/environment/useEnvironmentEntitlements.ts index b7c44d1b4c..4c1586dfce 100644 --- a/packages/app-shell/src/environment/useEnvironmentEntitlements.ts +++ b/packages/app-shell/src/environment/useEnvironmentEntitlements.ts @@ -21,11 +21,12 @@ * ## One request per organisation, however many readers (objectui#12808) * * Every reader of this hook shares ONE resource per (control-plane origin, - * organisation): the in-flight resolution and its settled answer. The Cloud - * pricing page mounts one `cloud:plan-status` per plan card and the Welcome - * page mounts `cloud:onboarding-next` beside `cloud:workspace-timezone-notice`; - * each used to issue its own GET, and Chrome serialises identical in-flight - * GETs, so three cards paid three round trips in series. + * organisation): the in-flight resolution and its settled answer. When this + * landed, the Cloud pricing page mounted one `cloud:plan-status` per plan card + * and the Welcome page mounted `cloud:onboarding-next` beside + * `cloud:workspace-timezone-notice` (the first two have since retired, + * objectui#12954); each used to issue its own GET, and Chrome serialises + * identical in-flight GETs, so three cards paid three round trips in series. * * Freshness is unchanged, because nothing here is time-based: * - the answer is held only while at least one reader is mounted. When the diff --git a/packages/app-shell/src/hooks/__tests__/useNavRunAction.test.tsx b/packages/app-shell/src/hooks/__tests__/useNavRunAction.test.tsx index 2d93c88595..0d72b7a078 100644 --- a/packages/app-shell/src/hooks/__tests__/useNavRunAction.test.tsx +++ b/packages/app-shell/src/hooks/__tests__/useNavRunAction.test.tsx @@ -21,7 +21,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, waitFor } from '@testing-library/react'; import { NAV_RUN_ACTION_PARAM } from '@object-ui/layout'; -import { useNavRunAction, navRunActionHref } from '../useNavRunAction'; +import { useNavRunAction } from '../useNavRunAction'; const ROUTE = '/apps/cloud_control/sys_environment'; @@ -146,30 +146,3 @@ describe('useNavRunAction — not armed (arming is destructive, #4123)', () => { expect(seen.every((s) => s === null)).toBe(true); }); }); - -describe('navRunActionHref — the non-nav producer uses the same wire format', () => { - it('appends the declared param to a bare route', () => { - expect(navRunActionHref(ROUTE, 'create_environment')).toBe( - `${ROUTE}?${NAV_RUN_ACTION_PARAM}=create_environment`, - ); - }); - - it('joins with & when the route already carries a query', () => { - expect(navRunActionHref(`${ROUTE}?tab=all`, 'create_environment')).toBe( - `${ROUTE}?tab=all&${NAV_RUN_ACTION_PARAM}=create_environment`, - ); - }); - - it('encodes the name', () => { - expect(navRunActionHref(ROUTE, 'a b')).toBe(`${ROUTE}?${NAV_RUN_ACTION_PARAM}=a%20b`); - }); - - it('round-trips into the hook — producer and consumer agree by construction', () => { - const href = navRunActionHref(ROUTE, 'create_environment'); - window.history.replaceState({}, '', href); - - const { seen } = probe(() => true); - - expect(seen[0]).toBe('create_environment'); - }); -}); diff --git a/packages/app-shell/src/hooks/useNavRunAction.ts b/packages/app-shell/src/hooks/useNavRunAction.ts index df480aa988..103566daeb 100644 --- a/packages/app-shell/src/hooks/useNavRunAction.ts +++ b/packages/app-shell/src/hooks/useNavRunAction.ts @@ -194,18 +194,3 @@ export function useOfferedNavRunAction( return useConsumeOnce(requested, () => candidate !== undefined && !hidden); } - -/** - * Build a list-surface deep link that runs `actionName` on arrival. - * - * For the one producer that is NOT a nav item: a page widget whose target route - * came from page metadata rather than from a `NavigationItem` (see - * `CloudOnboardingNext`). It still goes through {@link NAV_RUN_ACTION_PARAM}, - * so there is no second spelling of the wire format — only a second, equally - * declared, source for the action NAME. - */ -export function navRunActionHref(route: string, actionName: string): string { - if (!actionName) return route; - const sep = route.includes('?') ? '&' : '?'; - return `${route}${sep}${NAV_RUN_ACTION_PARAM}=${encodeURIComponent(actionName)}`; -} diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index a56be4102e..b83c16d18a 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -338,10 +338,6 @@ import './console/connect/ConnectAgentWidget.js'; // Generic product-page blocks — hero, stat, usage-bar, checklist, tile-grid // (objectui#12197). import './console/blocks/productBlocks.js'; -// SDUI widget for the Cloud Welcome page's state-aware onboarding next-step. -import './console/home/CloudOnboardingNext.js'; -// SDUI widget for the Cloud pricing page's "current plan" marker (objectui#10919). -import './console/home/CloudPlanStatus.js'; // SDUI widget for the Cloud welcome page's seeded workspace-timezone line (objectui#11930). import './console/home/CloudWorkspaceTimezoneNotice.js'; // SDUI widget: read-only admin diagnostic for the env's effective AI model diff --git a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts index 3f2b6d5c38..1804f95f0e 100644 --- a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts +++ b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts @@ -164,9 +164,11 @@ const REFUSED_AT_TYPE = 0; * and `record:attachments`, the two spec-18 record panels the default record * page emits, armed from their `ComponentPropsMap` rows. objectui#12733: minus * the Installed Apps widget's key and its `app-shell:` twin, both retired from - * the registry with the view. + * the registry with the view. objectui#12954: minus `cloud:onboarding-next` and + * its `app-shell:` twin, retired from the registry with the widget; the armed + * `cloud:plan-status` left with its arm, so it moved nothing here. */ -const NAMESPACED_REFUSED_AT_TYPE = 304; +const NAMESPACED_REFUSED_AT_TYPE = 302; /** The bare registry keys — the population the card measured. */ const BARE_KEYS = KNOWN_SCHEMA_TYPES.filter((key) => !key.includes(':')); @@ -608,12 +610,22 @@ describe('registered NAMESPACED component types refused at `type` — a ratchet expect(BARE_KEYS).not.toContain('line_items'); }); - it('counts `cloud:plan-status` armed — it registered WITH its arm (objectui#10919)', () => { - // One registry key (`skipFallback: true`, so no bare `plan-status`), armed in - // `@object-ui/types/zod` in the same change, so the pin above did not move. - expect(NAMESPACED_KEYS).toContain('cloud:plan-status'); - expect(BARE_KEYS).not.toContain('plan-status'); + it('refuses `cloud:plan-status` BY NAME at `type` — its registration left and its arm became a refusal (objectui#12954)', () => { + // It was one registry key, armed, so the pair leaving in one change moves + // neither count. The arm stays as `retiredNodeType()`'s refusal (the + // objectui#8802 shape), so the strict face answers with a `custom` issue + // naming the remedy — not the generic `invalid_union` `refusedAtType` reads. + expect(NAMESPACED_KEYS).not.toContain('cloud:plan-status'); + const result = validateAuthoredDocument({ type: 'cloud:plan-status', properties: { plan: 'free' } }); + expect(result.success).toBe(false); + const named = (result.success ? [] : result.error.issues).filter( + (issue) => issue.code === 'custom' && issue.path.length === 1 && issue.path[0] === 'type', + ); + expect(named).toHaveLength(1); + expect(named[0]?.message).toContain('visibleWhen'); expect(refusedAtType('cloud:plan-status')).toBe(false); + // Control: an unknown `cloud:` literal still takes the generic path. + expect(refusedAtType('cloud:no-such-widget-12954')).toBe(true); }); it('counts `cloud:workspace-timezone-notice` armed — it registered WITH its arm (objectui#11930)', () => { diff --git a/packages/cli/src/utils/known-schema-types.ts b/packages/cli/src/utils/known-schema-types.ts index 181cc84d1f..047c243f99 100644 --- a/packages/cli/src/utils/known-schema-types.ts +++ b/packages/cli/src/utils/known-schema-types.ts @@ -50,7 +50,6 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'app-schema-renderer', 'app-shell:cloud-connection:panel', 'app-shell:cloud:ai-model-status', - 'app-shell:cloud:onboarding-next', 'app-shell:mcp:connect-agent', 'app:launcher', 'article', @@ -80,8 +79,6 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'cite', 'cloud-connection:panel', 'cloud:ai-model-status', - 'cloud:onboarding-next', - 'cloud:plan-status', 'cloud:workspace-timezone-notice', 'code', 'code-editor', diff --git a/packages/i18n/src/__tests__/console-copy-system-noun-12956.test.ts b/packages/i18n/src/__tests__/console-copy-system-noun-12956.test.ts index 80a7759ff3..7d6519e88d 100644 --- a/packages/i18n/src/__tests__/console-copy-system-noun-12956.test.ts +++ b/packages/i18n/src/__tests__/console-copy-system-noun-12956.test.ts @@ -15,7 +15,8 @@ * * Out of scope on purpose (the card's exclusions): `console.settingsView`, * where 「环境变量」 means environment variables, and `cloudOnboarding.*`, - * which explains the cloud noun once. + * which explained the cloud noun once (that family has since retired with its + * widget, objectui#12954). */ import { describe, it, expect } from 'vitest'; import { builtInLocales } from '../locales'; diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index caafb3f9d0..e231d6bd21 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -3458,16 +3458,6 @@ const ar = { importAction: "استيراد", dismiss: "تجاهل", }, - cloudOnboarding: { - hintCreate: "البيئة نسخة مستقلة تشغّل تطبيقاتك، ولها رابطها وقاعدة بياناتها وخطتها.", - hintReady: "بيئتك جاهزة — نسخة مستقلة تشغّل تطبيقاتك، ولها رابطها وقاعدة بياناتها وخطتها.", - createEnvironment: "أنشئ بيئتك", - openWorkspace: "فتح", - manageEnvironments: "إدارة البيئات", - }, - cloudPlanStatus: { - current: "الخطة الحالية", - }, cloudWorkspaceTimezoneNotice: { seeded: "تم تعيين المنطقة الزمنية للبيئة إلى {{zone}} وفقًا لمتصفحك عند إنشائها. يمكنك تغييرها من الإعدادات → التوطين.", }, diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 0612187056..d94ba09b1c 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -3221,16 +3221,6 @@ const de = { importAction: "Importieren", dismiss: "Verwerfen", }, - cloudOnboarding: { - hintCreate: "Eine Umgebung ist eine private Instanz, in der Ihre Apps laufen, mit eigener URL, Datenbank und eigenem Tarif.", - hintReady: "Ihre Umgebung ist bereit — eine private Instanz, in der Ihre Apps laufen, mit eigener URL, Datenbank und eigenem Tarif.", - createEnvironment: "Umgebung erstellen", - openWorkspace: "Öffnen", - manageEnvironments: "Umgebungen verwalten", - }, - cloudPlanStatus: { - current: "Aktueller Tarif", - }, cloudWorkspaceTimezoneNotice: { seeded: "Die Zeitzone der Umgebung wurde beim Erstellen anhand Ihres Browsers auf {{zone}} festgelegt. Sie können sie unter Einstellungen → Lokalisierung ändern.", }, diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index d744eb8c39..01c68472a1 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -3866,17 +3866,6 @@ const en = { importAction: 'Import', dismiss: 'Dismiss', }, - cloudOnboarding: { - hintCreate: 'An environment is a private instance that runs your apps, with its own URL, database, and plan.', - hintReady: 'Your environment is ready — a private instance that runs your apps, with its own URL, database, and plan.', - createEnvironment: 'Create your environment', - openWorkspace: 'Open', - manageEnvironments: 'Manage environments', - }, - // `cloud:plan-status` — the Cloud pricing page's current-plan marker (objectui#10919). - cloudPlanStatus: { - current: 'Current plan', - }, // `cloud:workspace-timezone-notice` — the Cloud welcome page's line naming the // timezone a workspace was seeded with at creation (objectui#11930). `{{zone}}` // is the seed, an IANA id printed verbatim. diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index cf3a6eef54..93ee1d704b 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -3281,16 +3281,6 @@ const es = { importAction: "Importar", dismiss: "Descartar", }, - cloudOnboarding: { - hintCreate: "Un entorno es una instancia privada que ejecuta sus aplicaciones, con su propia URL, base de datos y plan.", - hintReady: "Su entorno está listo: una instancia privada que ejecuta sus aplicaciones, con su propia URL, base de datos y plan.", - createEnvironment: "Cree su entorno", - openWorkspace: "Abrir", - manageEnvironments: "Gestionar entornos", - }, - cloudPlanStatus: { - current: "Plan actual", - }, cloudWorkspaceTimezoneNotice: { seeded: "La zona horaria del entorno se configuró en {{zone}} a partir de su navegador al crearlo. Puede cambiarla en Configuración → Localización.", }, diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 8960b43f92..7ab11917f4 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -3277,16 +3277,6 @@ const fr = { importAction: "Importer", dismiss: "Ignorer", }, - cloudOnboarding: { - hintCreate: "Un environnement est une instance privée qui exécute vos applications, avec sa propre URL, sa base de données et son offre.", - hintReady: "Votre environnement est prêt — une instance privée qui exécute vos applications, avec sa propre URL, sa base de données et son offre.", - createEnvironment: "Créer votre environnement", - openWorkspace: "Ouvrir", - manageEnvironments: "Gérer les environnements", - }, - cloudPlanStatus: { - current: "Offre actuelle", - }, cloudWorkspaceTimezoneNotice: { seeded: "Le fuseau horaire de l’environnement a été défini sur {{zone}} d’après votre navigateur lors de sa création. Vous pouvez le modifier dans Paramètres → Localisation.", }, diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 9a4f946942..5a8b530e8d 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -3223,16 +3223,6 @@ const ja = { importAction: "インポート", dismiss: "閉じる", }, - cloudOnboarding: { - hintCreate: "環境は、アプリを実行する専用のインスタンスで、独自の URL・データベース・プランを持ちます。", - hintReady: "準備が整った環境は、アプリを実行する専用のインスタンスで、独自の URL・データベース・プランを持ちます。", - createEnvironment: "環境を作成", - openWorkspace: "開く", - manageEnvironments: "環境を管理", - }, - cloudPlanStatus: { - current: "現在のプラン", - }, cloudWorkspaceTimezoneNotice: { seeded: "環境のタイムゾーンは、作成時にブラウザーに合わせて {{zone}} に設定されました。「設定 → ローカリゼーション」で変更できます。", }, diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 430d90774b..3024206eac 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -3220,16 +3220,6 @@ const ko = { importAction: "가져오기", dismiss: "닫기", }, - cloudOnboarding: { - hintCreate: "환경은 앱을 실행하는 전용 인스턴스로, 고유한 URL과 데이터베이스, 요금제를 갖습니다.", - hintReady: "준비된 환경은 앱을 실행하는 전용 인스턴스로, 고유한 URL과 데이터베이스, 요금제를 갖습니다.", - createEnvironment: "환경 만들기", - openWorkspace: "열기", - manageEnvironments: "환경 관리", - }, - cloudPlanStatus: { - current: "현재 요금제", - }, cloudWorkspaceTimezoneNotice: { seeded: "환경 시간대는 생성 시 브라우저에 맞춰 {{zone}}(으)로 설정되었습니다. 설정 → 현지화에서 변경할 수 있습니다.", }, diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index c484f1f2d3..65f99577e0 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -3276,16 +3276,6 @@ const pt = { importAction: "Importar", dismiss: "Dispensar", }, - cloudOnboarding: { - hintCreate: "Um ambiente é uma instância privada que executa seus aplicativos, com URL, banco de dados e plano próprios.", - hintReady: "Seu ambiente está pronto — uma instância privada que executa seus aplicativos, com URL, banco de dados e plano próprios.", - createEnvironment: "Crie seu ambiente", - openWorkspace: "Abrir", - manageEnvironments: "Gerenciar ambientes", - }, - cloudPlanStatus: { - current: "Plano atual", - }, cloudWorkspaceTimezoneNotice: { seeded: "O fuso horário do ambiente foi definido como {{zone}} com base no seu navegador quando ele foi criado. Você pode alterá-lo em Configurações → Localização.", }, diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 40b33dac10..4a339678ef 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -3350,16 +3350,6 @@ const ru = { importAction: "Импорт", dismiss: "Скрыть", }, - cloudOnboarding: { - hintCreate: "Окружение — это отдельный экземпляр, в котором работают ваши приложения, со своим URL, базой данных и тарифом.", - hintReady: "Ваше окружение готово — это отдельный экземпляр, в котором работают ваши приложения, со своим URL, базой данных и тарифом.", - createEnvironment: "Создать окружение", - openWorkspace: "Открыть", - manageEnvironments: "Управление окружениями", - }, - cloudPlanStatus: { - current: "Текущий тариф", - }, cloudWorkspaceTimezoneNotice: { seeded: "Часовой пояс окружения был задан по вашему браузеру при его создании: {{zone}}. Его можно изменить в разделе «Настройки → Локализация».", }, diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 71e9362aad..e044c92f0f 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -3360,16 +3360,6 @@ const zh = { importAction: '导入', dismiss: '忽略', }, - cloudOnboarding: { - hintCreate: '环境是运行你的应用的独立实例,有自己的网址、数据库和套餐。', - hintReady: '你的环境已就绪——它是运行你的应用的独立实例,有自己的网址、数据库和套餐。', - createEnvironment: '创建你的环境', - openWorkspace: '打开', - manageEnvironments: '管理环境', - }, - cloudPlanStatus: { - current: '当前套餐', - }, cloudWorkspaceTimezoneNotice: { seeded: '环境时区在创建时已按你的浏览器设为 {{zone}},可在「设置 → 本地化」中修改。', }, diff --git a/packages/types/src/__tests__/any-component-emit-headroom-11573.test.ts b/packages/types/src/__tests__/any-component-emit-headroom-11573.test.ts index d55749f879..2b3d142ba9 100644 --- a/packages/types/src/__tests__/any-component-emit-headroom-11573.test.ts +++ b/packages/types/src/__tests__/any-component-emit-headroom-11573.test.ts @@ -313,7 +313,7 @@ describe('`AnyComponentSchema` declaration emit headroom (objectui#11573)', () = { member: 'AIComponentSchema', kind: 'category union', printsAs: 'AIComponentZodType' }, { member: 'DesignerUnionSchema', kind: 'category union', printsAs: 'DesignerUnionZodType' }, { member: 'PublicBlockComponentSchema', kind: 'category union', printsAs: 'PublicBlockComponentZodType' }, - { member: 'CloudPlanStatusSchema', kind: 'arm', printsAs: 'inline' }, + { member: 'RetiredCloudPlanStatusNodeSchema', kind: 'arm', printsAs: 'inline' }, { member: 'CloudWorkspaceTimezoneNoticeSchema', kind: 'arm', printsAs: 'inline' }, { member: 'RecordApprovalDecisionBlockSchema', kind: 'arm', printsAs: 'inline' }, { member: 'RecordApprovalsBlockSchema', kind: 'arm', printsAs: 'inline' }, diff --git a/packages/types/src/__tests__/arm-named-export-8784.test.ts b/packages/types/src/__tests__/arm-named-export-8784.test.ts index ba48071d77..ae18512520 100644 --- a/packages/types/src/__tests__/arm-named-export-8784.test.ts +++ b/packages/types/src/__tests__/arm-named-export-8784.test.ts @@ -180,6 +180,11 @@ const ABSENT_BY_DECISION: Readonly> = { + 'The decision is written on the barrel itself, beside the `complex.zod.js` block: ' + '"it is deliberately NOT exported — nothing outside this package parses against a ' + 'refusal arm" (objectui#8802, maintainer ruling 2026-09-09).', + 'cloud:plan-status': + 'cloud.zod.ts#RetiredCloudPlanStatusNodeSchema — the same ADR-0049 refusal arm, for the ' + + 'retired `cloud:plan-status` literal (objectui#12954), in the shape of the `kanban` row above. ' + + 'The decision is written on the barrel beside the `cloud.zod.js` export: "it is deliberately ' + + 'NOT exported — nothing outside this package parses against a refusal arm".', // ⭐ The two rows objectui#9067 moved here out of ABSENT_PENDING_DECISION. They // quote `.changeset/8499-node-slot-registered-arms.md` rather than cite it, diff --git a/packages/types/src/__tests__/cloud-plan-status-arm-10919.test.ts b/packages/types/src/__tests__/cloud-plan-status-arm-10919.test.ts deleted file mode 100644 index eda09738eb..0000000000 --- a/packages/types/src/__tests__/cloud-plan-status-arm-10919.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * objectui#10919 — the `cloud:plan-status` arm (`../zod/cloud.zod.ts`). - * - * `@object-ui/app-shell` registers `cloud:plan-status` for the Cloud pricing - * page, and this arm lands with it so `AnyComponentSchema` claims the type - * instead of refusing it at `type`. The arm is declared from the widget's read - * points: a required `properties` bag holding exactly `plan`, a non-empty - * string, and both content channels refused by name. - * - * Refusals are asserted by issue `code` and `path` — the envelope a consumer - * reads — and not by message wording. - */ - -import { describe, expect, it } from 'vitest'; - -import { - AnyComponentSchema, - CloudPlanStatusSchema, - StrictAnyComponentSchema, - safeValidateSchema, -} from '../zod/index.zod.js'; - -const TYPE = 'cloud:plan-status'; - -/** The issues of a refused parse, as `code` + dotted `path`. */ -function issuesOf(document: unknown): { code: string; path: string; message: string }[] { - const result = safeValidateSchema(document); - expect(result.success, 'expected a refusal').toBe(false); - if (result.success) return []; - return result.error.issues.map((issue) => ({ - code: issue.code, - path: issue.path.join('.'), - message: issue.message, - })); -} - -describe('`cloud:plan-status` — the node the pricing page authors (objectui#10919)', () => { - it('accepts one plan-card marker on every face', () => { - const node = { type: TYPE, properties: { plan: 'free' } }; - expect(CloudPlanStatusSchema.safeParse(node).success).toBe(true); - expect(safeValidateSchema(node).success).toBe(true); - expect(StrictAnyComponentSchema.safeParse(node).success).toBe(true); - }); - - it('accepts the node inside a page, with the spec page-component keys cloud writes beside it', () => { - const page = { - type: 'page', - children: [ - { - type: TYPE, - id: 'plan_free_current', - className: 'self-start', - properties: { plan: 'free' }, - }, - ], - }; - expect(safeValidateSchema(page).success).toBe(true); - }); - - it('is claimed at `type` — the union no longer refuses the literal', () => { - // The ratchet's reading (`registered-types-validate-ratchet-10859.test.ts`): - // a bare `{ type }` fails on the missing bag, NOT with `invalid_union` at `type`. - const issues = issuesOf({ type: TYPE }); - expect(issues.some((i) => i.code === 'invalid_union' && i.path === 'type')).toBe(false); - // Control: an unclaimed `cloud:` literal IS refused there. - const unclaimed = issuesOf({ type: 'cloud:no-such-widget-10919', properties: { plan: 'free' } }); - expect(unclaimed.some((i) => i.code === 'invalid_union' && i.path === 'type')).toBe(true); - }); -}); - -describe('`cloud:plan-status` — what the arm refuses (objectui#10919)', () => { - it('refuses a node with no `properties` bag', () => { - expect(issuesOf({ type: TYPE })).toEqual([ - expect.objectContaining({ code: 'invalid_type', path: 'properties' }), - ]); - }); - - it('refuses a bag that names no plan', () => { - expect(issuesOf({ type: TYPE, properties: {} })).toEqual([ - expect.objectContaining({ code: 'invalid_type', path: 'properties.plan' }), - ]); - }); - - it('refuses an empty plan', () => { - expect(issuesOf({ type: TYPE, properties: { plan: '' } })).toEqual([ - expect.objectContaining({ code: 'too_small', path: 'properties.plan' }), - ]); - }); - - it('refuses a plan that is not a string', () => { - expect(issuesOf({ type: TYPE, properties: { plan: 1 } })).toEqual([ - expect.objectContaining({ code: 'invalid_type', path: 'properties.plan' }), - ]); - }); - - it('refuses a key the widget does not read, inside the bag', () => { - expect(issuesOf({ type: TYPE, properties: { plan: 'free', label: 'Current' } })).toEqual([ - expect.objectContaining({ code: 'unrecognized_keys', path: 'properties' }), - ]); - }); - - it('refuses a flat `plan` — the widget reads the bag only', () => { - // Tolerant face: the bag is missing, so the node is refused there. - expect(issuesOf({ type: TYPE, plan: 'free' })).toEqual([ - expect.objectContaining({ code: 'invalid_type', path: 'properties' }), - ]); - // Strict face: a flat `plan` beside a valid bag is an unknown node key. - const strict = StrictAnyComponentSchema.safeParse({ type: TYPE, plan: 'free', properties: { plan: 'free' } }); - expect(strict.success).toBe(false); - expect(strict.error?.issues.map((i) => i.code)).toContain('unrecognized_keys'); - }); - - it.each(['children', 'body'])('refuses `%s` by name — the widget reads neither content channel', (key) => { - const issues = issuesOf({ type: TYPE, properties: { plan: 'free' }, [key]: [{ type: 'text', content: 'x' }] }); - expect(issues).toEqual([expect.objectContaining({ code: 'invalid_type', path: key })]); - expect(issues[0].message).toContain(TYPE); - }); - - it('reaches the arm through the union, not a second schema', () => { - // The union dispatches on the literal to this arm: the same refusal either way. - const bare = CloudPlanStatusSchema.safeParse({ type: TYPE, properties: { plan: '' } }); - const viaUnion = AnyComponentSchema.safeParse({ type: TYPE, properties: { plan: '' } }); - expect(bare.success).toBe(false); - expect(viaUnion.success).toBe(false); - expect(viaUnion.error?.issues.map((i) => [i.code, i.path.join('.')])).toEqual( - bare.error?.issues.map((i) => [i.code, i.path.join('.')]), - ); - }); -}); diff --git a/packages/types/src/__tests__/cloud-plan-status-retired-12954.test.ts b/packages/types/src/__tests__/cloud-plan-status-retired-12954.test.ts new file mode 100644 index 0000000000..60127d2313 --- /dev/null +++ b/packages/types/src/__tests__/cloud-plan-status-retired-12954.test.ts @@ -0,0 +1,117 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The `cloud:plan-status` NODE TYPE is retired, and a document naming it is + * refused BY NAME, with its remedy (objectui#12954). + * + * The widget, its registration and its member-by-member arm + * (`CloudPlanStatusSchema`, objectui#10919) went when the Cloud pricing page + * stopped placing the node (objectstack-ai/cloud#3010). The literal was + * declared on a face, so the retirement keeps a `retiredNodeType()` arm in + * `AnyComponentSchema` — the shape objectui#8802 ruled for the bare `kanban` + * key (maintainer ruling 2026-09-09), pinned in + * `bare-kanban-node-key-retired-8802.test.ts`, whose suites this file follows. + * + * ## ⚠️ The mechanism, which is NOT the passthrough rule + * + * `AnyComponentSchema` is a DISCRIMINATED union: an unclaimed literal matches no + * arm, so deleting the arm would already refuse — with the union's remedy-free + * `Invalid input`. The refusal arm claims the literal and answers with a + * `custom` issue at `type` naming what to author instead. Suite 1 measures that + * refusal and its remedy; suite 3 measures that an unknown `cloud:` literal + * still gets the generic path, which is the control that separates the two. + */ + +import { describe, it, expect } from 'vitest'; +import { safeValidateSchema } from '../zod/index.zod'; +import type { AnySchema } from '../index'; + +/* -------------------------------------------------------------------------- */ +/* Compile-time pins — read by tsc -p tsconfig.test.json, not by vitest. */ +/* -------------------------------------------------------------------------- */ + +type Assert = T; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type IsNever = [T] extends [never] ? true : false; + +// The TypeScript half of the retirement: `AnySchema` has no member for the +// literal, so `tsc` refuses it at the authoring site. +type _AnySchemaHasNoPlanStatusArm = Assert>, true>>; +// Non-vacuity on the same instrument: the surviving `cloud:` arm IS selectable. +type _AnySchemaKeepsTimezoneNotice = Assert< + Equal>, false> +>; + +/* -------------------------------------------------------------------------- */ +/* Runtime — through `safeValidateSchema`, the tolerant node union. */ +/* -------------------------------------------------------------------------- */ + +interface IssueLike { + readonly path?: readonly PropertyKey[]; + readonly message?: string; + readonly code?: string; + readonly errors?: readonly (readonly IssueLike[])[]; +} + +/** Flatten zod's nested union issues into `{ path, message, code }` rows. */ +function flattenIssues(issues: readonly IssueLike[]): Array<{ path: string; message: string; code: string }> { + return issues.flatMap((issue) => [ + { path: (issue.path ?? []).join('.'), message: issue.message ?? '', code: issue.code ?? '' }, + ...(issue.errors ?? []).flatMap((nested) => flattenIssues(nested)), + ]); +} + +function refusals(schema: unknown) { + const result = safeValidateSchema(schema); + return result.success ? [] : flattenIssues(result.error.issues as unknown as IssueLike[]); +} + +/** The plan-card node that used to validate under the retired arm, spelled unchanged. */ +const RETIRED_NODE = { type: 'cloud:plan-status', properties: { plan: 'free' } } as const; + +describe('suite 1 — a `type: "cloud:plan-status"` document is REFUSED, by name (objectui#12954)', () => { + it('refuses the document with a `custom` issue at `type`', () => { + const found = refusals(RETIRED_NODE); + expect(found.filter((f) => f.code === 'custom' && f.path === 'type')).toHaveLength(1); + }); + + it('the refusal names the retirement and the remedy, not just a type complaint', () => { + // ⭐ The whole reason the arm survives as a refusal instead of being + // deleted. Suite 3 is its control. + const [issue] = refusals(RETIRED_NODE).filter((f) => f.code === 'custom' && f.path === 'type'); + expect(issue?.message).toContain('`cloud:plan-status` is a RETIRED node type'); + expect(issue?.message).toContain('visibleWhen'); + expect(issue?.message).toContain('/api/v1/cloud/home'); + }); + + it('refuses a bare node too, whatever else it carries', () => { + expect(refusals({ type: 'cloud:plan-status' }).filter((f) => f.code === 'custom' && f.path === 'type')).toHaveLength(1); + }); +}); + +describe('suite 2 — the refusal is not a validator that turned strict', () => { + it('the surviving `cloud:` node on the same call is still accepted', () => { + // Firing control: without it every assertion in suite 1 would also pass + // under a `safeValidateSchema` that had started refusing everything. + expect(refusals({ type: 'cloud:workspace-timezone-notice' })).toEqual([]); + }); +}); + +describe('suite 3 — control: an unknown `cloud:` literal takes the GENERIC path', () => { + it('a `cloud:` name no arm claims is refused without the remedy text', () => { + // ⭐ The separator. If this document ALSO carried the guidance, suite 1 + // would be measuring the union's boilerplate rather than this arm. + const found = refusals({ type: 'cloud:no-such-widget-12954' }); + const text = found.map((r) => r.message).join('\n'); + expect(text).not.toEqual(''); + expect(found.some((f) => f.code === 'custom')).toBe(false); + expect(text).not.toContain('visibleWhen'); + expect(text).not.toContain('RETIRED'); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 07efdc2ca9..e153c08a28 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -953,7 +953,7 @@ import type { z } from 'zod'; import { AIConfigSchema, AIFieldSuggestionSchema, AIFormAssistSchema, AIRecommendationItemSchema, AIRecommendationsSchema, NLQueryResultSchema, NLQuerySchema } from '../zod/ai.zod.js'; import { AppComponentSchema, AppSchemaRendererNodeSchema, MenuItemSchema as AppMenuItemSchema, NavigationAreaSchema, NavigationItemSchema } from '../zod/app.zod.js'; import { BaseSchema, ComponentConfigSchema, ComponentInputSchema, ComponentMetaSchema, KeyedI18nLabelSchema, SchemaNodeSchema } from '../zod/base.zod.js'; -import { CloudPlanStatusSchema, CloudWorkspaceTimezoneNoticeSchema } from '../zod/cloud.zod.js'; +import { CloudWorkspaceTimezoneNoticeSchema } from '../zod/cloud.zod.js'; import { ChecklistBlockItemSchema, ChecklistBlockSchema, HeroBlockSchema, StatBlockSchema, TileGridBlockAddTileSchema, TileGridBlockItemSchema, TileGridBlockSchema, UsageBarBlockSchema } from '../zod/product-blocks.zod.js'; import { RecordApprovalDecisionBlockSchema } from '../zod/record-approval-decision.zod.js'; import { RecordApprovalsBlockSchema, RecordAttachmentsBlockSchema } from '../zod/record-panels.zod.js'; @@ -974,7 +974,7 @@ import { DetailSectionNodeSchema, DetailViewFieldSchema, DetailViewSchema, Detai import type { AIConfig as Ts_AIConfig, AIFieldSuggestion as Ts_AIFieldSuggestion, AIFormAssistSchema as Ts_AIFormAssistSchema, AIRecommendationItem as Ts_AIRecommendationItem, AIRecommendationsSchema as Ts_AIRecommendationsSchema, NLQueryResult as Ts_NLQueryResult, NLQuerySchema as Ts_NLQuerySchema } from '../ai'; import type { AppComponentSchema as Ts_AppComponentSchema, AppSchemaRendererNodeSchema as Ts_AppSchemaRendererNodeSchema, NavigationArea as Ts_NavigationArea } from '../app'; import type { BaseSchema as Ts_BaseSchema, ComponentConfig as Ts_ComponentConfig, ComponentInput as Ts_ComponentInput, ComponentMeta as Ts_ComponentMeta, KeyedI18nLabel as Ts_KeyedI18nLabel } from '../base'; -import type { CloudPlanStatusSchema as Ts_CloudPlanStatusSchema, CloudWorkspaceTimezoneNoticeSchema as Ts_CloudWorkspaceTimezoneNoticeSchema } from '../cloud'; +import type { CloudWorkspaceTimezoneNoticeSchema as Ts_CloudWorkspaceTimezoneNoticeSchema } from '../cloud'; import type { ChecklistBlockItem as Ts_ChecklistBlockItem, ChecklistBlockSchema as Ts_ChecklistBlockSchema, HeroBlockSchema as Ts_HeroBlockSchema, StatBlockSchema as Ts_StatBlockSchema, TileGridBlockAddTile as Ts_TileGridBlockAddTile, TileGridBlockItem as Ts_TileGridBlockItem, TileGridBlockSchema as Ts_TileGridBlockSchema, UsageBarBlockSchema as Ts_UsageBarBlockSchema } from '../product-blocks'; import type { RecordApprovalDecisionBlockSchema as Ts_RecordApprovalDecisionBlockSchema } from '../record-approval-decision'; import type { RecordApprovalsBlockSchema as Ts_RecordApprovalsBlockSchema, RecordAttachmentsBlockSchema as Ts_RecordAttachmentsBlockSchema } from '../record-panels'; @@ -1813,8 +1813,6 @@ const MIRRORS = { 'base.zod.ts#ComponentInputSchema': ComponentInputSchema, 'base.zod.ts#ComponentMetaSchema': ComponentMetaSchema, 'base.zod.ts#KeyedI18nLabelSchema': KeyedI18nLabelSchema, - // objectui#11515 — the zod-only arm's TS twin. - 'cloud.zod.ts#CloudPlanStatusSchema': CloudPlanStatusSchema, // objectui#11930 — the arm and its TS twin landed together. 'cloud.zod.ts#CloudWorkspaceTimezoneNoticeSchema': CloudWorkspaceTimezoneNoticeSchema, // objectui#12197 — the product-page blocks' arms and their TS twins landed together. @@ -2030,7 +2028,6 @@ interface Declared { 'base.zod.ts#ComponentInputSchema': Ts_ComponentInput; 'base.zod.ts#ComponentMetaSchema': Ts_ComponentMeta; 'base.zod.ts#KeyedI18nLabelSchema': Ts_KeyedI18nLabel; - 'cloud.zod.ts#CloudPlanStatusSchema': Ts_CloudPlanStatusSchema; 'cloud.zod.ts#CloudWorkspaceTimezoneNoticeSchema': Ts_CloudWorkspaceTimezoneNoticeSchema; 'product-blocks.zod.ts#ChecklistBlockItemSchema': Ts_ChecklistBlockItem; 'product-blocks.zod.ts#ChecklistBlockSchema': Ts_ChecklistBlockSchema; @@ -4837,6 +4834,11 @@ const EXCLUSIONS: Readonly> = { // `./bare-kanban-node-key-retired-8802.test.ts`. 'complex.zod.ts#RetiredKanbanNodeSchema': 'a RETIRED node type refusal arm (objectui#8802), not a mirror — its only member is the `type` literal it refuses on, and the TS half of that retirement is the ABSENCE of an arm in `ComplexSchema`', + // objectui#12954 — the same kind of arm for the retired `cloud:plan-status` + // literal, pinned in `./cloud-plan-status-retired-12954.test.ts`. Its + // former pair `cloud.zod.ts#CloudPlanStatusSchema` left `MIRRORS` with it. + 'cloud.zod.ts#RetiredCloudPlanStatusNodeSchema': + 'a RETIRED node type refusal arm (objectui#12954), not a mirror — its only member is the `type` literal it refuses on, and the TS half of that retirement is the ABSENCE of a `cloud:plan-status` member in `AnySchema`', 'ai.zod.ts#AIComponentSchema': "a union OVER the mirrors, not an object of its own — its members are checked individually above", 'product-blocks.zod.ts#ProductBlockComponentSchema': @@ -5308,7 +5310,7 @@ const ZOD_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'zod'); * MINUEND under it had moved. Nothing failed on any of those days, because nothing * compared the registry to a number. objectui#7433 is that absence, not the digits. */ -const EXPECTED_MIRROR_PAIRS = 206; +const EXPECTED_MIRROR_PAIRS = 205; /** * A ledger this file can size from its own AST. `WiderThanDeclared` joined at diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index 356457f5b2..86315804f5 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -9,7 +9,7 @@ /** * @object-ui/types - Cloud Widget Schemas * - * The TypeScript twin of `zod/cloud.zod.ts` (objectui#11515, objectui#11930). + * The TypeScript twin of `zod/cloud.zod.ts` (objectui#11930). * * @module cloud * @packageDocumentation @@ -17,58 +17,6 @@ import type { BaseSchema } from './base.js'; -/** - * `cloud:plan-status` — the "current plan" marker for one plan card, the SDUI - * widget `@object-ui/app-shell` registers for the Cloud pricing page: the - * TypeScript twin of `zod/cloud.zod.ts`'s `CloudPlanStatusSchema` - * (objectui#11515). - * - * The zod arm landed with the registration (objectui#10919) and no - * declaration here, so no TypeScript type named the node. This interface - * declares the same members, read from the widget's read points: the arm's - * module header gives them, because `@objectstack/spec` has no - * `ComponentPropsMap` row for a `cloud:` widget. - * - * - `properties.plan` — the plan code of the card the node sits on, written - * in the `properties` bag, the one spelling the widget reads. Required. - * The arm also refuses an empty string (`.min(1)`), a value check this - * type cannot state. - * - `properties` holds that one key and no other: the arm's bag is strict. - * - Neither content channel is read, so both are refused by name, the twin - * of the arm's two `retirementTombstone` members. - * - * The parity pair is `cloud.zod.ts#CloudPlanStatusSchema` in - * `__tests__/zod-mirror-parity.test.ts`. The widget's own props type stays in - * `@object-ui/app-shell`. - */ -export interface CloudPlanStatusSchema extends BaseSchema { - type: 'cloud:plan-status'; - /** The props bag: `plan`, and no other key. */ - properties: { - /** - * The plan code of the card this node marks, spelled exactly as the - * control plane's entitlements summary reports it (for example `free`). - * The badge renders only when it equals the organization's plan. - */ - plan: string; - }; - /** - * REFUSED BY NAME (objectui#10919) — `cloud:plan-status` reads neither - * content channel: `CloudPlanStatus` reads only `properties.plan` and - * `className`, and `SchemaRenderer` strips both channels out of the props - * it spreads. - * - * @deprecated Not a channel `cloud:plan-status` reads — nothing renders it. - */ - body?: never; - /** - * REFUSED BY NAME (objectui#10919), for the reason `body` gives. - * - * @deprecated Not a channel `cloud:plan-status` reads — nothing renders it. - */ - children?: never; -} - /** * `cloud:workspace-timezone-notice` — the Cloud welcome page's line naming the * timezone a workspace was seeded with at creation, the SDUI widget diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index aea6b3982a..1f3daadff0 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -852,7 +852,7 @@ import type { import type { CRUDComponentSchema } from './crud.js'; import type { ObjectQLComponentSchema, ListViewSchema } from './objectql.js'; import type { AppComponentSchema, AppSchemaRendererNodeSchema } from './app.js'; -import type { CloudPlanStatusSchema, CloudWorkspaceTimezoneNoticeSchema } from './cloud.js'; +import type { CloudWorkspaceTimezoneNoticeSchema } from './cloud.js'; import type { HeroBlockSchema, StatBlockSchema, @@ -985,9 +985,8 @@ export type { } from './ai.js'; export type { - // Cloud widgets — the `cloud:plan-status` node's TS twin (objectui#11515) - // and the `cloud:workspace-timezone-notice` node's (objectui#11930). - CloudPlanStatusSchema, + // Cloud widgets — the `cloud:workspace-timezone-notice` node's TS twin + // (objectui#11930). CloudWorkspaceTimezoneNoticeSchema, } from './cloud.js'; @@ -1116,10 +1115,10 @@ export type AnySchema = | NLQuerySchema | ViewComponentSchema | ActionBarSchema - // objectui#11515 — the TS twins of two zod-only arms; the third, - // `DetailSectionNodeSchema`, is a member through `ViewComponentSchema`. + // objectui#11515 — the TS twin of a zod-only arm; its sibling + // `DetailSectionNodeSchema` is a member through `ViewComponentSchema`. The + // third, `cloud:plan-status`, retired with its widget (objectui#12954). | AppSchemaRendererNodeSchema - | CloudPlanStatusSchema // objectui#11930 — landed with its zod arm. | CloudWorkspaceTimezoneNoticeSchema // objectui#12342 — landed with its zod arm. diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index 2da6def629..1356d380f9 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -324,11 +324,12 @@ The last six arrived with `@objectstack/spec` 17.5.0, whose rows for them were m - `AppSchemaRendererNodeSchema` — `app-schema-renderer`, `@object-ui/layout`'s whole-shell node (objectui#11440). It declares its registration's three inputs: `schema`, the app document the shell draws, which is the `app` document's own arm in `app.zod.ts`, the same schema object, by reference (objectui#11494); `mobileNavMode` (`'drawer'` | `'bottom_nav'`); and `basePath`. The document is nested, `{ "type": "app-schema-renderer", "schema": { "type": "app", "navigation": [...] } }`, and keeps its own refusals there (`mobileNavMode` inside `schema` is refused, objectui#11363). Its keys written flat on the node are refused by the strict face as unrecognized keys, and the node does not draw them: one spelling. Both content channels are refused: a node draws neither. - `DetailSectionNodeSchema` — `detail-section`, one field section of `@object-ui/plugin-detail` as a node (objectui#11440): the ten `DetailViewSectionSchema` members its registration publishes, flat on the node, `fields` required. It is not `record:details`'s section shape (that one names its heading `label` and takes field names), so it is a node of its own. -### Cloud Widgets (2) +### Cloud Widgets (1) `@object-ui/app-shell`'s `cloud:` SDUI widgets. `@objectstack/spec` has no `ComponentPropsMap` row for them, so each arm is declared here from the keys its widget reads. -- `CloudPlanStatusSchema` — `cloud:plan-status`, the pricing page's "current plan" marker: `properties` must be exactly `{ plan }`, a non-empty plan code. - `CloudWorkspaceTimezoneNoticeSchema` — `cloud:workspace-timezone-notice`, the welcome page's line naming the workspace's seeded timezone: the widget reads no prop, so `properties` may only be `{}`. The zone comes from the organization's entitlements summary. +`cloud:plan-status` is RETIRED (objectui#12954). Its arm stays in `AnyComponentSchema` as a refusal, not exported: a node naming it is refused by name at `type`, with the remedy of a `visibleWhen` on the page's summary. + ## Schema Structure All component schemas follow the @objectstack/spec UI specification format: diff --git a/packages/types/src/zod/cloud.zod.ts b/packages/types/src/zod/cloud.zod.ts index 4fe384ae3e..84536b71db 100644 --- a/packages/types/src/zod/cloud.zod.ts +++ b/packages/types/src/zod/cloud.zod.ts @@ -9,11 +9,11 @@ /** * @object-ui/types/zod - Cloud Widget Zod Validators * - * The arms for `@object-ui/app-shell`'s armed `cloud:` SDUI widgets: - * `cloud:plan-status`, for the Cloud pricing page - * (`console/home/CloudPlanStatus.tsx`, objectui#10919), and + * The arm for `@object-ui/app-shell`'s armed `cloud:` SDUI widget: * `cloud:workspace-timezone-notice`, for the Cloud welcome page - * (`console/home/CloudWorkspaceTimezoneNotice.tsx`, objectui#11930). + * (`console/home/CloudWorkspaceTimezoneNotice.tsx`, objectui#11930) — and + * the refusal arm of the retired `cloud:plan-status` (objectui#12954), which + * {@link RetiredCloudPlanStatusNodeSchema} explains. * * ## Why the arm lands with the registration * @@ -30,18 +30,6 @@ * nothing is read by reference: the members below are the keys the widget * reads, and no other. * - * `cloud:plan-status`: - * - * - `properties.plan` — the plan code of the card the node sits on. The page - * authors it in the `properties` bag, the one spelling a page component is - * written in, and the widget reads it there alone. Required: a node that - * names no plan can never mark anything. It is an opaque, non-empty - * string, because the plan catalog belongs to the control plane; ObjectUI - * does not list the codes. - * - `properties` is closed: the widget reads no other key of the bag. - * - `body` / `children` — refused by name. The widget renders a badge and - * reads neither content channel (objectui#9256's rule for such nodes). - * * `cloud:workspace-timezone-notice`: * * - `properties` — optional and EMPTY: the widget reads no key of the bag. @@ -61,39 +49,34 @@ import { z } from 'zod'; import { BaseSchema } from './base.zod.js'; -import { retirementTombstone } from './tombstone.zod.js'; - -/** One refusal string for both content channels of `CloudPlanStatusSchema`. */ -const CLOUD_PLAN_STATUS_NEITHER_CHANNEL = - 'REFUSED (objectui#10919) — `cloud:plan-status` reads NEITHER content channel: its registration hands ' - + 'the node to `CloudPlanStatus`, which reads only `properties.plan` and `className`, and ' - + '`SchemaRenderer` strips both channels out of the props bag it spreads. An authored value would ' - + 'render NOTHING — no render-time error or warning and no element; only the parser tier\'s ' - + '`not-a-container` warning (objectui#9910) noticed it, because the registration declares no ' - + '`children` input. What it renders instead: a "Current plan" badge when `properties.plan` is the ' - + 'organization\'s plan, and nothing otherwise.'; +import { retiredNodeType, retirementTombstone } from './tombstone.zod.js'; /** - * `cloud:plan-status` — the "current plan" marker for one plan card - * (objectui#10919). Pinned by `../__tests__/cloud-plan-status-arm-10919.test.ts`. + * ⛔ `cloud:plan-status` is RETIRED (objectui#12954) — this is its NAMED + * REFUSAL, the half a deletion would not have given. + * + * The widget, its registration and its member-by-member arm went: the Cloud + * pricing page stopped placing the node (objectstack-ai/cloud#3010). The + * literal was declared on this face (an arm of `AnyComponentSchema`), so it is + * the case `retiredNodeType()` exists for, in the shape of + * `complex.zod.ts#RetiredKanbanNodeSchema` (objectui#8802, maintainer ruling + * 2026-09-09): deleting the arm would already refuse, but with the union's + * remedy-free `Invalid input`. What stays is an arm claiming the literal, so a + * stale `{ "type": "cloud:plan-status" }` document is refused BY NAME, at + * `type`, with the remedy the retirement's changeset states. + * + * Deliberately NOT exported from `index.zod.ts`: nothing outside this package + * parses against a refusal arm. No TypeScript twin either — the TS half of the + * retirement is the ABSENCE of a `cloud:plan-status` member in `AnySchema`. + * + * Pinned in `../__tests__/cloud-plan-status-retired-12954.test.ts`. */ -export const CloudPlanStatusSchema = BaseSchema.extend({ - type: z.literal('cloud:plan-status'), - properties: z - .strictObject({ - plan: z - .string() - .min(1) - .describe( - 'The plan code of the card this node marks, spelled exactly as the control plane\'s ' - + 'entitlements summary reports it (for example `free`). The badge renders only when it equals ' - + 'the organization\'s plan.', - ), - }) - .describe('The `cloud:plan-status` props bag: `plan`, and no other key.'), - body: retirementTombstone(CLOUD_PLAN_STATUS_NEITHER_CHANNEL), - children: retirementTombstone(CLOUD_PLAN_STATUS_NEITHER_CHANNEL), -}); +export const RetiredCloudPlanStatusNodeSchema = retiredNodeType( + 'cloud:plan-status', + 'Its widget retired with it (objectui#12954). Compose the page from generic blocks, as ' + + 'objectstack-ai/cloud#3010 did: mark the current plan with a `visibleWhen` on the same ' + + 'summary the page\'s blocks read (`GET /api/v1/cloud/home`), in place of `cloud:plan-status`.', +); /** One refusal string for both content channels of `CloudWorkspaceTimezoneNoticeSchema`. */ const CLOUD_WORKSPACE_TIMEZONE_NOTICE_NEITHER_CHANNEL = diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index b938e0923b..740668203b 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -534,7 +534,12 @@ export { // ============================================================================ // Cloud Widgets - `@object-ui/app-shell`'s `cloud:` SDUI widgets (objectui#10919) // ============================================================================ -export { CloudPlanStatusSchema, CloudWorkspaceTimezoneNoticeSchema } from './cloud.zod.js'; +// ⛔ `CloudPlanStatusSchema` RETIRED with the `cloud:plan-status` widget +// (objectui#12954). `RetiredCloudPlanStatusNodeSchema` takes its place inside +// `AnyComponentSchema` so an authored `type: "cloud:plan-status"` is refused BY +// NAME with its remedy; it is deliberately NOT exported — nothing outside this +// package parses against a refusal arm. +export { CloudWorkspaceTimezoneNoticeSchema } from './cloud.zod.js'; // ============================================================================ // `record:approval_decision` - `@object-ui/app-shell`'s approval decision panel (objectui#12342) @@ -569,7 +574,7 @@ import { AIComponentSchema } from './ai.zod.js'; import { ProductBlockComponentSchema } from './product-blocks.zod.js'; import { DesignerUnionSchema } from './designer.zod.js'; import { PublicBlockComponentSchema } from './public-blocks.zod.js'; -import { CloudPlanStatusSchema, CloudWorkspaceTimezoneNoticeSchema } from './cloud.zod.js'; +import { CloudWorkspaceTimezoneNoticeSchema, RetiredCloudPlanStatusNodeSchema } from './cloud.zod.js'; import { RecordApprovalDecisionBlockSchema } from './record-approval-decision.zod.js'; import { RecordApprovalsBlockSchema, RecordAttachmentsBlockSchema } from './record-panels.zod.js'; import { nestedComponentJudgment } from './nested-component-walk.js'; @@ -675,7 +680,8 @@ export const AnyComponentSchema = defineNodeComponentUnion(z.discriminatedUnion( AIComponentSchema, DesignerUnionSchema, PublicBlockComponentSchema, - CloudPlanStatusSchema, + // objectui#12954 — the retired `cloud:plan-status` literal's named refusal. + RetiredCloudPlanStatusNodeSchema, CloudWorkspaceTimezoneNoticeSchema, // objectui#12342 — landed with its registration. RecordApprovalDecisionBlockSchema, diff --git a/scripts/__tests__/check-sdui-registration-pins.test.ts b/scripts/__tests__/check-sdui-registration-pins.test.ts index 46fd49067f..63b5a01332 100644 --- a/scripts/__tests__/check-sdui-registration-pins.test.ts +++ b/scripts/__tests__/check-sdui-registration-pins.test.ts @@ -236,7 +236,7 @@ describe('the real workspace', () => { expect(modulesRead).toBeGreaterThan(0); expect(keys.length, 'an empty key set makes every assertion in this gate vacuous').toBeGreaterThan(0); for (const control of RULED_CONTROLS) { - expect(keys, `${control} is one of the three registrations the 2026-08-29 ruling pins`).toContain(control); + expect(keys, `${control} is one of the registrations the 2026-08-29 ruling pins`).toContain(control); } // The derivation must point at the module it read the key from, or a drop // would be reported without saying which array entry promised it — and it diff --git a/scripts/__tests__/check-side-effects-array.test.ts b/scripts/__tests__/check-side-effects-array.test.ts index 9c50f2fda0..588d24ef73 100644 --- a/scripts/__tests__/check-side-effects-array.test.ts +++ b/scripts/__tests__/check-side-effects-array.test.ts @@ -422,16 +422,17 @@ describe('the real workspace', () => { expect(main([], repoRoot)).toBe(EXIT_OK); }); - it('finds the three registrations the 2026-08-29 ruling names as controls', () => { + it('finds the registrations the 2026-08-29 ruling names as controls', () => { // A FLOOR on the derivation, not a copy of it: the enumeration is derived - // from the module bodies, and these three are the modules whose registrations + // from the module bodies, and these are the modules whose registrations // `"sideEffects": false` was measured to drop to 0 chunks (objectui#6535). // If the walk stops seeing them, the array agrees with an empty enumeration. + // The ruling named three; `CloudOnboardingNext.tsx` retired with its widget + // (objectui#12954). const { results } = evaluate(repoRoot); const appShell = results.find((r) => r.name === '@object-ui/app-shell'); expect(appShell, '@object-ui/app-shell must still declare a `sideEffects` array').toBeDefined(); expect(appShell!.registrars).toContain('src/console/connect/ConnectAgentWidget.tsx'); - expect(appShell!.registrars).toContain('src/console/home/CloudOnboardingNext.tsx'); expect(appShell!.registrars).toContain('src/console/diagnostics/CloudAiModelStatus.tsx'); }); diff --git a/scripts/check-sdui-registration-pins.mjs b/scripts/check-sdui-registration-pins.mjs index 1fa07fdeac..198a2aadac 100644 --- a/scripts/check-sdui-registration-pins.mjs +++ b/scripts/check-sdui-registration-pins.mjs @@ -26,7 +26,9 @@ * * The maintainer ruling of 2026-08-29 names those three as the controls this * work must pin present at >= 1 chunk each. This file is that pin, and it is - * wider than the three on purpose (see below). + * wider than the three on purpose (see below). `cloud:onboarding-next` has + * since retired with its widget (objectui#12954), so two of the three remain + * as controls. * * ## The key set is DERIVED from the array, not listed here * @@ -36,8 +38,8 @@ * component keys does this source register?". Change the array and the pinned * key set changes with it; there is no second list to keep honest. * - * `RULED_CONTROLS` below is not that list. It is an ANTI-VACUITY FLOOR: three - * keys the ruling names, asserted to be IN the derived set, so a derivation that + * `RULED_CONTROLS` below is not that list. It is an ANTI-VACUITY FLOOR: the + * keys the ruling names that are still registered, asserted to be IN the derived set, so a derivation that * quietly stopped seeing registrations cannot report "0 keys, all present". * A floor is checked against the derivation; a list would replace it. * @@ -71,8 +73,9 @@ export const EXIT_DROPPED = 1; export const EXIT_NO_MEASUREMENT = 2; /** - * The three registrations the 2026-08-29 ruling names as this work's controls, - * each measured at 0 chunks under `"sideEffects": false`. + * The registrations the 2026-08-29 ruling names as this work's controls, each + * measured at 0 chunks under `"sideEffects": false`. The ruling named three; + * `cloud:onboarding-next` left with its widget (objectui#12954). * * ⚠️ This is a FLOOR on the derived set, never the set itself. It answers "is * the derivation still seeing the registrations the ruling cared about?", and @@ -80,7 +83,6 @@ export const EXIT_NO_MEASUREMENT = 2; */ export const RULED_CONTROLS = Object.freeze([ 'cloud:ai-model-status', - 'cloud:onboarding-next', 'mcp:connect-agent', ]); diff --git a/scripts/check-side-effects-array.mjs b/scripts/check-side-effects-array.mjs index 210c2a7018..71dd64e6fb 100644 --- a/scripts/check-side-effects-array.mjs +++ b/scripts/check-side-effects-array.mjs @@ -22,7 +22,8 @@ * - `sideEffects: false` -> the bundler drops every module whose exports go * unused, including three live SDUI widget * registrations (`mcp:connect-agent`, - * `cloud:onboarding-next`, `cloud:ai-model-status`) + * `cloud:onboarding-next` -- since retired, + * objectui#12954 -- `cloud:ai-model-status`) * that the barrel pulls in through BARE side-effect * imports. They fall to 0 chunks. Closed by * measurement in objectui#6535 / PR #6682. diff --git a/scripts/vite-declared-lazy-views.ts b/scripts/vite-declared-lazy-views.ts index 8e13c87c84..4edb6a4734 100644 --- a/scripts/vite-declared-lazy-views.ts +++ b/scripts/vite-declared-lazy-views.ts @@ -63,9 +63,9 @@ import type { Plugin, Rollup } from 'vite'; * 2026-08-29): the package now declares a precise `sideEffects` ARRAY, guarded * by `scripts/check-side-effects-array.mjs`. `"sideEffects": false` stays * disproven and is NOT what shipped — measured, it silently DROPS three real - * SDUI widget registrations (`mcp:connect-agent`, `cloud:onboarding-next`, - * `cloud:ai-model-status`, all registered through bare side-effect imports in - * the barrel). The ARRAY names those modules, and + * SDUI widget registrations (`mcp:connect-agent`, `cloud:onboarding-next` -- + * since retired, objectui#12954 -- and `cloud:ai-model-status`, all registered + * through bare side-effect imports in the barrel). The ARRAY names those modules, and * `scripts/check-sdui-registration-pins.mjs` weighs the built console for them. * * This plugin is NOT made redundant by that. It is scoped to AppContent's