diff --git a/.changeset/stack-themes-carrier-retired.md b/.changeset/stack-themes-carrier-retired.md
new file mode 100644
index 0000000000..9d715ee2b1
--- /dev/null
+++ b/.changeset/stack-themes-carrier-retired.md
@@ -0,0 +1,86 @@
+---
+"@objectstack/spec": minor
+---
+
+feat(spec): retire the `themes` carrier key and `ThemeSchema` — the authoring surface nothing ever applied (#10485, ADR-0049)
+
+**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
+launch-window convention ships it as `minor`; the migration prescription is
+registered under protocol major 18, where `os migrate meta` users will look).
+Maintainer ruling 2026-08-21, recorded verbatim on #10485: 「B:退役授权面 —
+收掉 `themes` 载体键与 schema,`app.branding` 留作唯一颜色面;objectui 引擎代码
+与单测保留。」
+
+`defineStack({ themes })` was a real authoring surface — parsed strictly at the
+authoring gate, ingested and stored by artifact ingest
+(`ARTIFACT_FIELD_TO_TYPE`) — with ZERO consumers past that point, measured:
+no non-test read of `.themes` or of stored `theme` items anywhere in
+core/runtime/rest/services/plugins; `theme` never in `MetadataTypeSchema`,
+`DEFAULT_METADATA_TYPE_REGISTRY` or `BUILTIN_METADATA_TYPE_SCHEMAS`; the only
+mounted `ThemeProvider` is the app-shell chrome light/dark toggle (unrelated to
+`ThemeSchema`); and no stack- or app-level key ever selected an active theme.
+An author who wrote a theme shipped it through every green gate and the console
+looked exactly the same.
+
+**What is refused:** the top-level `themes:` key. `ObjectStackDefinitionSchema`
+is a `strictObject`, so the key is deleted from the shape and the unknown-key
+rejection carries the retirement prescription via the schema's `guidance` entry
+(removal citation, why it was inert, and the `app.branding` replacement).
+`ThemeSchema`, `ColorPaletteSchema`, `TypographySchema`, `BorderRadiusSchema`,
+`ShadowSchema`, `ThemeModeSchema`, `defineTheme` and the `Theme` /
+`ThemeParsed` / `ColorPalette` / `Typography` / `BorderRadius` / `Shadow` /
+`ThemeMode` types are removed from `@objectstack/spec` / `@objectstack/spec/ui`
+(orphaned value schemas leave with their one consumer, #3950). `PUT
+/api/v1/meta/theme/:name` now gets the #8421 unrecognised-type refusal — the
+`themes: 'theme'` fold left `PLURAL_TO_SINGULAR` and with it the generated
+URL-spelling contract — instead of the pre-#10194 store-anything branch.
+
+**What stays:** `app.branding.primaryColor` / `accentColor` — the one live
+colour surface (objectui's `AppShell` reads it and derives `--primary`,
+`--accent` and friends) — plus objectui's `ThemeEngine` / `ThemeContext` engine
+code and their unit tests, explicitly retained by the ruling. Legacy stored
+`theme` rows are untouched: reads still answer, DELETE still works, and
+`applyConversionsToStoredItem` passes them through unchanged.
+
+The retirement kit:
+
+- strict deletion + `guidance` prescription at the stack schema
+ (`packages/spec/src/stack.zod.ts`); `packages/spec/src/ui/theme.zod.ts`
+ deleted whole
+- ADR-0087 registration: retired-def entries `ui/Theme`, `ui/ThemeMode`,
+ `ui/ColorPalette`, `ui/Typography`, `ui/BorderRadius`, `ui/Shadow` and the
+ D3 **semantic** entry `stack-themes-carrier-retired` (protocol 18). Semantic
+ rather than a D2 conversion on the lossless-only scope guard: a stack may
+ declare N themes and M apps, so which palette entry becomes which app's
+ `branding.primaryColor` is a judgment the transform cannot make — the entry
+ prescribes the hand move instead of auto-deleting authored content
+- ingest mapping removed (`packages/metadata/src/plugin.ts`), CLI stats row
+ removed, showcase example re-based on app branding
+- pin tests: `stack-top-level-strict.test.ts` (refusal carries `#10485` +
+ `app.branding` + no rename suggestion; replacement parses green; no theme
+ export survives on `./ui`) and `protocol.unrecognised-meta-type.test.ts`
+ (`/meta/theme` refused with the ADR-0112 envelope, nothing stored)
+- generated baselines/docs follow the schema (`authorable-surface/`,
+ `json-schema.manifest/`, api-surface, export-origins, meta-url-spelling,
+ spec-changes, upgrade guide, reference docs, skill references)
+
+## FROM → TO
+
+```ts
+// before — parsed green, stored by artifact ingest, applied by NOTHING:
+defineStack({
+ themes: [{ name: 'corporate', label: 'Corporate', mode: 'light',
+ colors: { primary: '#7C3AED' } }],
+});
+
+// after — delete the key; colour the console where something reads it:
+defineApp({
+ name: 'my_app',
+ label: 'My App',
+ branding: { primaryColor: '#7C3AED', accentColor: '#06B6D4' },
+});
+// a custom CSS variable your own stylesheet consumed has no spec slot any
+// more — move it into your own CSS.
+```
+
+
diff --git a/content/docs/getting-started/examples.mdx b/content/docs/getting-started/examples.mdx
index 47bcb0878d..032ff92250 100644
--- a/content/docs/getting-started/examples.mdx
+++ b/content/docs/getting-started/examples.mdx
@@ -277,7 +277,7 @@ os compile # Build to dist/objectstack.json
A kitchen-sink workspace built for demonstration and debugging. It exercises
nearly every metadata type, view type, and chart type in a single app — objects,
views, apps, pages, dashboards, reports, datasets, flows, jobs, agents, security
-profiles, translations, themes, webhooks, and more. Use it as a living reference
+profiles, translations, webhooks, and more. Use it as a living reference
when you want to see how a particular metadata type is authored.
```bash
diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx
index d52e165136..93397ba6e3 100644
--- a/content/docs/getting-started/quick-reference.mdx
+++ b/content/docs/getting-started/quick-reference.mdx
@@ -46,9 +46,9 @@ Core business logic and data modeling schemas.
| **[Postgres Driver](/docs/references/data/driver-postgres)** | `driver/postgres.zod.ts` | PostgresConfig | PostgreSQL configuration |
| **[Mongo Driver](/docs/references/data/driver-mongo)** | `driver/mongo.zod.ts` | MongoConfig | MongoDB configuration |
-## UI Protocol (11 of 17 schemas)
+## UI Protocol (10 of 16 schemas)
-Presentation layer - views, forms, dashboards, and themes.
+Presentation layer - views, forms, dashboards, and app branding.
| Protocol | Source File | Key Schemas | Purpose |
|:---------|:-----------|:------------|:--------|
@@ -61,7 +61,6 @@ Presentation layer - views, forms, dashboards, and themes.
| **[Action](/docs/references/ui/action)** | `action.zod.ts` | Action, ActionType | UI button actions |
| **[Component](/docs/references/ui/component)** | `component.zod.ts` | PageComponent variants | Reusable UI components |
| **[Chart](/docs/references/ui/chart)** | `chart.zod.ts` | Chart, ChartType | Chart definitions |
-| **[Theme](/docs/references/ui/theme)** | `theme.zod.ts` | Theme, ColorPalette | Theming and branding |
| **[Widget Contract](/docs/protocol/objectui/widget-contract)** ↗ | `widget.zod.ts` | FieldWidgetProps | Props a custom field widget receives — the contract is documented with ObjectUI, outside `references/ui/` |
## Kernel Protocol (17 of 31 schemas)
diff --git a/content/docs/getting-started/quick-start.mdx b/content/docs/getting-started/quick-start.mdx
index c3e0ac60f8..16c09fbb9d 100644
--- a/content/docs/getting-started/quick-start.mdx
+++ b/content/docs/getting-started/quick-start.mdx
@@ -160,7 +160,7 @@ app-showcase/
├── objectstack.config.ts
├── src/
│ ├── data/ # objects, extensions, hooks, mappings, analytics, seed
-│ ├── ui/ # views, pages, apps, actions, dashboards, reports, datasets, themes
+│ ├── ui/ # views, pages, apps, actions, dashboards, reports, datasets
│ ├── automation/ # flows, jobs, webhooks
│ ├── security/ # permission sets, positions, sharing rules, capabilities
│ ├── system/ # apis, books, connectors, datasources, emails, server, translations
@@ -203,7 +203,6 @@ is the full authorable set, ordered data → interface → automation → integr
| `dashboards` | Chart and metric boards | [Dashboards](/docs/ui/dashboards) |
| `reports` | Saved analytical queries with grouping and totals | [Reference](/docs/references/ui/report) |
| `actions` | Buttons and bulk operations, with CEL visibility | [Actions](/docs/ui/actions) |
-| `themes` | Color tokens and branding | [Reference](/docs/references/ui/theme) |
| `translations` | i18n bundles for labels and messages | [Translations](/docs/ui/translations) |
| `docs` | In-app Markdown documentation items | [Doc Pages](/docs/ui/doc-pages) |
| `books` | Ordered navigation spines over those doc items | [Reference](/docs/references/system/book) |
diff --git a/content/docs/protocol/objectui/widget-contract.mdx b/content/docs/protocol/objectui/widget-contract.mdx
index c083846ffd..52feaec6f6 100644
--- a/content/docs/protocol/objectui/widget-contract.mdx
+++ b/content/docs/protocol/objectui/widget-contract.mdx
@@ -269,54 +269,29 @@ parse-clean no-op. Large datasets page via the view's `pagination` block.
## Theme
-ObjectUI theming is defined by `ThemeSchema` in `packages/spec/src/ui/theme.zod.ts`, and it declares **ten** authorable keys — that list is the whole vocabulary. Five of them are identity and inheritance: `name` (a snake_case identifier) and `label` are required, `description` is optional, `mode` is one of `light`, `dark`, or `auto` (default `light`), and `extends` names another theme to inherit from. The other five are the token surface:
-
-| Key | Required | Shape | What it puts on the document |
-|-------|-------|-------|-------|
-| `colors` | ✅ | `ColorPalette`; only `primary` is mandatory inside it | The shadcn palette variables — **renamed on the way out**: `surface` emits `--card`, `text` emits `--foreground`, `textSecondary` emits `--muted-foreground`, `disabled` emits `--muted`, `error` emits `--destructive`. |
-| `borderRadius` | — | A scale object (`none`/`sm`/`base`/`md`/`lg`/`xl`/`2xl`/`full`), not a single token | `--radius-sm`, `--radius-md`, ... — and `base` emits the bare `--radius`. |
-| `shadows` | — | The same stops plus `inner` | `--shadow-sm`, `--shadow-md`, ... — and `base` emits the bare `--shadow`. |
-| `typography` | — | One live key since #5021: `fontFamily.base` | `--font-sans`. |
-| `customVars` | — | A flat string map | Every entry verbatim, `--` prefixed if you omit it: `z-modal: '1050'` emits `--z-modal: 1050`. This is the declared door for any other custom property. |
-
-```yaml
-name: corporate
-label: Corporate
-mode: light
-colors:
- primary: '#2563eb'
- surface: '#ffffff'
- text: '#111827'
-borderRadius:
- base: 0.25rem
- md: 0.375rem
-shadows:
- base: '0 1px 3px rgb(0 0 0 / 0.1)'
-typography:
- fontFamily:
- base: 'Inter, system-ui, sans-serif'
-customVars:
- space-4: 1rem
+The `themes` authoring surface was **retired** in `@objectstack/spec` 17.1
+(#10485, ADR-0049 enforce-or-remove). Authored themes were parsed and stored,
+but no framework package ever read them back and nothing selected an active
+theme, so a declared theme never changed anything on screen. A stack that still
+declares `themes:` is now refused at parse with a prescription pointing here.
+
+**`app.branding` is the one colour surface.** Set
+`branding.primaryColor` / `branding.accentColor` on the app
+(`packages/spec/src/ui/app.zod.ts`): objectui's `AppShell` converts them to
+HSL and writes `--primary`, `--primary-foreground`, `--ring`,
+`--sidebar-primary`, `--sidebar-ring`, `--accent` and `--accent-foreground`,
+re-deriving them on the light/dark flip.
+
+```ts
+export const MyApp = defineApp({
+ name: 'my_app',
+ label: 'My App',
+ branding: { primaryColor: '#2563eb', accentColor: '#06b6d4' },
+});
```
-`ThemeSchema` is `.strict()` (#4001), so a key outside that list is a **parse failure** at `defineStack({ themes })` / `defineTheme()`, carrying its own prescription — not a value silently dropped while the theme still reports valid.
-
-
- **Older theme samples no longer parse** — check yours before copying it forward.
- **#3494** removed `spacing`, `breakpoints`, `logo`, `density`, `wcagContrast`,
- `rtl`, `touchTarget` and `keyboardNavigation`: the theme engine never emitted a
- variable for any of them, so authoring one was a silent no-op. **#5021**
- (`@objectstack/spec` 17.0.0, ADR-0049) removed `animation`, `zIndex`, the
- `typography.fontSize` / `fontWeight` / `lineHeight` / `letterSpacing` scales and
- `typography.fontFamily.heading` / `mono`: those *were* emitted, faithfully and
- for years, but no first-party component or stylesheet has ever read one.
- The prescription in both waves is `customVars`, and it is a byte-for-byte
- replacement — `customVars` carrying `font-size-lg: 1.125rem` puts exactly the
- same `--font-size-lg` on the document the retired scale did. Run
- `os migrate meta --from 16` to rewrite stored metadata automatically.
-
-
-Widgets inherit the active theme automatically; they do not each carry their own copy of the palette or the font stack.
+Widgets read the resulting CSS variables (`hsl(var(--primary))`, …); they do
+not each carry their own copy of the palette.
## What's Next?
diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx
index 3dc3f1d836..0241c62d32 100644
--- a/content/docs/references/index.mdx
+++ b/content/docs/references/index.mdx
@@ -1,6 +1,6 @@
---
title: Protocol Reference
-description: Every schema published by @objectstack/spec — 1589 schemas across 14 protocol modules
+description: Every schema published by @objectstack/spec — 1583 schemas across 14 protocol modules
---
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -32,8 +32,8 @@ counts are sums of the rows they head. Regenerate with
| [Shared Protocol](/docs/references/shared) | 8 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 36 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
-| [UI Protocol](/docs/references/ui) | 17 | 162 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
-| **Total** | **199** | **1589** | 14 protocol modules |
+| [UI Protocol](/docs/references/ui) | 16 | 156 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
+| **Total** | **198** | **1583** | 14 protocol modules |
---
@@ -364,7 +364,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a
## UI Protocol
-**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **17 pages, 162 schemas**
+**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 156 schemas**
Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer.
@@ -385,7 +385,6 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay
| [`report.zod.ts`](/docs/references/ui/report) | `JoinedReportBlock`, `Report`, `ReportChart`, `ReportSort`, `ReportType` |
| [`responsive.zod.ts`](/docs/references/ui/responsive) | `BreakpointColumnMap`, `BreakpointName`, `BreakpointOrderMap`, `ResponsiveConfig`, `ResponsiveStyles`, `StyleMap` |
| [`sharing.zod.ts`](/docs/references/ui/sharing) | `SharingConfig` |
-| [`theme.zod.ts`](/docs/references/ui/theme) | `BorderRadius`, `ColorPalette`, `Shadow`, `Theme`, `ThemeMode`, `Typography` |
| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `FormButtonConfig`, `FormField`, `FormFieldPublicPicker`, `FormSection`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListMapConfig`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` |
---
diff --git a/content/docs/references/ui/index.mdx b/content/docs/references/ui/index.mdx
index 19cb0ebd07..7fee054ec5 100644
--- a/content/docs/references/ui/index.mdx
+++ b/content/docs/references/ui/index.mdx
@@ -21,6 +21,5 @@ This section contains all protocol schemas for the ui layer of ObjectStack.
-
diff --git a/content/docs/references/ui/meta.json b/content/docs/references/ui/meta.json
index 5eedbea53a..761d57bf10 100644
--- a/content/docs/references/ui/meta.json
+++ b/content/docs/references/ui/meta.json
@@ -16,7 +16,6 @@
"report",
"---Interaction & Layout---",
"responsive",
- "theme",
"---Platform---",
"i18n",
"notification",
diff --git a/content/docs/references/ui/theme.mdx b/content/docs/references/ui/theme.mdx
deleted file mode 100644
index d8e46c344d..0000000000
--- a/content/docs/references/ui/theme.mdx
+++ /dev/null
@@ -1,144 +0,0 @@
----
-title: Theme
-description: Theme protocol schemas
----
-
-{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
-
-Color Palette Schema
-Defines brand colors and their variants.
-
-
-**Source:** `packages/spec/src/ui/theme.zod.ts`
-
-
-## TypeScript Usage
-
-```typescript
-import { BorderRadiusSchema, ColorPaletteSchema, ShadowSchema, ThemeSchema, ThemeModeSchema, TypographySchema } from '@objectstack/spec/ui';
-import type { BorderRadius, ColorPalette, Shadow, Theme, ThemeMode, Typography } from '@objectstack/spec/ui';
-
-// Validate data
-const result = BorderRadiusSchema.parse(data);
-```
-
----
-
-## BorderRadius
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **none** | `string` | optional | No border radius (0) |
-| **sm** | `string` | optional | Small border radius (e.g., 0.125rem) |
-| **base** | `string` | optional | Base border radius (e.g., 0.25rem) |
-| **md** | `string` | optional | Medium border radius (e.g., 0.375rem) |
-| **lg** | `string` | optional | Large border radius (e.g., 0.5rem) |
-| **xl** | `string` | optional | Extra large border radius (e.g., 0.75rem) |
-| **2xl** | `string` | optional | 2X large border radius (e.g., 1rem) |
-| **full** | `string` | optional | Full border radius (50%) |
-
-
----
-
-## ColorPalette
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **primary** | `string` | ✅ | Primary brand color (hex, rgb, or hsl) |
-| **secondary** | `string` | optional | Secondary brand color |
-| **accent** | `string` | optional | Accent color for highlights |
-| **success** | `string` | optional | Success state color (default: green) |
-| **warning** | `string` | optional | Warning state color (default: yellow) |
-| **error** | `string` | optional | Error state color (default: red) |
-| **info** | `string` | optional | Info state color (default: blue) |
-| **background** | `string` | optional | Background color |
-| **surface** | `string` | optional | Surface/card background color |
-| **text** | `string` | optional | Primary text color |
-| **textSecondary** | `string` | optional | Secondary text color |
-| **border** | `string` | optional | Border color |
-| **disabled** | `string` | optional | Disabled state color |
-| **primaryLight** | `string` | optional | Lighter shade of primary |
-| **primaryDark** | `string` | optional | Darker shade of primary |
-| **secondaryLight** | `string` | optional | Lighter shade of secondary |
-| **secondaryDark** | `string` | optional | Darker shade of secondary |
-
-
----
-
-## Shadow
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **none** | `string` | optional | No shadow |
-| **sm** | `string` | optional | Small shadow |
-| **base** | `string` | optional | Base shadow |
-| **md** | `string` | optional | Medium shadow |
-| **lg** | `string` | optional | Large shadow |
-| **xl** | `string` | optional | Extra large shadow |
-| **2xl** | `string` | optional | 2X large shadow |
-| **inner** | `string` | optional | Inner shadow (inset) |
-
-
----
-
-## Theme
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **name** | `string` | ✅ | Unique theme identifier (snake_case) |
-| **label** | `string` | ✅ | Human-readable theme name |
-| **description** | `string` | optional | Theme description |
-| **mode** | `Enum<'light' \| 'dark' \| 'auto'>` | optional (default: `"light"`) | Theme mode (light, dark, or auto) |
-| **colors** | `{ primary: string; secondary?: string; accent?: string; success?: string; … }` | ✅ | Color palette configuration |
-| **typography** | `{ fontFamily?: object }` | optional | Typography settings |
-| **borderRadius** | `{ none?: string; sm?: string; base?: string; md?: string; … }` | optional | Border radius scale |
-| **shadows** | `{ none?: string; sm?: string; base?: string; md?: string; … }` | optional | Box shadow effects |
-| **animation** | `never` | optional | [REMOVED] `theme.animation` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — unlike the #3494 props above, the engine DID emit `--duration-*` and `--timing-*`, faithfully and for years; what never existed was a reader. No first-party component or stylesheet has ever consumed one, so every transition ran at the renderer default whatever you declared. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "duration-fast": "150ms", "timing-ease_in": "cubic-bezier(0.4, 0, 1, 1)" }` emits exactly the same properties). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
-| **zIndex** | `never` | optional | [REMOVED] `theme.zIndex` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--z-base` … `--z-tooltip` and nothing read one, so an overlay you "lifted" still stacked by document order. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "z-modal": "1050" }` emits exactly the same `--z-modal`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
-| **customVars** | `Record` | optional | Custom CSS variables (key-value pairs) |
-| **extends** | `string` | optional | Base theme to extend from |
-| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
-| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
-| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
-| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
-| **_packageId** | `string` | optional | Owning package machine id. |
-| **_packageVersion** | `string` | optional | Owning package version. |
-| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
-
-
----
-
-## ThemeMode
-
-### Allowed Values
-
-* `light`
-* `dark`
-* `auto`
-
-
----
-
-## Typography
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **fontFamily** | `{ base?: string }` | optional | |
-| **fontSize** | `never` | optional | [REMOVED] `theme.typography.fontSize` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--font-size-xs` … `--font-size-4xl` faithfully and NO first-party component or stylesheet has ever read one, so a declared type scale was real CSS that styled nothing. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "font-size-lg": "1.125rem" }` emits exactly the same `--font-size-lg`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
-| **fontWeight** | `never` | optional | [REMOVED] `theme.typography.fontWeight` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--font-weight-*` and nothing read it, so text rendered at the inherited weight whatever you declared. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "font-weight-semibold": "600" }` emits exactly the same `--font-weight-semibold`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
-| **lineHeight** | `never` | optional | [REMOVED] `theme.typography.lineHeight` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--line-height-*` and nothing read it, so every block kept its inherited leading. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "line-height-relaxed": "1.75" }` emits exactly the same `--line-height-relaxed`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
-| **letterSpacing** | `never` | optional | [REMOVED] `theme.typography.letterSpacing` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--letter-spacing-*` and nothing read it, so tracking never moved. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "letter-spacing-wide": "0.025em" }` emits exactly the same `--letter-spacing-wide`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
-
-
----
-
diff --git a/content/docs/ui/index.mdx b/content/docs/ui/index.mdx
index 32997c2285..769d1128c7 100644
--- a/content/docs/ui/index.mdx
+++ b/content/docs/ui/index.mdx
@@ -1,6 +1,6 @@
---
title: UI Engine
-description: Apps, views, dashboards, themes, and public forms — server-driven UI declared as metadata and rendered by the ObjectUI runtime.
+description: Apps, views, dashboards, branding, and public forms — server-driven UI declared as metadata and rendered by the ObjectUI runtime.
---
# UI Engine
@@ -32,7 +32,7 @@ export const CrmApp = App.create({
- **Apps** group navigation, branding, and entry points for one audience.
- **Views** present object records as `grid` (the standard data table), `kanban`, `gallery`, `calendar`, `timeline`, `gantt`, `map`, `chart`, or `tree`. Forms are their own view kind with per-mode layouts ([Views](/docs/ui/views)).
- **Pages** compose free-form layouts from widgets; **Dashboards** combine charts, reports, and datasets for analytics.
-- **Themes** define palettes, a base font family, radii and shadows as metadata — the showcase example ships light and dark theme variants.
+- **Branding** colours an app: `app.branding.primaryColor` / `accentColor` drive the console's `--primary` / `--accent` variables. (The standalone `themes` collection was retired in spec 17.1, ADR-0049 — it was parsed but never applied.)
- **Public forms** expose a scoped slice of an object to external audiences through **anonymous entry routes** for public data collection ([Forms](/docs/ui/forms), [public data collection](/docs/ui/public-data-collection)).
- The **Setup App** — the platform's built-in administration UI — is itself rendered from the same protocol ([Setup App](/docs/ui/setup-app)).
@@ -41,12 +41,8 @@ export const CrmApp = App.create({
-
-
-
-
diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx
index 7f60c43fe7..11f42ab61c 100644
--- a/content/docs/ui/react-pages.mdx
+++ b/content/docs/ui/react-pages.mdx
@@ -182,8 +182,10 @@ a modal's `bg-black/50` backdrop rendered fully transparent in production.
Style a react page two ways instead:
**1. Layout and chrome — inline `style={{ … }}` with theme tokens.** Colors come from the
-active theme as `hsl(var(--token))`, so the page follows light/dark and any theme the
-deployment installs:
+base stylesheet's shadcn token set as `hsl(var(--token))` — the values are bare HSL
+triples, and `app.branding.primaryColor` / `accentColor` re-derive `--primary` /
+`--accent` on the light/dark flip — so the page follows light/dark and the app's
+branding:
```jsx
` … — the #4909 open shape at the runtime. Closed anyway, on two measurements: `.strip` already discarded those extras before the engine saw them (so no author depends on the openness and nothing the renderer receives changes), and `customVars` is a DECLARED escape hatch that emits an arbitrary CSS custom property by name, so closing the token scales removes no capability and only removes a second, undocumented way to spell one — the way whose typos are indistinguishable from intent. Curation is measured throughout: the shadcn vocabulary (`card`→`surface`, `foreground`→`text`, `destructive`→`error`) comes from objectui's own `COLOR_TO_CSS_MAP`, which RENAMES every palette key on the way out; `md`→`base` on `fontSize` and `base`→`normal` on `fontWeight` are a same-file scale disagreement (`borderRadius`/`shadows` declare `md`, `fontSize` does not); `radius`→`base` because `base` is emitted as the bare `--radius`, the one radius variable objectui's CSS actually reads; and `easeIn`→`ease_in` because `animation.timing` is the file's single snake_case vocabulary, so the camelCase spelling is an author obeying AGENTS.md #3 rather than making a typo. The eight #3494 removals get one distinct tombstone each. ⚠️ **Two of those tombstones deliberately prescribe NO replacement slot**: `touchTarget`/`keyboardNavigation` read like they should point at `ui/touch.zod.ts`/`ui/keyboard.zod.ts`, which 批 13 measured as having no carrier at all — prescribing them would walk an author out of a loud rejection into a silent one, the ledger's finding 7. **#4988 then retired both modules outright**, so the two tombstones' refusal to name a replacement is now the only correct wording available: had they pointed at `ui/touch.zod.ts` / `ui/keyboard.zod.ts`, that prescription would today name a deleted file — finding 7 with an extra major on top. ⚠️ **Separately filed — and ANSWERED at #5021, which is why this row's site count fell 14 → 6.** 批 15 recorded that `--font-size-*`, `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--z-*`, `--duration-*`, `--timing-*`, `--font-heading` and `--font-mono` have ZERO first-party consumers (only the colour vars, `--radius*`, `--shadow*` and `--font-sans` are read), and refused to act on it inside a strictness batch: that is ADR-0049 liveness, not unknown keys, and the two must not be run together — strictness makes a dropped key loud, it cannot make a slot live. The refusal was correct and the separation is what made the follow-up answerable. #5021 re-measured against objectui `main` (2026-08-04) with `--font-sans`/`--radius`/`--shadow`/`--primary` as positive controls **in the same run**, the maintainer ruled RETIRE over both alternatives (wire consumers / bless as a public token surface — the latter rejected as a stability promise attached to a slot the platform's own UI ignores, the #4583 shape), and `typography.fontSize`/`.fontWeight`/`.lineHeight`/`.letterSpacing`, `typography.fontFamily.heading`/`.mono`, `animation` and `zIndex` are now `retiredKey()` tombstones prescribing `customVars`. **Note what this row's arithmetic does NOT say**: the eight sites left `ui/` from the `strict` column (120 → 112), and `strip` is unchanged at 75 — a retirement removes closed doors, so it cannot move this ratchet's open-site debt in either direction. The two campaigns stayed disjoint to the end. ⚠️ The prescription is `customVars` **because it was measured live**, not because it is the nearest-looking slot: the engine emits each entry as `--: ` verbatim, so every retired variable is reproducible byte for byte and the retirement removes no capability — the distinction from `touchTarget`/`keyboardNavigation` two sentences up, which got NO replacement precisely because theirs would have been a guess. The five aliases pointing at the retired keys (`animations`/`motion`/`transitions` → `animation`, `layers`/`stacking` → `zIndex`) and the seven pointing into the retired typography scales were **deleted with their targets**, not re-pointed — leaving them would answer an author with "did you mean `zIndex`?" and then reject `zIndex`, finding 7's exact shape, and this file has now signposted that failure mode three times |
| `app.zod.ts` | authorable | **strict as of #4001 PR B** — `AppSchema` + branding / area / context-selector / contribution, and the nav-item union converted to `z.discriminatedUnion('type', …)` (the union-error question, settled empirically: matched-branch-only errors, exact recursive paths, `toJSONSchema` clean). Per-target `params` stay open. PR A (#4142) tombstoned the seven audit-dead keys first |
| `dashboard.zod.ts` | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DashboardWidgetSchema` has been strict since the ADR-0021 cutover; 批 14 closed the two NESTED holes inside it (`compareTo`'s object arm, `layout`), the same strict-shell-over-strip-children silhouette 批 13 found on `page.components[]`. `DashboardWidgetOptionsSchema` stays `passthrough` **deliberately** (renderer escape hatch) and the `responsive` tombstone (#4876) is untouched. ⚠️ **The `compareTo` union caveat this row carried is RESOLVED, and it is the one entry in this table whose limit was dissolved rather than worked around.** 批 14 recorded that `compareTo` was a UNION, so its curated prescription was produced but never delivered — `zodIssuesToFields` maps only top-level issues and a failed union collapses to a bare `Invalid input` (#5014) — with the rejection itself unaffected. **#5011 removed the union**: the slot converged onto the analytics executor's own contract, `{ kind, dimension? }`, a plain strict object whose message IS top-level. The reason was not the message, it was worse — all three declared arms were broken on the ADR-0021 dataset path (the two strings silently dropped by the renderer, `{ offset }` throwing `compareTo requires a timeDimension "undefined"`), while all three worked on the legacy inline path: same key, two fates, the failing one blessed. The union-free shape is the design benefit, pinned in `dashboard-compareto.test.ts` so it cannot silently return. **#5014 still binds every OTHER curated message this campaign has put inside a union arm** — this row is one slot's correction, not the finding's retraction. ⚠️ **#5010 retired four more widget keys and moved this row's posture by nothing, which is the point.** The `#4956` drill gave `DashboardWidgetSchema`'s 22 widget-level keys their first per-key verdicts and found six dead; `actionUrl`/`actionType`/`actionIcon` (a per-widget action BUTTON no renderer in either repo has ever drawn — all 14 `actionUrl` reads in `DashboardRenderer` are scoped to `header.actions[]`) and `aria` (ARIA attributes that never reached the DOM — the dashboard-level `aria` the #3896 sweep removed, one level down) are now `retiredKey` tombstones beside `responsive`. **Strip sites remain 0 and the strictness verdict is untouched**, because a retirement is ADR-0049 work and this ratchet is not: closing a door makes a *dropped* key loud, it cannot make a *declared* one live — the same boundary `theme.zod.ts` records two rows up, met here from the other side. The removal also settled a second-order cost the strictness campaign could never have reached: `packages/lint`'s dashboard action-ref rule enforced ERROR-severity reference integrity on `widgets[].actionUrl`, its docblock calling the key "the per-widget button" and claiming to mirror a runtime dispatch that does not exist, so an author could FAIL A BUILD because a control that cannot render pointed at an action that also did not — an enforcement gate sustaining the very false affordance ADR-0049 wrote it to delete. That widget branch is gone, pinned. ⚠️ **`colorVariant`, the fifth dead key, is deliberately NOT retired here and this row must not be read as closing it**: the rewrite target the #4956 triage assumed (`options.colorVariant`) measured dead too — `options` only reaches a renderer through `componentSchema` on the INLINE path, and `dataset` is required on this schema, so every spec-authorable widget is dataset-bound and renders through `DatasetWidget`, which has no colour affordance at all. Moving the key there would relocate 16 authored sites from one dead slot to another and mint a second inert key. Returned for adjudication; `chartConfig`'s dashboard-face inertness (11 of 12 keys, #5175) is the same shape on the neighbouring slot |
| `widget.zod.ts` | ~~authorable (p)~~ **no door** | **no authoring door (measured, #4001 批 16)** — the `(p)` resolved NEGATIVE for the whole file, the second such run after 批 13's five. Three independent measurements on 2026-08-04: (1) nothing under `packages/spec/src` imports this module except the `ui/index.ts` barrel, so no schema anywhere declares a carrier key for a widget shape — `field.widget` is a `z.string()` naming a registered *component* and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack` (4 766 nodes) reaches none of the six shapes, while `PageSchema` / `ObjectListViewSchema` resolve in the same run, a fresh `z.object` and a deliberate look-alike both resolve unreachable, and a synthetic carrier flips all six to reachable; (3) zero `.parse()` / `.safeParse()` in `objectstack`, `objectui` or `cloud` outside this file's own tests — objectui re-exports the inferred TYPES only and under different names (`RuntimeWidgetManifest` / `FieldWidgetComponentProps`, #4115 / #3161), and a `cloud` code search returns 0 for every symbol against a working index (`"@objectstack/spec"` → 345). ADR-0049 enforce-or-remove is **#5055**. ⚠️ **The campaign's own BFS said REACHABLE on the first run** — a false positive in the derived-clone bridge, filed as **#5056**: zod's `.describe()` returns a clone that SHARES the original `_zod.def`, so `WidgetManifestSchema.name` / `.label` (a described `SnakeCaseIdentifierSchema` / `I18nLabelSchema`) are def-identical to the same leaves on live schemas, and a bridge firing on ANY one shared property links two unrelated shapes. 2 shared keys of 20. The error is one-directional — it can only manufacture a door, i.e. it can only make a batch tighten something dead. Corrected to whole-shape overlap in `ui/door-reachability.testkit.ts` and pinned in `widget.test.ts` ✅ **#5055 ANSWERED the ADR-0049 call, and the answer SPLIT 8/1** (maintainer ruling 2026-08-06; window moved v18 → v17 on 2026-08-07). Eight of the nine sites were REMOVED — `WidgetManifestSchema`, `WidgetLifecycleSchema`, `WidgetEventSchema`, `WidgetPropertySchema` and `WidgetSourceSchema` (3 union branches) — after all three measurements above were re-run on `origin/main` with their controls passing in the same run. Route 3 ("nothing parses it → neither"): no carrier key means no shape for a `retiredKey()` tombstone and no source for a D2 conversion, so the declared record is the D3 `SemanticMigration` `ui-widget-i18n-family-retired` plus `RETIRED_DEFS_BY_MAJOR`. `WidgetManifest.performance`'s own tombstone (#3896) was subsumed by the removal of the shape that carried it. ⚠️ **The NINTH site, `FieldWidgetPropsSchema`, was KEPT — do not finish this file.** Its evidence shape differs and the difference arrived one day before 批 16 measured: it is a REACT PROPS CONTRACT, never authorable (absent from `authorable-surface/` and `json-schema.manifest/` — `onChange` is a `z.function()`), so "zero parse" is its design rather than its defect; and objectui PR #3289 (merged 2026-08-03) renamed `@object-ui/fields`' validation slot onto this contract's `error` with no alias, made the form renderer produce it, and pinned it in `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` as a deliberate tripwire — "the day the spec stops exporting `FieldWidgetProps`, this file stops compiling". Re-verified on objectui `origin/main` 2026-08-07. That is a live cross-repo compile-time consumer, and `tsc` is where a props contract is enforced. So this row's remaining site stays `no door` **and stays**: unreachability is not the retirement trigger for a shape that was never authorable. Pinned bidirectionally in `ui/widget-i18n-retirement.test.ts`. ⚠️ The #5056 fixture moved with the schema: `door-reachability.testkit.test.ts` rebuilds the same 2-of-19 shared-leaf shape locally, so the instrument's regression bound is still measured rather than remembered |
@@ -660,6 +659,8 @@ sites left to be a verdict about.
| `report.zod.ts` | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `ReportSchema` was already strict; `ReportSortSchema` and `JoinedReportBlockSchema` were not. The order key is the THIRD spelling of "sort" an author meets (`SortNodeSchema`'s `{field, order}`, the widget's flat `sortBy`/`sortOrder`, this `{by, direction}`), and the mappings run in opposite directions, so none is inferrable. ⚠️ `ReportSchema`'s OWN alias table carries a live false prescription (`filter` → `filters`, a key it also rejects; the real key is `runtimeFilter`) — out of 批 14's scope, filed as #5013 and pinned as a known defect in `strictness-batch14.test.ts` so the list cannot outlive it |
| `sharing.zod.ts` | authorable | **Was this ledger's first `split` row — one file, two verdicts — and #5015 resolved the dead half, so the split is now history rather than a live classification.** `SharingConfigSchema` is a **live door** and is all that remains: `FormViewSchema.sharing` carries it, `rest-server.ts` mounts the anonymous form routes on `sharing.allowAnonymous` + `sharing.publicLink`, and both example apps author it (`app-showcase` `inquiry.view.ts`, `app-crm` `lead.view.ts`) — **strict as of #4001 批 14**. `EmbedConfigSchema` was the other verdict, **`no door`**: nothing in the repo so much as named the symbol, BFS-unreachable, zero parse. It was not tightened — *"a precisely-validated dead slot is the more convincing lie"* (#4583) — and the ADR-0049 call filed as #5015 came back **REMOVE** (2026-08-04); the shape is gone. Keep the split on the record even though the file no longer needs it: it is why the classification question is asked per SCHEMA rather than per file, and a file-level verdict here would have been wrong in one direction or the other whichever way it fell — either tightening a dead slot or leaving the live anonymous-access door open |
+`theme.zod.ts` had this ledger's longest row (authorable, strict as of #4001 批 15, site count 14 → 6 after #5021's retirements) until **#10485 retired the file whole** (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, disposition B: 退役授权面). The row's two measured doors — `stack.zod.ts`'s `themes: z.array(ThemeSchema)` and `defineTheme()` — were the whole pipeline: authored themes were parsed and ingested and read by NOTHING downstream, so the carrier was strict-deleted with a `guidance` prescription (`app.branding` is the one colour surface) and every schema in the file left with it, six retired-def entries plus the D3 semantic entry `stack-themes-carrier-retired` recording the move. The row's history — the per-block passthrough split, the measured alias curation, finding 7's three signposts — lives on in the git history of this file and in the tombstone record on #5021/#3494; what this ledger keeps is the same lesson `notification.zod.ts`'s paragraph above records: strictness made the dropped keys loud, and it took ADR-0049 — a different question — to notice the whole surface was a door to nowhere.
+
`notification.zod.ts` had a row here (`authorable (p)`, resolved to **`no door`** at #4001 批 14) until #5015 retired `NotificationActionSchema` under ADR-0049 enforce-or-remove. The file survives and still exports its three presentation enums (`NotificationType` / `NotificationSeverity` / `NotificationPosition`, which objectui's toaster reads as a vocabulary) — but those are `z.enum`s, so the file now has **zero object sites** and nothing left for this ledger to classify. #4610 had already dropped two sites from it by deleting the `Notification` / `NotificationConfig` wrappers for having zero consumers; removing the action shape they would have carried is the end of that same thread. Worth keeping the trail: the row's value was never its site count but its demonstration that *having a consumer is not having an authoring door* — objectui read `NotificationActionSchema.shape.variant` as a vocabulary the whole time the shape was unreachable and unparsed.
**批 13 的五行 triage 行已在 #4988 删除,去向记在这里** — `animation.zod.ts` /
diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts
index 41abf04899..5b4b169fd8 100644
--- a/examples/app-showcase/objectstack.config.ts
+++ b/examples/app-showcase/objectstack.config.ts
@@ -42,7 +42,6 @@ import {
allCapabilities,
allSharingRules,
} from './src/security/index.js';
-import { allThemes } from './src/ui/themes/index.js';
import { ShowcaseTranslationBundle } from './src/system/translations/index.js';
import { ShowcaseSeedData } from './src/data/seed/index.js';
import { allCubes } from './src/data/analytics/showcase.cube.js';
@@ -208,7 +207,7 @@ export default defineStack({
datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset, ShowcaseInvoiceDataset, ShowcaseAccountDataset],
reports: allReports,
actions: allActions,
- themes: allThemes,
+ // `themes` retired at #10485 (ADR-0049) — recolour via `app.branding` instead.
// Logic
flows: allFlows,
diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts
index 2f361069ec..11d36f5040 100644
--- a/examples/app-showcase/src/coverage.ts
+++ b/examples/app-showcase/src/coverage.ts
@@ -274,7 +274,9 @@ export const COVERAGE = {
automation: 'automation/flows/index.ts (incl. approval nodes) + automation/webhooks/index.ts + automation/jobs/index.ts + system/emails/index.ts',
},
i18nAndTheming: {
- coveredBy: 'system/translations/index.ts (en + zh-CN), ui/themes/index.ts (light + dark)',
+ // `themes` was retired at #10485 (ADR-0049) — colour coverage now rides the
+ // apps' `branding` blocks; `app.branding` is the one colour surface.
+ coveredBy: 'system/translations/index.ts (en + zh-CN); app branding blocks (ui/apps/*)',
},
docs: {
source: 'ADR-0046 (doc metadata)',
diff --git a/examples/app-showcase/src/docs/showcase_tour_ui.md b/examples/app-showcase/src/docs/showcase_tour_ui.md
index 38012ee3ab..e60f0c1732 100644
--- a/examples/app-showcase/src/docs/showcase_tour_ui.md
+++ b/examples/app-showcase/src/docs/showcase_tour_ui.md
@@ -1,6 +1,6 @@
---
title: "Tour · UI"
-description: Guided tour of the UI domain — apps, views, pages, dashboards, reports, datasets, actions, themes, and portals.
+description: Guided tour of the UI domain — apps, views, pages, dashboards, reports, datasets, and actions.
---
# Guided tour — UI
@@ -68,9 +68,10 @@ canonical example of each linked from that page.
`action.bulkEnabled` is not a third way — it was retired in spec 17 and
its tombstone points at `bulkActions`.
-## Themes
+## Branding
-`src/ui/themes/` ships light + dark theme variants.
+Each app's `branding` block (`primaryColor` / `accentColor`) is the colour
+surface — the `themes` collection was retired in spec 17.1 (ADR-0049).
Continue with the [Automation tour](./showcase_tour_automation.md), or go
back to the [overview](./showcase_index.md).
diff --git a/examples/app-showcase/src/ui/themes/index.ts b/examples/app-showcase/src/ui/themes/index.ts
deleted file mode 100644
index e0325a37e8..0000000000
--- a/examples/app-showcase/src/ui/themes/index.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-const colors = {
- primary: '#7C3AED',
- secondary: '#6C757D',
- accent: '#06B6D4',
- background: '#FFFFFF',
- surface: '#F8F9FA',
- text: '#1F2937',
- textSecondary: '#6B7280',
- border: '#E5E7EB',
- success: '#10B981',
- warning: '#F59E0B',
- error: '#EF4444',
- info: '#3B82F6',
-};
-
-export const ShowcaseLightTheme = {
- name: 'showcase_light',
- label: 'Showcase Light',
- description: 'Default showcase theme — violet accent, light mode.',
- mode: 'light' as const,
- colors,
-};
-
-export const ShowcaseDarkTheme = {
- name: 'showcase_dark',
- label: 'Showcase Dark',
- description: 'Showcase theme — dark mode.',
- mode: 'dark' as const,
- colors: { ...colors, background: '#0B0F19', surface: '#111827', text: '#F9FAFB', textSecondary: '#9CA3AF', border: '#1F2937' },
-};
-
-export const allThemes = [ShowcaseLightTheme, ShowcaseDarkTheme];
diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts
index 3f57bed43b..8197c195d8 100644
--- a/packages/cli/src/commands/explain.ts
+++ b/packages/cli/src/commands/explain.ts
@@ -168,7 +168,10 @@ export const SCHEMAS: Record = {
{ name: 'description', type: 'string', description: 'App description' },
{ name: 'navigation', type: 'NavItem[]', description: 'Menu tree structure' },
{ name: 'logo', type: 'string', description: 'Logo URL or asset path' },
- { name: 'theme', type: 'string', description: 'Theme reference' },
+ // `theme` here described a key that is only an alias of `branding`; the
+ // `themes` metadata surface itself was retired at #10485 (ADR-0049) —
+ // `app.branding` is the one colour surface.
+ { name: 'branding', type: 'AppBranding', description: 'Brand colors and logo (primaryColor, accentColor, logo)' },
{ name: 'defaultRoute', type: 'string', description: 'Landing page route' },
],
example: `{
diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts
index f2eae30076..4be479ce67 100644
--- a/packages/cli/src/utils/format.ts
+++ b/packages/cli/src/utils/format.ts
@@ -346,7 +346,6 @@ export interface MetadataStats {
apis: number;
positions: number;
permissions: number;
- themes: number;
datasources: number;
translations: number;
plugins: number;
@@ -386,7 +385,6 @@ export function collectMetadataStats(config: any): MetadataStats {
apis: count(config.apis),
positions: count(config.positions),
permissions: count(config.permissions),
- themes: count(config.themes),
datasources: count(config.datasources),
translations: count(config.translations),
plugins: count(config.plugins),
@@ -771,7 +769,6 @@ export function printMetadataStats(stats: MetadataStats) {
['Dashboards', stats.dashboards],
['Reports', stats.reports],
['Actions', stats.actions],
- ['Themes', stats.themes],
],
},
{
diff --git a/packages/metadata-protocol/src/protocol.code-only-types.test.ts b/packages/metadata-protocol/src/protocol.code-only-types.test.ts
index e09d783ba8..3f4e1add61 100644
--- a/packages/metadata-protocol/src/protocol.code-only-types.test.ts
+++ b/packages/metadata-protocol/src/protocol.code-only-types.test.ts
@@ -396,14 +396,15 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => {
// `getMetaTypes()` synthesises those with allowRuntimeCreate:true;
// the write gate must keep agreeing with what it advertises.
const { protocol, rows } = makeProtocol(undefined);
- // [#10194] `theme` resolves a schema through UNREGISTERED_KIND_SCHEMAS
- // now, so the probe body must be spec-valid — the door under test
+ // [#6245] `webhook` resolves a schema through UNREGISTERED_KIND_SCHEMAS,
+ // so the probe body must be spec-valid — the door under test
// (authorization) is unchanged, but a malformed body would 422
- // before proving anything about it.
+ // before proving anything about it. (`theme` was the specimen until
+ // #10485 retired that kind out of the spelling contract.)
const result = await protocol.saveMetaItem({
- type: 'theme',
- name: 'rc3_probe_theme',
- item: { name: 'rc3_probe_theme', label: 'Probe', colors: { primary: '#3b82f6' } },
+ type: 'webhook',
+ name: 'rc3_probe_webhook',
+ item: { name: 'rc3_probe_webhook', label: 'Probe', object: 'task', triggers: ['create'], url: 'https://example.com/hook' },
});
expect(result.success).toBe(true);
expect(metaRows(rows).length).toBe(1);
@@ -496,9 +497,10 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => {
item: { name: 'rc3_receipt_view', object: 'task', events: ['beforeUpdate'] },
},
{
- type: 'theme', // no static registry entry (plugin-registered)
- // [#10194] spec-valid body — theme resolves a schema now.
- item: { name: 'rc3_receipt_view', label: 'Receipt', colors: { primary: '#3b82f6' } },
+ type: 'webhook', // no static registry entry (plugin-registered)
+ // [#6245] spec-valid body — webhook resolves a schema.
+ // (`theme` was the specimen until #10485 retired that kind.)
+ item: { name: 'rc3_receipt_view', label: 'Receipt', object: 'task', triggers: ['create'], url: 'https://example.com/hook' },
},
];
diff --git a/packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.ts b/packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.ts
index 6013ee70cb..a087ed74ad 100644
--- a/packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.ts
+++ b/packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.ts
@@ -30,21 +30,22 @@
* ## Why the sample spans THREE classes and not just the withdrawn four
*
* A suite that only pinned the four withdrawn types would be satisfied by a
- * blanket flip of the synthesis — which would break `PUT /meta/theme/dark`, the
- * operation the plugin path exists to serve, and would be a worse outcome than
- * the defect being closed. So every case here carries its class, and the
+ * blanket flip of the synthesis — which would break `PUT /meta/webhook/stripe`,
+ * the operation the plugin path exists to serve, and would be a worse outcome
+ * than the defect being closed. So every case here carries its class, and the
* classes are checked against each other:
*
* 1. **statically declared** (`view`, `hook`, `agent`) — the flag comes off
* the registry entry, as it always did, in both the `true` and the `false`
* direction;
- * 2. **URL-map-only plugin kinds** (`theme`, and its five siblings) — no
+ * 2. **URL-map-only plugin kinds** (`webhook`, and its four siblings) — no
* registry entry, IN the static spelling contract, still advertised and
* still mintable. This is the discriminating control: without it the change
* cannot show its narrowing is narrow;
- * 3. **withdrawn** (`policy`, `data`, `package`, `kind`) — live
- * `SchemaRegistry` keys an ordinary `registerApp` produces, in NEITHER half
- * of the static contract, advertised `false` and refused.
+ * 3. **withdrawn** (`policy`, `data`, `package`, `kind` — and, since #10485,
+ * `theme`, whose carrier retired out of the spelling contract while legacy
+ * stored rows can still hold the key live) — live `SchemaRegistry` keys,
+ * in NEITHER half of the static contract, advertised `false` and refused.
*
* Harness: the real `getMetaTypes()` and the real `saveMetaItem()` on one
* protocol instance over a stub engine, so agreement is MEASURED across the two
@@ -152,13 +153,24 @@ const SAMPLE: Array<{
item: { name: 'probe_agent', label: 'Probe' },
},
{
- type: 'theme',
+ type: 'webhook',
klass: 'url-map-only',
creatable: true,
- // [#10194] spec-valid body — `theme` resolves a schema through
- // UNREGISTERED_KIND_SCHEMAS now, and the "behaves as advertised" case
+ // [#6245] spec-valid body — `webhook` resolves a schema through
+ // UNREGISTERED_KIND_SCHEMAS, and the "behaves as advertised" case
// drives this body through a real write, so a malformed one would
// 422 and misread the ADVERTISEMENT door this suite measures.
+ // (`theme` held this slot until #10485 retired the themes surface.)
+ item: { name: 'probe_webhook', label: 'Probe', object: 'task', triggers: ['create'], url: 'https://example.com/hook' },
+ },
+ {
+ // [#10485] `theme` moved from class 2 to class 3: the carrier retired
+ // out of the spelling contract, while a legacy environment's stored
+ // rows can still hold the key in the live set — so it must be
+ // advertised `false` and refused at the mint door, like the four.
+ type: 'theme',
+ klass: 'withdrawn',
+ creatable: false,
item: { name: 'probe_theme', label: 'Probe', colors: { primary: '#3b82f6' } },
},
{ type: 'policy', klass: 'withdrawn', creatable: false, item: { name: 'probe_policy', label: 'Probe' } },
@@ -231,17 +243,17 @@ describe('#8421 — the read door and the mint door agree, across all three clas
}
});
- it('the six URL-map-only plugin kinds are ALL still advertised as creatable', async () => {
- // The blanket-flip guard, quantified rather than sampled. `theme` above
- // is the one driven end-to-end through a write; these five have no
- // hand-written spec-valid body here, so they are pinned on the door
+ it('the five URL-map-only plugin kinds are ALL still advertised as creatable', async () => {
+ // The blanket-flip guard, quantified rather than sampled. `webhook`
+ // above is the one driven end-to-end through a write; the others have
+ // no hand-written spec-valid body here, so they are pinned on the door
// that this change actually moved — the advertisement. Breaking any of
// them is the one outcome that would make this change worse than the
- // defect it closes.
+ // defect it closes. (`theme` left the set at #10485.)
const { protocol } = makeProtocol();
const listing = await protocol.getMetaTypes();
for (const kind of [
- 'analytics_cube', 'connector', 'rag_pipeline', 'sharing_rule', 'theme', 'webhook',
+ 'analytics_cube', 'connector', 'rag_pipeline', 'sharing_rule', 'webhook',
]) {
const entry = listing.entries.find((e: any) => e.type === kind);
expect(entry, `${kind} must be listed`).toBeDefined();
diff --git a/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts b/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts
index 1930d0b8c4..1f1d36ae72 100644
--- a/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts
+++ b/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts
@@ -27,7 +27,8 @@
*
* - a DECLARED type still saves (`view`), and so does a type whose only write
* channel is runtime (`hook`);
- * - a PLUGIN kind with no static registry entry still saves (`theme`) — the
+ * - a PLUGIN kind with no static registry entry still saves (`webhook`;
+ * `theme` was the specimen until #10485 retired that kind entirely) — the
* operation option C would have broken, and the one this change must not;
* - READS of an unrecognised type still answer, because the live type set
* legitimately holds keys the static contract does not (`data`, `kind` and
@@ -163,6 +164,18 @@ describe('#8421 — an unrecognised `/meta` type is refused instead of minted',
expect(metaRows(rows).length).toBe(0);
});
+ it("[#10485] `theme` is now on the refused side — the retired kind left the spelling contract", async () => {
+ // Until #10485, `theme` was a URL-map-only plugin kind and this suite's
+ // ACCEPTED specimen. The retirement removed the `themes: 'theme'` fold
+ // from `PLURAL_TO_SINGULAR`, so `/meta/theme` now earns the same
+ // ADR-0112 refusal as any minted namespace — loud, and nothing stored.
+ const { protocol, rows } = makeProtocol();
+ await expect(
+ protocol.saveMetaItem({ type: 'theme', name: 'dark', item: { name: 'dark', label: 'Dark', colors: { primary: '#3b82f6' } } }),
+ ).rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400 });
+ expect(metaRows(rows).length).toBe(0);
+ });
+
it('names the offending type and why, per the 2026-08-12 refusal ruling', async () => {
const { protocol } = makeProtocol();
await expect(
@@ -201,10 +214,12 @@ describe('#8421 — the traffic that must keep working', () => {
item: { name: 'probe_item', object: 'task', events: ['beforeUpdate'] },
},
{
- type: 'theme',
+ // `theme` held this slot until #10485 retired the themes surface
+ // (ADR-0049) and `theme` left the URL-spelling contract with it.
+ type: 'webhook',
why: 'PLUGIN kind — no static registry entry at all',
- // [#10194] spec-valid body — theme resolves a schema now.
- item: { name: 'probe_item', label: 'Probe', colors: { primary: '#3b82f6' } },
+ // [#6245] spec-valid body — webhook resolves a schema.
+ item: { name: 'probe_item', label: 'Probe', object: 'task', triggers: ['create'], url: 'https://example.com/hook' },
},
];
@@ -220,20 +235,20 @@ describe('#8421 — the traffic that must keep working', () => {
it('POSITIVE CONTROL — the plugin path still serves its first create', async () => {
// The measurement that disqualified option C, kept as a live control:
- // `theme` has ZERO items at this moment, which is exactly the state a
+ // `webhook` has ZERO items at this moment, which is exactly the state a
// live-registry check would have refused. The refusal that shipped
// consults the static contract instead, so the first create of a
// plugin kind is untouched.
const { protocol, rows } = makeProtocol();
- // [#10194] spec-valid body — theme resolves a schema now, and this
- // control measures the STATIC-contract door, not the shape check.
+ // Spec-valid body — this control measures the STATIC-contract door,
+ // not the shape check. (`theme` was the specimen until #10485.)
const result = await protocol.saveMetaItem({
- type: 'theme',
- name: 'dark',
- item: { name: 'dark', label: 'Dark', colors: { primary: '#3b82f6' } },
+ type: 'webhook',
+ name: 'first_hook',
+ item: { name: 'first_hook', label: 'First', object: 'task', triggers: ['create'], url: 'https://example.com/hook' },
});
expect(result.success).toBe(true);
- expect(metaRows(rows)[0]!.type).toBe('theme');
+ expect(metaRows(rows)[0]!.type).toBe('webhook');
});
});
diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts
index 2dc31f9e78..dd7f5062e9 100644
--- a/packages/metadata/src/plugin.ts
+++ b/packages/metadata/src/plugin.ts
@@ -73,7 +73,10 @@ const ARTIFACT_FIELD_TO_TYPE: Record = {
dashboards: 'dashboard',
reports: 'report',
actions: 'action',
- themes: 'theme',
+ // `themes: 'theme'` removed at #10485 (ADR-0049): the carrier key is
+ // retired, so a parsed artifact can no longer carry the field — and the
+ // ingest half of the dead pipeline (items stored, read by nothing) goes
+ // with the authoring half rather than surviving it as drift.
workflows: 'workflow',
flows: 'flow',
// ADR-0090 D3: stacks declare `positions` (stack.zod.ts); the retired
diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts
index 2b8602e421..38ebf1a4a4 100644
--- a/packages/objectql/src/protocol-meta.test.ts
+++ b/packages/objectql/src/protocol-meta.test.ts
@@ -1555,6 +1555,13 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
// to take one from — #6242 row 2), so it carries the pure
// no-schema fall-through control below. Each newly-bound type's own
// behaviour, door and 422 both, is pinned in the tests below.
+ //
+ // [#10485] `theme` then left the CONTRACT ITSELF (ADR-0049 — the
+ // `themes` carrier and `ThemeSchema` retired; the `themes: 'theme'`
+ // fold left `PLURAL_TO_SINGULAR`), so it is no longer a URL-map-only
+ // kind at all: both halves of its old pair now earn the #8421
+ // unrecognised refusal, pinned once below; `webhook` carries the
+ // two-halves door pin for the class.
// ───────────────────────────────────────────────────────────────
it('accepts brand-new plugin-registered type (no static registry entry)', async () => {
@@ -1755,16 +1762,23 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
// change that quietly CLOSED the door fails the "accepts" half.
// ───────────────────────────────────────────────────────────────
- it('accepts a spec-valid `theme` item (write door unchanged by the schema binding)', async () => {
+ // [#10485] `theme` left this pair: the carrier retired out of the
+ // spelling contract, so BOTH halves now earn the #8421 unrecognised
+ // refusal before any schema is consulted — pinned once below. The
+ // still-bound `webhook` door keeps the two-halves pin alive for the
+ // #6245 class.
+ it('accepts a spec-valid `webhook` item (write door unchanged by the schema binding)', async () => {
mockEngine.findOne.mockResolvedValue(null);
const result = await scoped.saveMetaItem({
- type: 'theme',
- name: 'my_theme',
+ type: 'webhook',
+ name: 'my_hook',
item: {
- name: 'my_theme',
- label: 'My Theme',
- colors: { primary: '#3b82f6' },
+ name: 'my_hook',
+ label: 'My Hook',
+ object: 'task',
+ triggers: ['create'],
+ url: 'https://example.com/hook',
},
organizationId: 'org_alpha',
});
@@ -1772,20 +1786,17 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
expect(result.success).toBe(true);
});
- it('refuses a spec-INVALID `theme` item with 422 instead of storing it unvalidated', async () => {
+ it('refuses a spec-INVALID `webhook` item with 422 instead of storing it unvalidated', async () => {
mockEngine.findOne.mockResolvedValue(null);
- // The exact body the old "plugin-registered types" case above used
- // to save with `success: true`: `tokens` is an ALIAS of
- // `customVars` (so the strict surface names the real key), and the
- // required `colors` block is missing. Stored verbatim, this is the
- // theme that fails at RENDER — the console's own styling surface —
- // with nothing at the write point to say so.
+ // Required keys missing (`object`, `triggers`, `url`): stored
+ // verbatim this is the webhook that never fires, with nothing at
+ // the write point to say so.
await expect(
scoped.saveMetaItem({
- type: 'theme',
- name: 'my_theme',
- item: { name: 'my_theme', label: 'Test', tokens: {} },
+ type: 'webhook',
+ name: 'my_hook',
+ item: { name: 'my_hook', label: 'Test' },
organizationId: 'org_alpha',
}),
).rejects.toMatchObject({
@@ -1794,6 +1805,22 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
});
});
+ it('[#10485] refuses `theme` outright — the retired kind is no longer addressable', async () => {
+ mockEngine.findOne.mockResolvedValue(null);
+
+ await expect(
+ scoped.saveMetaItem({
+ type: 'theme',
+ name: 'my_theme',
+ item: { name: 'my_theme', label: 'My Theme', colors: { primary: '#3b82f6' } },
+ organizationId: 'org_alpha',
+ }),
+ ).rejects.toMatchObject({
+ code: 'INVALID_REQUEST',
+ status: 400,
+ });
+ });
+
it('accepts a spec-valid `analytics_cube` item (write door unchanged by the schema binding)', async () => {
mockEngine.findOne.mockResolvedValue(null);
diff --git a/packages/qa/downstream-contract/src/additional-domains.fixtures.ts b/packages/qa/downstream-contract/src/additional-domains.fixtures.ts
index fe312bca7d..2d46f2037b 100644
--- a/packages/qa/downstream-contract/src/additional-domains.fixtures.ts
+++ b/packages/qa/downstream-contract/src/additional-domains.fixtures.ts
@@ -20,7 +20,6 @@ import type { SharingRule, PermissionSet } from '@objectstack/spec/security';
import type { Position } from '@objectstack/spec/identity';
import type { EmailTemplateDefinition, TranslationBundle } from '@objectstack/spec/system';
import type { Webhook } from '@objectstack/spec/automation';
-import type { Theme } from '@objectstack/spec/ui';
export const DcDatasource: Datasource = {
name: 'dc_primary',
@@ -131,18 +130,9 @@ export const DcMapping: Mapping = {
fieldMapping: [{ source: 'Name', target: 'name', transform: 'none' }],
};
-export const DcTheme: Theme = {
- name: 'dc_light',
- label: 'DC Light',
- mode: 'light',
- colors: {
- primary: '#1E6FD9',
- secondary: '#6C757D',
- background: '#FFFFFF',
- surface: '#F8F9FA',
- text: '#212529',
- },
-};
+// `DcTheme` left with `ThemeSchema` (#10485, ADR-0049 — the theme authoring
+// surface is retired; the freeze pins author state against a LIVE surface, and
+// this one no longer exists).
export const DcTranslationBundle: TranslationBundle = {
en: {
diff --git a/packages/qa/downstream-contract/test/contract.test.ts b/packages/qa/downstream-contract/test/contract.test.ts
index 7e89b1a255..e1ccf5ff67 100644
--- a/packages/qa/downstream-contract/test/contract.test.ts
+++ b/packages/qa/downstream-contract/test/contract.test.ts
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { describe, it, expect } from 'vitest';
-import { ActionSchema, ReportSchema, PageSchema, ThemeSchema } from '@objectstack/spec/ui';
+import { ActionSchema, ReportSchema, PageSchema } from '@objectstack/spec/ui';
import { DatasourceSchema, MappingSchema, CubeSchema, ObjectExtensionSchema } from '@objectstack/spec/data';
import { ConnectorSchema } from '@objectstack/spec/integration';
import { SharingRuleSchema, PermissionSetSchema } from '@objectstack/spec/security';
@@ -43,7 +43,7 @@ describe('downstream consumer contract (#2035)', () => {
['ObjectExtension', ObjectExtensionSchema, more.DcObjectExtension],
['Cube', CubeSchema, more.DcCube],
['Mapping', MappingSchema, more.DcMapping],
- ['Theme', ThemeSchema, more.DcTheme],
+ // ['Theme', …] left with ThemeSchema (#10485, ADR-0049).
['TranslationBundle', TranslationBundleSchema, more.DcTranslationBundle],
];
diff --git a/packages/rest/src/meta-unknown-type-read-refusal.test.ts b/packages/rest/src/meta-unknown-type-read-refusal.test.ts
index a07c59eb8e..a76520234e 100644
--- a/packages/rest/src/meta-unknown-type-read-refusal.test.ts
+++ b/packages/rest/src/meta-unknown-type-read-refusal.test.ts
@@ -18,8 +18,10 @@
// regression than the bug. Three populations therefore have to keep answering
// `200` with an empty collection, and each is here for a different reason:
//
-// * types in the static spelling contract (`sharing_rule`, `theme`,
-// `objects`, `api`) — declared, addressable, frequently empty;
+// * types in the static spelling contract (`sharing_rule`, `webhook`,
+// `objects`, `api`) — declared, addressable, frequently empty
+// (`theme` was one of them until #10485 retired its carrier out of the
+// contract — it now earns the refusal, pinned below);
// * live-only keys an ordinary `registerApp` produces (`data`, `kind`,
// `package`, `policy`) — outside the static contract but ENUMERATED by
// `GET /meta/types`, which is precisely why #8421 refused to raise the
@@ -168,7 +170,7 @@ describe('[#9488] the read door and the write door agree on one invented name',
describe('[#9488] a type that EXISTS and has no items still answers 200 with an empty collection', () => {
// The static spelling contract's own members — declared and addressable,
// whether or not this deployment holds a single item of them.
- it.each(['sharing_rule', 'sharingRules', 'theme', 'themes', 'objects', 'object', 'api', 'external_catalogs'])(
+ it.each(['sharing_rule', 'sharingRules', 'webhook', 'webhooks', 'objects', 'object', 'api', 'external_catalogs'])(
'declared type %s', async (type) => {
const { rest } = setup();
@@ -179,6 +181,21 @@ describe('[#9488] a type that EXISTS and has no items still answers 200 with an
},
);
+ it.each(['theme', 'themes'])(
+ '[#10485] retired spelling %s is refused — it left the static contract with its carrier',
+ async (type) => {
+ // Until #10485 both spellings answered 200-empty here. The
+ // retirement removed the `themes: 'theme'` fold, so a read now
+ // gets the same ADR-0112 refusal an invented name does.
+ const { rest } = setup();
+
+ const res = await listType(rest, type);
+
+ expect(res.statusCode).toBe(400);
+ expect(res.body?.code).toBe('INVALID_REQUEST');
+ },
+ );
+
// The population #8421 named when it REFUSED to raise the static verdict on
// the read entries: live `SchemaRegistry` keys an ordinary `registerApp`
// produces, which `GET /meta/types` enumerates. Refusing these would answer
diff --git a/packages/runtime/src/meta-compound-arity-mint-door.test.ts b/packages/runtime/src/meta-compound-arity-mint-door.test.ts
index b0ce3c21ae..a2d0437c0e 100644
--- a/packages/runtime/src/meta-compound-arity-mint-door.test.ts
+++ b/packages/runtime/src/meta-compound-arity-mint-door.test.ts
@@ -254,16 +254,17 @@ describe('#8421 — the compound `/meta` arity is not a metadata-type claim', ()
it('CONTROL — a recognised type at the simple arity is unaffected', async () => {
const { engine, dispatcher } = makeStack();
- // [#10194] spec-valid body — `theme` resolves a schema through
- // UNREGISTERED_KIND_SCHEMAS now, and this control measures the ARITY
- // door, so a malformed body would 422 and misread it.
+ // [#6245] spec-valid body — `webhook` resolves a schema through
+ // UNREGISTERED_KIND_SCHEMAS, and this control measures the ARITY
+ // door, so a malformed body would 422 and misread it. (`theme` was
+ // the specimen until #10485 retired that kind.)
const res = responseOf(await dispatcher.handleMetadata(
- '/theme/midnight', ctx(), 'PUT',
- { name: 'midnight', label: 'Midnight', colors: { primary: '#3b82f6' } },
+ '/webhook/midnight_hook', ctx(), 'PUT',
+ { name: 'midnight_hook', label: 'Midnight', object: 'task', triggers: ['create'], url: 'https://example.com/hook' },
));
expect(res.status).toBe(200);
- expect(metaRow(engine, 'theme', 'midnight')).toBeDefined();
+ expect(metaRow(engine, 'webhook', 'midnight_hook')).toBeDefined();
});
it('CONTROL — the capability gate still fires first on the compound form', async () => {
diff --git a/packages/runtime/src/meta-field-overlay-lock.test.ts b/packages/runtime/src/meta-field-overlay-lock.test.ts
index 75d62dbc49..590d1c40f0 100644
--- a/packages/runtime/src/meta-field-overlay-lock.test.ts
+++ b/packages/runtime/src/meta-field-overlay-lock.test.ts
@@ -696,26 +696,28 @@ describe('#7743 — PUT /meta/field/