From 38c84358d8a42f459d95e4d44df07825acb928a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 15:17:46 +0000 Subject: [PATCH 1/3] retire(studio): drop the global_nav designer surfaces with the rc.6 spec bump (objectstack#7100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `global_nav` was an `ACTION_LOCATIONS` member no running-app surface ever rendered — the console's ⌘K palette reads no action metadata at all — and the Studio designer previewed a mock command-palette frame for it anyway. @objectstack/spec 17.0.0-rc.6 retired the member (objectstack#6888, maintainer ruling 2026-08-09 direction 2); this removes the designer surfaces that outlived it, plus the bump that makes them stop compiling. - ActionPreview.tsx: the mock ⌘K placement frame is gone. The metadata strip still echoes the declared token, deliberately — reporting what a draft says is honest; the frame CLAIMED the platform renders it. - ActionDefaultInspector.tsx: the `LOCATION_LABELS` entry is gone. The map is `Record`, so the retirement arrived as a compile error (objectui#3017's mechanism firing as designed). - block-config.ts + both locale tables: the palette option and its now-orphaned i18n key. - components' action:bar doc comment aligned (its enum is `[...ACTION_LOCATIONS]` and followed on its own). - ActionEngine.getActionsForLocation is UNCHANGED: narrowing it would be a second rejection point beside the schema's. The type and `ActionLocationSchema` are the enforcement, both pinned. The bump (all 37 `@objectstack/*` declarations, rc.5 -> rc.6) rides along because the two cannot compile apart, and its fallout is repaired here: the `…Input` alias retirement (types/theme, types/spec-report, core/ActionRunner), the five dead i18n re-exports, the `I18nLabel` widening at 8 render sites, `.extend` -> `.safeExtend` on a now-refined `GlobalFilterSchema`, and two parity lists re-pointed. NOT green: the bump's remaining surface is out of this card's scope and filed — objectui#4165 (contract conflict, blocks), #4167 (adaptation remainder), #4163 (I18nLabel consumer story), #4164 (name reclaim available). Part of objectstack-ai/objectstack#7100 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .changeset/global-nav-studio-retire-rc6.md | 35 ++++ apps/console/package.json | 4 +- apps/site/package.json | 2 +- package.json | 2 +- packages/app-shell/package.json | 6 +- packages/app-shell/src/layout/AppSidebar.tsx | 21 +- .../app-shell/src/layout/UnifiedSidebar.tsx | 16 +- .../src/views/metadata-admin/i18n.ts | 9 +- .../inspectors/ActionDefaultInspector.tsx | 10 +- .../inspectors/DashboardDefaultInspector.tsx | 19 +- .../inspectors/DashboardWidgetInspector.tsx | 33 ++- .../metadata-admin/previews/ActionPreview.tsx | 20 +- .../previews/DashboardPreview.tsx | 11 +- .../ActionPreview.locations.test.tsx | 113 +++++++++++ .../previews/__tests__/block-config.test.ts | 41 +++- .../metadata-admin/previews/block-config.ts | 5 +- packages/auth/package.json | 2 +- packages/collaboration/package.json | 2 +- packages/components/package.json | 2 +- .../src/renderers/action/action-bar.tsx | 9 +- packages/core/package.json | 4 +- packages/core/src/actions/ActionRunner.ts | 25 ++- packages/data-objectstack/package.json | 4 +- packages/fields/package.json | 2 +- packages/layout/package.json | 2 +- packages/mobile/package.json | 2 +- packages/plugin-charts/package.json | 2 +- packages/plugin-chatbot/package.json | 2 +- packages/plugin-dashboard/package.json | 2 +- packages/plugin-detail/package.json | 2 +- packages/plugin-form/package.json | 2 +- packages/plugin-gantt/package.json | 2 +- packages/plugin-grid/package.json | 4 +- packages/plugin-list/package.json | 4 +- packages/plugin-map/package.json | 2 +- packages/plugin-report/package.json | 2 +- packages/plugin-timeline/package.json | 2 +- packages/plugin-tree/package.json | 2 +- packages/plugin-view/package.json | 2 +- packages/providers/package.json | 2 +- packages/react/package.json | 2 +- .../hooks/__tests__/useActionEngine.test.ts | 74 +++++++ packages/runner/package.json | 2 +- packages/sdui-parser/package.json | 2 +- packages/types/package.json | 2 +- .../page-nav-misc-spec-parity.test.ts | 37 +++- .../spec-ui-schema-reexports.test.ts | 14 +- packages/types/src/index.ts | 16 +- packages/types/src/spec-report.ts | 16 +- packages/types/src/theme.ts | 23 ++- packages/types/src/zod/complex.zod.ts | 13 +- pnpm-lock.yaml | 190 +++++++++--------- 52 files changed, 628 insertions(+), 196 deletions(-) create mode 100644 .changeset/global-nav-studio-retire-rc6.md create mode 100644 packages/app-shell/src/views/metadata-admin/previews/__tests__/ActionPreview.locations.test.tsx diff --git a/.changeset/global-nav-studio-retire-rc6.md b/.changeset/global-nav-studio-retire-rc6.md new file mode 100644 index 0000000000..e3269a1189 --- /dev/null +++ b/.changeset/global-nav-studio-retire-rc6.md @@ -0,0 +1,35 @@ +--- +"@object-ui/app-shell": minor +"@object-ui/components": minor +"@object-ui/types": minor +"@object-ui/core": minor +--- + +Retire the `global_nav` Studio designer surfaces, and track the `@objectstack` family at `17.0.0-rc.6` (objectstack#7100 / objectstack#6888). + +## The retirement + +`global_nav` was an `ACTION_LOCATIONS` member no running-app surface ever rendered. The console's ⌘K palette (`app-shell/src/chrome/CommandPalette.tsx`) builds its groups from nav items, objects, dashboards, pages, reports, recent items, record search and theme; it holds no reference to `global_nav`, to `actionRendersAt`, or to any action-metadata source. An action declaring `locations: ['global_nav']` therefore never reached a user. + +The Studio designer previewed it anyway — a mock frame reading `⌘K · Command palette` with the author's button inside it. That is the sharp edge the maintainer's 2026-08-09 ruling on objectstack#6888 named: an authoring tool promising a surface the product does not have teaches authors, and every AI copying this corpus, to declare dead metadata. `@objectstack/spec` `17.0.0-rc.6` retired the member (7 members → 6) with a named rejection message; this release removes the designer surfaces that outlived it. + +- `metadata-admin/previews/ActionPreview.tsx` — the mock command-palette placement frame is gone. The metadata strip above it still ECHOES whatever `locations` the draft declares, deliberately: reporting what a (possibly stale) draft says is honest, whereas the frame CLAIMED the platform renders it. +- `metadata-admin/inspectors/ActionDefaultInspector.tsx` — the `global_nav` entry is gone from `LOCATION_LABELS`. That map is typed `Record< ActionLocation, string >`, so the retirement reached it as a compile error rather than as a silently stale dropdown — the mechanism objectui#3017 installed, firing as designed. +- `metadata-admin/previews/block-config.ts` — the `record:quick_actions` location dropdown no longer offers it, and both locale tables drop the now-orphaned `…option.location.global_nav` key. +- `@object-ui/components`' `action:bar` doc comment is aligned. The component's published enum is `[...ACTION_LOCATIONS]`, so it followed the retirement on its own; only the prose was stale. + +`@object-ui/core`'s `ActionEngine.getActionsForLocation` is **unchanged and still answers a literal string match**. Narrowing it to the six live members would put a second rejection point beside the schema's — the tolerant-consumer shape the strict-contract rule forbids, inverted. Enforcement stays where it belongs: the parameter type is now six-membered so no type-correct caller can spell the retired value, and `ActionLocationSchema` rejects it by name at authoring and publish time. + +## The dependency move + +All 37 `@objectstack/*` declarations across 30 `package.json` files move from `^17.0.0-rc.5` to `^17.0.0-rc.6`, and `pnpm-lock.yaml` resolves one copy of each family package at rc.6. The siblings move with `spec` because `client` / `formula` / `lint` pin it **exactly** — leaving them behind would keep two copies of the spec in the tree, the split brain objectui#3560 called out. + +Bumping the pin and repairing the fallout cannot be split: at rc.5 the `Record< ActionLocation, string >` above is missing a key, at rc.6 it has an excess one. + +## Breaking, in FROM → TO form + +- **`@object-ui/types`' `Theme` now binds the spec's `Theme`, not `ThemeInput`.** rc.6 retired every `…Input` alias and moved the bare name onto the `z.input` side (`X` = `z.input`, `XParsed` = `z.infer`). The runtime shape and this package's exported name are unchanged — `Theme` was, and still is, the AUTHORING shape where `mode` is optional. Re-pointing at `ThemeParsed` would have been the silent swap. +- **`SpecReport` / `SpecReportChart` re-point to `ReportParsed` / `ReportChartParsed`, and `SpecReportInput` / `SpecReportChartInput` to `Report` / `ReportChart`.** Same rename, same rule: each local alias keeps the SIDE it had at rc.5. +- **`@object-ui/types` no longer re-exports `I18nObject`, `LocaleConfig`, `PluralRule`, `DateFormat` or `NumberFormat`** — all five were retired by rc.6. They were dead re-exports here: nothing in this repo imported them from `@object-ui/types` (`@object-ui/i18n`'s formatter vocabulary in `utils/spec-formatters.ts` is locally declared and never bound the spec symbols). `I18nLabel` survives and is unchanged as a name. +- **`I18nLabel` itself widened from `string` to `string | Record< string, string >`** — rc.6 folded the retired `I18nObject`'s per-locale map into it and ships `resolveI18nLabel(label, locale)` as the shared resolver. Every read in this repo that lands in a text slot now goes through that resolver, so an inline map renders its locale instead of `[object Object]`. Reads the compiler cannot see are audited separately in objectui#4163. +- **`@object-ui/types`' `GlobalFilterSchema` derives via `.safeExtend`, not `.extend`.** rc.6's `GlobalFilterSchema` carries a refinement and zod 4 refuses `.extend()` on a refined object outright, which threw at module load. `.safeExtend` is zod's prescribed replacement and KEEPS the refinement, so the spec's cross-field rule now also runs on this package's dialect — which is the intended behaviour, since the pinned divergences widen individual fields and were never meant to switch off a whole-object rule. diff --git a/apps/console/package.json b/apps/console/package.json index bfe7e8aa01..9560915984 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -89,8 +89,8 @@ "@object-ui/providers": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/client": "^17.0.0-rc.5", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/client": "^17.0.0-rc.6", + "@objectstack/spec": "^17.0.0-rc.6", "@tailwindcss/postcss": "^4.3.3", "@tailwindcss/typography": "^0.5.20", "@testing-library/jest-dom": "^7.0.0", diff --git a/apps/site/package.json b/apps/site/package.json index 498a84231b..9dbb3fdcd6 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -33,7 +33,7 @@ "@object-ui/plugin-view": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "fumadocs-core": "16.14.1", "fumadocs-mdx": "15.2.2", "fumadocs-ui": "16.14.1", diff --git a/package.json b/package.json index 9d9245721a..8de2c98847 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "devDependencies": { "@changesets/cli": "^2.31.1", "@eslint/js": "^10.0.1", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@playwright/test": "^1.62.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index e533fdde8a..99d94c4087 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -49,9 +49,9 @@ "@object-ui/providers": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/formula": "^17.0.0-rc.5", - "@objectstack/lint": "^17.0.0-rc.5", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/formula": "^17.0.0-rc.6", + "@objectstack/lint": "^17.0.0-rc.6", + "@objectstack/spec": "^17.0.0-rc.6", "@sentry/react": "^10.69.0", "jsonc-parser": "^3.3.1", "lucide-react": "^1.29.0", diff --git a/packages/app-shell/src/layout/AppSidebar.tsx b/packages/app-shell/src/layout/AppSidebar.tsx index 02937540d6..f0aff1e347 100644 --- a/packages/app-shell/src/layout/AppSidebar.tsx +++ b/packages/app-shell/src/layout/AppSidebar.tsx @@ -63,6 +63,13 @@ import { useRecentItems } from '../hooks/useRecentItems'; import { useFavorites } from '../hooks/useFavorites'; import { useNavPins } from '../hooks/useNavPins'; import { resolveI18nLabel, matchAppBySegment, appRouteSegment } from '../utils'; +// Two resolvers, two vocabularies — the alias is what keeps them apart. +// `resolveI18nLabel` above is objectui's own and resolves a TRANSLATION-KEY ref +// (`{ key, defaultValue, params }`) through i18next. `resolveInlineI18nLabel` is +// the spec's `resolveI18nLabel`, new in @objectstack/spec 17.0.0-rc.6, and +// resolves the INLINE per-locale map (`{ en: …, 'zh-CN': … }`) that the same +// release folded into `I18nLabel`. Neither accepts the other's shape. +import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import { useAppContextSelectors } from './ContextSelectors'; @@ -143,7 +150,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri const isWorkspaceAdmin = useIsWorkspaceAdmin(); const navigate = useNavigate(); const location = useLocation(); - const { t } = useObjectTranslation(); + const { t, language } = useObjectTranslation(); const { objectLabel: resolveNavObjectLabel, viewLabel: resolveNavViewLabel } = useObjectLabel(); // Swipe-from-left-edge gesture to open sidebar on mobile @@ -514,15 +521,23 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri {visibleAreas.map((area) => { const AreaIcon = getIcon(area.icon); const isActiveArea = area.id === activeArea?.id; + // `NavigationArea.label` is the spec's `I18nLabel`, which + // @objectstack/spec 17.0.0-rc.6 widened from plain `string` + // to `string | Record` (the inline + // per-locale map, folded in from the retired `I18nObject`). + // Rendering it raw would print `[object Object]` for the + // map form, so it goes through the spec's own shared + // resolver rather than a local guess. + const areaLabel = resolveInlineI18nLabel(area.label, language); return ( setActiveAreaId(area.id)} > - {area.label} + {areaLabel} ); diff --git a/packages/app-shell/src/layout/UnifiedSidebar.tsx b/packages/app-shell/src/layout/UnifiedSidebar.tsx index 50afa55901..fe5383cd0b 100644 --- a/packages/app-shell/src/layout/UnifiedSidebar.tsx +++ b/packages/app-shell/src/layout/UnifiedSidebar.tsx @@ -49,6 +49,10 @@ import { useFavorites } from '../hooks/useFavorites'; import { useNavPins } from '../hooks/useNavPins'; import { useNavActionDispatch } from '../hooks/useNavActionDispatch'; import { resolveI18nLabel, matchAppBySegment, appRouteSegment } from '../utils'; +// Aliased to keep it apart from objectui's own `resolveI18nLabel` above: this is +// the spec's resolver (new in @objectstack/spec 17.0.0-rc.6) for the INLINE +// per-locale map form of `I18nLabel`, not for a translation-key ref. +import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; // useObjectLabel provides appLabel/appDescription for convention-based // i18n lookup — `{ns}.apps.{name}.label` resolves to the translated label @@ -152,7 +156,7 @@ function isOverviewGroup(item: NavigationItem): boolean { export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { const { isMobile, setOpenMobile } = useSidebar(); const location = useLocation(); - const { t } = useObjectTranslation(); + const { t, language } = useObjectTranslation(); const { objectLabel: resolveNavObjectLabel, dashboardLabel: resolveNavDashboardLabel, navGroupLabel: resolveNavGroupLabel, viewLabel: resolveNavViewLabel } = useObjectLabel(); const { context, currentAppName } = useNavigationContext(); const { user, activeOrganization } = useAuth(); @@ -506,15 +510,21 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { {visibleAreas.map((area) => { const AreaIcon = getIcon(area.icon); const isActiveArea = area.id === activeArea?.id; + // Same as AppSidebar: `NavigationArea.label` is the spec's + // `I18nLabel`, widened in @objectstack/spec 17.0.0-rc.6 to + // `string | Record`, so the inline + // per-locale form has to be resolved before it reaches a + // text slot or it renders as `[object Object]`. + const areaLabel = resolveInlineI18nLabel(area.label, language); return ( setActiveAreaId(area.id)} > - {area.label} + {areaLabel} ); diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 00ed90875d..5ba584270e 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -661,7 +661,10 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.pageBlock.option.location.record_related': 'Record related', 'engine.inspector.pageBlock.option.location.list_toolbar': 'List toolbar', 'engine.inspector.pageBlock.option.location.list_item': 'List item', - 'engine.inspector.pageBlock.option.location.global_nav': 'Global nav', + // `…option.location.global_nav` retired with the location itself + // (objectstack#6888, @objectstack/spec 17.0.0-rc.6). Its only reader was the + // dropdown option removed from `previews/block-config.ts`; a key kept past its + // option is dead vocabulary that the next author reads as a live surface. 'engine.inspector.pageBlock.field.ai:input.agentName': 'Agent', 'engine.inspector.pageBlock.placeholder.ai:input.agentName': 'agent name', 'engine.inspector.pageBlock.field.ai:input.placeholder': 'Input placeholder', @@ -2392,7 +2395,9 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.pageBlock.option.location.record_related': '记录关联列表', 'engine.inspector.pageBlock.option.location.list_toolbar': '列表工具栏', 'engine.inspector.pageBlock.option.location.list_item': '列表行', - 'engine.inspector.pageBlock.option.location.global_nav': '全局导航', + // `…option.location.global_nav` retired with the location itself — see the + // matching note in the `en` table above. Removed from BOTH tables in the same + // edit so the two key sets stay identical. 'engine.inspector.pageBlock.field.ai:input.agentName': '智能体', 'engine.inspector.pageBlock.placeholder.ai:input.agentName': '智能体名称', 'engine.inspector.pageBlock.field.ai:input.placeholder': '输入框占位提示', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx index 137357d4f1..319b06c2ac 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx @@ -119,8 +119,15 @@ const PARAM_TYPE_OPTS = [ * the spec ADDS is a missing-key error here rather than a silently absent * dropdown entry, and one it REMOVES is an excess-property error. * + * That mechanism is what fired here: `@objectstack/spec` 17.0.0-rc.6 retired + * `global_nav` from `ACTION_LOCATIONS` (objectstack#6888), and the entry that + * used to sit at the end of this map — `'Global nav / command palette'` — + * became a TS2353 excess property the moment the resolved vocabulary dropped to + * six. It is removed rather than re-typed; the six below are the whole + * vocabulary. + * * Insertion order is the display order — an authoring-friendly grouping - * (record → list → global), deliberately not the spec's declaration order. + * (record → list), deliberately not the spec's declaration order. */ const LOCATION_LABELS: Record = { record_header: 'Record header', @@ -129,7 +136,6 @@ const LOCATION_LABELS: Record = { record_related: 'Record · related list', list_toolbar: 'List toolbar', list_item: 'List · row', - global_nav: 'Global nav / command palette', }; const LOCATIONS: Array<{ value: ActionLocation; label: string }> = ( diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx index 48f0714b02..83a4aa1627 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx @@ -45,6 +45,14 @@ import { SchemaForm } from '../SchemaForm'; import { getDashboardForm, getDashboardSchema } from '../dashboard-schema'; import { mergeServerFields } from '../mergeServerFields'; import { t } from '../i18n'; +// `DashboardWidget.title` is the spec's `I18nLabel`, which @objectstack/spec +// 17.0.0-rc.6 widened from plain `string` to `string | Record` +// (the inline per-locale map, folded in from the retired `I18nObject`). Every +// read below lands in a text slot — a ` {w?.type} {!readOnly && ( diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx index d2eceb6890..64b6517b4d 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx @@ -33,6 +33,9 @@ import type { DashboardWidgetSchema } from '@object-ui/types'; import { resolveDashboardFilterDefs, type DashboardFilterDef } from '@object-ui/core'; import type { MetadataInspectorProps } from '../inspector-registry'; import { t, tFormat } from '../i18n'; +// The spec's `I18nLabel` resolver (new in @objectstack/spec 17.0.0-rc.6), +// aliased apart from objectui's same-named translation-KEY resolver. +import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; import { InspectorCheckboxField, InspectorReorderButtons, moveArray } from './_shared'; import { InspectorComboField, type InspectorComboOption } from './InspectorComboField'; import { DatasetNamesEditor } from './ReportDefaultInspector'; @@ -183,7 +186,11 @@ export function DashboardWidgetInspector({ function moveWidget(to: number) { onPatch({ widgets: moveArray(widgetsAll, index, to) }); if (widget.id) { - onSelectionChange?.({ kind: 'widget', id: widget.id, label: widget.title ?? undefined }); + onSelectionChange?.({ + kind: 'widget', + id: widget.id, + label: resolveInlineI18nLabel(widget.title, locale), + }); } } @@ -195,7 +202,9 @@ export function DashboardWidgetInspector({ {t('engine.inspector.widget.kind', locale)}
- {widget.title || selection.label || `Widget ${index + 1}`} + {resolveInlineI18nLabel(widget.title, locale) || + selection.label || + `Widget ${index + 1}`}
@@ -221,12 +230,30 @@ export function DashboardWidgetInspector({
+ {/* The ONE authoring — not display — read of `widget.title`, and the only + site in this change where following the rc.6 widening mechanically + would have destroyed data. `I18nLabel` now admits an inline per-locale + map, and this is a single-line text input: resolving the map into it + and writing `e.target.value` straight back would silently collapse + every other locale the author wrote, on the first keystroke. So the + input stays the plain-string editor it has always been, and a + map-valued title is shown resolved and READ-ONLY instead of being + flattened. Nothing in any corpus can hit this path yet — `I18nLabel` + was plain `string` through 17.0.0-rc.5, so no stored widget title can + be a map — which is exactly why the conservative branch is safe to + take now and why authoring the map form is follow-up work + (objectui#4163, part 2) rather than a guess made here. */} patchWidget({ title: e.target.value })} disabled={readOnly} + readOnly={widget.title != null && typeof widget.title !== 'string'} /> diff --git a/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx index c85fb82dcf..83f24fcf03 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx @@ -578,14 +578,18 @@ function PlacementPreview({ locations, label, icon, variant, iconOnly }: { )} - {locations.includes('global_nav') && ( - -
-
⌘K · Command palette
-
{label}
-
- - )} + {/* No `global_nav` frame. The location was retired from the spec's + `ACTION_LOCATIONS` in @objectstack/spec 17.0.0-rc.6 (objectstack#6888, + maintainer ruling 2026-08-09 direction 2) because no running-app + surface ever rendered it: the console's ⌘K palette + (`chrome/CommandPalette.tsx`) builds its groups from nav items, + objects, dashboards, pages, reports, recent items, record search and + theme, and reads no action metadata at all. The frame that used to + stand here drew the author a mock "⌘K · Command palette" preview, so + the designer promised a surface the product does not have — the + ADR-0078 "declares, 'renders', reports success, and does nothing" + shape, which is the sharpest harm the ruling names. Pinned negatively + in `__tests__/ActionPreview.locations.test.tsx`. */} ); } diff --git a/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx index e1b6ad6e4a..f84ed669ae 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx @@ -23,6 +23,9 @@ import type { MetadataPreviewProps } from '../preview-registry'; import { PreviewShell, PreviewErrorBoundary, PreviewMessage } from './PreviewShell'; import { uniqueId, appendArray } from '../inspectors/_shared'; import { t as tr } from '../i18n'; +// The spec's own `I18nLabel` resolver, aliased so it is never confused with +// objectui's same-named translation-KEY resolver in `app-shell/src/utils`. +import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; import { AddWidgetPicker } from './AddWidgetPicker'; import { WIDGET_TYPE_META } from './widget-types'; @@ -62,10 +65,14 @@ export function DashboardPreview({ onSelectionChange({ kind: 'widget', id: widgetId, - label: w?.title || widgetId, + // `DashboardWidget.title` became `string | Record` in + // @objectstack/spec 17.0.0-rc.6 (`I18nLabel` absorbed the inline + // per-locale map). `SelectionChange.label` is a `string`, so the map + // form is resolved here rather than stringified at the render site. + label: resolveInlineI18nLabel(w?.title, locale) || widgetId, }); }, - [onSelectionChange, widgets], + [onSelectionChange, widgets, locale], ); const handleReorder = React.useCallback( diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/ActionPreview.locations.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/__tests__/ActionPreview.locations.test.tsx new file mode 100644 index 0000000000..09a656cf9f --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/ActionPreview.locations.test.tsx @@ -0,0 +1,113 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#6888 / objectstack#7100 — the placement preview draws a frame for + * every action location the product actually renders, and for no other. + * + * ## What this replaces + * + * `ActionPreview` used to close its `PlacementPreview` with a `global_nav` + * frame drawing the author a mock command palette: + * + * ⌘K · Command palette + * [ New Task ] + * + * No such surface exists. The console's palette (`chrome/CommandPalette.tsx`) + * builds its groups from nav items, objects, dashboards, pages, reports, recent + * items, record search and theme; it holds no reference to `global_nav`, to + * `actionRendersAt`, or to any action-metadata source at all. So an author who + * declared `locations: ['global_nav']`, saw it previewed and shipped it got a + * button that rendered nowhere — the ADR-0078 "declares, 'renders', reports + * success, and does nothing" shape, arriving through the location vocabulary. + * The maintainer's 2026-08-09 ruling on #6888 (direction 2) retired the member; + * @objectstack/spec 17.0.0-rc.6 shipped that retirement. + * + * ## Why the assertions are paired + * + * Deleting the frame makes any "does not render global_nav" check pass forever, + * including if `PlacementPreview` were deleted whole or silently stopped + * rendering. So each negative pin here sits next to a positive one over the SAME + * render: a live location must still draw its frame in the same tree where the + * retired one must not appear. A vacuous pass then requires both halves to fail + * at once, which no plausible edit does. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, within, cleanup } from '@testing-library/react'; +import { ACTION_LOCATIONS } from '@objectstack/spec/ui'; + +import { ActionPreview } from '../ActionPreview'; + +afterEach(cleanup); + +function renderWithLocations(locations: string[]) { + return render( + , + ); +} + +/** + * The "Where it appears" section — the PLACEMENT simulation, and the only + * surface this change is about. + * + * Scoping matters and is not incidental. Higher up, the metadata strip echoes + * every token the draft declares as a chip under "Locations:", so an unscoped + * `queryByText('global_nav')` matches the ECHO and can never distinguish it + * from a frame. The echo is correct and deliberately untouched: it reports what + * this (possibly stale) draft says, which is honest, whereas the frame CLAIMED + * the platform renders it. That distinction is the whole retirement, so the + * test has to be able to see it. + */ +function placement() { + const heading = screen.getByText('Where it appears'); + // Section renders:
{title}
{children}
+ return within(heading.parentElement!.parentElement!); +} + +describe('ActionPreview placement frames', () => { + it('draws a frame for a live location', () => { + renderWithLocations(['record_header']); + expect(placement().getByText('record_header')).toBeTruthy(); + }); + + it('does NOT draw a frame for the retired global_nav location', () => { + // Both halves over one render: `record_header` proves the placement preview + // is alive and drawing, `global_nav` proves the retired frame is gone. + renderWithLocations(['record_header', 'global_nav']); + expect(placement().getByText('record_header')).toBeTruthy(); + expect(placement().queryByText('global_nav')).toBeNull(); + }); + + it('renders no command-palette mock for a global_nav-only action', () => { + renderWithLocations(['global_nav']); + // The mock frame's caption was literally "⌘K · Command palette". Asserting + // the visible string rather than a testid is deliberate: the harm the + // ruling names is what the AUTHOR SEES, so the pin is on what is displayed. + expect(screen.queryByText(/Command palette/i)).toBeNull(); + expect(placement().queryByText('global_nav')).toBeNull(); + // The section header still renders (it is gated on `locations.length`, not + // on how many frames draw), and its body is now empty. That is the truthful + // answer for a stale draft whose only location the platform retired — it + // appears nowhere — so it is pinned rather than left to look like a bug. + expect(screen.getByText('Where it appears')).toBeTruthy(); + }); + + it('the vocabulary this preview draws from no longer declares global_nav', () => { + // The upstream half of the same fact. Without it, a future spec that + // re-added the member would leave the pins above passing while the designer + // silently stopped previewing a location the product does declare. + expect(ACTION_LOCATIONS as readonly string[]).not.toContain('global_nav'); + expect(ACTION_LOCATIONS.length).toBe(6); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts index c48cbf5280..0f202c2514 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { PageComponentType, RecordDetailsProps } from '@objectstack/spec/ui'; +import { ACTION_LOCATIONS, PageComponentType, RecordDetailsProps } from '@objectstack/spec/ui'; import { BLOCK_CONFIG, blockHasConfig, type PlaceholderSpec } from '../block-config'; import { BLOCK_TYPE_META, PALETTE_EXCLUSIONS } from '../block-types'; import { t } from '../../i18n'; @@ -243,4 +243,43 @@ describe('page palette ↔ spec PageComponentType coverage', () => { ); expect(orphanPanels, 'these expose a config panel but cannot be authored').toEqual([]); }); + + describe("record:quick_actions `location` — the designer's action-location dropdown", () => { + const locationField = () => + BLOCK_CONFIG['record:quick_actions'].find((f) => f.name === 'location') as + | { options?: Array<{ value: string }> } + | undefined; + + // POSITIVE half. Without it the negative pin below passes on an empty + // option list, which is how a deleted dropdown reads as a passing test. + it('offers exactly the locations the spec declares', () => { + const offered = (locationField()?.options ?? []).map((o) => o.value); + expect([...offered].sort()).toEqual([...ACTION_LOCATIONS].sort()); + }); + + // NEGATIVE pin, converted from the coverage the removed option used to + // carry. `global_nav` was retired from `ACTION_LOCATIONS` in + // @objectstack/spec 17.0.0-rc.6 (objectstack#6888, maintainer ruling + // 2026-08-09 direction 2): no running-app surface ever rendered it, and the + // console's ⌘K palette reads no action metadata at all. An option the + // schema now rejects by name must not be offerable, or the designer teaches + // authors — and every AI copying this corpus — to write metadata that fails + // to parse. + it('does NOT offer the retired global_nav location', () => { + const offered = (locationField()?.options ?? []).map((o) => o.value); + expect(offered.length, 'option list is empty — the pin would be vacuous').toBeGreaterThan(0); + expect(offered).not.toContain('global_nav'); + expect(ACTION_LOCATIONS as readonly string[]).not.toContain('global_nav'); + }); + + // The i18n side of the same removal: an option key kept past its option is + // dead vocabulary the next author reads as a live surface, so BOTH locale + // tables lost `…option.location.global_nav`. `t()` returns the key + // unchanged on a miss, which is exactly "this locale has no translation". + it('has no leftover translation for the retired option in either locale', () => { + const key = 'engine.inspector.pageBlock.option.location.global_nav'; + expect(t(key, 'en-US')).toBe(key); + expect(t(key, 'zh-CN')).toBe(key); + }); + }); }); diff --git a/packages/app-shell/src/views/metadata-admin/previews/block-config.ts b/packages/app-shell/src/views/metadata-admin/previews/block-config.ts index 8972f627c0..8996c6699a 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/block-config.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/block-config.ts @@ -472,7 +472,10 @@ export const BLOCK_CONFIG: Record = { { value: 'record_related', label: 'engine.inspector.pageBlock.option.location.record_related' }, { value: 'list_toolbar', label: 'engine.inspector.pageBlock.option.location.list_toolbar' }, { value: 'list_item', label: 'engine.inspector.pageBlock.option.location.list_item' }, - { value: 'global_nav', label: 'engine.inspector.pageBlock.option.location.global_nav' }, + // No `global_nav` option: the location was retired from the spec's + // `ACTION_LOCATIONS` in @objectstack/spec 17.0.0-rc.6 (objectstack#6888). + // Offering it here would let the designer author a value the schema now + // rejects by name. Pinned negatively in `__tests__/block-config.test.ts`. ], }, ], diff --git a/packages/auth/package.json b/packages/auth/package.json index e2b9b7f2fe..1453a46e19 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -38,7 +38,7 @@ }, "dependencies": { "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "better-auth": "^1.6.26" }, "devDependencies": { diff --git a/packages/collaboration/package.json b/packages/collaboration/package.json index 50814228d3..5a43c3c6a7 100644 --- a/packages/collaboration/package.json +++ b/packages/collaboration/package.json @@ -41,7 +41,7 @@ "@object-ui/types": "workspace:*" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@types/react": "19.2.18", "react": "19.2.8", "typescript": "^6.0.3", diff --git a/packages/components/package.json b/packages/components/package.json index 16fcff4251..012a656582 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -40,7 +40,7 @@ "@object-ui/react-runtime": "workspace:*", "@object-ui/sdui-parser": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@radix-ui/react-accordion": "^1.2.20", "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-aspect-ratio": "^1.1.15", diff --git a/packages/components/src/renderers/action/action-bar.tsx b/packages/components/src/renderers/action/action-bar.tsx index f13c30d297..b06e2e9e12 100644 --- a/packages/components/src/renderers/action/action-bar.tsx +++ b/packages/components/src/renderers/action/action-bar.tsx @@ -15,8 +15,13 @@ * `maxVisible` threshold are grouped into an overflow "More" dropdown. * * This is the "bridge" component that connects ActionSchema metadata to the UI, - * enabling server-driven action rendering at list_toolbar, record_header, - * list_item, record_more, record_related, and global_nav locations. + * enabling server-driven action rendering at every location the spec declares: + * list_toolbar, list_item, record_header, record_more, record_related and + * record_section. (`global_nav` used to close that list; it was retired from + * `ACTION_LOCATIONS` in @objectstack/spec 17.0.0-rc.6 — objectstack#6888 — as a + * location no running-app surface ever rendered. The enum this component + * publishes is `[...ACTION_LOCATIONS]`, so it followed the retirement on its + * own; only this prose had to be aligned.) * * @example * ```tsx diff --git a/packages/core/package.json b/packages/core/package.json index 74e37d70f2..3efa776478 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -32,8 +32,8 @@ }, "dependencies": { "@object-ui/types": "workspace:*", - "@objectstack/formula": "^17.0.0-rc.5", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/formula": "^17.0.0-rc.6", + "@objectstack/spec": "^17.0.0-rc.6", "lodash": "^4.18.1", "zod": "^4.4.3" }, diff --git a/packages/core/src/actions/ActionRunner.ts b/packages/core/src/actions/ActionRunner.ts index 719f65113e..68373203e1 100644 --- a/packages/core/src/actions/ActionRunner.ts +++ b/packages/core/src/actions/ActionRunner.ts @@ -22,7 +22,7 @@ */ import type { RunnableActionType } from '@object-ui/types'; -import type { ActionInput as SpecActionInput } from '@objectstack/spec/ui'; +import type { Action as SpecActionInput } from '@objectstack/spec/ui'; import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator'; import { hasDeclaredPredicate } from '../evaluator/declaredPredicate'; import { globalUndoManager, type UndoableOperation } from './UndoManager'; @@ -244,14 +244,21 @@ export interface ActionDef { // hand-copied: a hand-written duplicate of a spec shape is a second contract // that drifts silently, which is the failure this whole issue is about. // - // Derived from `z.input` (`ActionInput`), not `z.infer` (`Action`), and the - // difference is load-bearing rather than stylistic. `ActionSchema` is a - // `ZodPipe` whose transforms narrow the authored shape — `visible` is authored - // as `string | { dialect, source }` but INFERS to the object form alone. This - // runner consumes authored/stored rows, which #3903 established are rehydrated - // UNPARSED, so it sees the input shape. Deriving from `Action` would have - // type-errored the raw-string predicate that `ActionEngine` explicitly - // supports and that `ActionEngine.visibility.test.ts` pins. + // Derived from `z.input`, not `z.infer`, and the difference is load-bearing + // rather than stylistic. `ActionSchema` is a `ZodPipe` whose transforms narrow + // the authored shape — `visible` is authored as `string | { dialect, source }` + // but INFERS to the object form alone. This runner consumes authored/stored + // rows, which #3903 established are rehydrated UNPARSED, so it sees the input + // shape. Deriving from the `z.infer` side would type-error the raw-string + // predicate that `ActionEngine` explicitly supports and that + // `ActionEngine.visibility.test.ts` pins. + // + // WHICH SPEC NAME carries `z.input` changed under us in `@objectstack/spec` + // 17.0.0-rc.6: up to rc.5 it was `ActionInput` (with `Action` = `z.infer`), + // and rc.6 retired every `…Input` alias and moved the bare name onto the input + // side (`Action` = `z.input`, `ActionParsed` = `z.infer`). The import above is + // therefore `Action as SpecActionInput` — the local alias keeps naming the + // side this file needs, so every derivation below is unchanged in meaning. // // `shortcut` and `bulkEnabled` land here as `undefined`, not as a usable type // — that is correct and deliberate. Spec 17 retired both as `retiredKey()` diff --git a/packages/data-objectstack/package.json b/packages/data-objectstack/package.json index 176c4e6b43..0715bcb71a 100644 --- a/packages/data-objectstack/package.json +++ b/packages/data-objectstack/package.json @@ -32,8 +32,8 @@ "dependencies": { "@object-ui/core": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/client": "^17.0.0-rc.5", - "@objectstack/spec": "^17.0.0-rc.5" + "@objectstack/client": "^17.0.0-rc.6", + "@objectstack/spec": "^17.0.0-rc.6" }, "devDependencies": { "tsup": "^8.5.1", diff --git a/packages/fields/package.json b/packages/fields/package.json index 1a75f4dc61..a40cafe3f6 100644 --- a/packages/fields/package.json +++ b/packages/fields/package.json @@ -37,7 +37,7 @@ "@object-ui/providers": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "clsx": "^2.1.1", "lucide-react": "^1.29.0", "react-markdown": "^10.1.0", diff --git a/packages/layout/package.json b/packages/layout/package.json index 0fc6ccbb58..2a5add518c 100644 --- a/packages/layout/package.json +++ b/packages/layout/package.json @@ -42,7 +42,7 @@ "react-router-dom": "^6.0.0 || ^7.0.0" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@vitejs/plugin-react": "^6.0.5", "react-router-dom": "^7.18.2", "vite": "^8.2.1", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 498b988acf..5eb4658521 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -40,7 +40,7 @@ "@object-ui/types": "workspace:*" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@types/react": "19.2.18", "react": "19.2.8", "typescript": "^6.0.3", diff --git a/packages/plugin-charts/package.json b/packages/plugin-charts/package.json index 35355a4547..23a1c46bf0 100644 --- a/packages/plugin-charts/package.json +++ b/packages/plugin-charts/package.json @@ -44,7 +44,7 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", "@vitejs/plugin-react": "^6.0.5", diff --git a/packages/plugin-chatbot/package.json b/packages/plugin-chatbot/package.json index 5f67079a6c..1432a348d4 100644 --- a/packages/plugin-chatbot/package.json +++ b/packages/plugin-chatbot/package.json @@ -38,7 +38,7 @@ "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", "@radix-ui/react-slot": "^1.3.3", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@radix-ui/react-use-controllable-state": "^1.2.6", "ai": "^7.0.56", "class-variance-authority": "^0.7.1", diff --git a/packages/plugin-dashboard/package.json b/packages/plugin-dashboard/package.json index b44b7c022f..4fcf51d219 100644 --- a/packages/plugin-dashboard/package.json +++ b/packages/plugin-dashboard/package.json @@ -43,7 +43,7 @@ }, "devDependencies": { "@object-ui/plugin-charts": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@types/react-grid-layout": "^2.1.0", "@vitejs/plugin-react": "^6.0.5", "react-grid-layout": "^2.2.4", diff --git a/packages/plugin-detail/package.json b/packages/plugin-detail/package.json index 9ca27ce13c..90c7eba797 100644 --- a/packages/plugin-detail/package.json +++ b/packages/plugin-detail/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@object-ui/i18n": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "lucide-react": "^1.29.0" }, "peerDependencies": { diff --git a/packages/plugin-form/package.json b/packages/plugin-form/package.json index 9dfeb7efec..2bd1f08031 100644 --- a/packages/plugin-form/package.json +++ b/packages/plugin-form/package.json @@ -28,7 +28,7 @@ "@object-ui/permissions": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "lucide-react": "^1.29.0" }, "peerDependencies": { diff --git a/packages/plugin-gantt/package.json b/packages/plugin-gantt/package.json index 9f38fe7369..02f46495c4 100644 --- a/packages/plugin-gantt/package.json +++ b/packages/plugin-gantt/package.json @@ -38,7 +38,7 @@ "@object-ui/plugin-detail": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "lucide-react": "^1.29.0", "sonner": "^2.0.7" }, diff --git a/packages/plugin-grid/package.json b/packages/plugin-grid/package.json index d265529c26..2aa5bdee67 100644 --- a/packages/plugin-grid/package.json +++ b/packages/plugin-grid/package.json @@ -29,7 +29,7 @@ "@object-ui/permissions": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@tanstack/react-virtual": "^3.14.9", "exceljs": "^4.4.0", "lucide-react": "^1.29.0" @@ -40,7 +40,7 @@ }, "devDependencies": { "@object-ui/data-objectstack": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@vitejs/plugin-react": "^6.0.5", "msw": "^2.15.0", "typescript": "^6.0.3", diff --git a/packages/plugin-list/package.json b/packages/plugin-list/package.json index d21556228c..fdcf00d853 100644 --- a/packages/plugin-list/package.json +++ b/packages/plugin-list/package.json @@ -42,7 +42,7 @@ "@object-ui/permissions": "workspace:^", "@object-ui/react": "workspace:^", "@object-ui/types": "workspace:^", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, @@ -55,7 +55,7 @@ "@object-ui/permissions": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", "@vitejs/plugin-react": "^6.0.5", diff --git a/packages/plugin-map/package.json b/packages/plugin-map/package.json index 0b575bd7e3..68ef4142c5 100644 --- a/packages/plugin-map/package.json +++ b/packages/plugin-map/package.json @@ -35,7 +35,7 @@ "@object-ui/core": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "lucide-react": "^1.29.0", "maplibre-gl": "^6.2.0", "react-map-gl": "^8.1.2", diff --git a/packages/plugin-report/package.json b/packages/plugin-report/package.json index 6158f6e033..10316f63d9 100644 --- a/packages/plugin-report/package.json +++ b/packages/plugin-report/package.json @@ -43,7 +43,7 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@types/node": "^26.1.2", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", diff --git a/packages/plugin-timeline/package.json b/packages/plugin-timeline/package.json index 8e0fe5e8a5..ef8d156438 100644 --- a/packages/plugin-timeline/package.json +++ b/packages/plugin-timeline/package.json @@ -37,7 +37,7 @@ "@object-ui/mobile": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "class-variance-authority": "^0.7.1", "zod": "^4.4.3" }, diff --git a/packages/plugin-tree/package.json b/packages/plugin-tree/package.json index eacd49c26a..62dc8843ab 100644 --- a/packages/plugin-tree/package.json +++ b/packages/plugin-tree/package.json @@ -36,7 +36,7 @@ "@object-ui/i18n": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "lucide-react": "^1.29.0" }, "peerDependencies": { diff --git a/packages/plugin-view/package.json b/packages/plugin-view/package.json index c7095b4e3d..e8217ba256 100644 --- a/packages/plugin-view/package.json +++ b/packages/plugin-view/package.json @@ -31,7 +31,7 @@ "@object-ui/plugin-grid": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "class-variance-authority": "^0.7.1", "lucide-react": "^1.29.0" }, diff --git a/packages/providers/package.json b/packages/providers/package.json index 1ea4937ed7..59a457403a 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5" + "@objectstack/spec": "^17.0.0-rc.6" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/packages/react/package.json b/packages/react/package.json index e32b903772..4db2939c2b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -34,7 +34,7 @@ "@object-ui/data-objectstack": "workspace:*", "@object-ui/i18n": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "react-hook-form": "^7.84.0" }, "peerDependencies": { diff --git a/packages/react/src/hooks/__tests__/useActionEngine.test.ts b/packages/react/src/hooks/__tests__/useActionEngine.test.ts index 4bfcd9ca27..87cb88368b 100644 --- a/packages/react/src/hooks/__tests__/useActionEngine.test.ts +++ b/packages/react/src/hooks/__tests__/useActionEngine.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, vi } from 'vitest'; import { renderHook, act } from '@testing-library/react'; +import { ActionLocationSchema } from '@objectstack/spec/ui'; import { useActionEngine } from '../useActionEngine'; describe('useActionEngine', () => { @@ -28,6 +29,14 @@ describe('useActionEngine', () => { locations: ['list_item'], }, { + // Deliberately STALE, in the same posture as the `bulkEnabled` and + // `shortcut` keys this fixture also keeps: `global_nav` was retired from + // the spec's `ACTION_LOCATIONS` in @objectstack/spec 17.0.0-rc.6 + // (objectstack#6888), so metadata like this can no longer be authored. + // Kept rather than deleted because the coverage that matters now is the + // INVERSE — see 'does not offer an action declaring the retired + // global_nav location' below. Deleting the entry would delete the only + // input that can prove the retired location leaks nowhere. name: 'global_search', type: 'script', target: 'true', @@ -36,6 +45,16 @@ describe('useActionEngine', () => { }, ]; + /** The six locations `ACTION_LOCATIONS` declares as of 17.0.0-rc.6. */ + const LIVE_LOCATIONS = [ + 'list_toolbar', + 'list_item', + 'record_header', + 'record_more', + 'record_related', + 'record_section', + ] as const; + describe('getActionsForLocation', () => { it('returns actions for list_toolbar', () => { const { result } = renderHook(() => @@ -75,6 +94,61 @@ describe('useActionEngine', () => { const actions = result.current.getActionsForLocation('record_related'); expect(actions.length).toBe(0); }); + + // `global_nav` was retired from `ACTION_LOCATIONS` in @objectstack/spec + // 17.0.0-rc.6 (objectstack#6888) because no running-app surface ever + // rendered it. `sampleActions` still declares it, so this pins what a stale + // declaration can and cannot do: it must surface at NONE of the six live + // locations. + it('does not offer an action declaring the retired global_nav location', () => { + const { result } = renderHook(() => + useActionEngine({ actions: sampleActions }), + ); + + for (const location of LIVE_LOCATIONS) { + const names = result.current + .getActionsForLocation(location) + .map((a) => a.name); + expect(names, `global_search leaked into ${location}`).not.toContain( + 'global_search', + ); + } + }); + + // The OTHER half, and it is deliberately NOT the symmetric one. Asking the + // engine for `'global_nav'` still returns the stale action, and that is + // correct: `ActionEngine.getActionsForLocation` is a literal string match + // over whatever a host registered, not a vocabulary filter. Narrowing it to + // the six live members would be renderer-side enforcement of a spec + // vocabulary — precisely the tolerant/lenient consumer shape AGENTS.md #0.1 + // forbids, and it would put a SECOND rejection point beside the schema's. + // + // Enforcement lives in two places instead, both pinned here: + // 1. the TYPE — `getActionsForLocation(location: ActionLocation)` is now + // six-membered, so no type-correct caller can spell the retired value + // at all (the cast below is what a stale caller would have to write); + // 2. the SCHEMA — `ActionLocationSchema` rejects it by name at authoring + // and publish time, with the retirement message pointing at the fix. + // Between them, metadata carrying `global_nav` cannot be saved, and no live + // caller can ask for it. This test was written expecting the engine to + // return `[]` and measured otherwise; the measurement is what it now pins. + it('leaves the retired location to the schema, not to a runtime filter', () => { + const { result } = renderHook(() => + useActionEngine({ actions: sampleActions }), + ); + + const retired = result.current.getActionsForLocation( + 'global_nav' as (typeof LIVE_LOCATIONS)[number], + ); + expect(retired.map((a) => a.name)).toEqual(['global_search']); + + const rejected = ActionLocationSchema.safeParse('global_nav'); + expect(rejected.success).toBe(false); + expect(rejected.error?.issues?.[0]?.message).toContain( + 'was removed from `ACTION_LOCATIONS`', + ); + expect(ActionLocationSchema.safeParse('record_header').success).toBe(true); + }); }); describe('getBulkActions', () => { diff --git a/packages/runner/package.json b/packages/runner/package.json index 74417bc6ed..c692a4734b 100644 --- a/packages/runner/package.json +++ b/packages/runner/package.json @@ -37,7 +37,7 @@ "tailwindcss-animate": "^1.0.7" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "@tailwindcss/postcss": "^4.3.3", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index aaf926c97e..f6f51f7259 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -33,6 +33,6 @@ "devDependencies": { "@object-ui/core": "workspace:*", "@object-ui/react": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.5" + "@objectstack/spec": "^17.0.0-rc.6" } } diff --git a/packages/types/package.json b/packages/types/package.json index e8178996c7..ebf2a9058a 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -90,7 +90,7 @@ "directory": "packages/types" }, "dependencies": { - "@objectstack/spec": "^17.0.0-rc.5", + "@objectstack/spec": "^17.0.0-rc.6", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts b/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts index 6509dd657d..dcdc04b802 100644 --- a/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts +++ b/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts @@ -561,8 +561,9 @@ describe('renamed local dialects do not collide with a spec export (objectui#307 ['PageRegion', 'PageNodeRegion'], ['PageRegionSchema', 'PageNodeRegionSchema'], ['ResponsiveConfig', 'MobileResponsiveConfig'], - ['WidgetManifest', 'RuntimeWidgetManifest'], - ['WidgetSource', 'RuntimeWidgetSource'], + // `WidgetManifest` / `WidgetSource` moved OUT of this list on the + // 17.0.0-rc.6 bump — see the block below. The tripwire fired for the second + // time, the same way objectui#3363 recorded the first. ])('the spec still owns `%s`, which is why the rename to `%s` happened', (owned) => { // The other direction of the tripwire: if the spec RETIRES one of these, // the local dialect can take the natural name back. A workaround should not @@ -586,6 +587,38 @@ describe('renamed local dialects do not collide with a spec export (objectui#307 * * The third did not move — see the `OfflineConfig` block below. */ + /** + * THE TRIPWIRE FIRED A SECOND TIME, on the @objectstack/spec 17.0.0-rc.6 bump + * (objectstack#7100). rc.6 retired the whole widget-manifest vocabulary — + * `WidgetManifest`, `WidgetSource`, `WidgetProperty`, `WidgetEvent`, + * `WidgetLifecycle` and their `…Schema`/`…Parsed` twins — so the spec stopped + * owning the two names that forced this package's `Runtime…` prefixes, and + * both rows moved from the "spec still owns" list above to this one. + * + * NOT reclaimed here, deliberately. The reclaim is available, not required: + * `RuntimeWidgetManifest`/`RuntimeWidgetSource` are exported names, so taking + * the bare names back is a cross-package rename with its own review, and this + * PR is a `global_nav` retirement that the bump merely rides under. What the + * rows below do is keep the vacancy PINNED — if the spec re-publishes either + * name while objectui holds it, that is a live collision, and this goes red. + * The unlock is recorded rather than taken: objectui#4164. + */ + it.each([ + ['WidgetManifest', 'RuntimeWidgetManifest'], + ['WidgetSource', 'RuntimeWidgetSource'], + ])( + 'the spec no longer owns `%s`, so `%s` keeps its prefix by choice, not by force (objectstack#7100)', + (vacated) => { + expect( + names, + `spec owns '${vacated}' again. The rc.6 retirement that vacated it has ` + + `been undone upstream, so the prefix on the local dialect is load-bearing ` + + `again — move this row back to the "spec still owns" list, and re-triage ` + + `any reclaim planned in objectui#4164.`, + ).not.toContain(vacated); + }, + ); + it.each([ ['GestureType', 'TouchGestureType'], ['GestureConfig', 'TouchGestureConfig'], diff --git a/packages/types/src/__tests__/spec-ui-schema-reexports.test.ts b/packages/types/src/__tests__/spec-ui-schema-reexports.test.ts index aa3dc9f78e..43175daeee 100644 --- a/packages/types/src/__tests__/spec-ui-schema-reexports.test.ts +++ b/packages/types/src/__tests__/spec-ui-schema-reexports.test.ts @@ -92,11 +92,15 @@ const DROPPED_SCHEMA_EXPORTS = [ 'AriaPropsSchema', // I18n 'I18nLabelSchema', - 'I18nObjectSchema', - 'LocaleConfigSchema', - 'PluralRuleSchema', - 'DateFormatSchema', - 'NumberFormatSchema', + // `I18nObjectSchema`, `LocaleConfigSchema`, `PluralRuleSchema`, + // `DateFormatSchema` and `NumberFormatSchema` were removed from this list on + // the @objectstack/spec 17.0.0-rc.6 bump (objectstack#7100): the spec RETIRED + // all five, so each row asserted "this name is not re-exported from + // @object-ui/types" about a name that no longer exists anywhere — vacuously + // true forever, which is what the guard above this list exists to catch. + // `I18nLabelSchema` SURVIVES rc.6 and stays denied; the release folded the + // per-locale record form into `I18nLabel` itself (`string` → + // `string | Record`) and ships `resolveI18nLabel` for it. // Responsive Design 'SpecResponsiveConfigSchema', 'BreakpointColumnMapSchema', diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 6b77dbef79..f6b60305ea 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1188,13 +1188,19 @@ export type { // ============================================================================ // v2.0.7 Spec UI Types — I18n // ============================================================================ +// `I18nObject`, `LocaleConfig`, `PluralRule`, `DateFormat` and `NumberFormat` +// (and their `…Schema` values) were retired from `@objectstack/spec/ui` in +// 17.0.0-rc.6. Nothing in this repo imported them from here — the only names +// that looked like consumers are `Intl.NumberFormat` call sites and +// `@object-ui/i18n`'s own locally declared formatter vocabulary in +// `utils/spec-formatters.ts`, which never bound the spec symbols. So these five +// were dead re-exports and are dropped rather than re-declared locally. +// `I18nLabel` SURVIVES rc.6 and stays bound by reference: it is the authoring +// label union (`string | Record`) that `ActionParam.label` and +// friends are typed against, and rc.6 additionally publishes the inline-map +// half as `InlineLocaleMap` plus the shared `resolveI18nLabel` resolver. export type { I18nLabel, - I18nObject, - LocaleConfig, - PluralRule, - DateFormat, - NumberFormat, } from '@objectstack/spec/ui'; // ============================================================================ diff --git a/packages/types/src/spec-report.ts b/packages/types/src/spec-report.ts index 1fb7483475..512e5b333f 100644 --- a/packages/types/src/spec-report.ts +++ b/packages/types/src/spec-report.ts @@ -40,11 +40,19 @@ * when translating a Report column into an ObjectQL `AggregationNode`. */ +// `@objectstack/spec` 17.0.0-rc.6 retired every `…Input` alias and moved the +// bare name onto the `z.input` side: `Report`/`ReportChart` are now +// `z.input` and `ReportParsed`/`ReportChartParsed` carry the +// `z.infer` shape these bindings used to read under the bare name. Both pairs +// below are therefore re-pointed so that each local alias keeps the meaning it +// had at rc.5 — `SpecReport`/`SpecReportChart` stay PARSED, `SpecReportInput`/ +// `SpecReportChartInput` stay INPUT. Following the rename by name alone (`X` → +// `X`) would have silently swapped the two. import type { - Report as SpecReportType_, - ReportInput as SpecReportInputType_, - ReportChart as SpecReportChartType_, - ReportChartInput as SpecReportChartInputType_, + ReportParsed as SpecReportType_, + Report as SpecReportInputType_, + ReportChartParsed as SpecReportChartType_, + ReportChart as SpecReportChartInputType_, } from '@objectstack/spec/ui'; import { diff --git a/packages/types/src/theme.ts b/packages/types/src/theme.ts index 7acb5e5682..57fa904f65 100644 --- a/packages/types/src/theme.ts +++ b/packages/types/src/theme.ts @@ -81,24 +81,31 @@ import type { * by reference (objectstack#4115) rather than restated. * * The interface this replaces was member-for-member identical to the spec's - * `ThemeInput` (verified by the mutual-assignability probe in + * authoring theme shape (verified by the mutual-assignability probe in * `__tests__/page-nav-misc-spec-parity.test.ts`), so the only thing the copy * added was a second place to drift from — under the spec's own symbol name, * which is what makes such a copy read as canonical to the next reader. * - * `ThemeInput`, not `Theme`: the spec's `z.infer` is the - * PARSED shape, where `mode` is required because `.default('auto')` has already - * run. Everything in objectui that carries a `Theme` is on the authoring side — - * theme JSON as stored, edited and handed to `ThemeProvider` — so the input - * shape (`mode` optional) is the one that is true here. This is the + * **The `z.input` shape, not the parsed one**, and in `@objectstack/spec` + * 17.0.0-rc.6 that shape is spelled `Theme`. Up to rc.5 the spec published + * three names — `Theme` (= `z.infer`), `ThemeParsed` (= `z.infer`) and + * `ThemeInput` (= `z.input`) — and this binding took `ThemeInput`. rc.6 retired + * every `…Input` alias and made the bare name the input: `Theme` is now + * `z.input` and `ThemeParsed` is the `z.infer` side. So + * `ThemeInput` → `Theme` is a rename that preserves this binding's meaning + * exactly; re-pointing at `ThemeParsed` would be the silent semantic change. + * The reason the input side is the true one here is unchanged: `mode` is + * required in the parsed shape because `.default('auto')` has already run, and + * everything in objectui that carries a `Theme` is on the authoring side — + * theme JSON as stored, edited and handed to `ThemeProvider`. This is the * `.default()`/`z.input` rule from objectui#3169: writing side → input type. * * This is the canonical JSON shape for a theme. It can be serialized, stored, * and applied at runtime via ThemeProvider. */ -export type { ThemeInput as Theme } from '@objectstack/spec/ui'; +export type { Theme } from '@objectstack/spec/ui'; -import type { ThemeInput as Theme } from '@objectstack/spec/ui'; +import type { Theme } from '@objectstack/spec/ui'; // ============================================================================ // ObjectUI Component Schemas (UI rendering) diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 15ca470ffd..6b9922511a 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -327,8 +327,19 @@ export const DashboardWidgetSchema = specFieldsExcept(SpecDashboardWidgetSchema. * that object form, which the spec's `string | number | boolean` rejects. * * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. + * + * `.safeExtend`, not `.extend`, since @objectstack/spec 17.0.0-rc.6: the spec's + * `GlobalFilterSchema` now carries a refinement, and zod 4 refuses `.extend()` + * on a refined object outright — *"Cannot overwrite keys on object schemas + * containing refinements. Use `.safeExtend()` instead."* — which threw at + * MODULE LOAD and took six `@object-ui/types` suites down with it before this + * file ran a single test. `.safeExtend` is zod's own prescribed replacement and + * KEEPS the refinement (measured: one check before, one check after), so the + * spec's cross-field rule still runs on objectui's dialect. That is the + * behaviour we want — the three divergences below widen individual FIELDS, and + * were never meant to switch off a whole-object rule. */ -export const GlobalFilterSchema = SpecGlobalFilterSchema.extend({ +export const GlobalFilterSchema = SpecGlobalFilterSchema.safeExtend({ options: z.array(z.union([ z.string(), z.object({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e36ecee63..2af93336f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,8 +35,8 @@ importers: specifier: ^10.0.1 version: 10.0.1(eslint@10.8.0(jiti@2.7.0)) '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@playwright/test': specifier: ^1.62.1 version: 1.62.1 @@ -267,11 +267,11 @@ importers: specifier: workspace:* version: link:../../packages/types '@objectstack/client': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -405,8 +405,8 @@ importers: specifier: workspace:* version: link:../../packages/types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) fumadocs-core: specifier: 16.14.1 version: 16.14.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.29.0(react@19.2.8))(next@16.3.0(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.4.3) @@ -721,14 +721,14 @@ importers: specifier: workspace:* version: link:../types '@objectstack/formula': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@objectstack/lint': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@sentry/react': specifier: ^10.69.0 version: 10.69.0(react@19.2.8) @@ -818,8 +818,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) better-auth: specifier: ^1.6.26 version: 1.6.26(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(mongodb@7.2.0)(next@16.3.0(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10) @@ -911,8 +911,8 @@ importers: version: link:../types devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -947,8 +947,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@radix-ui/react-accordion': specifier: ^1.2.20 version: 1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1131,11 +1131,11 @@ importers: specifier: workspace:* version: link:../types '@objectstack/formula': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) lodash: specifier: ^4.18.1 version: 4.18.1 @@ -1193,11 +1193,11 @@ importers: specifier: workspace:* version: link:../types '@objectstack/client': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) devDependencies: tsup: specifier: ^8.5.1 @@ -1230,8 +1230,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -1347,8 +1347,8 @@ importers: version: 3.6.0 devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@vitejs/plugin-react': specifier: ^6.0.5 version: 6.0.5(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.10)(yaml@2.9.0)) @@ -1372,8 +1372,8 @@ importers: version: link:../types devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1547,8 +1547,8 @@ importers: version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.6)(react@19.2.8)(redux@5.0.1) devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1589,8 +1589,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@radix-ui/react-slot': specifier: ^1.3.3 version: 1.3.3(@types/react@19.2.18)(react@19.2.8) @@ -1708,8 +1708,8 @@ importers: specifier: workspace:* version: link:../plugin-charts '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@types/react-grid-layout': specifier: ^2.1.0 version: 2.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1817,8 +1817,8 @@ importers: specifier: workspace:* version: link:../i18n '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) lucide-react: specifier: ^1.29.0 version: 1.29.0(react@19.2.8) @@ -1936,8 +1936,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) lucide-react: specifier: ^1.29.0 version: 1.29.0(react@19.2.8) @@ -1991,8 +1991,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) lucide-react: specifier: ^1.29.0 version: 1.29.0(react@19.2.8) @@ -2052,8 +2052,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@tanstack/react-virtual': specifier: ^3.14.9 version: 3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2193,8 +2193,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -2232,8 +2232,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) lucide-react: specifier: ^1.29.0 version: 1.29.0(react@19.2.8) @@ -2382,8 +2382,8 @@ importers: version: 3.6.0 devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@types/node': specifier: ^26.1.2 version: 26.1.2 @@ -2427,8 +2427,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2482,8 +2482,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) lucide-react: specifier: ^1.29.0 version: 1.29.0(react@19.2.8) @@ -2546,8 +2546,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2580,8 +2580,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) react: specifier: 19.2.8 version: 19.2.8 @@ -2614,8 +2614,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) react: specifier: 19.2.8 version: 19.2.8 @@ -2688,8 +2688,8 @@ importers: version: 1.0.7(tailwindcss@4.3.3) devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -2727,14 +2727,14 @@ importers: specifier: workspace:* version: link:../react '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) packages/types: dependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.5 - version: 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) zod: specifier: ^4.4.3 version: 4.4.3 @@ -4080,26 +4080,26 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@objectstack/client@17.0.0-rc.5': - resolution: {integrity: sha512-E1wiOK90d6CbiYtbQ0157oA8BKTBnf6JfizZ0vcH7gh89rrCCeL6dIsmo4SQyOYh+ZWtBRJMttrhLCLm35LBXQ==} + '@objectstack/client@17.0.0-rc.6': + resolution: {integrity: sha512-gdHhcY3JTRRmjlosIUP2WE4wG0ciwfYWu+HqyvtjFHVnhuNOoDOuzsKy2M/CppeOaKDay21Bs/HNH9ljGRQOnQ==} engines: {node: '>=22.0.0'} - '@objectstack/core@17.0.0-rc.5': - resolution: {integrity: sha512-kw8A9ERMscZwEnOXGDFmnUW0QdYcEhvsiHW6H4HgBQYJEqkbWXjBaS0ujbWxbn/hT5A+pEAiVQc2RcDVx/yHYA==} + '@objectstack/core@17.0.0-rc.6': + resolution: {integrity: sha512-78VndPhlxUXAupQVJ04YDdFbyIYcMJxw1I+Q7dlUuf+NGrlP7VzWwAQZm98dRH3q+M5wEvvV5t0myLhmVEpmKQ==} engines: {node: '>=22.0.0'} - '@objectstack/formula@17.0.0-rc.5': - resolution: {integrity: sha512-50DI3S/A8cyR4amwROxOcYD1r4om7bo/3Gf6MugEfWXfejzLpc9EUS3maIlYiW0orAmo20SHA81Q4cB+qGUxGw==} + '@objectstack/formula@17.0.0-rc.6': + resolution: {integrity: sha512-7vK6WgjeaO4rijaI4qZucRLsYGOPnrO/vuSd6wJCr1MpsH9go1yWLVte8itF0mTU0PhqXcnwtPHYCsxYnu4Y1Q==} - '@objectstack/lint@17.0.0-rc.5': - resolution: {integrity: sha512-0huaGN11ub7iqjuRzmiyUoh0Sju1U8tpVkp062ilOOF2bf0xzxgSVUdQrJlFNjV4+gzLJnkbqha1/4Y8Tr4dNA==} + '@objectstack/lint@17.0.0-rc.6': + resolution: {integrity: sha512-xZsa8DB2h76M0b6R4pFNnVZ1IzwGPxRI2vZhsFa5qNMiEWOLTc+WGVB76X18syzh0hhSMf69BR0G1G40/nOa5Q==} engines: {node: '>=22.0.0'} - '@objectstack/sdui-parser@17.0.0-rc.5': - resolution: {integrity: sha512-gi61YonxZnXQ5gPMa7CShz8d3Yg1ZGYC24jbDtOerbgOlBowc36Qrp3UKIL2JjW6PBQJJxUY+Y0h4lDwAEs/BA==} + '@objectstack/sdui-parser@17.0.0-rc.6': + resolution: {integrity: sha512-bao+TMFCGwu73ilvoJCrXLHWBvXhzHo/um1IHFJPcLXSRyee0Mea8rDy6Q2Ks04/VhYOza4M9rG1npoLk5DqQA==} - '@objectstack/spec@17.0.0-rc.5': - resolution: {integrity: sha512-9AFBwdwCaFPUFxGAShRMvW3UQAW+Gz0ET1CEJEHL5f2Qh/Bu7hGMBT7NY8fhYqfajAroe08jZGx3JMOFgS2v5Q==} + '@objectstack/spec@17.0.0-rc.6': + resolution: {integrity: sha512-xDdZVm7KtpcKZVyt0LSaGQ8OtAgaIZnPqug5zPkndySFGQKdjPtiANUfVVX4H/L25HdEXbzodFZwBbPYR37zFQ==} engines: {node: '>=22.0.0'} peerDependencies: ai: ^7.0.0 @@ -12447,32 +12447,32 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@objectstack/client@17.0.0-rc.5(ai@7.0.56(zod@4.4.3))': + '@objectstack/client@17.0.0-rc.6(ai@7.0.56(zod@4.4.3))': dependencies: - '@objectstack/core': 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) - '@objectstack/spec': 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + '@objectstack/core': 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) + '@objectstack/spec': 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) transitivePeerDependencies: - ai - '@objectstack/core@17.0.0-rc.5(ai@7.0.56(zod@4.4.3))': + '@objectstack/core@17.0.0-rc.6(ai@7.0.56(zod@4.4.3))': dependencies: - '@objectstack/spec': 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + '@objectstack/spec': 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) zod: 4.4.3 transitivePeerDependencies: - ai - '@objectstack/formula@17.0.0-rc.5(ai@7.0.56(zod@4.4.3))': + '@objectstack/formula@17.0.0-rc.6(ai@7.0.56(zod@4.4.3))': dependencies: '@marcbachmann/cel-js': 8.0.0 - '@objectstack/spec': 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + '@objectstack/spec': 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) transitivePeerDependencies: - ai - '@objectstack/lint@17.0.0-rc.5(ai@7.0.56(zod@4.4.3))': + '@objectstack/lint@17.0.0-rc.6(ai@7.0.56(zod@4.4.3))': dependencies: - '@objectstack/formula': 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) - '@objectstack/sdui-parser': 17.0.0-rc.5 - '@objectstack/spec': 17.0.0-rc.5(ai@7.0.56(zod@4.4.3)) + '@objectstack/formula': 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) + '@objectstack/sdui-parser': 17.0.0-rc.6 + '@objectstack/spec': 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) sucrase: 3.35.1 @@ -12480,9 +12480,9 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/sdui-parser@17.0.0-rc.5': {} + '@objectstack/sdui-parser@17.0.0-rc.6': {} - '@objectstack/spec@17.0.0-rc.5(ai@7.0.56(zod@4.4.3))': + '@objectstack/spec@17.0.0-rc.6(ai@7.0.56(zod@4.4.3))': dependencies: zod: 4.4.3 optionalDependencies: From db9dad96173ead776a58a863433ddb98f0512c2c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 17:15:21 +0000 Subject: [PATCH 2/3] fix(deps): repair the rc.6 fallout the first pass missed, and pin the one conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second pass over the @objectstack/spec 17.0.0-rc.6 bump. The first commit fixed every COMPILE error; a full `vitest run` (14615 tests) then surfaced the runtime half. All mechanical, each pinned; the one non-mechanical case is left as a tripwire rather than decided. - types/zod/complex.zod.ts: `GlobalFilterSchema` composes by spreading the spec's `.shape`. rc.6 closed BOTH extension doors on a now-refined schema — `.extend()` throws at module load, `.safeExtend()` types the three overridden keys as `never` (TS2322, which only `apps/site`'s build caught). The spread preserves rc.5 behaviour exactly and decides nothing: the refinement-vs-stored-`{preset}` conflict is objectui#4165, pinned by a new tripwire that reds from either side. The pin isolates the refinement by widening `defaultValue` first — the bare spec schema rejects `{preset}` on the OLD field type and the refinement never runs, so the obvious shorter assertion would have pinned the wrong thing. - core/actionKeys.ts: `description` joins SPEC_ACTION_KEYS (rc.6 added it to `ActionSchema`). Inventory only — `ActionDef` is untouched. - metadata-admin flow tests x2: `FlowNodeSchema` became pipe-wrapped, so `.shape` read `undefined`. Access-path repair (walk the wrapper); the blocks themselves are unchanged upstream, and a non-vacuity guard now says so. - clientValidation.optOuts: INVERTED — rc.6 makes `SharingRuleSchema` declare the seven ADR-0010 envelope keys, so the create door now ACCEPTS a stamped `_packageId`. Upstream convergence, pinned as accept, with a bogus-key case proving the door is still a door. - clientValidation.viewDiagnostics: fixture supplies a `label`; rc.6's widened `I18nLabel` made every label a LEAF union, which #3606's root-union expansion does not reach. The resulting bare `Invalid input` is recorded as its own pin (objectui#4163), not papered over. - components/record-picker parity: fixture drops `displayField` — an ADR-0087 D2 tombstone refused by name, which was failing all three `filter` assertions. - console registry parity: `SPEC_SHAPE_EMPTY_ON_THE_PIN` deleted exactly as its own self-clearing test instructed (rc.6 lands `PageContainerProps`), with a tombstone assertion so the deletion cannot be silently undone. - i18n residue ratchet: matches `const { t, … } = useObjectTranslation(` by pattern. Both sidebars legitimately also take `language` now, to resolve the spec's inline per-locale labels. Still red, filed, NOT fixed here: 2 console-parity exemption-list entries and `check:spec-symbols`' 13 name collisions (objectui#4167) — each is per-symbol triage, not a mechanical edit — and objectui#4165, which blocks ready. Part of objectstack-ai/objectstack#7100 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .../registry-inputs-spec-parity.test.ts | 54 +++++++------ .../clientValidation.optOuts.test.ts | 34 ++++++++- .../clientValidation.viewDiagnostics.test.ts | 37 ++++++++- ...ow-node-config.spec-reconciliation.test.ts | 40 +++++++++- .../flow-canvas-seeds.spec-parse.test.tsx | 33 +++++++- .../record-picker-inputs-spec-parity.test.ts | 19 ++++- packages/core/src/actions/actionKeys.ts | 9 +++ .../residue-namespaces-3546.test.tsx | 13 +++- .../report-chart-query-spec-parity.test.ts | 75 +++++++++++++++++++ packages/types/src/zod/complex.zod.ts | 49 +++++++++--- 10 files changed, 309 insertions(+), 54 deletions(-) diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 57b5870914..d1b8c3d571 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -265,24 +265,24 @@ const EXPECTED_WITHOUT_INPUTS = [ 'nav:menu', ]; -/** - * Covered blocks whose spec props schema legitimately resolves to ZERO keys on - * the pinned `@objectstack/spec`, so the non-empty probe guard below must not - * judge them (objectui#4027). +/* + * `SPEC_SHAPE_EMPTY_ON_THE_PIN` — DELETED on the @objectstack/spec 17.0.0-rc.6 + * bump (objectstack#7100), exactly as it was designed to be. * - * The guard exists to catch a BROKEN reader — a `specTopLevelKeys` that stopped - * resolving `.shape` and returned `[]` for everything. These three return `[]` - * for a real reason instead: rc.5 still maps them to `EmptyProps`, while - * objectstack#5775 / PR objectstack#6281 replaced that with the shared - * `PageContainerProps` upstream. Without this carve-out the guard would report - * "spec shape did not resolve" for a shape that resolved perfectly well and is - * simply empty — a false accusation against the probe. + * It carved `page:footer` / `page:section` / `page:sidebar` out of the + * non-empty probe guard because rc.5 still mapped all three to `EmptyProps`, + * so their shapes resolved to `{}` for a real reason rather than a broken + * reader (objectui#4027). objectstack#5775 / PR objectstack#6281 replaced that + * with the shared `PageContainerProps` upstream, and rc.6 is where this repo + * resolves it: `children` now appears in each of the three shapes. * - * Self-clearing, and pinned as such by `the empty-shape carve-out still - * describes an empty shape` below: once the pin moves, `children` appears in - * each shape and that assertion fails until this list is deleted. + * The list was self-clearing by construction, and its companion test — `the + * empty-shape carve-out still describes an empty shape` — is what fired, + * carrying its own instruction ("delete it from SPEC_SHAPE_EMPTY_ON_THE_PIN"). + * Both the list and that test are gone; the plain non-empty guard now covers + * all three again, which is the state the carve-out was always temporary + * against. */ -const SPEC_SHAPE_EMPTY_ON_THE_PIN = ['page:footer', 'page:section', 'page:sidebar']; /** * Off-spec top-level inputs ACCEPTED for now, each with the reason. @@ -561,24 +561,20 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // would return `[]`, every input would read as off-spec, and the failure // would look like a repo-wide regression instead of a broken probe. An // empty result here means "fix the reader", not "fix the inputs". - for (const type of covered.filter((t) => !SPEC_SHAPE_EMPTY_ON_THE_PIN.includes(t))) { + for (const type of covered) { expect(specTopLevelKeys(type).length, `${type} spec shape did not resolve`).toBeGreaterThan(0); } }); - it('the empty-shape carve-out still describes an empty shape', () => { - // The carve-out above cannot be allowed to outlive its cause: these three - // resolve to `{}` because the PINNED rc.5 still maps them to `EmptyProps`, - // not because the probe is broken. The moment the pin carries - // `PageContainerProps`, `children` appears in the shape and this assertion - // fails — which is the instruction to delete the list and let the plain - // non-empty guard cover all three again. - for (const type of SPEC_SHAPE_EMPTY_ON_THE_PIN) { - expect(covered, `${type} no longer declares inputs — the carve-out is dead`).toContain(type); - expect( - specTopLevelKeys(type), - `${type}'s spec shape is no longer empty — delete it from SPEC_SHAPE_EMPTY_ON_THE_PIN`, - ).toEqual([]); + it('the three former carve-out blocks now resolve a real shape', () => { + // The tombstone of `SPEC_SHAPE_EMPTY_ON_THE_PIN` (see above). Keeping one + // assertion on the three names is what stops the deletion being silently + // undone by a future pin that regresses them to `EmptyProps` — the guard + // above would then read as "the probe is broken" for all three at once, + // which is the misdiagnosis objectui#4027 was filed about. + for (const type of ['page:footer', 'page:section', 'page:sidebar']) { + expect(covered, `${type} no longer declares inputs`).toContain(type); + expect(specTopLevelKeys(type), `${type} regressed to an empty spec shape`).toContain('children'); } }); diff --git a/packages/app-shell/src/views/metadata-admin/clientValidation.optOuts.test.ts b/packages/app-shell/src/views/metadata-admin/clientValidation.optOuts.test.ts index 8ba4b20b60..68a29714b3 100644 --- a/packages/app-shell/src/views/metadata-admin/clientValidation.optOuts.test.ts +++ b/packages/app-shell/src/views/metadata-admin/clientValidation.optOuts.test.ts @@ -109,13 +109,41 @@ describe('validateMetadataDraft("sharing_rule") — objectui#3561', () => { */ it('does not gate the edit door — a stored body may carry `_packageId`', async () => { const stored = { ...SHARING_RULE, _packageId: 'crm_pkg' }; - // Authoring door: strict, so the stamped key is an unrecognized key. + + // AUTHORING DOOR — INVERTED on the @objectstack/spec 17.0.0-rc.6 bump, and + // the inversion is upstream convergence rather than a regression here. + // + // This assertion used to read `expect(create.ok).toBe(false)`, on the stated + // premise that `SharingRuleSchema` "declares NONE of the seven ADR-0010 + // envelope keys" — so a stamped `_packageId` reached the strict object as an + // unrecognized key. rc.6 made the schema declare all seven (`_lock`, + // `_lockDocsUrl`, `_lockReason`, `_lockSource`, `_packageId`, + // `_packageVersion`, `_provenance`), the way `ActionSchema` has since rc.2. + // A stamped body is therefore legal input now, and the create door accepts + // it — which is the behaviour the envelope was always supposed to have. + // + // Pinned as accept, with the strictness it does NOT cost asserted right + // below: the boundary this file guards is "does the client stay no stricter + // than the server", and that still holds. const create = await validateMetadataDraft('sharing_rule', stored, undefined, { mode: 'create', }); - expect(create.ok).toBe(false); + expect(create.ok).toBe(true); + + // …and the door is still a door. A genuinely unknown key is still refused, + // so the assertion above pins "the envelope became legal", not "the schema + // stopped being strict" — without this, reverting the schema to a permissive + // object would leave the pin above green. + const bogus = await validateMetadataDraft( + 'sharing_rule', + { ...SHARING_RULE, notAKeyAnySchemaDeclares: 1 }, + undefined, + { mode: 'create' }, + ); + expect(bogus.ok).toBe(false); + // Edit door: no client gate, so the server stays authoritative and the - // client cannot report a stored body as broken. + // client cannot report a stored body as broken. Unchanged by rc.6. const edit = await validateMetadataDraft('sharing_rule', stored, undefined, { mode: 'edit' }); expect(edit.ok).toBe(true); expect(edit.issues).toEqual([]); diff --git a/packages/app-shell/src/views/metadata-admin/clientValidation.viewDiagnostics.test.ts b/packages/app-shell/src/views/metadata-admin/clientValidation.viewDiagnostics.test.ts index 2649eef86e..24b43ec250 100644 --- a/packages/app-shell/src/views/metadata-admin/clientValidation.viewDiagnostics.test.ts +++ b/packages/app-shell/src/views/metadata-admin/clientValidation.viewDiagnostics.test.ts @@ -198,11 +198,44 @@ describe('view edit path — union diagnostics are expanded to the selected memb }); it('leaves non-union failures exactly as they were', async () => { - // `page` has a plain object schema — no union, so the mapping is untouched. - const res = await validateMetadataDraft('page', { name: 'p', type: 42 }); + // `page` has a plain object schema — no ROOT union, so the mapping is + // untouched. The draft carries a valid `label`: see the pin below for why + // that is now load-bearing rather than incidental. + const res = await validateMetadataDraft('page', { name: 'page_x', label: 'Page X', type: 42 }); expect(res.ok).toBe(false); expect(res.issues.length).toBeGreaterThan(0); expect(res.issues.every((i) => i.message !== 'Invalid input')).toBe(true); + // The specific message is what "exactly as they were" means here. + expect(res.issues.some((i) => /Invalid option/.test(i.message))).toBe(true); + }); + + /** + * A LEAF union still reports `Invalid input`, and rc.6 created leaf unions + * where there were none — recorded here rather than fixed (objectui#4163). + * + * objectui#3606's expansion is about a ROOT union: it picks the member the + * draft was aiming at so the author reads that member's diagnostics instead of + * zod's aggregate `Invalid input`. It does not, and was never asked to, reach + * a union nested at a FIELD. + * + * That distinction cost nothing until now because the fields in question were + * scalars. @objectstack/spec 17.0.0-rc.6 widened `I18nLabel` from `string` to + * `string | Record< string, string >`, so every `label` in the vocabulary + * became a leaf union — and an author who simply OMITS a required label now + * reads `Invalid input` where they used to read a typed message naming the + * field. It is a small regression, it is upstream in origin, and repairing it + * means teaching the mapper about leaf unions, which is a diagnostics design + * change rather than a bump repair. + * + * Pinned so the gap is measured rather than remembered: when the mapper learns + * leaf unions, this test goes red and should be deleted along with the note. + */ + it('a leaf union (rc.6 `I18nLabel`) still yields the bare `Invalid input` — objectui#4163', async () => { + const res = await validateMetadataDraft('page', { name: 'page_x', type: 'record' }); + expect(res.ok).toBe(false); + const labelIssue = res.issues.find((i) => /(^|\.)label$/.test(String(i.path ?? ''))); + expect(labelIssue, 'expected a diagnostic on `label`').toBeTruthy(); + expect(labelIssue!.message).toBe('Invalid input'); }); }); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts index 1a9dea7ded..2c34aa927b 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts @@ -85,6 +85,33 @@ function unwrapped(schema: unknown): unknown { return cur; } +/** + * The object shape behind a schema that may be wrapped in a pipe/effect. + * + * `unwrap()` above only walks `.optional()`/`.default()`; it cannot get past a + * `ZodPipe` or a refinement wrapper, which expose neither `.shape` nor + * `.unwrap`. `FlowNodeSchema` became one of those in @objectstack/spec + * 17.0.0-rc.6, so the direct `FlowNodeSchema.shape` this file used to read went + * `undefined` and every assertion below died on `Cannot read properties of + * undefined`. The BLOCKS themselves are untouched — `connectorConfig`, + * `waitEventConfig` and `boundaryConfig` are all still declared, verified by + * walking the wrapper — so this is an access-path repair, not a changed + * expectation. + */ +function objectShape(schema: unknown, depth = 0): Record | null { + const s = schema as Record | undefined; + if (!s || depth > 8) return null; + if (s.shape) return s.shape as Record; + const def = (s._def ?? s.def) as Record | undefined; + if (!def) return null; + if (def.shape) return def.shape as Record; + for (const key of ['in', 'out', 'innerType', 'schema', 'left', 'right']) { + const found = def[key] ? objectShape(def[key], depth + 1) : null; + if (found) return found; + } + return null; +} + /** A field render-gated behind the never-matching `__legacy__` controller. */ function isLegacyGated(f: FlowConfigField): boolean { return f.showWhen?.field === '__legacy__'; @@ -217,7 +244,16 @@ describe('sibling-block forms ↔ FlowNodeSchema blocks (framework#4278 ratchet) // no feature detection: the hand-written groups for wait / connector_action // / boundary_event must edit exactly the keys the spec block declares — // #4161 / #4210 verified them by hand once; this keeps them verified. - const FlowNodeSchema = spec.FlowNodeSchema as { shape: Record }; + const flowNodeShape = objectShape(spec.FlowNodeSchema); + + it('the spec still exposes the FlowNode block shape this suite reads', () => { + // Guards the assertions below from passing vacuously if the walk stops + // resolving — the failure mode rc.6 produced, seen one level up. + expect(flowNodeShape, 'could not resolve FlowNodeSchema’s object shape').toBeTruthy(); + expect(Object.keys(flowNodeShape ?? {})).toEqual( + expect.arrayContaining(['waitEventConfig', 'connectorConfig', 'boundaryConfig']), + ); + }); const BLOCKS: ReadonlyArray<{ type: string; block: string }> = [ { type: 'wait', block: 'waitEventConfig' }, @@ -231,7 +267,7 @@ describe('sibling-block forms ↔ FlowNodeSchema blocks (framework#4278 ratchet) .filter((f) => f.path[0] === block) .map((f) => f.path[1]!), )].sort(); - const blockKeys = zodKeys(unwrapped(FlowNodeSchema.shape[block])); + const blockKeys = zodKeys(unwrapped(flowNodeShape?.[block])); expect( blockKeys.filter((k) => !formKeys.includes(k)), diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.spec-parse.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.spec-parse.test.tsx index 4dca3a8744..7fed76bfa7 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.spec-parse.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.spec-parse.test.tsx @@ -74,6 +74,35 @@ function unwrapped(schema: ZodLike | undefined): ZodLike | undefined { return cur; } +/** + * The object shape behind a schema that may be wrapped in a pipe/effect. + * + * `unwrapped()` walks `.optional()`/`.default()` only, and cannot get past a + * `ZodPipe` or refinement wrapper — which exposes neither `.shape` nor + * `.unwrap`. `FlowNodeSchema` became one in @objectstack/spec 17.0.0-rc.6, so + * the `FlowNodeSchema.shape?.[block]` reads below silently became `undefined` + * and `liveKeys` failed on its own "expected a Zod object schema" guard. The + * blocks are unchanged upstream, so this restores the access path rather than + * the expectation. Same repair, same reason, as + * `inspectors/flow-node-config.spec-reconciliation.test.ts`. + */ +function objectShape(schema: unknown, depth = 0): Record | null { + const s = schema as Record | undefined; + if (!s || depth > 8) return null; + if (s.shape) return s.shape as Record; + const def = (s._def ?? s.def) as Record | undefined; + if (!def) return null; + if (def.shape) return def.shape as Record; + for (const key of ['in', 'out', 'innerType', 'schema', 'left', 'right']) { + const found = def[key] ? objectShape(def[key], depth + 1) : null; + if (found) return found; + } + return null; +} + +/** `FlowNodeSchema`'s block shape, resolved through any wrapper (see above). */ +const flowNodeShape = objectShape(FlowNodeSchema); + /** * Keys a Zod object schema declares as LIVE authoring surface. `retiredKey()` * tombstones stay in the shape so their rejection can carry the upgrade @@ -144,8 +173,8 @@ describe('designer node seeds ↔ spec FlowNodeSchema (#3316)', () => { for (const block of BLOCKS) { const seeded = extras[block]; if (!seeded || typeof seeded !== 'object') continue; - const retired = retiredKeys(FlowNodeSchema.shape?.[block] as ZodLike | undefined); - const live = liveKeys(FlowNodeSchema.shape?.[block] as ZodLike | undefined); + const retired = retiredKeys(flowNodeShape?.[block]); + const live = liveKeys(flowNodeShape?.[block]); for (const key of Object.keys(seeded as Record)) { if (retired.includes(key)) offenders.push(`${type}.${block}.${key} (retired in spec)`); else if (!live.includes(key)) offenders.push(`${type}.${block}.${key} (not declared by spec)`); diff --git a/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts b/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts index decbc08d2e..115affd955 100644 --- a/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts +++ b/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts @@ -54,8 +54,20 @@ const inputNames = () => inputs().map((i) => i.name); const input = (name: string) => inputs().find((i) => i.name === name); const filterDescription = () => input('filter')?.description ?? ''; -/** A minimal spec-valid props object, so a `filter` probe fails only on `filter`. */ -const withFilter = (filter: unknown) => ({ object: 'account', displayField: 'name', filter }); +/** + * A minimal spec-valid props object, so a `filter` probe fails only on `filter`. + * + * `displayField: 'name'` used to be the second key here, and it is deliberately + * NOT re-spelled to a survivor: @objectstack/spec 17.0.0 retired + * `element:record_picker`'s `displayField` as an ADR-0087 D2 tombstone (#5775), + * and rc.6 is where this repo first resolves that. A tombstone is refused BY + * NAME, so the old fixture stopped being "minimal and valid" and started being + * "valid except for one retired key" — which made all three `filter` assertions + * below fail on `displayField` instead. `object` alone is a complete valid + * shape, and keeping the fixture at the true minimum is what stops it drifting + * into the same trap again. + */ +const withFilter = (filter: unknown) => ({ object: 'account', filter }); describe('element:record_picker — registry inputs vs @objectstack/spec', () => { it('is registered with a non-empty `inputs` surface', () => { @@ -84,7 +96,6 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => // consumer before it was declared here. const undeclared = ElementRecordPickerPropsSchema.safeParse({ object: 'account', - displayField: 'name', notASpecKey: 1, } as never); expect(undeclared.success).toBe(true); @@ -158,7 +169,7 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => expect(input('filter')).toBeDefined(); expect(input('filter')?.defaultValue).toBeUndefined(); expect( - ElementRecordPickerPropsSchema.safeParse({ object: 'account', displayField: 'name' }).data, + ElementRecordPickerPropsSchema.safeParse({ object: 'account' }).data, ).not.toHaveProperty('filter'); }); diff --git a/packages/core/src/actions/actionKeys.ts b/packages/core/src/actions/actionKeys.ts index e747e95fcc..2358e7af4d 100644 --- a/packages/core/src/actions/actionKeys.ts +++ b/packages/core/src/actions/actionKeys.ts @@ -172,6 +172,15 @@ export const SPEC_ACTION_KEYS = [ 'bulkEnabled', 'component', 'confirmText', + // Added to `ActionSchema` in @objectstack/spec 17.0.0-rc.6. Listed here purely + // because this array's contract is "every property the spec's `ActionSchema` + // declares" — restating the spec, not adopting a feature. `ActionDef` does NOT + // declare `description` and is not changed: whether the runner or any renderer + // should READ it is a separate question, and `actionKeys.pin.test.ts`'s + // `ActionDef` half stays green without it. What this entry buys is that an + // action carrying a `description` is no longer reported as having an unknown + // key, which is the only thing the inventory is consulted for. + 'description', 'disabled', 'errorMessage', 'execute', diff --git a/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx b/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx index d62f9d905c..6070cfd60f 100644 --- a/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx +++ b/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx @@ -770,7 +770,18 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { CREATE_WORKSPACE, ]) { expect(sourceOf(rel), `${rel} no longer binds the pack hook`).toContain('useObjectTranslation'); - expect(sourceOf(rel), `${rel} stopped destructuring t`).toContain('const { t } = useObjectTranslation();'); + // `t` must come out of the hook's destructuring — but it need not be the + // ONLY thing destructured. The literal `const { t } = …` this used to + // match broke the moment a file legitimately also took `language`, which + // both sidebars now do to resolve the spec's inline per-locale + // `I18nLabel` (widened in @objectstack/spec 17.0.0-rc.6). What this + // suite actually depends on is that `t` is i18next's, bound from this + // hook, so the pattern asserts exactly that and stays blind to which + // siblings ride along. + expect( + /const \{[^}]*\bt\b[^}]*\} = useObjectTranslation\(/.test(sourceOf(rel)), + `${rel} stopped destructuring t from useObjectTranslation()`, + ).toBe(true); } // RecordDetailView takes it from @object-ui/react's re-export, with language. expect(sourceOf(RECORD_DETAIL)).toContain('const { t, language } = useObjectTranslation();'); diff --git a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts index fbca9a317c..f25a2f2584 100644 --- a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts +++ b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts @@ -29,6 +29,9 @@ */ import { describe, it, expect } from 'vitest'; +// Needed to isolate the rc.6 `GlobalFilterSchema` refinement from the field type +// it hides behind — see the objectui#4165 pin below. +import { z } from 'zod'; import { createRequire } from 'node:module'; import { readFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; @@ -149,6 +152,78 @@ describe('GlobalFilterSchema pinned divergences', () => { }).success).toBe(false); }); + /** + * OPEN CONFLICT, pinned rather than resolved — objectui#4165. + * + * The divergence above used to be about a FIELD TYPE, and only that: the spec + * typed `defaultValue` as `string | number | boolean`, objectui widened the key + * to `z.any()`, and widening the key was ENOUGH — nothing else in the spec had + * an opinion about the object form. + * + * @objectstack/spec 17.0.0-rc.6 added a whole-object refinement that refuses + * `{ preset }` BY NAME, with a message enumerating the three spellings a + * `type: 'date'` filter may use (a preset name, an ISO date, a date-macro + * token). A refinement is not a field type, so widening the key no longer + * escapes it: any derivation that CARRIES the refinement refuses the object + * form regardless of how `defaultValue` is typed. + * + * Read the two assertions below in that light, because the obvious shorter + * version is a trap. `SpecGlobalFilterSchema.safeParse({ …, defaultValue: { + * preset } })` does fail — but it fails with `invalid_union` on the FIELD, the + * same way it failed at rc.5, and the refinement never runs. Asserting that + * would pin the OLD behaviour while reading like a pin on the new one. The + * refinement is only observable once the field admits the object, so it is + * isolated here by widening the key first — which is exactly the shape + * objectui's own dialect has. + * + * The conflict this leaves is a producer/consumer one with three possible + * answers (follow the spec and migrate stored dashboards; keep the divergence; + * the spec's refinement is an upstream defect), and it is filed rather than + * guessed — the deciding half belongs to the spec owner. Until it is ruled, + * `GlobalFilterSchema` is composed by spreading the spec's `.shape`, carrying + * the FIELDS by reference and leaving the refinement behind: the exact + * behaviour this schema had before rc.6, so the bump changes nothing here. The + * spread is not a preference — rc.6 closed both other doors (`.extend()` + * throws at module load, `.safeExtend()` types the three overrides as `never`), + * so some spelling had to change, and this is the one that decides nothing. + * + * This test is the tripwire on that standstill and goes red from EITHER side: + * if objectui stops accepting the object form (resolved consumer-side), or if + * the refinement is withdrawn or reworded upstream (resolved producer-side). + */ + it('does NOT carry the spec rc.6 refinement that refuses `{ preset }` (objectui#4165)', () => { + const stored = { field: 'created_at', type: 'date', defaultValue: { preset: 'last_7_days' } }; + + // objectui side: still accepts what its own normalizer writes. + expect(GlobalFilterSchema.safeParse(stored).success).toBe(true); + + // Spec side, refinement ISOLATED from the field type: widen `defaultValue` + // exactly as objectui's dialect does, but keep the spec's checks (that is + // what `.safeExtend` preserves). What refuses the value now can only be the + // rc.6 refinement, and asserting its MESSAGE is what separates "the spec's + // `defaultValue` is narrow" (true since forever) from "the spec has a rule + // about this exact shape" (new in rc.6, and the reason #4165 exists). + const specWithRefinement = SpecGlobalFilterSchema.safeExtend({ + defaultValue: z.any().optional(), + }); + const refused = specWithRefinement.safeParse(stored); + expect(refused.success).toBe(false); + expect( + refused.error?.issues?.some((i) => /is not a value a .*date.* filter can resolve/.test(i.message)), + 'the spec no longer refuses `{ preset }` with its rc.6 refinement message — ' + + 'either the refinement was withdrawn or its wording changed. Re-read objectui#4165 ' + + 'before touching this: the standstill it documents may be over.', + ).toBe(true); + + // …and the bare preset NAME, which the refinement's message points authors + // at, is already legal on both sides. That is why #4165's leading candidate + // is a rewrite rather than a capability loss. + const bare = { field: 'created_at', type: 'date', defaultValue: 'last_7_days' }; + expect(specWithRefinement.safeParse(bare).success).toBe(true); + expect(SpecGlobalFilterSchema.safeParse(bare).success).toBe(true); + expect(GlobalFilterSchema.safeParse(bare).success).toBe(true); + }); + it('keeps `optionsFrom.labelField` optional', () => { expect(GlobalFilterSchema.safeParse({ field: 'owner', optionsFrom: { object: 'users', valueField: 'id' }, diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 6b9922511a..d3cdebd911 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -328,18 +328,45 @@ export const DashboardWidgetSchema = specFieldsExcept(SpecDashboardWidgetSchema. * * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. * - * `.safeExtend`, not `.extend`, since @objectstack/spec 17.0.0-rc.6: the spec's - * `GlobalFilterSchema` now carries a refinement, and zod 4 refuses `.extend()` - * on a refined object outright — *"Cannot overwrite keys on object schemas - * containing refinements. Use `.safeExtend()` instead."* — which threw at - * MODULE LOAD and took six `@object-ui/types` suites down with it before this - * file ran a single test. `.safeExtend` is zod's own prescribed replacement and - * KEEPS the refinement (measured: one check before, one check after), so the - * spec's cross-field rule still runs on objectui's dialect. That is the - * behaviour we want — the three divergences below widen individual FIELDS, and - * were never meant to switch off a whole-object rule. + * ## Spread, not `.extend` — and NOT `.safeExtend` either (objectui#4165) + * + * @objectstack/spec 17.0.0-rc.6 put a refinement on `GlobalFilterSchema`, and + * that closed BOTH extension doors on a schema whose whole purpose here is to + * override three keys: + * + * - `.extend()` — what this used to be — now throws outright: *"Cannot + * overwrite keys on object schemas containing refinements. Use + * `.safeExtend()` instead."* At MODULE LOAD, taking six `@object-ui/types` + * suites down before any of them ran a test. + * - `.safeExtend()` — zod's own suggested replacement — runs, but is "safe" + * precisely in the sense that it will not let you REPLACE an existing key's + * type. It types every incompatible override as `never`, so `options`, + * `optionsFrom` and `defaultValue` — exactly the three divergences above — + * stop compiling (TS2322, `is not assignable to type 'never'`). + * + * So the spread below is not a style choice: it is the only spelling that keeps + * this schema the shape it has been since objectstack#4115. It composes the + * spec's fields BY REFERENCE (`.shape`, so a spec field change still lands + * here) and replaces the three, which is byte-for-byte what `.extend()` + * produced before rc.6. + * + * What it does NOT do is carry the spec's new refinement across, and that is + * deliberate and TEMPORARY. The refinement rejects `defaultValue: + * { preset: 'last_7_days' }` on a `type: 'date'` filter — the exact object form + * `@object-ui/core`'s `normalizeDateDefault` PRODUCES and stored dashboards + * carry (framework#4475), and the stated reason for the third divergence. That + * is a producer/consumer conflict, not a mechanical repair: adopting the rule + * means changing what objectui writes and migrating stored dashboards, and + * declining it permanently means objectui accepts metadata the platform + * refuses. Neither is a call to make in passing, so this preserves the status + * quo EXACTLY — nothing about this schema's behaviour changes on the rc.6 bump — + * and the ruling is tracked in objectui#4165. `__tests__/report-chart-query- + * spec-parity.test.ts` pins the gap explicitly so it cannot fade into folklore. + * + * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. */ -export const GlobalFilterSchema = SpecGlobalFilterSchema.safeExtend({ +export const GlobalFilterSchema = z.object({ + ...SpecGlobalFilterSchema.shape, options: z.array(z.union([ z.string(), z.object({ From 7b813b2b2e92b880dec96eeaad3adb779ebb718d Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 10 Aug 2026 18:55:18 +0000 Subject: [PATCH 3/3] fix(deps): triage the 14 rc.6 spec-symbol collisions and refresh the console parity exemptions (#4167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#4167, objectstack#4115. `check:spec-symbols` reported 13 collisions on the @objectstack/spec 17.0.0-rc.6 bump and a 14th (`GlobalFilterSchema`) appeared during the bump itself; each is triaged on its own rather than blanket-renamed. Renamed (3) — the spec's same-named export means something else: MetadataState -> MetadataCacheState (react, app-shell) resolveI18nLabel -> resolveKeyedI18nLabel (react, app-shell, + apps/console) DateRangePreset -> FilterBuilderDateRangePreset (types) Bound or derived (10): DATE_RANGE_PRESETS (+ a `satisfies` completeness pin on the local bounds table), NavigationMode (+ a both-directions type pin), AddressValue, BreakpointColumnMap, BreakpointOrderMap, KanbanConfig, CalendarConfig, GanttConfig. Four copies were losing information: GanttConfig declared 6 of the spec's 17 keys while plugin-gantt reads all of them, AddressValue 5 of 7 parts, and DATE_RANGE_PRESETS was the third copy objectstack#4614 extracted the vocabulary to collapse. ALLOW (1): GlobalFilterSchema, the same `.shape`-spread dialect as SelectOptionSchema beside it — it collided only because rc.6's refinement forced `.extend()` to be respelled, moving a derivation the guard could see into an object literal it deliberately does not descend into. Nothing about #4165 is encoded. Console registry-inputs-spec-parity: all 12 off-spec exemptions deleted (rc.6 landed objectstack#6776 and objectstack#5775, verified per key against the resolved shape), and `element:record_picker` publishes `sort` / `limit` / `emptyText` — read by the renderer all along, and the red demanding them was predicted in writing by the exemption it replaces. 49 tests/2 failing -> 51/0. Also repairs 3 pre-existing type-check errors of this card's own rc.6 family, found only by running `type-check` (which the bump PR did not): the retired `ThemeInput` alias re-pointed BY SIDE, and `DroppedFieldsEvent.reason`'s new `primary_key` arm. The consumer gap that arm exposes is objectui#3935. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- ...ecord-picker-sort-limit-empty-text-4167.md | 46 ++++ .changeset/spec-symbol-collisions-rc6-4167.md | 92 ++++++++ .../registry-inputs-spec-parity.test.ts | 201 ++++++++---------- .../src/pages/system/AppManagementPage.tsx | 6 +- apps/console/src/utils.ts | 14 +- .../app-shell/src/chrome/CommandPalette.tsx | 24 +-- .../app-shell/src/console/ai/AiChatPage.tsx | 10 +- .../app-shell/src/console/home/AppCard.tsx | 6 +- .../src/console/home/HomeAppsStrip.tsx | 4 +- packages/app-shell/src/index.ts | 2 +- packages/app-shell/src/layout/AppHeader.tsx | 4 +- packages/app-shell/src/layout/AppSidebar.tsx | 26 +-- packages/app-shell/src/layout/AppSwitcher.tsx | 6 +- .../app-shell/src/layout/ConsoleLayout.tsx | 6 +- .../app-shell/src/layout/ContextSelectors.tsx | 4 +- .../app-shell/src/layout/UnifiedSidebar.tsx | 9 +- .../AppSidebar.derivedAreaVisibility.test.tsx | 2 +- ...fiedSidebar.derivedAreaVisibility.test.tsx | 2 +- .../appSidebarSettingsTargets.test.tsx | 2 +- .../systemNavDatasourcesHop.test.tsx | 2 +- .../__tests__/systemNavObjectsHop.test.tsx | 2 +- .../systemNavSettingsTarget.test.tsx | 2 +- .../src/providers/MetadataProvider.tsx | 6 +- packages/app-shell/src/utils/index.ts | 21 +- .../app-shell/src/views/DashboardView.tsx | 8 +- .../app-shell/src/views/SearchResultsPage.tsx | 4 +- .../src/renderers/basic/record-picker.tsx | 44 ++++ .../src/renderers/data-display/breadcrumb.tsx | 6 +- .../src/renderers/navigation/header-bar.tsx | 4 +- .../utils/__tests__/dashboard-filters.test.ts | 48 +++++ packages/core/src/utils/dashboard-filters.ts | 55 ++++- .../src/spec-symbol-batch6.test.ts | 22 +- packages/fields/src/widgets/AddressField.tsx | 29 ++- packages/layout/package.json | 2 +- packages/layout/src/ResponsiveGrid.tsx | 42 ++-- .../plugin-designer/src/AppCreationWizard.tsx | 4 +- .../src/NavigationDesigner.tsx | 14 +- packages/react/src/SchemaRenderer.tsx | 4 +- .../react/src/context/AppShellContext.tsx | 23 +- ...ffline-nav-performance-spec-parity.test.ts | 39 +++- .../react/src/hooks/useNavigationOverlay.ts | 20 +- packages/react/src/index.ts | 2 +- packages/react/src/utils/i18n.ts | 40 +++- .../page-nav-misc-spec-parity.test.ts | 22 +- packages/types/src/data-protocol.ts | 33 ++- packages/types/src/index.ts | 4 +- packages/types/src/objectql.ts | 82 +++---- scripts/check-spec-symbol-derivation.mjs | 22 ++ 48 files changed, 771 insertions(+), 301 deletions(-) create mode 100644 .changeset/record-picker-sort-limit-empty-text-4167.md create mode 100644 .changeset/spec-symbol-collisions-rc6-4167.md diff --git a/.changeset/record-picker-sort-limit-empty-text-4167.md b/.changeset/record-picker-sort-limit-empty-text-4167.md new file mode 100644 index 0000000000..1916bbff0d --- /dev/null +++ b/.changeset/record-picker-sort-limit-empty-text-4167.md @@ -0,0 +1,46 @@ +--- +"@object-ui/components": minor +--- + +`element:record_picker` publishes `sort`, `limit` and `emptyText` as authoring +inputs (objectui#4167). + +All three were already READ by the renderer and declared by the contract — the +renderer has passed `sort` into `$orderby` and `limit` into `$top` since the +block existed, and `emptyText` decides the no-rows message — but none of them +appeared in `inputs`, so every layer that reads a manifest said they did not +exist. `packages/components/src/renderers/layout/page.tsx` builds the JSX-page +compiler's prop whitelist from `getKnownTypes()` plus these `inputs`, so writing +any of the three on a JSX page drew an `unknown-prop` warning from +`sdui-parser/src/validate.ts` on a key the renderer then went on to honour. + +That is objectui#3407's shape — honoured, undiscoverable — and this is the same +repair objectui#3808 made for `record:details.hideFields` and objectui#3830 made +for `element:record_picker.filter`. `@objectstack/spec` 17.0.0-rc.6 is what made +it actionable: objectstack#5775 declared the three upstream, and the reverse +direction of the console's registry parity gate went red demanding them the +moment the pin moved — a red the previous exemption had predicted in writing and +called "correct and wanted". + +Each description documents the renderer's real behaviour rather than restating +the schema, because that is the half an author cannot read off the contract: + +- **`sort`** and **`limit`** are both overridden OUTRIGHT by a node-level + `dataSource` binding (`dataSource.sort ?? sort`), not merged with it — so a + node that carries a `dataSource` silently ignores them. +- **`limit`** defaults to 50 in the renderer, not in the schema, and a record + outside the limit cannot be picked at all with nothing in the control to say + more exist. +- **`emptyText`** is published as `string` against a contract of + `string | Record< string, string >`: rc.6 widened it to `I18nLabel`, and this + renderer passes the value straight into a text node with no locale resolution, + so only the plain-string form renders today. The description says so rather + than advertising a shape the renderer drops — the narrowed-type treatment + objectui#3832 describes, with the render-site gap tracked in objectui#4163. + +The console's `registry-inputs-spec-parity` suite also drops all twelve of its +off-spec exemptions, which rc.6 obsoleted at once (objectstack#6776 declared +`page:header.recordChrome` / `showStar` / `showCopyId`, `page:accordion.variant` +and `page:tabs.tabStyle`; objectstack#5775 declared the `element:record_picker` +trio and `children` on the four page containers). The forward direction of that +gate now runs with no cover of any kind. diff --git a/.changeset/spec-symbol-collisions-rc6-4167.md b/.changeset/spec-symbol-collisions-rc6-4167.md new file mode 100644 index 0000000000..1a6755052f --- /dev/null +++ b/.changeset/spec-symbol-collisions-rc6-4167.md @@ -0,0 +1,92 @@ +--- +"@object-ui/types": minor +"@object-ui/core": minor +"@object-ui/react": minor +"@object-ui/app-shell": minor +"@object-ui/layout": minor +"@object-ui/fields": minor +"@object-ui/components": minor +"@object-ui/plugin-designer": patch +--- + +Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6` +(objectui#4167, objectstack#4115). + +The rc.6 bump published nine names this repo already declared locally, on top of +four that predate it — `check:spec-symbols` reported all thirteen at once, and a +fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was +triaged on its own rather than blanket-renamed, because the right answer differs +per symbol: five bind to the spec, three are renamed because the spec's +same-named export means something else, five arrive by derivation, and one is a +declared dialect with a written reason. + +**Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and +`@object-ui/types`** — three exported names changed, because the spec exports the +same name for a *different* thing: + +| package | was | now | what the spec's same-named export actually is | +|:--|:--|:--|:--| +| `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) | +| `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale | +| `types` | `DateRangePreset` | `FilterBuilderDateRangePreset` | the thirteen HISTORICAL dashboard filter-bar presets; this one is the filter-builder set, which adds eight FUTURE windows the dashboard schema rejects | + +`resolveI18nLabel` is the one where the collision had already started costing +something. rc.6 widened `I18nLabel` from `string` to +`string | Record< string, string >`, so the same authored value now reaches +either resolver — and each answers wrongly, silently, for the other's input: the +keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no +`defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale +tags. The rc.6 bump PR met this and aliased the spec's import as +`resolveInlineI18nLabel` in five files, with hand-written comments at two of +them. That is a review convention, which is what objectstack#4115 exists to +replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the +name says which vocabulary it resolves at every call site. + +**Eleven keep their names and are now imported or derived from the spec** instead +of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`, +`BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`, +`GanttConfig`, plus the three renamed above at their new names. + +**Four of the copies were losing information, not just duplicating it.** + +- **`GanttConfig` declared six keys and called itself canonical; rc.6's + `GanttConfigSchema` declares seventeen.** The eleven it never mentioned — + `parentField`, `typeField`, `baselineStartField`, `baselineEndField`, + `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`, + `quickFilters`, `autoZoomToFilter` — are all read by + `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx` + intersection that existed only because this type did not carry them. It now + derives from the spec, with `timeSegments` (shift segmentation) as the one + genuinely local extension; the schema is `$loose` upstream, so that key is + legal metadata rather than a second dialect. +- **`GanttConfig.tooltipFields` carried the comment "not part of the upstream + GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec. +- **`AddressValue` declared five of the spec's seven parts** — `countryCode` and + `formatted` were missing, under a comment already claiming to be "the part + names of `AddressSchema`". The widget still renders five inputs; binding the + type stops it from asserting the platform cannot store the other two, and makes + the `{ ...address }` write-through say so. +- **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a + vocabulary the spec extracted in objectstack#4614 precisely to collapse — its + own doc comment names this module as one of the three. It is now the spec's + array by reference, and the local date-macro bounds table is pinned complete + against it with `satisfies`, so a preset the schema gains without bounds here + is a compile error rather than a filter that validates clean and then selects + nothing. + +`NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`); +it is bound directly, with a both-directions type pin that it stays the same type +as the config's own `mode`. `KanbanConfig` / `CalendarConfig` / +`BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict` +schemas and are now re-exports — "still exact" is the argument for binding them, +since a copy with nothing to protect can only drift. + +`GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition +dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's +new refinement forced `.extend()` to be respelled as a `.shape` spread — which +moved a derivation the guard could see into an object literal it deliberately +does not descend into. The dialect is unchanged and its three divergences are +pinned; which side moves on the refinement itself is objectui#4165. + +`@objectstack/spec` moves from `devDependencies` to `dependencies` in +`@object-ui/layout`: its public type surface now references the spec. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index d1b8c3d571..a1b355c6db 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -296,115 +296,38 @@ const EXPECTED_WITHOUT_INPUTS = [ * is an upstream issue plus an entry here, never a local widening. Every reason * therefore has to cite an issue, which `references a tracking issue` asserts. * - * All eight entries below were verified against renderer read sites, not - * assumed — see objectui#3797 and objectstack#6776 for the per-key evidence. + * ## EMPTY as of @objectstack/spec 17.0.0-rc.6 (objectui#4167) + * + * All twelve entries were deleted on the rc.6 bump, by the `carries no stale + * exemption` test below, which named every one of them at once. Emptying it is + * the discipline working end to end rather than an absence of divergence: each + * entry cited the upstream issue that owned it, and rc.6 is where both of those + * issues landed. Verified per key against the resolved + * `ComponentPropsMap[type].shape` at this pin, not from the issues' wording: + * + * - **objectstack#6776** — `page:header` now declares `recordChrome`, + * `showStar` and `showCopyId`; `page:accordion` declares `variant`; + * `page:tabs` declares `tabStyle`. The five keys the renderer had read all + * along are contract now. `page:tabs` is the interesting one: the spec + * declares BOTH `tabStyle` and `type`, so the carrier collision written up in + * the deleted entry was resolved upstream by declaring the alias rather than + * by renaming — which is why `page:tabs.type` stays in + * `UNPUBLISHED_EXEMPTIONS` below (spec-declared, unpublishable in a flat + * carrier) while `tabStyle` needs no cover at all. + * - **objectstack#5775 / PR objectstack#6281** — `element:record_picker` + * declares `labelField`, `valueField` and `label`; `page:card` declares + * `children` (replacing the retired `body`); and `page:section` / + * `page:footer` / `page:sidebar` carry the shared `PageContainerProps`, whose + * one key is `children`. Those seven were the objectui#4027 stale-pin set, + * predicted in their own reasons ("Delete this entry when the pin moves") and + * deleted exactly there. + * + * The map stays declared rather than removed: a future divergence needs + * somewhere to be registered, and `exemptedFor` below reads it. Empty is a + * state, not a deletion — every forward-direction assertion now runs with no + * cover of any kind, which is the strongest reading this gate has ever had. */ -const OFF_SPEC_EXEMPTIONS: Record = { - // ── page:header — three author-facing booleans the spec never declared ──── - // Read at `containers.tsx:979/980/981` and consumed at `:1453` (which of the - // two header layouts renders) and `:1531/:1532` (the RecordTitleChip star and - // copy-id affordances). Author-reachable, NOT host-injected: this block's - // host injections come through RecordContext (`headerSystemActions`, - // `isFavorite`, `onToggleFavorite`) and are deliberately undeclared. - // `recordChrome` already has in-repo authors — `preview-samples.ts:68` writes - // it under `properties`, the exact shape the upstream linter warns on, and - // `plugin-detail/src/synth/buildDefaultPageSchema.ts:413` emits it on every - // synthesized record page. Direction is therefore "the spec should declare - // it", filed as objectstack#6776; withdrawing them here would delete live, - // author-reachable configuration. - // - // (objectui#3797 suggested these might be renderer-only props deliberately - // kept by objectui#3226 / PR #3265. Checked: #3265 (`d2363e710`) narrowed the - // LEGACY `page-header` alias in `packages/layout`, not this canonical block — - // there is no such deliberate-retention record.) - 'page:header.recordChrome': - 'Renderer reads it (containers.tsx:979) to pick the bare-h1 layout; authored in-repo by preview-samples.ts:68 and emitted by buildDefaultPageSchema.ts:413. Awaiting the spec declaration in objectstack#6776.', - 'page:header.showStar': - 'Renderer reads it (containers.tsx:980) and passes it to RecordTitleChip (:1531) to hide the follow star. Awaiting the spec declaration in objectstack#6776.', - 'page:header.showCopyId': - 'Renderer reads it (containers.tsx:981) and passes it to RecordTitleChip (:1532) to hide the copy-id button. Awaiting the spec declaration in objectstack#6776.', - - // ── page:accordion.variant ─────────────────────────────────────────────── - // Read at `containers.tsx:734` and consumed at `:735` to pick each panel's - // border class, so `variant: 'card'` renders visibly differently. The spec - // declares no equivalent at all. - 'page:accordion.variant': - "Renderer reads it (containers.tsx:734) and it changes per-panel borders (:735); the renderer's own comment documents `variant: 'card'` as an author opt-in. Awaiting the spec declaration in objectstack#6776.", - - // ── page:tabs.tabStyle — a carrier collision, not ordinary drift ────────── - // The spec DOES declare this concept, spelled `type` (`PageTabsProps.type`), - // and `containers.tsx:381` reads both (`properties.type || tabStyle`). Two - // spellings for one semantic is exactly what #0.1 forbids, so the local - // instinct is to withdraw `tabStyle` — but both local moves are wrong: - // - withdrawing it deletes the only spelling the FLAT carrier can express. - // `SchemaRenderer.tsx:251-270` deliberately refuses to hoist - // `properties.type` onto the node (it would shadow the dispatch key, and - // its comment names this very case), and a flat node is - // `{ type: 'page:tabs', tabStyle: 'card' }` where `type` is the tag; - // - publishing `type` instead declares a key this repo's own parser cannot - // validate: `sdui-parser/src/validate.ts:20-30` lists `'type'` in - // `BASE_PROPS`, so it is skipped as a base prop on every node. - // The only remaining lever is upstream, where the shape (rename `type` to - // `tabStyle` per objectstack#5775's precedent, vs declare an alias) is a spec - // contract decision this repo must not guess. Both options and their costs are - // written out in objectstack#6776. - 'page:tabs.tabStyle': - "Renderer reads it (containers.tsx:381) and it is the only spelling the flat SDUI carrier can express — the spec's `type` is unhoistable (SchemaRenderer.tsx:251-270) and unvalidatable as an input (validate.ts BASE_PROPS). Convergence is an upstream contract decision: objectstack#6776.", - - // ── element:record_picker — ALREADY settled upstream, stale pin only ────── - // objectstack#5775 (ADR-0087 D2) declared `labelField` / `valueField` / - // `label` (plus `sort` / `limit` / `emptyText`) and turned `displayField` / - // `searchFields` / `multiple` into `retiredKey()` tombstones — converging on - // the `labelField` this renderer actually reads - // (`renderers/basic/record-picker.tsx:80-81`), which is the direction - // objectui#3797 guessed at. Verified on objectstack `origin/main` - // (`packages/spec/src/ui/component.zod.ts:715-786`). It is not in a published - // release yet: the newest `@objectstack/spec` on npm is `17.0.0-rc.5`, which - // predates #5775 and is what this repo pins. So these three flags are a - // stale-pin artifact with NOTHING to do in either repo — and the - // `no stale exemption` test below deletes them for us, loudly, the moment the - // pin moves. - 'element:record_picker.labelField': - 'Already declared upstream by objectstack#5775 (converging on the spelling this renderer reads); flagged only because the pinned @objectstack/spec@17.0.0-rc.5 predates it. Delete this entry when the pin moves.', - 'element:record_picker.valueField': - 'Already declared upstream by objectstack#5775; flagged only because the pinned @objectstack/spec@17.0.0-rc.5 predates it. Delete this entry when the pin moves.', - 'element:record_picker.label': - 'Already declared upstream by objectstack#5775; flagged only because the pinned @objectstack/spec@17.0.0-rc.5 predates it. Delete this entry when the pin moves.', - - // ── page container `children` — ALREADY settled upstream, stale pin only ─── - // The other half of the same upstream issue as the record_picker trio above, - // and the same stale-pin shape (objectui#4027). objectstack#5775 - // (PR objectstack#6281, merged 2026-08-07) declared `PageCardProps.children` - // as the canonical composition slot — retiring `body`, its second spelling — - // and gave `page:section` / `page:footer` / `page:sidebar` the shared - // `PageContainerProps`, whose one key is `children`, replacing the - // `EmptyProps` that had declared "zero props" for three components whose only - // job is to render a child list. Verified in that PR's merged diff - // (`packages/spec/src/ui/component.zod.ts`, `PageContainerProps` + the - // `ComponentPropsMap` entries), not from the issue's wording. - // - // The pinned `@objectstack/spec@17.0.0-rc.5` predates all of it: its - // `PageCardProps` still lists `body` and no `children`, and the three thin - // containers are still `EmptyProps` — so this gate reads four correct, - // contract-following declarations as off-spec. Nothing to do in either repo; - // the `no stale exemption` test below deletes these four for us, loudly, the - // moment the pin moves. - // - // Nothing about `children` moves a validation verdict either way, which is - // why publishing it ahead of the pin is safe: `validate.ts` lists `children` - // in `BASE_PROPS` (never an `unknown-prop`), and `codegen.ts:emitInterface` - // filters `slot` inputs out of the generated `.d.ts`, where - // `SduiBaseProps.children` already types it. The designer panel is the only - // surface that changes. - 'page:card.children': - 'Already declared upstream by objectstack#5775 / PR objectstack#6281 as the canonical card content slot (replacing the retired `body`); flagged only because the pinned @objectstack/spec@17.0.0-rc.5 predates it. objectui#4027. Delete this entry when the pin moves.', - 'page:section.children': - 'Already declared upstream by objectstack#5775 / PR objectstack#6281 via the shared `PageContainerProps` (this renderer has always rendered `schema.children`); flagged only because the pinned @objectstack/spec@17.0.0-rc.5 still maps this block to `EmptyProps`. objectui#4027. Delete this entry when the pin moves.', - 'page:footer.children': - 'Already declared upstream by objectstack#5775 / PR objectstack#6281 via the shared `PageContainerProps` (this renderer has always rendered `schema.children`); flagged only because the pinned @objectstack/spec@17.0.0-rc.5 still maps this block to `EmptyProps`. objectui#4027. Delete this entry when the pin moves.', - 'page:sidebar.children': - 'Already declared upstream by objectstack#5775 / PR objectstack#6281 via the shared `PageContainerProps` (this renderer has always rendered `schema.children`); flagged only because the pinned @objectstack/spec@17.0.0-rc.5 still maps this block to `EmptyProps`. objectui#4027. Delete this entry when the pin moves.', -}; +const OFF_SPEC_EXEMPTIONS: Record = {}; /** * Spec-declared top-level keys deliberately NOT published, each with the reason. @@ -719,4 +642,66 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) expect(Object.keys(UNPUBLISHED_EXEMPTIONS)).not.toContain(`${type}.${key}`); } }); + + it('the three keys the rc.6 bump added to element:record_picker are discoverable', () => { + // objectui#4167, and pinned by name for exactly the reason the five above + // are: the derived reverse-direction assertion would go green just as + // readily if these three were added to `UNPUBLISHED_EXEMPTIONS` instead of + // declared, and "exempt it" is the cheaper move under time pressure. The + // exemption that covered the retired `displayField` / `searchFields` / + // `multiple` trio predicted this red in writing and called it "correct and + // wanted"; this is what banking that prediction looks like. + // + // The first assertion is not redundant with the second. It is the + // non-vacuity half: if a later pin dropped these from + // `ElementRecordPickerProps`, `undiscoverableSpecKeys` would stop naming + // them and every derived assertion would pass while the inputs sat there + // publishing keys the contract no longer has. + for (const key of ['sort', 'limit', 'emptyText']) { + expect( + specTopLevelKeys('element:record_picker'), + `spec no longer declares element:record_picker.${key}`, + ).toContain(key); + expect( + declaredInputs('element:record_picker') ?? [], + `element:record_picker does not publish ${key}`, + ).toContain(key); + expect(Object.keys(UNPUBLISHED_EXEMPTIONS)).not.toContain(`element:record_picker.${key}`); + } + }); + + it('the twelve rc.6-obsoleted off-spec exemptions are gone, and their keys are contract now', () => { + // The tombstone for the emptied `OFF_SPEC_EXEMPTIONS` (see its comment). + // Without this, "the list is empty" and "the list was accidentally deleted + // along with the divergences it covered" look identical, and every forward + // assertion passes either way — the same misdiagnosis objectui#4027 records + // for the `SPEC_SHAPE_EMPTY_ON_THE_PIN` carve-out, which is why that one + // also left an assertion behind rather than just a comment. + // + // Asserted as "the spec declares it AND the block publishes it", not merely + // "no longer exempted": these twelve keys were author-reachable + // configuration the renderers had always honoured, so a pin that regressed + // any of them must fail here loudly rather than quietly re-open an + // exemption-shaped hole. + const settledUpstream: Array<[string, string]> = [ + ['page:header', 'recordChrome'], + ['page:header', 'showStar'], + ['page:header', 'showCopyId'], + ['page:accordion', 'variant'], + ['page:tabs', 'tabStyle'], + ['element:record_picker', 'labelField'], + ['element:record_picker', 'valueField'], + ['element:record_picker', 'label'], + ['page:card', 'children'], + ['page:section', 'children'], + ['page:footer', 'children'], + ['page:sidebar', 'children'], + ]; + for (const [type, key] of settledUpstream) { + expect(specTopLevelKeys(type), `${type} spec no longer declares ${key}`).toContain(key); + expect(declaredInputs(type) ?? [], `${type} stopped publishing ${key}`).toContain(key); + expect(Object.keys(OFF_SPEC_EXEMPTIONS)).not.toContain(`${type}.${key}`); + } + expect(Object.keys(OFF_SPEC_EXEMPTIONS)).toEqual([]); + }); }); diff --git a/apps/console/src/pages/system/AppManagementPage.tsx b/apps/console/src/pages/system/AppManagementPage.tsx index 92205e8f90..4fb19bfc33 100644 --- a/apps/console/src/pages/system/AppManagementPage.tsx +++ b/apps/console/src/pages/system/AppManagementPage.tsx @@ -29,7 +29,7 @@ import { } from 'lucide-react'; import { toast } from 'sonner'; import { useMetadata } from '@object-ui/app-shell'; -import { resolveI18nLabel } from '../../utils'; +import { resolveKeyedI18nLabel } from '../../utils'; export function AppManagementPage() { const navigate = useNavigate(); @@ -211,14 +211,14 @@ export function AppManagementPage() {
- {resolveI18nLabel(app.label) || app.name} + {resolveKeyedI18nLabel(app.label) || app.name} {isDefault && Default} {isActive ? 'Active' : 'Inactive'}
{app.description && ( -

{resolveI18nLabel(app.description)}

+

{resolveKeyedI18nLabel(app.description)}

)}
diff --git a/apps/console/src/utils.ts b/apps/console/src/utils.ts index b3569c684a..499860276e 100644 --- a/apps/console/src/utils.ts +++ b/apps/console/src/utils.ts @@ -3,12 +3,16 @@ */ /** - * Resolves an I18nLabel to a plain string. - * I18nLabel can be either a string or an object { key, defaultValue?, params? }. - * When it's an object and a `t` function is provided, it resolves the key - * through the i18n translation system. Otherwise returns defaultValue or key. + * Resolves objectui's KEYED i18n label (`{ key, defaultValue?, params? }`) to a + * plain string. + * + * When a `t` function is provided the key is resolved through the i18n + * translation system; otherwise `defaultValue`, then the key itself. NOT the + * spec's `resolveI18nLabel`, which resolves the INLINE per-locale map form — + * see `packages/react/src/utils/i18n.ts` for why the names diverge + * (objectui#4167). */ -export function resolveI18nLabel( +export function resolveKeyedI18nLabel( label: string | { key: string; defaultValue?: string; params?: Record } | undefined, t?: (key: string, options?: any) => string, ): string | undefined { diff --git a/packages/app-shell/src/chrome/CommandPalette.tsx b/packages/app-shell/src/chrome/CommandPalette.tsx index 8249cf4be1..ac2ac9c1f1 100644 --- a/packages/app-shell/src/chrome/CommandPalette.tsx +++ b/packages/app-shell/src/chrome/CommandPalette.tsx @@ -32,7 +32,7 @@ import { useRecordSearch } from '@object-ui/react'; import { useTheme } from './ThemeProvider'; import { useExpressionContext, evaluateVisibility } from '../providers/ExpressionProvider'; import { useObjectTranslation } from '@object-ui/i18n'; -import { resolveI18nLabel, getRecordDisplayName, appRouteSegment } from '../utils'; +import { resolveKeyedI18nLabel, getRecordDisplayName, appRouteSegment } from '../utils'; import { getIcon } from '../utils/getIcon'; import { useRecentItems } from '../context/RecentItemsProvider'; import { useCommandPalette } from '../context/CommandPaletteProvider'; @@ -144,7 +144,7 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour // Prefer the i18n-resolved object label; fall back to the hit's plain // label (already objectName when the def had no string label). const objDef = objectsByName.get(hit.objectName); - const label = resolveI18nLabel(objDef?.label, t) || hit.objectLabel; + const label = resolveKeyedI18nLabel(objDef?.label, t) || hit.objectLabel; group = { objectLabel: label, icon: hit.icon, hits: [] }; byObject.set(hit.objectName, group); order.push(hit.objectName); @@ -262,11 +262,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour return ( runCommand(() => navigate(resolveHref(item, baseUrl, templateContext).href))} > - {resolveI18nLabel(item.label, t)} + {resolveKeyedI18nLabel(item.label, t)} ); })} @@ -281,11 +281,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour .map(item => ( runCommand(() => navigate(resolveHref(item, baseUrl, templateContext).href))} > - {resolveI18nLabel(item.label, t)} + {resolveKeyedI18nLabel(item.label, t)} ))} @@ -299,11 +299,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour .map(item => ( runCommand(() => navigate(resolveHref(item, baseUrl, templateContext).href))} > - {resolveI18nLabel(item.label, t)} + {resolveKeyedI18nLabel(item.label, t)} ))} @@ -317,11 +317,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour .map(item => ( runCommand(() => navigate(resolveHref(item, baseUrl, templateContext).href))} > - {resolveI18nLabel(item.label, t)} + {resolveKeyedI18nLabel(item.label, t)} ))} @@ -339,11 +339,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour return ( runCommand(() => onAppChange(appRouteSegment(app) ?? app.name))} > - {resolveI18nLabel(app.label, t)} + {resolveKeyedI18nLabel(app.label, t)} {app.name === activeApp?.name && ( {t('console.commandPalette.current')} )} diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index a9c8a68b93..845206ebbf 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -22,7 +22,7 @@ import { Package as PackageIcon, Sparkles as SparklesIcon } from 'lucide-react'; import { useAdapter } from '../../providers/AdapterProvider'; import { useMetadata } from '../../providers/MetadataProvider'; import { formatPublishFailures, type PublishFailure } from '../../views/studio-design/metadataError'; -import { resolveI18nLabel } from '../../utils'; +import { resolveKeyedI18nLabel } from '../../utils'; import { ExcelImportBar } from './ExcelImportBar'; import { Select, @@ -260,7 +260,7 @@ export function isPlatformBuiltinApp(app: { _packageId?: unknown }): boolean { */ interface MetadataAppItem { name: string; - label?: Parameters[0]; + label?: Parameters[0]; _packageId?: string; } @@ -1749,7 +1749,7 @@ export function ChatPane({ const app = (metadataApps ?? []).find( (a) => a._packageId === boundPackageId, ); - return app ? appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) }) : boundPackageId; + return app ? appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }) : boundPackageId; }, [boundPackageId, metadataApps, appLabel, t]); // ADR-0057 A1.b edit mode — the resolved name of the app being EDITED @@ -1761,7 +1761,7 @@ export function ChatPane({ const app = (metadataApps ?? []).find( (a) => a._packageId === editPackageId, ); - return app ? appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) }) : undefined; + return app ? appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }) : undefined; }, [editPackageId, metadataApps, appLabel, t]); // Per-surface empty-state branding (Build = authoring, Ask = data Q&A). On the @@ -1810,7 +1810,7 @@ export function ChatPane({ if (!pkg || byPackage.has(pkg) || isPlatformBuiltinApp(app)) { continue; } - byPackage.set(pkg, appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) })); + byPackage.set(pkg, appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) })); } return Array.from(byPackage, ([id, label]) => ({ id, label })); }, [metadataApps, appLabel, t]); diff --git a/packages/app-shell/src/console/home/AppCard.tsx b/packages/app-shell/src/console/home/AppCard.tsx index 2fb03c2bfa..73886e2b0b 100644 --- a/packages/app-shell/src/console/home/AppCard.tsx +++ b/packages/app-shell/src/console/home/AppCard.tsx @@ -9,7 +9,7 @@ import { Star, StarOff, ArrowUpRight } from 'lucide-react'; import { Card, CardContent, Button, Badge } from '@object-ui/components'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; -import { resolveI18nLabel, appRouteSegment } from '../../utils'; +import { resolveKeyedI18nLabel, appRouteSegment } from '../../utils'; import { useFavorites } from '../../hooks/useFavorites'; import { getIcon } from '../../utils/getIcon'; import { cn } from '@object-ui/components'; @@ -44,8 +44,8 @@ export function AppCard({ app, onClick, isFavorite, index = 0 }: AppCardProps) { const { toggleFavorite } = useFavorites(); const Icon = getIcon(app.icon); - const label = appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) }); - const description = appDescription({ name: app.name, description: resolveI18nLabel(app.description, t) }); + const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }); + const description = appDescription({ name: app.name, description: resolveKeyedI18nLabel(app.description, t) }); const accent = ACCENTS[(hashStr(app.name) + index) % ACCENTS.length]; const handleToggleFavorite = (e: React.MouseEvent) => { diff --git a/packages/app-shell/src/console/home/HomeAppsStrip.tsx b/packages/app-shell/src/console/home/HomeAppsStrip.tsx index ed3b434f69..7b38c13377 100644 --- a/packages/app-shell/src/console/home/HomeAppsStrip.tsx +++ b/packages/app-shell/src/console/home/HomeAppsStrip.tsx @@ -16,7 +16,7 @@ import { useMemo, useState } from 'react'; import { LayoutGrid, Store, Star } from 'lucide-react'; import { Button, cn } from '@object-ui/components'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; -import { resolveI18nLabel } from '../../utils'; +import { resolveKeyedI18nLabel } from '../../utils'; import { getIcon } from '../../utils/getIcon'; import type { FavoriteItem } from '../../hooks/useFavorites'; @@ -104,7 +104,7 @@ export function HomeAppsStrip({
{visible.map((app) => { const Icon = getIcon(app.icon); - const label = appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) }); + const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }); const fav = favNames.has(app.name); const grad = ICON_GRADIENTS[hashStr(app.name) % ICON_GRADIENTS.length]; return ( diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index ed72a151b7..aad19f9987 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -36,7 +36,7 @@ export type { } from './types'; export type { - MetadataState, + MetadataCacheState, MetadataContextValue, MetadataTypeStatus, } from './providers/MetadataProvider'; diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx index 82fb3cd7ad..0f31f27e6d 100644 --- a/packages/app-shell/src/layout/AppHeader.tsx +++ b/packages/app-shell/src/layout/AppHeader.tsx @@ -66,7 +66,7 @@ import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import type { BreadcrumbItem as BreadcrumbItemType } from '@object-ui/types'; import { useAuth, getUserInitials, useIsWorkspaceAdmin } from '@object-ui/auth'; import { useMetadata } from '../providers/MetadataProvider'; -import { resolveI18nLabel, preferLocal, matchAppBySegment, appRouteSegment, appStudioRoutePath } from '../utils'; +import { resolveKeyedI18nLabel, preferLocal, matchAppBySegment, appRouteSegment, appStudioRoutePath } from '../utils'; import { getIcon } from '../utils/getIcon'; import { bearerAuthHeaders } from '../utils/authToken'; import { useMobileViewSwitcher } from './MobileViewSwitcherContext'; @@ -1115,7 +1115,7 @@ export function AppHeader({ .filter((a: any) => a.active !== false && a.hidden === true && a.name !== 'account') .map((app: any) => { const AppIcon = getIcon(app.icon); - const label = appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) }); + const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }); return ( {logo ? ( - {resolveI18nLabel(activeApp.label, + {resolveKeyedI18nLabel(activeApp.label, ) : ( React.createElement(getIcon(activeApp.icon), { className: "size-4" }) )}
- {resolveI18nLabel(activeApp.label, t)} + {resolveKeyedI18nLabel(activeApp.label, t)} - {resolveI18nLabel(activeApp.description, t) || t('layout.appSwitcher.appsAvailable', { defaultValue: '{{count}} apps available', count: activeApps.length })} + {resolveKeyedI18nLabel(activeApp.description, t) || t('layout.appSwitcher.appsAvailable', { defaultValue: '{{count}} apps available', count: activeApps.length })}
@@ -420,7 +422,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
{app.icon ? React.createElement(getIcon(app.icon), { className: "size-3" }) : }
- {resolveI18nLabel(app.label, t)} + {resolveKeyedI18nLabel(app.label, t)} {activeApp.name === app.name && } ))} @@ -779,7 +781,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri return ( - {resolveI18nLabel(item.label, t)} + {resolveKeyedI18nLabel(item.label, t)} ); }); diff --git a/packages/app-shell/src/layout/AppSwitcher.tsx b/packages/app-shell/src/layout/AppSwitcher.tsx index 9d548af3f6..f307a996e2 100644 --- a/packages/app-shell/src/layout/AppSwitcher.tsx +++ b/packages/app-shell/src/layout/AppSwitcher.tsx @@ -17,7 +17,7 @@ import { } from '@object-ui/components'; import { ChevronDown, Check } from 'lucide-react'; import { useMetadata } from '../providers/MetadataProvider'; -import { resolveI18nLabel, matchAppBySegment, appRouteSegment } from '../utils'; +import { resolveKeyedI18nLabel, matchAppBySegment, appRouteSegment } from '../utils'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import { getIcon } from '../utils/getIcon'; @@ -41,7 +41,7 @@ export function AppSwitcher({ activeAppName, onAppChange }: AppSwitcherProps) { if (!activeApp) return null; - const appLabelText = appLabel({ name: activeApp.name, label: resolveI18nLabel(activeApp.label, t) }); + const appLabelText = appLabel({ name: activeApp.name, label: resolveKeyedI18nLabel(activeApp.label, t) }); return ( @@ -58,7 +58,7 @@ export function AppSwitcher({ activeAppName, onAppChange }: AppSwitcherProps) { {activeApps.map((app: any) => { const AppIcon = getIcon(app.icon); - const label = appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) }); + const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }); const isActive = activeApp.name === app.name; return ( ({ })); vi.mock('../../utils', () => ({ - resolveI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), + resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), matchAppBySegment: (apps: Array<{ name?: string }>, segment?: string) => apps.find((a) => a?.name === segment), appRouteSegment: (app: { name?: string }) => app?.name, diff --git a/packages/app-shell/src/layout/__tests__/UnifiedSidebar.derivedAreaVisibility.test.tsx b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.derivedAreaVisibility.test.tsx index e1f42d2336..7db2ffd8af 100644 --- a/packages/app-shell/src/layout/__tests__/UnifiedSidebar.derivedAreaVisibility.test.tsx +++ b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.derivedAreaVisibility.test.tsx @@ -71,7 +71,7 @@ vi.mock('../../providers/ExpressionProvider', () => ({ })); vi.mock('../../utils', () => ({ - resolveI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), + resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), matchAppBySegment: (apps: Array<{ name?: string }>, segment?: string) => apps.find((a) => a?.name === segment), appRouteSegment: (app: { name?: string }) => app?.name, diff --git a/packages/app-shell/src/layout/__tests__/appSidebarSettingsTargets.test.tsx b/packages/app-shell/src/layout/__tests__/appSidebarSettingsTargets.test.tsx index 4584431fd5..478a3f0c39 100644 --- a/packages/app-shell/src/layout/__tests__/appSidebarSettingsTargets.test.tsx +++ b/packages/app-shell/src/layout/__tests__/appSidebarSettingsTargets.test.tsx @@ -123,7 +123,7 @@ vi.mock('../../providers/ExpressionProvider', () => ({ })); vi.mock('../../utils', () => ({ - resolveI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), + resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), matchAppBySegment: (apps: Array<{ name?: string }>, segment?: string) => apps.find((a) => a?.name === segment), appRouteSegment: (app: { name?: string }) => app?.name, diff --git a/packages/app-shell/src/layout/__tests__/systemNavDatasourcesHop.test.tsx b/packages/app-shell/src/layout/__tests__/systemNavDatasourcesHop.test.tsx index cce754a47f..5199f343bf 100644 --- a/packages/app-shell/src/layout/__tests__/systemNavDatasourcesHop.test.tsx +++ b/packages/app-shell/src/layout/__tests__/systemNavDatasourcesHop.test.tsx @@ -96,7 +96,7 @@ vi.mock('../../providers/ExpressionProvider', () => ({ })); vi.mock('../../utils', () => ({ - resolveI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), + resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), matchAppBySegment: (apps: Array<{ name?: string }>, segment?: string) => apps.find((a) => a?.name === segment), appRouteSegment: (app: { name?: string }) => app?.name, diff --git a/packages/app-shell/src/layout/__tests__/systemNavObjectsHop.test.tsx b/packages/app-shell/src/layout/__tests__/systemNavObjectsHop.test.tsx index 684d10afff..7f3d6e0368 100644 --- a/packages/app-shell/src/layout/__tests__/systemNavObjectsHop.test.tsx +++ b/packages/app-shell/src/layout/__tests__/systemNavObjectsHop.test.tsx @@ -112,7 +112,7 @@ vi.mock('../../providers/ExpressionProvider', () => ({ })); vi.mock('../../utils', () => ({ - resolveI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), + resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), matchAppBySegment: (apps: Array<{ name?: string }>, segment?: string) => apps.find((a) => a?.name === segment), appRouteSegment: (app: { name?: string }) => app?.name, diff --git a/packages/app-shell/src/layout/__tests__/systemNavSettingsTarget.test.tsx b/packages/app-shell/src/layout/__tests__/systemNavSettingsTarget.test.tsx index 579694f46d..a6ac09939f 100644 --- a/packages/app-shell/src/layout/__tests__/systemNavSettingsTarget.test.tsx +++ b/packages/app-shell/src/layout/__tests__/systemNavSettingsTarget.test.tsx @@ -127,7 +127,7 @@ vi.mock('../../providers/ExpressionProvider', () => ({ })); vi.mock('../../utils', () => ({ - resolveI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), + resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), matchAppBySegment: (apps: Array<{ name?: string }>, segment?: string) => apps.find((a) => a?.name === segment), appRouteSegment: (app: { name?: string }) => app?.name, diff --git a/packages/app-shell/src/providers/MetadataProvider.tsx b/packages/app-shell/src/providers/MetadataProvider.tsx index d8e8023b08..12cfafafa5 100644 --- a/packages/app-shell/src/providers/MetadataProvider.tsx +++ b/packages/app-shell/src/providers/MetadataProvider.tsx @@ -10,12 +10,12 @@ import { expandViewContainer } from '@objectstack/spec/ui'; import { type ObjectStackAdapter } from '@object-ui/data-objectstack'; import { normalizeSchemaReferenceKeys } from '@object-ui/core'; import { resolveInlineMode } from '@object-ui/plugin-form'; -import { MetadataCtx, useMetadata, type MetadataContextValue, type MetadataState } from '@object-ui/react'; +import { MetadataCtx, useMetadata, type MetadataContextValue, type MetadataCacheState } from '@object-ui/react'; import { usePreviewDrafts } from '../preview/PreviewModeContext'; import { createConsoleMetadataClient } from '../views/metadata-admin/metadataClientFactory'; import { subscribeCanvasInvalidate, subscribeMetadataRefresh } from '../assistant/assistantBus'; -export type { MetadataState, MetadataContextValue }; +export type { MetadataCacheState, MetadataContextValue }; export { useMetadataItem } from '@object-ui/react'; export { useMetadata }; @@ -68,7 +68,7 @@ const EAGER_TYPES = ['app', 'view'] as const; */ const ERROR_RETRY_COOLDOWN_MS = 1000; -const TYPE_BY_STATE_KEY: Record, string> = { +const TYPE_BY_STATE_KEY: Record, string> = { apps: 'app', objects: 'object', dashboards: 'dashboard', diff --git a/packages/app-shell/src/utils/index.ts b/packages/app-shell/src/utils/index.ts index c6c2160692..a0a7e1b6b6 100644 --- a/packages/app-shell/src/utils/index.ts +++ b/packages/app-shell/src/utils/index.ts @@ -35,12 +35,23 @@ export { preferLocal } from './preferLocal'; export { appRouteSegment, matchAppBySegment, appStudioDesignPath, appStudioSurfacePath, appStudioRoutePath } from './appRoute'; /** - * Resolves an I18nLabel to a plain string. - * I18nLabel can be either a string or an object { key, defaultValue?, params? }. - * When it's an object and a `t` function is provided, it resolves the key - * through the i18n translation system. Otherwise returns defaultValue or key. + * Resolves objectui's KEYED i18n label to a plain string. + * + * The keyed form is `{ key, defaultValue?, params? }` — a reference INTO a + * translation bundle. With a `t` function it resolves the key through i18next; + * without one it falls back to `defaultValue`, then the key itself. + * + * Renamed from `resolveI18nLabel` in objectui#4167: `@objectstack/spec` + * 17.0.0-rc.6 publishes a function of that exact name over the INLINE LOCALE MAP + * vocabulary (`{ en: 'Owner', 'zh-CN': '负责人' }`, resolved against a BCP-47 + * locale), and rc.6's widening of `I18nLabel` to `string | Record< string, + * string >` means the same authored value can now reach either function — each + * answering wrongly, and silently, for the other's input. The full account is at + * the `@object-ui/react` twin (`packages/react/src/utils/i18n.ts`), which is the + * same vocabulary without a `t`. Import the spec's as `resolveInlineI18nLabel` + * when you need the map form; the two names now say which is which. */ -export function resolveI18nLabel( +export function resolveKeyedI18nLabel( label: string | { key: string; defaultValue?: string; params?: Record } | undefined, t?: (key: string, options?: any) => string, ): string | undefined { diff --git a/packages/app-shell/src/views/DashboardView.tsx b/packages/app-shell/src/views/DashboardView.tsx index ceb81821cf..1175e08008 100644 --- a/packages/app-shell/src/views/DashboardView.tsx +++ b/packages/app-shell/src/views/DashboardView.tsx @@ -34,7 +34,7 @@ import { MetadataPanel, useMetadataInspector } from './MetadataInspector'; import { SkeletonDashboard } from '../skeletons'; import { useMetadata } from '../providers/MetadataProvider'; import { useExpressionContext } from '../providers/ExpressionProvider'; -import { resolveI18nLabel, preferLocal } from '../utils'; +import { resolveKeyedI18nLabel, preferLocal } from '../utils'; import { useAdapter } from '../providers/AdapterProvider'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; @@ -182,8 +182,8 @@ export function DashboardView({ dataSource }: { dataSource?: any }) { // fall back to `label` (the metadata display name) and finally to // the raw `name`. const headerSrc = (previewSchema as any) || dashboard; - const resolvedTitle = resolveI18nLabel(headerSrc.title, t); - const resolvedLabel = resolveI18nLabel(dashboard.label, t); + const resolvedTitle = resolveKeyedI18nLabel(headerSrc.title, t); + const resolvedLabel = resolveKeyedI18nLabel(dashboard.label, t); const fallbackLabel = dashboardLabel({ name: dashboard.name, label: resolvedLabel }); const display = resolvedTitle || fallbackLabel || dashboard.name; return ( @@ -195,7 +195,7 @@ export function DashboardView({ dataSource }: { dataSource?: any }) { const rawDesc = headerSrc.description ?? dashboard.description; const desc = dashboardDescription({ name: dashboard.name, - description: resolveI18nLabel(rawDesc, t), + description: resolveKeyedI18nLabel(rawDesc, t), }); return desc ? (

{desc}

diff --git a/packages/app-shell/src/views/SearchResultsPage.tsx b/packages/app-shell/src/views/SearchResultsPage.tsx index a06b02ce51..a44a2e37d4 100644 --- a/packages/app-shell/src/views/SearchResultsPage.tsx +++ b/packages/app-shell/src/views/SearchResultsPage.tsx @@ -28,7 +28,7 @@ import { useRecordSearch } from '@object-ui/react'; import { useMetadata } from '../providers/MetadataProvider'; import { useAdapter } from '../providers/AdapterProvider'; import { matchAppBySegment } from '../utils/appRoute'; -import { resolveI18nLabel, getRecordDisplayName } from '../utils'; +import { resolveKeyedI18nLabel, getRecordDisplayName } from '../utils'; import { getIcon } from '../utils/getIcon'; import { resolveHref } from '@object-ui/layout'; import { useAuth } from '@object-ui/auth'; @@ -174,7 +174,7 @@ export function SearchResultsPage() { let group = byObject.get(hit.objectName); if (!group) { const objDef = objectsByName.get(hit.objectName); - const label = resolveI18nLabel(objDef?.label, t) || hit.objectLabel; + const label = resolveKeyedI18nLabel(objDef?.label, t) || hit.objectLabel; group = { label, icon: objDef?.icon ?? hit.icon, hits: [] }; byObject.set(hit.objectName, group); order.push(hit.objectName); diff --git a/packages/components/src/renderers/basic/record-picker.tsx b/packages/components/src/renderers/basic/record-picker.tsx index 90f66a01bb..bcff036120 100644 --- a/packages/components/src/renderers/basic/record-picker.tsx +++ b/packages/components/src/renderers/basic/record-picker.tsx @@ -265,6 +265,50 @@ ComponentRegistry.register('record_picker', ElementRecordPickerRenderer, { { name: 'valueField', type: 'string', label: 'Value Field' }, { name: 'placeholder', type: 'string', label: 'Placeholder' }, { name: 'label', type: 'string', label: 'Label' }, + // ── sort / limit / emptyText — declared on the rc.6 bump (objectui#4167) ── + // `@objectstack/spec` 17.0.0-rc.6 lands objectstack#5775's other half: these + // three arrive as newly DECLARED keys on `ElementRecordPickerProps`, and the + // reverse direction of the parity gate went red demanding them the moment + // the pin moved. That red was predicted, in writing, by the exemption that + // covered the retired trio ("`sort` / `limit` / `emptyText` … become + // brand-new A-class gaps, and this gate will go RED demanding them. That red + // is correct and wanted"). All three were already READ here before they were + // declared anywhere, which is the objectui#3407 shape — honoured, and + // undiscoverable to every layer that reads a manifest. + { + name: 'sort', + // `'array'` is the spec's shape, not a chosen arm: `sort` is + // `z.array(z.object({ field, order: 'asc'|'desc' })).optional()`. Verified + // against `ElementRecordPickerPropsSchema.safeParse` — the array of + // `{ field, order }` parses, and the terse string spelling `'name asc'` + // does NOT, which is worth saying in the description because it is the + // form an author is most likely to reach for. + type: 'array', + label: 'Sort', + description: + 'Row order, as an array of `{ field, order }` entries — `[{ field: "name", order: "asc" }]`. It becomes the `$orderby` of the picker\'s own query, so it decides the order records are offered in. `order` is `asc` or `desc`; the terse string form (`"name asc"`) is not accepted by the contract. PRECEDENCE: identical to `filter` above and for the same reason — the renderer reads `dataSource.sort ?? sort`, so a node-level `dataSource` binding (or the saved view its `view` names) REPLACES this key outright rather than merging with it; it applies only when the node carries no `dataSource`, or that `dataSource` and its view both leave `sort` unset.', + }, + { + name: 'limit', + type: 'number', + label: 'Limit', + description: + 'Maximum number of records the picker offers, as a whole number. It becomes the `$top` of the picker\'s own query, so it bounds what the user can choose from rather than how the list is displayed — a record outside the limit cannot be picked at all, and the control gives no sign that more exist. DEFAULT: 50 when neither this nor `dataSource.limit` is set, applied by the renderer (`record-picker.tsx:107`), not by the schema. PRECEDENCE: `dataSource.limit ?? limit ?? 50` — a node-level binding wins outright.', + }, + { + name: 'emptyText', + // A NARROWED type, named here for the objectui#3832 reason: the contract + // is `string | Record< string, string >` — rc.6 widened it to the same + // `I18nLabel` union it widened everywhere else — and `ComponentInput.type` + // is one coarse control kind with no way to spell a union. Publishing + // `'string'` therefore describes the form this renderer actually resolves, + // and the description carries the half the type cannot, which is the same + // treatment `element:text_input.defaultValue`'s `string | number` gets. + type: 'string', + label: 'Empty Text', + description: + 'Text shown in place of the row list when the query returns no records (renderer default "No records", `record-picker.tsx:213`). Unlike `filter` / `sort` / `limit` this is display-only — it never reaches the query, and a node-level `dataSource` binding does not override it. KNOWN GAP: the contract also accepts an inline per-locale map (`{ en: "None", "zh-CN": "无记录" }`) — rc.6 widened this key to `I18nLabel` — and this renderer passes the value straight into a text node with no locale resolution, so only the plain-string form renders today. Tracked with the rest of the widened-`I18nLabel` render-site audit in objectui#4163; this input publishes the string form deliberately rather than advertising a shape the renderer drops.', + }, ], }); diff --git a/packages/components/src/renderers/data-display/breadcrumb.tsx b/packages/components/src/renderers/data-display/breadcrumb.tsx index 04c497bfe0..ae910a8121 100644 --- a/packages/components/src/renderers/data-display/breadcrumb.tsx +++ b/packages/components/src/renderers/data-display/breadcrumb.tsx @@ -10,7 +10,7 @@ import { ComponentRegistry } from '@object-ui/core'; import type { BreadcrumbSchema } from '@object-ui/types'; import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from '../../ui/breadcrumb'; import { renderChildren } from '../../lib/utils'; -import { resolveI18nLabel } from '@object-ui/react'; +import { resolveKeyedI18nLabel } from '@object-ui/react'; ComponentRegistry.register('breadcrumb', ({ schema, ...props }: { schema: BreadcrumbSchema; [key: string]: any }) => { @@ -32,9 +32,9 @@ ComponentRegistry.register('breadcrumb',
{idx === (schema.items?.length || 0) - 1 ? ( - {resolveI18nLabel(item.label) ?? ''} + {resolveKeyedI18nLabel(item.label) ?? ''} ) : ( - {resolveI18nLabel(item.label) ?? ''} + {resolveKeyedI18nLabel(item.label) ?? ''} )} {idx < (schema.items?.length || 0) - 1 && } diff --git a/packages/components/src/renderers/navigation/header-bar.tsx b/packages/components/src/renderers/navigation/header-bar.tsx index 6e35a228a5..f9592541b5 100644 --- a/packages/components/src/renderers/navigation/header-bar.tsx +++ b/packages/components/src/renderers/navigation/header-bar.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import type { HeaderBarSchema, BreadcrumbItem as BreadcrumbItemType } from '@object-ui/types'; -import { resolveI18nLabel, SchemaRenderer } from '@object-ui/react'; +import { resolveKeyedI18nLabel, SchemaRenderer } from '@object-ui/react'; import { SidebarTrigger, Separator, @@ -28,7 +28,7 @@ import { import { ChevronDown, Search } from 'lucide-react'; function BreadcrumbLabel({ crumb, isLast }: { crumb: BreadcrumbItemType; isLast: boolean }) { - const label = resolveI18nLabel(crumb.label) ?? ''; + const label = resolveKeyedI18nLabel(crumb.label) ?? ''; if (crumb.siblings && crumb.siblings.length > 0) { return ( diff --git a/packages/core/src/utils/__tests__/dashboard-filters.test.ts b/packages/core/src/utils/__tests__/dashboard-filters.test.ts index ca5c20126a..fcff965d1d 100644 --- a/packages/core/src/utils/__tests__/dashboard-filters.test.ts +++ b/packages/core/src/utils/__tests__/dashboard-filters.test.ts @@ -13,6 +13,7 @@ import { buildFilterCondition, buildWidgetScopedFilter, DATE_RANGE_FILTER_NAME, + DATE_RANGE_PRESETS, type DashboardFilterDef, } from '../dashboard-filters'; import { mergeFilters } from '../merge-filters'; @@ -434,3 +435,50 @@ describe('mergeFilters', () => { expect(mergeFilters({}, undefined)).toBeUndefined(); }); }); + +/** + * `DATE_RANGE_PRESETS` stopped being `Object.keys(PRESET_RANGES)` and became a + * re-export of the spec's const in objectui#4167 (objectstack#4115). + * + * A COPY of a const passes every value comparison — that insight is why the + * guard's own header says reference identity is the only check that separates a + * re-export from a fork, and it is the one assertion a `toEqual` on the member + * list cannot make. So the first test asks `toBe`, deliberately. + * + * The second is the other half of the burn-down and the one with teeth at + * runtime: the spec owns which presets EXIST, this module owns what each one + * RESOLVES TO, and nothing but the `satisfies` in `dashboard-filters.ts` ties + * them together. That is a compile-time pin, so it is erased here — this test + * is what makes the same claim visible in a suite run, and what would catch a + * future edit that re-annotated the table as `Record` (a string index + * signature satisfies every literal key, so the `satisfies` would go vacuously + * green while the bounds table quietly went missing entries). + */ +describe('DATE_RANGE_PRESETS is the spec\'s list, not a copy of it', () => { + it('is the spec\'s own array by REFERENCE, not an equal one', async () => { + const spec = await import('@objectstack/spec/ui'); + expect(DATE_RANGE_PRESETS).toBe(spec.DATE_RANGE_PRESETS); + }); + + it('every offered preset resolves to date-macro bounds', () => { + // `buildFilterCondition` warns and drops a preset it has no bounds for, so a + // preset in this list with no entry in the bounds table is a filter that + // validates clean and then selects nothing — the exact failure the spec's + // own comment on DATE_RANGE_PRESETS describes. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + for (const preset of DATE_RANGE_PRESETS) { + const built = buildFilterCondition( + { name: 'd', field: 'created_at', type: 'dateRange' }, + { preset }, + ); + expect(built, `preset "${preset}" produced no condition`).toBeDefined(); + } + expect( + warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes('unknown date range preset')), + ).toEqual([]); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/packages/core/src/utils/dashboard-filters.ts b/packages/core/src/utils/dashboard-filters.ts index 2e56fb34e5..77b5658c95 100644 --- a/packages/core/src/utils/dashboard-filters.ts +++ b/packages/core/src/utils/dashboard-filters.ts @@ -21,6 +21,7 @@ */ import type { DashboardComponentSchema, DashboardWidgetSchema, PageVariable } from '@object-ui/types'; +import { DATE_RANGE_PRESETS, type DateRangePreset } from '@objectstack/spec/ui'; import { resolveDateMacros } from './date-macros.js'; /** Reserved filter name for the dashboard's built-in date range. */ @@ -69,8 +70,20 @@ export interface DateRangeValue { * Date-range presets → date-macro token bounds. Tokens stay symbolic in the * generated condition; every widget renderer resolves them at query time via * `resolveDateMacros`, exactly like hand-authored widget filters. + * + * `satisfies Record` is the load-bearing half of + * objectui#4167, not decoration. `DATE_RANGE_PRESETS` below is now the spec's + * list rather than `Object.keys` of this table, so the two could otherwise + * drift in the one direction the spec's own comment on that const names as the + * failure mode: a preset the SCHEMA knows and this table has no bounds for + * "validates clean and then resolves to nothing" — it would reach the filter + * bar's dropdown, be selected, and produce no range. The `satisfies` makes that + * a compile error at the moment the spec adds a preset (missing key), and makes + * a local invention a compile error too (excess key). The annotation form + * `const PRESET_RANGES: Record` would NOT do this: a string + * index signature satisfies every literal key, so the check passes vacuously. */ -const PRESET_RANGES: Record = { +const PRESET_RANGES = { today: { from: '{today}', to: '{today}' }, yesterday: { from: '{yesterday}', to: '{yesterday}' }, this_week: { from: '{current_week_start}', to: '{current_week_end}' }, @@ -84,10 +97,42 @@ const PRESET_RANGES: Record = { last_7_days: { from: '{7_days_ago}', to: '{today}' }, last_30_days: { from: '{30_days_ago}', to: '{today}' }, last_90_days: { from: '{90_days_ago}', to: '{today}' }, -}; +} satisfies Record; + +/** + * Bounds for a preset NAME that may not be one. + * + * The runtime receives whatever a stored dashboard carries, so an unrecognised + * name must warn (see `buildFilterCondition`) rather than fail to compile. That + * read is confined here so the `satisfies` pin above stays the single place + * deciding which names exist — an unguarded `PRESET_RANGES[someString]` would + * have forced the table back to a `Record` and taken the pin with it. + */ +function presetBounds(preset: string): { from?: string; to?: string } | undefined { + return (PRESET_RANGES as Record)[preset]; +} -/** Preset keys the filter bar offers, in display order. */ -export const DATE_RANGE_PRESETS = Object.keys(PRESET_RANGES); +/** + * Preset keys the filter bar offers, in display order — the spec's list, + * RE-EXPORTED since objectui#4167 rather than derived from the local bounds + * table (objectstack#4115). + * + * `@objectstack/spec` 17.0.0-rc.6 publishes `DATE_RANGE_PRESETS`, and its own + * doc comment names this module as one of the three copies the extraction + * (objectstack#4614) existed to collapse: "it used to exist three times — + * inline in `dateRange.defaultRange`, as `PRESET_RANGES` in objectui's + * `dashboard-filters`, and as a hand-written table in + * `content/docs/ui/dashboards.mdx`". So the burn-down direction here is not a + * judgement call, it is upstream's stated intent, and the two lists were + * already identical in content AND display order — the copy had nothing to + * protect. + * + * What stays local is the BOUNDS table above, which is the other half of + * #4614's design ("the two vocabularies live one import apart and neither + * restates the other's grammar"): the spec owns which presets exist, this + * module owns what each one resolves to in date-macro tokens. + */ +export { DATE_RANGE_PRESETS }; /** * ISO calendar date, optionally carrying a time part — `2026-01-15`, @@ -285,7 +330,7 @@ export function buildFilterCondition( const v = value as DateRangeValue; if (typeof v === 'object') { const preset = typeof v.preset === 'string' && v.preset ? v.preset : undefined; - const range = preset ? PRESET_RANGES[preset] : undefined; + const range = preset ? presetBounds(preset) : undefined; const from = range?.from ?? v.from; const to = range?.to ?? v.to; if (preset && !range) { diff --git a/packages/data-objectstack/src/spec-symbol-batch6.test.ts b/packages/data-objectstack/src/spec-symbol-batch6.test.ts index ffb9a1ef14..00973a48f2 100644 --- a/packages/data-objectstack/src/spec-symbol-batch6.test.ts +++ b/packages/data-objectstack/src/spec-symbol-batch6.test.ts @@ -205,7 +205,27 @@ describe('DroppedFieldsEvent IS the spec type, not a mirror of it', () => { // reason had appeared. `Equal` is used rather than `extends` because // `'readonly' | 'readonly_when' extends string` is true in the drifted // direction too. - type _ReasonIsTheEnum = Assert>; + // + // THIS PIN FIRED, and its firing is the whole point (objectui#4167). + // `@objectstack/spec` 17.0.0-rc.6 added a third arm, `primary_key`, and this + // assertion is what reported it — a `tsc` error naming the exact widening, + // on a bump whose build and full vitest run were both green. Under the + // deleted hand copy's bare `string` the arm would have arrived silently, and + // `writeWarningToast` would have narrated it as "Read-only, so it did not + // take effect" with nobody the wiser. + // + // The member list is updated here; the CONSUMER gap is objectui#3935, which + // was filed BEFORE this arm shipped and named this exact moment as its + // activation condition ("the moment `.objectui-sha`'s spec pin moves past + // that change, a user who triggers the primary-key strip is told the field + // is read-only"). rc.6 is that moment, so #3935 stops being a prediction. + // Not fixed here: `emitWriteWarning`'s binary ternary needs replacing with + // an exhaustive map plus a third wording in both locale tables, which is a + // UX and i18n read of its own rather than bump adaptation. #3935 owns it and + // already prescribes the shape. + type _ReasonIsTheEnum = Assert< + Equal + >; type _ReasonIsNotString = Assert, false>>; expect(true).toBe(true); diff --git a/packages/fields/src/widgets/AddressField.tsx b/packages/fields/src/widgets/AddressField.tsx index b847e371f8..cf84b38d70 100644 --- a/packages/fields/src/widgets/AddressField.tsx +++ b/packages/fields/src/widgets/AddressField.tsx @@ -1,12 +1,13 @@ import React, { useId } from 'react'; import { Input, Label, EmptyValue } from '@object-ui/components'; +import type { AddressValue } from '@objectstack/spec/data'; import { FieldWidgetComponentProps } from './types'; import { toDomProps } from './toDomProps'; import { toHostGroupProps } from './toHostGroupProps'; /** - * Address data structure — the part names of `@objectstack/spec`'s - * `AddressSchema`, which is what the platform stores and what + * Address data structure — `z.input` of `@objectstack/spec`'s + * `AddressValueSchema`, which is what the platform stores and what * `/api/v1/data/**` serves back. * * The postal code is spelled `postalCode` (objectstack#5143). This widget used @@ -18,14 +19,24 @@ import { toHostGroupProps } from './toHostGroupProps'; * touched did survive an unrelated edit, because the write spread the whole * stored object through; the issue's account of that half is corrected in * `AddressField.postalCode.test.tsx`.) One name, on both sides: the contract's. + * + * IMPORTED rather than declared since objectui#4167. rc.6 publishes + * `AddressValue` under this exact name, and the local copy — whose own comment + * already claimed to be "the part names of `AddressSchema`" — declared five of + * the seven parts. `countryCode` and `formatted` were missing, which is + * objectstack#4115's failure class precisely: a canonical CLAIM over a narrower + * shape, believed by the next reader. The claim is now the code. + * + * The widget still renders five inputs, and that is a deliberate split rather + * than an omission: `formatted` is a derived one-line rendering of the parts + * (see {@link formatAddress}, which composes it) and `countryCode` is the ISO + * pair for `country`, neither of which a human types into a part box. Binding + * the type does not publish them as inputs — it stops the type from asserting + * the platform cannot store them, and it means a write from this widget no + * longer looks type-clean while dropping a key the contract carries: the + * `{ ...address }` spread in `handleFieldChange` preserves both, and now says so. */ -export interface AddressValue { - street?: string; - city?: string; - state?: string; - postalCode?: string; - country?: string; -} +export type { AddressValue }; /** * The shape written by builds up to and including 17.0.0-rc.1, whose postal diff --git a/packages/layout/package.json b/packages/layout/package.json index 2a5add518c..220133f1a0 100644 --- a/packages/layout/package.json +++ b/packages/layout/package.json @@ -30,6 +30,7 @@ "@object-ui/core": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", + "@objectstack/spec": "^17.0.0-rc.6", "clsx": "^2.1.1", "lucide-react": "^1.29.0", "react": "19.2.8", @@ -42,7 +43,6 @@ "react-router-dom": "^6.0.0 || ^7.0.0" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.6", "@vitejs/plugin-react": "^6.0.5", "react-router-dom": "^7.18.2", "vite": "^8.2.1", diff --git a/packages/layout/src/ResponsiveGrid.tsx b/packages/layout/src/ResponsiveGrid.tsx index f4a2b3eda6..fe56f6b8e5 100644 --- a/packages/layout/src/ResponsiveGrid.tsx +++ b/packages/layout/src/ResponsiveGrid.tsx @@ -8,32 +8,28 @@ import React from 'react'; import { cn } from '@object-ui/components'; +import type { BreakpointColumnMap, BreakpointOrderMap } from '@objectstack/spec/ui'; /** - * Spec-aligned breakpoint column map (mirrors @objectstack/spec BreakpointColumnMapSchema). - * Maps breakpoint names to grid column counts (1-12). - */ -export interface BreakpointColumnMap { - xs?: number; - sm?: number; - md?: number; - lg?: number; - xl?: number; - '2xl'?: number; -} - -/** - * Spec-aligned breakpoint order map (mirrors @objectstack/spec BreakpointOrderMapSchema). - * Maps breakpoint names to display order numbers. + * Breakpoint column map (`BreakpointColumnMapSchema`) and breakpoint order map + * (`BreakpointOrderMapSchema`), both re-exported from `@objectstack/spec/ui`. + * + * These were hand copies until objectui#4167, each carrying the word "mirrors" + * — the doc-comment shape objectstack#4115 catalogues, where the claim is what + * the next agent reads as canonical and the code is what actually decides. rc.6 + * publishes both names, and both copies happened to still be exact: six + * optional numbers keyed `xs`…`2xl`, on a `$strict` schema. "Still exact" is + * the argument FOR binding them, not against — the copies had nothing to + * protect, so the only thing they could do from here was drift, and a + * breakpoint the spec adds now arrives instead of silently not existing. + * + * `BreakpointOrderMap` has no read point in this package (`ResponsiveGrid` + * resolves columns only); it is published because `ResponsiveConfigSchema` + * pairs the two and an author configuring order needs the type. Bound rather + * than deleted for that reason — deleting a published type is a separate + * decision from stopping it being a fork. */ -export interface BreakpointOrderMap { - xs?: number; - sm?: number; - md?: number; - lg?: number; - xl?: number; - '2xl'?: number; -} +export type { BreakpointColumnMap, BreakpointOrderMap }; export interface ResponsiveGridProps { /** Grid column map per breakpoint */ diff --git a/packages/plugin-designer/src/AppCreationWizard.tsx b/packages/plugin-designer/src/AppCreationWizard.tsx index 449f242697..ba55e0c887 100644 --- a/packages/plugin-designer/src/AppCreationWizard.tsx +++ b/packages/plugin-designer/src/AppCreationWizard.tsx @@ -48,7 +48,7 @@ import { } from 'lucide-react'; import { clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; -import { resolveI18nLabel } from '@object-ui/react'; +import { resolveKeyedI18nLabel } from '@object-ui/react'; import { useDesignerTranslation } from './hooks/useDesignerTranslation'; import { useConfirmDialog } from './hooks/useConfirmDialog'; @@ -527,7 +527,7 @@ function NavigationBuilderStep({ {item.icon} )} - {item.type === 'separator' ? t('appDesigner.separatorLabel') : resolveI18nLabel(item.label)} + {item.type === 'separator' ? t('appDesigner.separatorLabel') : resolveKeyedI18nLabel(item.label)} { if (e.key === 'Enter') handleLabelCommit(); if (e.key === 'Escape') { - setLabelDraft(resolveI18nLabel(item.label) ?? ''); + setLabelDraft(resolveKeyedI18nLabel(item.label) ?? ''); setEditingLabel(false); } }} @@ -261,12 +261,12 @@ function NavItemRow({ )} onDoubleClick={() => { if (!readOnly && item.type !== 'separator') { - setLabelDraft(resolveI18nLabel(item.label) ?? ''); + setLabelDraft(resolveKeyedI18nLabel(item.label) ?? ''); setEditingLabel(true); } }} > - {resolveI18nLabel(item.label)} + {resolveKeyedI18nLabel(item.label)} )} @@ -413,7 +413,7 @@ function PreviewItem({ item, depth }: { item: NavigationItem; depth: number }) { style={{ marginLeft: depth * 12 }} > - {resolveI18nLabel(item.label)} + {resolveKeyedI18nLabel(item.label)} {item.type === 'group' && item.children?.map((child) => ( diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx index 3529f94a96..2b75772b13 100644 --- a/packages/react/src/SchemaRenderer.tsx +++ b/packages/react/src/SchemaRenderer.tsx @@ -27,7 +27,7 @@ import { import { SchemaRendererContext } from './context/SchemaRendererContext'; import { usePredicateScope } from './hooks/useExpression'; import { usePageVariables } from './hooks/usePageVariables'; -import { resolveI18nLabel } from './utils/i18n'; +import { resolveKeyedI18nLabel } from './utils/i18n'; /** * Dev-mode schema validation. @@ -108,7 +108,7 @@ function validateSchemaOnce(schema: any): _ValidationCacheEntry { function resolveAriaProps(schema: Record): Record { const aria: Record = {}; if (schema.ariaLabel) { - aria['aria-label'] = resolveI18nLabel(schema.ariaLabel); + aria['aria-label'] = resolveKeyedI18nLabel(schema.ariaLabel); } if (schema.ariaDescribedBy) { aria['aria-describedby'] = schema.ariaDescribedBy; diff --git a/packages/react/src/context/AppShellContext.tsx b/packages/react/src/context/AppShellContext.tsx index 829f9adf00..b7a2638902 100644 --- a/packages/react/src/context/AppShellContext.tsx +++ b/packages/react/src/context/AppShellContext.tsx @@ -15,7 +15,26 @@ export function useAdapter(): ObjectStackAdapter | null { // MetadataContext // --------------------------------------------------------------------------- -export interface MetadataState { +/** + * The metadata PROVIDER's cache — the collections `` holds in + * React state, plus its aggregate load flags. + * + * Renamed from `MetadataState` in objectui#4167. `@objectstack/spec` 17.0.0-rc.6 + * publishes `MetadataState` from `@objectstack/spec/system`, and it names a + * different thing in the SAME domain and the SAME two words: a metadata item's + * LIFECYCLE state, `'draft' | 'active' | 'deprecated' | 'archived'` + * (`MetadataStateSchema`). That is the `AuthProviderConfig` judgement from + * objectstack#4115 batch 5, not the `AuthProvider` one — nothing about a JSX + * element can be mistaken for a zod enum, but "the metadata state" absolutely + * can be read as canonical for the lifecycle enum by the next agent, and the + * two are mutually unassignable (an object of five arrays vs a string union), + * so nothing would have caught the misreading at the point it was made. + * + * `Cache` rather than a bare disambiguating prefix because it is what this is: + * `MetadataProvider` is a TTL cache with per-type entries, and these five + * arrays are its materialized contents. + */ +export interface MetadataCacheState { apps: any[]; objects: any[]; dashboards: any[]; @@ -27,7 +46,7 @@ export interface MetadataState { export type MetadataTypeStatus = 'idle' | 'loading' | 'ready' | 'error'; -export interface MetadataContextValue extends MetadataState { +export interface MetadataContextValue extends MetadataCacheState { refresh: (type?: string) => Promise; invalidate: (type: string, name?: string) => void; ensureType: (type: string) => Promise; diff --git a/packages/react/src/hooks/__tests__/offline-nav-performance-spec-parity.test.ts b/packages/react/src/hooks/__tests__/offline-nav-performance-spec-parity.test.ts index 5a99206023..fea2a5e208 100644 --- a/packages/react/src/hooks/__tests__/offline-nav-performance-spec-parity.test.ts +++ b/packages/react/src/hooks/__tests__/offline-nav-performance-spec-parity.test.ts @@ -48,9 +48,9 @@ import type { OfflineConfig, OfflineSyncConfig, } from '../useOffline'; -import type { NavigationConfig } from '../useNavigationOverlay'; +import type { NavigationConfig, NavigationMode } from '../useNavigationOverlay'; import type { SpecAuthoredInput } from '../../spec-input'; -import type { NavigationConfigSchema } from '@objectstack/spec/ui'; +import type { NavigationConfigSchema, NavigationMode as SpecNavigationMode } from '@objectstack/spec/ui'; import type { ConflictResolutionStrategy as SpecMergeConflictStrategy } from '@objectstack/spec/api'; /** Every name `@objectstack/spec` exports from any subpath — types AND values. */ @@ -273,6 +273,41 @@ describe('NavigationConfig derives from the spec, requiring only `mode`', () => ]; expect(all).toHaveLength(7); }); + + it('the exported NavigationMode is the spec union AND still this config\'s `mode`', () => { + // objectui#4167. `NavigationMode` used to be spelled `NavigationConfig['mode']` + // — one member access away from the spec, which reads as a hand-written + // union to `check:spec-symbols` and to a person. It is now bound to the + // spec's `NavigationMode` directly, and this is the pin the declaration + // promises: the two spellings must stay the SAME type. + // + // They can come apart in a way nothing else would catch. The equality holds + // today only because `NavigationConfig` above strips the `undefined` that + // `NavigationConfigSchema`'s `.default('page')` puts on `mode`'s authoring + // side. If the spec ever stops defaulting `mode`, or defaults it on a + // narrower union, `NavigationConfig['mode']` moves and the exported alias + // does not — and every call site keeps compiling, because the hook's + // `navigation?.mode ?? 'page'` would still be assignable either way. + // + // Both directions, deliberately: a one-way `extends` is satisfied by a + // narrowing as well as by equality, and a narrowing is exactly the drift + // that would delete a mode the hook switches on. + type _ModeIsSpecMode = Assert>; + type _ModeIsConfigMode = Assert>; + + // Runtime half — the type assertions above are erased, so this is what + // fails visibly if the union ever loses a member. + const everyMode: NavigationMode[] = [ + 'page', + 'drawer', + 'modal', + 'split', + 'popover', + 'new_window', + 'none', + ]; + expect(new Set(everyMode).size).toBe(7); + }); }); /** diff --git a/packages/react/src/hooks/useNavigationOverlay.ts b/packages/react/src/hooks/useNavigationOverlay.ts index 20c3308836..3a03ff4f48 100644 --- a/packages/react/src/hooks/useNavigationOverlay.ts +++ b/packages/react/src/hooks/useNavigationOverlay.ts @@ -19,7 +19,7 @@ import { useState, useCallback, useMemo } from 'react'; -import type { NavigationConfigSchema } from '@objectstack/spec/ui'; +import type { NavigationConfigSchema, NavigationMode as SpecNavigationMode } from '@objectstack/spec/ui'; import type { SpecAuthoredInput } from '../spec-input'; /** @@ -85,7 +85,23 @@ export function resolveOverlayWidth(navigation: NavigationConfig | undefined): s return undefined; } -export type NavigationMode = NavigationConfig['mode']; +/** + * The overlay modes — the spec's own union, DERIVED since objectui#4167. + * + * rc.6 publishes `NavigationMode` (`z.input`), and + * this alias resolved to exactly it already: `NavigationConfig['mode']` above is + * `NonNullable<…['mode']>`, and stripping the `undefined` that the schema's + * `.default()` puts on the authoring side leaves the seven-member enum itself. + * So the spec reference was one hop away rather than absent — the alias just + * reached it through a member access, which reads as a hand-written union to + * `check:spec-symbols` and, more to the point, to a person. + * + * Bound to the spec directly instead: the seven members now arrive from the + * schema that validates them. `__tests__/offline-nav-performance-spec-parity.test.ts` + * pins that this stays the same type as `NavigationConfig['mode']`, so the two + * spellings cannot silently come apart if the spec ever stops defaulting `mode`. + */ +export type NavigationMode = SpecNavigationMode; export interface UseNavigationOverlayOptions { /** The navigation configuration from the schema */ diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index a76f55ea91..4432889047 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -17,7 +17,7 @@ export * from './data-invalidation'; export * from './element-data-source/ElementDataSourceGate'; // i18n utilities -export { resolveI18nLabel } from './utils/i18n'; +export { resolveKeyedI18nLabel } from './utils/i18n'; // Write-error surfacing utilities (shared by drag-write plugins so a failed // PATCH — e.g. an RLS 403 — is never silently swallowed). diff --git a/packages/react/src/utils/i18n.ts b/packages/react/src/utils/i18n.ts index cbd394701a..c5ac4ceb4e 100644 --- a/packages/react/src/utils/i18n.ts +++ b/packages/react/src/utils/i18n.ts @@ -1,9 +1,41 @@ /** - * Resolves an I18nLabel to a plain string. - * I18nLabel can be either a string or an object { key, defaultValue?, params? }. - * When it's an object, we return the defaultValue or the key as fallback. + * Resolves objectui's KEYED i18n label to a plain string. + * + * The keyed form is `{ key, defaultValue?, params? }` — a reference INTO a + * translation bundle. This package has no `t()`, so it resolves the reference + * as far as it can without one: `defaultValue`, else the key itself. (The + * app-shell twin of this function takes a `t` and resolves the key properly.) + * + * ## Why this is not called `resolveI18nLabel` (objectui#4167) + * + * `@objectstack/spec` 17.0.0-rc.6 publishes a function of that exact name from + * `@objectstack/spec/ui`, over a DIFFERENT vocabulary: the INLINE LOCALE MAP + * (`{ en: 'Owner', 'zh-CN': '负责人' }`), resolved against a BCP-47 locale + * (`resolveI18nLabel(label, locale)`). Two resolvers, one name, and neither + * accepts the other's shape — which objectstack#4115 calls a planted premise + * rather than a naming nit, because the next agent reads the name and builds + * on it. + * + * rc.6 is also what made the clash dangerous rather than merely untidy. It + * widened the spec's `I18nLabel` from `string` to + * `string | Record< string, string >`, so the SAME authored value can now reach + * either function — and each answers wrongly for the other's input, silently: + * + * this one, given `{ en: 'Owner' }` → `undefined` (no `key`, no + * `defaultValue`) → renders empty; + * the spec's, given `{ key: 'nav.home' }` → treats `key`/`defaultValue`/ + * `params` as locale tags and picks + * one of them as the display text. + * + * Neither is a type error at the call site that matters, and both look like a + * working render until someone reads the output. PR #4169 met this head-on and + * aliased the spec's import as `resolveInlineI18nLabel` in five files, with + * hand-written comments explaining the clash at two of them — a review + * convention, which is exactly what objectstack#4115 exists to replace with a + * rule. `Keyed` is the counterpart of that `Inline`: the name now says which + * vocabulary it resolves, at every call site, with no comment required. */ -export function resolveI18nLabel(label: string | { key: string; defaultValue?: string; params?: Record } | undefined): string | undefined { +export function resolveKeyedI18nLabel(label: string | { key: string; defaultValue?: string; params?: Record } | undefined): string | undefined { if (label === undefined || label === null) return undefined; if (typeof label === 'string') return label; return label.defaultValue || label.key; diff --git a/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts b/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts index dcdc04b802..6c0e2bc129 100644 --- a/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts +++ b/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts @@ -50,8 +50,15 @@ import type { ActionParamSchema as SpecActionParamSchema, I18nLabel as SpecI18nLabel, NavigationArea as SpecNavigationArea, - Theme as SpecTheme, - ThemeInput as SpecThemeInput, + // Re-pointed BY SIDE on the rc.6 bump (objectui#4167), not by name. + // Up to rc.5 the spec published `Theme` (= `z.infer`) alongside `ThemeInput` + // (= `z.input`); rc.6 retired every `…Input` alias and moved the bare name + // onto the INPUT side, so `Theme` is now `z.input` and `ThemeParsed` is the + // `z.infer` side. Following the old NAMES here would have swapped both pins + // silently — which is why `ThemeInput` became `Theme` and `Theme` became + // `ThemeParsed`, rather than either binding staying where it was written. + Theme as SpecThemeInput, + ThemeParsed as SpecThemeParsed, } from '@objectstack/spec/ui'; import { NavigationAreaSchema } from '../zod/app.zod.js'; import type { NavigationArea, NavigationItem } from '../app.js'; @@ -271,7 +278,7 @@ describe('NavigationArea derives from the spec', () => { // Theme — derived (re-export of the spec's AUTHORING shape) // ───────────────────────────────────────────────────────────────────────────── -describe('Theme is the spec ThemeInput, not the parsed Theme', () => { +describe('Theme is the spec AUTHORING theme, not the parsed one', () => { it('accepts a theme with no `mode` (the authoring side)', () => { const authored: Theme = { name: 'acme', label: 'Acme', colors: { primary: '#0af' } }; expect(authored.mode).toBeUndefined(); @@ -283,12 +290,17 @@ describe('Theme is the spec ThemeInput, not the parsed Theme', () => { expect(back.name).toBe('acme'); }); - it('is NOT the spec parsed Theme, whose `mode` is required', () => { + it('is NOT the spec parsed theme, whose `mode` is required', () => { // `.default('auto')` has already run in `z.infer`, so the parsed type would // make `mode` mandatory and every stored objectui theme unrepresentable. // If the spec ever drops that default the two collapse and this pin fails, // which is the moment to re-read the derivation comment in `theme.ts`. - type ModeOfParsed = undefined extends SpecTheme['mode'] ? 'optional' : 'required'; + // + // rc.6 spells the `z.infer` side `ThemeParsed`. The pin reads the same fact + // it always did — following the NAME `Theme` here instead would have made + // this assertion compare the authoring side against itself and go green on + // nothing, which is exactly the swap the import comment warns about. + type ModeOfParsed = undefined extends SpecThemeParsed['mode'] ? 'optional' : 'required'; const parsedModeIs: ModeOfParsed = 'required'; expect(parsedModeIs).toBe('required'); diff --git a/packages/types/src/data-protocol.ts b/packages/types/src/data-protocol.ts index ba0c35cdce..f22f0784ee 100644 --- a/packages/types/src/data-protocol.ts +++ b/packages/types/src/data-protocol.ts @@ -550,13 +550,40 @@ export type AdvancedFilterOperator = export interface DateRangeFilter { start?: Date | string; end?: Date | string; - preset?: DateRangePreset; + preset?: FilterBuilderDateRangePreset; } /** - * Date range presets + * Date range presets accepted by the FILTER BUILDER vocabulary in this file. + * + * Renamed from `DateRangePreset` in objectui#4167, because `@objectstack/spec` + * started exporting that name in 17.0.0-rc.6 for a DIFFERENT, narrower set and + * a different surface (objectstack#4115). Keeping both under one name is the + * planted-premise failure that guard exists to stop — and here the two sets + * are not even nested by accident: + * + * - the spec's `DateRangePreset` is `(typeof DATE_RANGE_PRESETS)[number]`, + * thirteen HISTORICAL windows, and it is the dashboard filter-bar + * vocabulary (`dashboard.dateRange.defaultRange`, `globalFilters` of + * `type: 'date'`). Its `superRefine` on `GlobalFilterSchema` rejects + * anything outside those thirteen by name; + * - this one adds eight FUTURE windows (`tomorrow`, `next_week`, + * `next_month`, `next_quarter`, `next_year`, `next_7_days`, `next_30_days`, + * `next_90_days`) for the `FilterBuilderConfig` surface below, where a + * forward-looking range ("due next week") is the point. + * + * So an author or agent who read the spec's thirteen off this name would have + * been told eight windows exist that the dashboard schema rejects, and one who + * read this file's twenty-one off the spec's name would have been told eight + * that work here do not. The prefix says which vocabulary is being spelled; + * `FilterBuilderCondition` (objectui#3159, batch 5) named its sibling the same + * way for the same reason. + * + * Nothing in this repo consumed the old name outside {@link DateRangeFilter} + * directly above, so the rename is a public-surface change with no internal + * call-site churn — see the changeset for the importer-facing note. */ -export type DateRangePreset = +export type FilterBuilderDateRangePreset = | 'today' | 'yesterday' | 'tomorrow' diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index f6b60305ea..031ac8178f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -487,7 +487,9 @@ export type { AdvancedFilterCondition, AdvancedFilterOperator, DateRangeFilter, - DateRangePreset, + // Renamed from `DateRangePreset` in objectui#4167 — the spec owns that name + // for the narrower dashboard filter-bar vocabulary (objectstack#4115). + FilterBuilderDateRangePreset, FilterBuilderConfig, FilterFieldConfig, // Validation Schema (Phase 3.5) diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 3b6f0b64ef..cc9b7dbc6d 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -95,6 +95,7 @@ import type { RowColorConfig, GalleryConfig, TimelineConfig, + GanttConfig as SpecGanttConfig, } from '@objectstack/spec/ui'; /** @@ -120,56 +121,55 @@ export type ListViewTimelineConfig = TimelineConfig & { /** * Kanban Configuration * Canonical definition from @objectstack/spec/ui (KanbanConfigSchema). + * + * A RE-EXPORT since objectui#4167, not a copy. The three keys the copy spelled + * out (`groupByField` / `summarizeField` / `columns`) were the spec's three + * exactly, and `KanbanConfigSchema` is `$strict`, so there was never a + * divergence to preserve — only a second declaration under the spec's own name + * for the next agent to read as canonical (objectstack#4115). The zod side has + * derived from the spec all along (`zod/objectql.zod.ts`, which additionally + * carries the `groupField` / `cardFields` legacy aliases); this alias is now + * bound to the same source. */ -export type KanbanConfig = { - /** Field to group columns by (usually status/select) */ - groupByField: string; - /** Field to sum at top of column (e.g. amount) */ - summarizeField?: string; - /** Fields to show on cards */ - columns: string[]; -}; +export type { KanbanConfig } from '@objectstack/spec/ui'; /** * Calendar Configuration * Canonical definition from @objectstack/spec/ui (CalendarConfigSchema). + * + * A RE-EXPORT since objectui#4167, for the same reason as `KanbanConfig` above: + * the copy's four keys were the spec's four, on a `$strict` schema. */ -export type CalendarConfig = { - /** Start date field */ - startDateField: string; - /** End date field */ - endDateField?: string; - /** Title field */ - titleField: string; - /** Color field */ - colorField?: string; -}; +export type { CalendarConfig } from '@objectstack/spec/ui'; /** - * Gantt Configuration - * Canonical definition from @objectstack/spec/ui (GanttConfigSchema). + * Gantt Configuration — the spec's `GanttConfigSchema`, plus objectui's one + * remaining display-only extension. + * + * DERIVED since objectui#4167, and the copy it replaces was carrying two false + * claims of exactly the kind objectstack#4115 was filed about: + * + * - it declared SIX keys and called itself "canonical", while rc.6's + * `GanttConfigSchema` declares seventeen. The eleven it never mentioned — + * `parentField`, `typeField`, `baselineStartField`, `baselineEndField`, + * `groupByField`, `resourceView`, `assigneeField`, `effortField`, + * `capacity`, `quickFilters`, `autoZoomToFilter` — are not hypothetical + * upstream additions: `plugin-gantt/src/ObjectGantt.tsx` reads every one of + * them, through a local `GanttConfigEx` intersection that re-declared them + * because this type did not; + * - the `tooltipFields` comment said "not part of the upstream + * GanttConfigSchema". It is, as of rc.6, so the key now arrives from the + * spec and the note is gone with it. + * + * `timeSegments` is the one key the spec genuinely does not model, and it stays + * here declared as objectui's own. That is legal metadata rather than a second + * dialect: `GanttConfigSchema` is `$loose` upstream (see the note at + * `zod/objectql.zod.ts` — "the renderers grow config knobs"), so a key the spec + * does not declare passes its parse instead of being rejected. The intersection + * inherits that looseness, which is the spec's own decision for this vocabulary + * and not a widening taken here. */ -export type GanttConfig = { - /** Start date field */ - startDateField: string; - /** End date field */ - endDateField: string; - /** Title field */ - titleField: string; - /** Progress field (0-100) */ - progressField?: string; - /** Dependencies field */ - dependenciesField?: string; - /** Color field */ - colorField?: string; - /** - * Fields to surface in the hover tooltip (悬浮详情), in display order. - * ObjectUI display extension — not part of the upstream GanttConfigSchema. - * Each entry is either a field name (string) or `{ field, label? }` to - * override the label; values are formatted by field type. When omitted the - * tooltip falls back to the built-in start → end · duration · progress line. - */ - tooltipFields?: Array; +export type GanttConfig = SpecGanttConfig & { /** * Shift segmentation (班次/排班分段). ObjectUI display extension — not part of the * upstream GanttConfigSchema. When set, the day-mode timeline splits each diff --git a/scripts/check-spec-symbol-derivation.mjs b/scripts/check-spec-symbol-derivation.mjs index dec1a3535b..63468dc5aa 100644 --- a/scripts/check-spec-symbol-derivation.mjs +++ b/scripts/check-spec-symbol-derivation.mjs @@ -111,6 +111,28 @@ const ALLOW = { "adds a key, retires one, claims an extension name, or widens `value` itself.", issue: 4115, }, + "@object-ui/types:GlobalFilterSchema": { + reason: + "Spec-derived dialect composed the same way as `SelectOptionSchema` above, and excused " + + "for the same reason: the spec's keys flow in BY REFERENCE via " + + "`SpecGlobalFilterSchema.shape`, which this guard cannot see because a const's object " + + "literal is deliberately not descended into (a spread inside `z.object({…})` is " + + "indistinguishable, to an AST pass, from a hand-typed shape). Three keys are then " + + "replaced, each a documented divergence at the declaration: `options` and `optionsFrom` " + + "(objectui's wire forms) and `defaultValue` (widened to carry the `{ preset }` object " + + "`@object-ui/core`'s `normalizeDateDefault` produces and stored dashboards hold, " + + "framework#4475). Drift guard: " + + "packages/types/src/__tests__/report-chart-query-spec-parity.test.ts. " + + "The SPELLING is new, not the dialect: this was `SpecGlobalFilterSchema.extend({…})` " + + "until @objectstack/spec 17.0.0-rc.6 put a refinement on the schema, which makes " + + "`.extend()` throw at module load and types every override as `never` under " + + "`.safeExtend()`. The spread is the only remaining spelling that preserves the shape " + + "byte-for-byte, and it is what moved the derivation out of this guard's sight — so the " + + "collision is an artifact of the forced rewrite, not a new fork (objectui#4167). " + + "The refinement itself is deliberately NOT carried across and the standstill is pinned; " + + "which side moves is a producer/consumer contract decision owned by objectui#4165.", + issue: 4167, + }, "@object-ui/types:ListViewSchema": { reason: "TS twin of the spec-derived `ListViewSchema` zod node (objectql.zod.ts), which DOES " +