Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/global-nav-studio-retire-rc6.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions apps/console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
54 changes: 25 additions & 29 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,24 +265,24 @@
'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.
Expand Down Expand Up @@ -561,24 +561,20 @@
// 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');
}
});

Expand Down Expand Up @@ -618,7 +614,7 @@
const input = key.slice(dot + 1);
return !offSpecInputs(type).includes(input);
});
expect(stale).toEqual([]);

Check failure on line 617 in apps/console/src/__tests__/registry-inputs-spec-parity.test.ts

View workflow job for this annotation

GitHub Actions / Test (shard 2/4)

[@object-ui/console] src/__tests__/registry-inputs-spec-parity.test.ts > registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) > carries no stale exemption — a declared key must lose its entry

AssertionError: expected [ 'page:header.recordChrome', …(11) ] to deeply equal [] - Expected + Received - [] + [ + "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", + ] ❯ src/__tests__/registry-inputs-spec-parity.test.ts:617:19
});

// ── the REVERSE direction (objectui#3808) ──────────────────────────────────
Expand Down Expand Up @@ -653,7 +649,7 @@
it.each(covered)('%s publishes every top-level key its spec props schema declares', (type) => {
const exempt = new Set(unpublishedExemptedFor(type));
const undiscoverable = undiscoverableSpecKeys(type).filter((key) => !exempt.has(key));
expect(undiscoverable).toEqual([]);

Check failure on line 652 in apps/console/src/__tests__/registry-inputs-spec-parity.test.ts

View workflow job for this annotation

GitHub Actions / Test (shard 2/4)

[@object-ui/console] src/__tests__/registry-inputs-spec-parity.test.ts > registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) > element:record_picker publishes every top-level key its spec props schema declares

AssertionError: expected [ 'sort', 'limit', 'emptyText' ] to deeply equal [] - Expected + Received - [] + [ + "sort", + "limit", + "emptyText", + ] ❯ src/__tests__/registry-inputs-spec-parity.test.ts:652:28
});

it('every unpublished-key exemption names a key the spec really declares', () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions packages/app-shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 18 additions & 3 deletions packages/app-shell/src/layout/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string, string>` (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 (
<SidebarMenuItem key={area.id}>
<SidebarMenuButton
isActive={isActiveArea}
tooltip={area.label}
tooltip={areaLabel}
onClick={() => setActiveAreaId(area.id)}
>
<AreaIcon className="h-4 w-4" />
<span>{area.label}</span>
<span>{areaLabel}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
Expand Down
16 changes: 13 additions & 3 deletions packages/app-shell/src/layout/UnifiedSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<string, string>`, 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 (
<SidebarMenuItem key={area.id}>
<SidebarMenuButton
isActive={isActiveArea}
tooltip={area.label}
tooltip={areaLabel}
onClick={() => setActiveAreaId(area.id)}
>
<AreaIcon className="h-4 w-4" />
<span>{area.label}</span>
<span>{areaLabel}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down
Loading
Loading