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
19 changes: 19 additions & 0 deletions .changeset/theme-types-localized-5716.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@object-ui/types': minor
'@object-ui/providers': minor
---

Localize the theme document types: `@object-ui/types` now owns `Theme`, `ThemeMode` and `ColorPalette` (objectui#5716 ruling, 2026-08-23). The spec retired its theme module (objectstack#10485) while ObjectUI retained the theme system, so the types are hand-written from the last-published `@objectstack/spec` 17.1.0 shapes instead of re-exported — a spec dependency refresh past the retirement no longer breaks these packages.

Published-name REMOVALS from `@object-ui/types` (zero in-repo readers, deleted under the same ruling's rider):

- `Typography` — the shape lives on as the inline `Theme['typography']` member.
- `BorderRadius` — lives on as inline `Theme['borderRadius']`.
- `Shadow` — lives on as inline `Theme['shadows']`.
- `ThemeDefinition` — the deprecated alias of `Theme`; use `Theme`.

Also added: `THEME_MODES`, a runtime tuple witness of the theme mode vocabulary (`['auto', 'light', 'dark']`).

The `UI` protocol namespace (`import { UI } from '@object-ui/types'`) now resolves `UI.Theme` / `UI.ThemeMode` / `UI.ColorPalette` to the local owners, so they survive the upcoming spec refresh; the rest of the namespace continues to track `@objectstack/spec/ui`. After that refresh, retired spec/ui members (`UI.ThemeSchema`, `UI.ThemeModeSchema`, `UI.ThemeParsed`, `UI.Typography`, `UI.BorderRadius`, `UI.Shadow`, `UI.defineTheme`) drop out of the namespace.

`@object-ui/providers`: `ThemePreference` is now derived from `@object-ui/types`' `ThemeMode` instead of the retired spec `ThemeModeSchema` (same union: `'auto' | 'light' | 'dark' | 'system'`).
72 changes: 31 additions & 41 deletions packages/providers/src/__tests__/spec-symbol-batch7.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,41 +7,38 @@
*/

/**
* `@object-ui/providers` ↔ `@objectstack/spec` symbol-collision guard
* (objectui#3161, objectstack#4115 ledger batch 7).
* `ThemePreference` derivation pin
* (objectui#3161, objectstack#4115 ledger batch 7; re-homed by objectui#5716).
*
* `Theme` → `ThemePreference`, derived from the spec's `ThemeMode` plus the one
* legacy spelling this provider still honours.
* `Theme` → `ThemePreference`, derived from the theme MODE vocabulary plus the
* one legacy spelling this provider still honours. The batch-7 naming
* judgement is recorded at the declaration in `../types.ts`.
*
* The triage note on objectstack#4115 asked whether this `Theme` was the parsed
* runtime theme or the authored JSON. It was neither: it was
* `Theme['mode']` wearing `Theme`'s name — a string union standing where the
* spec has a whole document (`name`, `label`, `colors`, `typography`, …). That
* is the more misleading of the two failure shapes, because a session reading
* it would conclude an ObjectStack theme IS a string. `ThemeMode`, the spec's
* own name for what this really is, could not be taken either: the spec exports
* that too (the objectui#3169 rule — run the candidate past the guard first),
* and `'system'` would have made it a lie anyway.
* This file used to read the vocabulary from the spec's `ThemeModeSchema` and
* carried a designed tripwire: "if the spec retires `Theme`, this stops
* compiling and the rename is up for re-triage". The spec DID retire its
* theme module (objectstack#10485), the re-triage happened, and the
* objectui#5716 ruling made `@object-ui/types` the vocabulary's owner
* (`ThemeMode`, with the `THEME_MODES` runtime witness). So the pins below
* read the new owner — the tripwire fired, was triaged, and is retired.
*/

import { describe, it, expect } from 'vitest';
import { ThemeModeSchema } from '@objectstack/spec/ui';
import type { Theme as SpecTheme, ThemeModeSchema as SpecThemeModeSchema } from '@objectstack/spec/ui';
import { THEME_MODES } from '@object-ui/types';
import type { ThemeMode } from '@object-ui/types';

import type { ThemePreference } from '../types';

const specModes = (ThemeModeSchema as unknown as { options: readonly string[] }).options;

describe("'system' is this package's own legacy spelling, not the spec's", () => {
it('reads a non-empty mode enum from the spec (the probe is not vacuous)', () => {
expect(Array.isArray(specModes) && specModes.length > 0).toBe(true);
describe("'system' is this package's own legacy spelling, not the vocabulary's", () => {
it('reads a non-empty mode tuple from the owner (the probe is not vacuous)', () => {
expect(Array.isArray(THEME_MODES) && THEME_MODES.length > 0).toBe(true);
});

it('the spec never declared `system` — it spells the OS-following mode `auto`', () => {
expect(specModes).toContain('auto');
it('the vocabulary never declares `system` — the OS-following mode is spelled `auto`', () => {
expect(THEME_MODES).toContain('auto');
expect(
specModes.includes('system'),
"the spec now declares `system` too — drop the extra union member in types.ts, it is no longer a local alias",
(THEME_MODES as readonly string[]).includes('system'),
'the mode vocabulary now declares `system` too — drop the extra union member in types.ts, it is no longer a local alias',
).toBe(false);
});
});
Expand All@@ -53,26 +50,19 @@ describe("'system' is this package's own legacy spelling, not the spec's", () =>
type Assert<T extends true> = T;
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
type IsAny<T> = 0 extends 1 & T ? true : false;
type HasKey<T, K extends string> = K extends keyof T ? true : false;

type SpecThemeMode = (typeof SpecThemeModeSchema)['_zod']['input'];

describe('ThemePreference is the spec mode vocabulary plus one legacy member', () => {
describe('ThemePreference is the mode vocabulary plus one legacy member', () => {
it('is pinned at compile time', () => {
// Load-bearing import: if the spec retires `Theme`, this stops compiling
// and the rename is up for re-triage.
type _SpecIsReal = Assert<Equal<IsAny<SpecTheme>, false>>;

// The spec's `Theme` is a DOCUMENT — that is the whole reason this union
// could not keep the name.
type _SpecThemeIsADocument = Assert<HasKey<SpecTheme, 'colors'>>;
type _AndItHasAModeOfItsOwn = Assert<HasKey<SpecTheme, 'mode'>>;
// The owner's type is real (not `any`), and the runtime witness and the
// type are the same vocabulary — the pair cannot drift apart.
type _OwnerIsReal = Assert<Equal<IsAny<ThemeMode>, false>>;
type _WitnessCarriesTheType = Assert<Equal<(typeof THEME_MODES)[number], ThemeMode>>;

// …and this is exactly that mode, plus `system`. Derived, so a mode the
// spec adds appears here (and `ThemeProvider`'s branch must handle it
// `theme-mode-spec-parity.test.tsx` fails at runtime if it does not).
type _IsTheSpecModePlusLegacy = Assert<Equal<ThemePreference, SpecThemeMode | 'system'>>;
type _NothingElseIsLocal = Assert<Equal<Exclude<ThemePreference, SpecThemeMode>, 'system'>>;
// …and this is exactly that vocabulary, plus `system`. Derived, so a mode
// the owner adds appears here (and `ThemeProvider`'s branch must handle it
// `theme-mode-spec-parity.test.tsx` fails at runtime if it does not).
type _IsTheModeVocabularyPlusLegacy = Assert<Equal<ThemePreference, ThemeMode | 'system'>>;
type _NothingElseIsLocal = Assert<Equal<Exclude<ThemePreference, ThemeMode>, 'system'>>;

expect(true).toBe(true);
});
Expand Down
32 changes: 18 additions & 14 deletions packages/providers/src/__tests__/theme-mode-spec-parity.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,18 +7,24 @@
*/

/**
* Theme mode ↔ spec vocabulary parity + behavior (#2942).
* Theme mode ↔ vocabulary parity + behavior (#2942).
*
* The spec's OS-following mode is `auto` (`ThemeModeSchema`,
* `ui/theme.zod.ts`); this provider branched on its pre-spec `system`
* spelling only, so `mode: 'auto'` fell into `classList.add('auto')` — a
* class no Tailwind variant matches. The page locked to the light theme,
* the OS preference was ignored, and nothing errored.
* The vocabulary's OS-following mode is `auto`; this provider branched on its
* pre-spec `system` spelling only, so `mode: 'auto'` fell into
* `classList.add('auto')` — a class no Tailwind variant matches. The page
* locked to the light theme, the OS preference was ignored, and nothing
* errored.
*
* The vocabulary read here came from the spec's `ThemeModeSchema` until the
* spec retired its theme module (objectstack#10485) and the objectui#5716
* ruling moved ownership to `@object-ui/types` — `THEME_MODES` is the owner's
* runtime witness, kept as a tuple precisely so this parity coverage stays
* executable (the `SPEC_GESTURE_TYPES` precedent).
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render } from '@testing-library/react';
import React from 'react';
import { ThemeModeSchema } from '@objectstack/spec/ui';
import { THEME_MODES } from '@object-ui/types';
import { ThemeProvider } from '../ThemeProvider';

function mockMatchMedia(prefersDark: boolean) {
Expand All@@ -37,21 +43,19 @@ function mockMatchMedia(prefersDark: boolean) {
});
}

describe('ThemeProvider covers the spec theme-mode vocabulary', () => {
describe('ThemeProvider covers the theme-mode vocabulary', () => {
beforeEach(() => {
document.documentElement.className = '';
window.localStorage?.clear?.();
mockMatchMedia(true);
});

it('reads a non-empty enum from the spec', () => {
const raw = (ThemeModeSchema as unknown as { options?: readonly string[] }).options;
expect(Array.isArray(raw) && raw.length > 0, 'could not read ThemeModeSchema.options').toBe(true);
it('reads a non-empty mode tuple from the owner', () => {
expect(Array.isArray(THEME_MODES) && THEME_MODES.length > 0, 'could not read THEME_MODES').toBe(true);
});

it('every spec mode resolves to a real light/dark class — never a dead literal', () => {
const raw = (ThemeModeSchema as unknown as { options?: readonly string[] }).options ?? [];
for (const mode of raw) {
it('every declared mode resolves to a real light/dark class — never a dead literal', () => {
for (const mode of THEME_MODES) {
document.documentElement.className = '';
const { unmount } = render(
<ThemeProvider defaultTheme={mode as never} storageKey={`t-${mode}`}>
Expand Down
46 changes: 24 additions & 22 deletions packages/providers/src/types.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import type { ThemeModeSchema } from '@objectstack/spec/ui';
import type { ThemeMode } from '@object-ui/types';

export interface DataSourceProviderProps {
dataSource: any;
Expand All@@ -12,31 +12,33 @@ export interface MetadataProviderProps {
}

/**
* The theme MODE this provider stores and applies: the spec's `ThemeMode`
* vocabulary (`auto | light | dark`) plus the one legacy spelling this package
* still accepts.
* The theme MODE this provider stores and applies: the `ThemeMode` vocabulary
* (`auto | light | dark`) plus the one legacy spelling this package still
* accepts.
*
* Named `ThemePreference`, not `Theme` (objectui#3161, objectstack#4115 ledger
* batch 7). `@objectstack/spec/ui` owns `Theme` for a whole theme DOCUMENT —
* `{ name, label, mode, colors: { primary, background, … }, typography, … }` —
* so the local declaration was not a drifted copy of the spec's `Theme` at all;
* it was the spec's `Theme['mode']` under the spec's `Theme` name, which is the
* more misleading of the two failures: a session reading `Theme` here would
* conclude an ObjectStack theme is a string. The spec's own name for this
* concept, `ThemeMode`, is exported too, so THAT name could not be taken either
* (the "run the new name past the guard first" rule from objectui#3169 — it
* would have traded one collision for another); and it would be wrong anyway,
* because of `'system'`.
* batch 7). `Theme` names a whole theme DOCUMENT — `{ name, label, mode,
* colors: { primary, background, … }, typography, … }` — so the old local
* declaration was not a drifted copy of that document type at all; it was
* `Theme['mode']` under `Theme`'s name, which is the more misleading of the
* two failures: a session reading `Theme` here would conclude an ObjectStack
* theme is a string. `ThemeMode`, the vocabulary's own name for what this
* really is, could not be taken either (the "run the new name past the guard
* first" rule from objectui#3169 — it would have traded one collision for
* another); and it would be wrong anyway, because of `'system'`.
*
* The three real modes are now the spec enum rather than a hand copy of it, so
* a mode the spec adds arrives here and `ThemeProvider`'s branch must handle
* it. `'system'` stays as an explicit local union member: it is the pre-spec
* spelling of `auto`, still sitting in users' `localStorage`, and the provider
* treats the two identically (#2942, pinned in `theme-mode-spec-parity`).
* Reached through the schema's `_zod` carrier so this package takes no zod
* dependency — same technique as `packages/react/src/spec-input.ts`.
* Derivation history (objectui#5716): this union was born reading the spec's
* `ThemeModeSchema` through its `_zod` input carrier. The spec then retired
* its whole theme module (objectstack#10485), objectui assumed ownership of
* the theme document types, and the mode vocabulary's owner is now
* `@object-ui/types` (`ThemeMode`, with the `THEME_MODES` runtime witness) —
* so the derivation reads from there. A mode the owner adds still arrives
* here and `ThemeProvider`'s branch must handle it. `'system'` stays as an
* explicit local union member: it is the pre-spec spelling of `auto`, still
* sitting in users' `localStorage`, and the provider treats the two
* identically (#2942, pinned in `theme-mode-spec-parity`).
*/
export type ThemePreference = (typeof ThemeModeSchema)['_zod']['input'] | 'system';
export type ThemePreference = ThemeMode | 'system';

export interface ThemeProviderProps {
defaultTheme?: ThemePreference;
Expand Down
10 changes: 8 additions & 2 deletions packages/types/src/__tests__/spec-ui-schema-reexports.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,8 +105,14 @@ const DROPPED_SCHEMA_EXPORTS = [
'SpecResponsiveConfigSchema',
'BreakpointColumnMapSchema',
'BreakpointOrderMapSchema',
// theme.ts
'ThemeModeSchema',
// The `ThemeModeSchema` row (theme.ts) was removed by objectui#5716, ahead
// of the ratchet below firing: the spec retired its whole theme module
// (objectstack#10485) and the theme TYPE surface is owned by this package
// now — `theme.ts` no longer re-exports anything from `@objectstack/spec/ui`,
// so once the pin moves past the retirement the row would have asserted
// nothing. The mode vocabulary's runtime value is `THEME_MODES` (a local
// tuple, deliberately exported as a VALUE — the #2561 erasure cannot recur
// for it).
];

/**
Expand Down
30 changes: 20 additions & 10 deletions packages/types/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -699,23 +699,30 @@ import type { AppComponentSchema } from './app.js';
// Phase 2 Schemas - New Additions
// ============================================================================
export type {
// Theme System (aligned with @objectstack/spec)
// Theme System — the document vocabulary is owned HERE since objectui#5716
// (maintainer ruling 2026-08-23, option A — localize): the spec retired its
// theme module (objectstack#10485) while objectui RETAINED the theme system,
// so the types moved into `./theme` with the last-published 17.1.0 shapes as
// the blueprint. `Typography` / `BorderRadius` / `Shadow` /
// `ThemeDefinition` were DELETED under the same ruling's zero-reader rider
// (the first three live on as inline members of `Theme`).
Theme,
// `ThemeComponentSchema` RETIRED in objectui#5489 — the `type: 'theme'`
// component kind no renderer implemented. See `./theme` for the tombstone.
ThemeMode,
ColorPalette,
Typography,
BorderRadius,
Shadow,
// `Animation` / `ZIndex` retired with `theme.animation` / `theme.zIndex` in
// @objectstack/spec 17.0.0-rc.3 (objectstack#5021) — see `./theme`.
ThemeSwitcherSchema,
ThemePreviewSchema,
// Legacy aliases
ThemeDefinition,
} from './theme.js';

// Runtime witness for the theme mode vocabulary — a VALUE, so it must not sit
// inside the `export type` block above (#2561: inside one it is value-erased
// and resolves to `undefined` at runtime). Same pattern as
// `SPEC_GESTURE_TYPES` below.
export { THEME_MODES } from './theme.js';

export type {
// Report Presentation Layer (ObjectUI-specific UX enhancements:
// sections, schedule, export presets, conditional formatting, etc.).
Expand DownExpand Up@@ -972,10 +979,13 @@ export {
* ```
*/
export type * as Data from '@objectstack/spec/data';
// Deliberate public namespace export of the spec vocabulary. Note the #4171
// caveat: `UI.FormField` erases to `any` until the spec types its unions.
// eslint-disable-next-line no-restricted-imports
export type * as UI from '@objectstack/spec/ui';
// Deliberate public namespace export of the spec vocabulary — via the local
// shim module since objectui#5716, so the `UI.Theme`-family members are
// re-pointed at their owner (`./theme.ts`) and survive the spec's theme
// retirement instead of narrowing silently on the pin refresh. See the shim's
// header for the full reasoning. The #4171 caveat still applies:
// `UI.FormField` erases to `any` until the spec types its unions.
export type * as UI from './spec-ui-namespace.js';
export type * as System from '@objectstack/spec/system';
export type * as AI from '@objectstack/spec/ai';
export type * as API from '@objectstack/spec/api';
Expand Down
37 changes: 37 additions & 0 deletions packages/types/src/spec-ui-namespace.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The `UI` protocol namespace of `@object-ui/types` — the module behind
* `export type * as UI` in `index.ts`.
*
* Existence note (objectui#5716). `index.ts` used to point that namespace
* export straight at `@objectstack/spec/ui`, so the namespace tracked
* whatever the installed pin publishes — and on the dependency refresh past
* the spec's theme retirement (objectstack#10485), the `UI.Theme`-family
* members would have vanished with no error anywhere in this repo. The
* objectui#5716 ruling says that silent narrowing must stop for the theme
* family. This shim is the mechanism: an explicit re-export beats a star
* re-export of the same name, so the three theme document types below resolve
* to their owner (`./theme.ts`) — today, while the pin still publishes the
* retired names, and unchanged after the refresh removes them from the star.
*
* The REST of the namespace still tracks the spec by star — deliberately the
* same posture as the non-theme `@objectstack/spec/ui` re-export blocks in
* `index.ts`, which the same ruling left out of scope. Spec/ui names the
* refresh retires (`ThemeSchema`, `ThemeModeSchema`, `ThemeParsed`,
* `Typography`, `BorderRadius`, `Shadow`, `defineTheme`) drop out of the star
* with it; the named-type deletions on this package's own surface are
* recorded in the objectui#5716 changeset.
*/
// The star below is the sanctioned namespace re-export of the spec vocabulary
// (moved here from `index.ts:978`); the no-restricted-imports entry it trips
// guards named `FormField` imports, which a star cannot prove absent.
// eslint-disable-next-line no-restricted-imports
export type * from '@objectstack/spec/ui';
export type { Theme, ThemeMode, ColorPalette } from './theme.js';
Loading
Loading