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/. honours the registry overla it('#7894 POSITIVE CONTROL — a plugin-registered runtime type is still permitted', async () => { const { engine, dispatcher } = makeStack(); - // `theme` has no `DEFAULT_METADATA_TYPE_REGISTRY` entry at all. - // [#10194] spec-valid bodies — `theme` resolves a schema through - // UNREGISTERED_KIND_SCHEMAS now, and this control measures the #7894 + // `webhook` has no `DEFAULT_METADATA_TYPE_REGISTRY` entry at all. + // [#6245] spec-valid bodies — `webhook` resolves a schema through + // UNREGISTERED_KIND_SCHEMAS, and this control measures the #7894 // PERMISSION verdict, so a malformed body would 422 and misread it. + // (`theme` was the specimen until #10485 retired that kind out of the + // spelling contract.) const singular = 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(singular.status).toBe(200); - expect(metaRow(engine, 'theme', 'midnight')).toBeDefined(); + expect(metaRow(engine, 'webhook', 'midnight_hook')).toBeDefined(); // …and via its plural spelling, which the URL map carries from the // manifest map's limb — still one namespace, the singular one. const plural = responseOf(await dispatcher.handleMetadata( - '/themes/twilight', ctx(), 'PUT', - { name: 'twilight', label: 'Twilight', colors: { primary: '#3b82f6' } }, + '/webhooks/twilight_hook', ctx(), 'PUT', + { name: 'twilight_hook', label: 'Twilight', object: 'task', triggers: ['create'], url: 'https://example.com/hook' }, )); expect(plural.status).toBe(200); - expect(metaRow(engine, 'theme', 'twilight')).toBeDefined(); - expect(metaRow(engine, 'themes', 'twilight')).toBeUndefined(); + expect(metaRow(engine, 'webhook', 'twilight_hook')).toBeDefined(); + expect(metaRow(engine, 'webhooks', 'twilight_hook')).toBeUndefined(); }); it('#7894 POSITIVE CONTROL — the #7894 refusal still cannot reach a never-heard-of kind (#8421 CHANGED the boundary)', async () => { @@ -738,7 +740,7 @@ describe('#7743 — PUT /meta/field/. honours the registry overla // `@objectstack/metadata-protocol` for the full record. // // The discriminating control for that narrowing is the case directly - // ABOVE, which must stay green: `theme` has no registry entry either + // ABOVE, which must stay green: `webhook` has no registry entry either // and is still minted, because it IS in the static contract. If a // change ever breaks both, the narrowing stopped being narrow. const { engine, dispatcher } = makeStack(); diff --git a/packages/runtime/src/meta-write-org-scope.test.ts b/packages/runtime/src/meta-write-org-scope.test.ts index e012e4c912..1691246dee 100644 --- a/packages/runtime/src/meta-write-org-scope.test.ts +++ b/packages/runtime/src/meta-write-org-scope.test.ts @@ -327,8 +327,9 @@ describe('#7018 — the registry decides whether a metadata write carries the se expect(declaresOrgOverride('views')).toBe(declaresOrgOverride('view')); expect(declaresOrgOverride('flows')).toBe(declaresOrgOverride('flow')); // A runtime-registered type with no registry entry has no per-org read - // channel either, so it is env-wide too. - expect(declaresOrgOverride('theme')).toBe(false); + // channel either, so it is env-wide too. (`webhook` took this slot + // from `theme` at #10485 — the retired kind left the contract.) + expect(declaresOrgOverride('webhook')).toBe(false); // No active org in, no org out — for every type. expect(organizationIdForMetaWrite('view', undefined)).toBeUndefined(); }); diff --git a/packages/spec/PROTOCOL_MAP.md b/packages/spec/PROTOCOL_MAP.md index c63d35bff4..b7e14ba4a3 100644 --- a/packages/spec/PROTOCOL_MAP.md +++ b/packages/spec/PROTOCOL_MAP.md @@ -56,7 +56,6 @@ This document serves as the **Grand Map** of the ObjectStack specification. It l | [`chart.zod.ts`](src/ui/chart.zod.ts) | | **Charts**. Visualization configurations (Bar, Pie, Line, etc.). | | [`widget.zod.ts`](src/ui/widget.zod.ts) | | **Widgets**. Reusable UI components for dashboards and pages. | | [`component.zod.ts`](src/ui/component.zod.ts) | | **Components**. Low-level component definitions. | -| [`theme.zod.ts`](src/ui/theme.zod.ts) | | **Theming**. Color palettes and visual styling rules. | --- diff --git a/packages/spec/api-surface-signatures.json b/packages/spec/api-surface-signatures.json index 61cbb5119e..b2099d1182 100644 --- a/packages/spec/api-surface-signatures.json +++ b/packages/spec/api-surface-signatures.json @@ -21,7 +21,6 @@ "defineSharingRule": "sha256:239e6649b55712a4", "defineSkill": "sha256:b928cee8cb3c2861", "defineStack": "sha256:4d36d9603c011c44", - "defineTheme": "sha256:21563e61ca2209e8", "defineTool": "sha256:47ab5254a14f1cf5", "defineTranslationBundle": "sha256:2716798bbd575af2", "defineView": "sha256:00cf9d0d2f44e843", diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index a32100f87b..3d0bbf5610 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -155,7 +155,6 @@ "defineSharingRule (function)", "defineSkill (function)", "defineStack (function)", - "defineTheme (function)", "defineTool (function)", "defineTranslationBundle (function)", "defineView (function)", diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 972e7560f4..73a48b4051 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -47,8 +47,6 @@ "AssembledViewArtifactParsed (type)", "AssembledViewArtifactSchema (const)", "AssembledViewPartition (interface)", - "BorderRadius (type)", - "BorderRadiusSchema (const)", "BreakpointColumnMap (type)", "BreakpointColumnMapSchema (const)", "BreakpointName (type)", @@ -92,8 +90,6 @@ "ChartSeriesSchema (const)", "ChartType (type)", "ChartTypeSchema (const)", - "ColorPalette (type)", - "ColorPaletteSchema (const)", "ColumnPrefix (type)", "ColumnPrefixParsed (type)", "ColumnPrefixSchema (const)", @@ -331,25 +327,16 @@ "SelectionConfig (type)", "SelectionConfigParsed (type)", "SelectionConfigSchema (const)", - "Shadow (type)", - "ShadowSchema (const)", "SharingConfig (type)", "SharingConfigParsed (type)", "SharingConfigSchema (const)", "StyleMap (type)", "StyleMapSchema (const)", - "Theme (type)", - "ThemeMode (type)", - "ThemeModeSchema (const)", - "ThemeParsed (type)", - "ThemeSchema (const)", "TimelineConfig (type)", "TimelineConfigParsed (type)", "TimelineConfigSchema (const)", "TreeConfig (type)", "TreeConfigSchema (const)", - "Typography (type)", - "TypographySchema (const)", "UrlNavItem (type)", "UrlNavItemParsed (type)", "UrlNavItemSchema (const)", @@ -420,7 +407,6 @@ "defineForm (function)", "definePage (function)", "defineReport (function)", - "defineTheme (function)", "defineView (function)", "defineViewItem (function)", "diagnoseViewMetadata (function)", diff --git a/packages/spec/authorable-defaults/ui.json b/packages/spec/authorable-defaults/ui.json index 3945f9b364..6a7edd4d6b 100644 --- a/packages/spec/authorable-defaults/ui.json +++ b/packages/spec/authorable-defaults/ui.json @@ -105,7 +105,6 @@ "ui/SelectionConfig:type = \"none\"", "ui/SharingConfig:allowAnonymous = false", "ui/SharingConfig:enabled = false", - "ui/Theme:mode = \"light\"", "ui/TimelineConfig:scale = \"week\"", "ui/UrlNavItem:target = \"_self\"", "ui/UserActionsConfig:addRecordForm = false", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 8333f9d866..57d99fd848 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -139,14 +139,6 @@ "ui/AriaProps:ariaDescribedBy", "ui/AriaProps:ariaLabel", "ui/AriaProps:role", - "ui/BorderRadius:2xl", - "ui/BorderRadius:base", - "ui/BorderRadius:full", - "ui/BorderRadius:lg", - "ui/BorderRadius:md", - "ui/BorderRadius:none", - "ui/BorderRadius:sm", - "ui/BorderRadius:xl", "ui/BreakpointColumnMap:2xl", "ui/BreakpointColumnMap:lg", "ui/BreakpointColumnMap:md", @@ -238,23 +230,6 @@ "ui/ChartSeries:type", "ui/ChartSeries:variant", "ui/ChartSeries:yAxis", - "ui/ColorPalette:accent", - "ui/ColorPalette:background", - "ui/ColorPalette:border", - "ui/ColorPalette:disabled", - "ui/ColorPalette:error", - "ui/ColorPalette:info", - "ui/ColorPalette:primary", - "ui/ColorPalette:primaryDark", - "ui/ColorPalette:primaryLight", - "ui/ColorPalette:secondary", - "ui/ColorPalette:secondaryDark", - "ui/ColorPalette:secondaryLight", - "ui/ColorPalette:success", - "ui/ColorPalette:surface", - "ui/ColorPalette:text", - "ui/ColorPalette:textSecondary", - "ui/ColorPalette:warning", "ui/ColumnPrefix:field", "ui/ColumnPrefix:type", "ui/ColumnSummaryConfig:field", @@ -1118,39 +1093,12 @@ "ui/RowColorConfig:colors", "ui/RowColorConfig:field", "ui/SelectionConfig:type", - "ui/Shadow:2xl", - "ui/Shadow:base", - "ui/Shadow:inner", - "ui/Shadow:lg", - "ui/Shadow:md", - "ui/Shadow:none", - "ui/Shadow:sm", - "ui/Shadow:xl", "ui/SharingConfig:allowAnonymous", "ui/SharingConfig:allowedDomains", "ui/SharingConfig:enabled", "ui/SharingConfig:expiresAt", "ui/SharingConfig:password", "ui/SharingConfig:publicLink", - "ui/Theme:_lock", - "ui/Theme:_lockDocsUrl", - "ui/Theme:_lockReason", - "ui/Theme:_lockSource", - "ui/Theme:_packageId", - "ui/Theme:_packageVersion", - "ui/Theme:_provenance", - "ui/Theme:animation [RETIRED]", - "ui/Theme:borderRadius", - "ui/Theme:colors", - "ui/Theme:customVars", - "ui/Theme:description", - "ui/Theme:extends", - "ui/Theme:label", - "ui/Theme:mode", - "ui/Theme:name", - "ui/Theme:shadows", - "ui/Theme:typography", - "ui/Theme:zIndex [RETIRED]", "ui/TimelineConfig:colorField", "ui/TimelineConfig:endDateField", "ui/TimelineConfig:groupByField", @@ -1161,11 +1109,6 @@ "ui/TreeConfig:fields", "ui/TreeConfig:labelField", "ui/TreeConfig:parentField", - "ui/Typography:fontFamily", - "ui/Typography:fontSize [RETIRED]", - "ui/Typography:fontWeight [RETIRED]", - "ui/Typography:letterSpacing [RETIRED]", - "ui/Typography:lineHeight [RETIRED]", "ui/UrlNavItem:badge", "ui/UrlNavItem:badgeVariant", "ui/UrlNavItem:icon", diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index f256024404..6699a2fd4a 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -155,7 +155,6 @@ "defineSharingRule": "src/security/sharing.zod.ts#defineSharingRule (function)", "defineSkill": "src/ai/skill.zod.ts#defineSkill (function)", "defineStack": "src/stack.zod.ts#defineStack (function)", - "defineTheme": "src/ui/theme.zod.ts#defineTheme (function)", "defineTool": "src/ai/tool.zod.ts#defineTool (function)", "defineTranslationBundle": "src/system/translation.zod.ts#defineTranslationBundle (function)", "defineView": "src/ui/view.zod.ts#defineView (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 23c74a8f86..df6d7b3150 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -47,8 +47,6 @@ "AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)", "AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)", "AssembledViewPartition": "src/ui/assembled-views.zod.ts#AssembledViewPartition (interface)", - "BorderRadius": "src/ui/theme.zod.ts#BorderRadius (type)", - "BorderRadiusSchema": "src/ui/theme.zod.ts#BorderRadiusSchema (const)", "BreakpointColumnMap": "src/ui/responsive.zod.ts#BreakpointColumnMap (type)", "BreakpointColumnMapSchema": "src/ui/responsive.zod.ts#BreakpointColumnMapSchema (const)", "BreakpointName": "src/ui/responsive.zod.ts#BreakpointName (type)", @@ -92,8 +90,6 @@ "ChartSeriesSchema": "src/ui/chart.zod.ts#ChartSeriesSchema (const)", "ChartType": "src/ui/chart.zod.ts#ChartType (type)", "ChartTypeSchema": "src/ui/chart.zod.ts#ChartTypeSchema (const)", - "ColorPalette": "src/ui/theme.zod.ts#ColorPalette (type)", - "ColorPaletteSchema": "src/ui/theme.zod.ts#ColorPaletteSchema (const)", "ColumnPrefix": "src/ui/view.zod.ts#ColumnPrefix (type)", "ColumnPrefixParsed": "src/ui/view.zod.ts#ColumnPrefixParsed (type)", "ColumnPrefixSchema": "src/ui/view.zod.ts#ColumnPrefixSchema (const)", @@ -331,25 +327,16 @@ "SelectionConfig": "src/ui/view.zod.ts#SelectionConfig (type)", "SelectionConfigParsed": "src/ui/view.zod.ts#SelectionConfigParsed (type)", "SelectionConfigSchema": "src/ui/view.zod.ts#SelectionConfigSchema (const)", - "Shadow": "src/ui/theme.zod.ts#Shadow (type)", - "ShadowSchema": "src/ui/theme.zod.ts#ShadowSchema (const)", "SharingConfig": "src/ui/sharing.zod.ts#SharingConfig (type)", "SharingConfigParsed": "src/ui/sharing.zod.ts#SharingConfigParsed (type)", "SharingConfigSchema": "src/ui/sharing.zod.ts#SharingConfigSchema (const)", "StyleMap": "src/ui/responsive.zod.ts#StyleMap (type)", "StyleMapSchema": "src/ui/responsive.zod.ts#StyleMapSchema (const)", - "Theme": "src/ui/theme.zod.ts#Theme (type)", - "ThemeMode": "src/ui/theme.zod.ts#ThemeMode (type)", - "ThemeModeSchema": "src/ui/theme.zod.ts#ThemeModeSchema (const)", - "ThemeParsed": "src/ui/theme.zod.ts#ThemeParsed (type)", - "ThemeSchema": "src/ui/theme.zod.ts#ThemeSchema (const)", "TimelineConfig": "src/ui/view.zod.ts#TimelineConfig (type)", "TimelineConfigParsed": "src/ui/view.zod.ts#TimelineConfigParsed (type)", "TimelineConfigSchema": "src/ui/view.zod.ts#TimelineConfigSchema (const)", "TreeConfig": "src/ui/view.zod.ts#TreeConfig (type)", "TreeConfigSchema": "src/ui/view.zod.ts#TreeConfigSchema (const)", - "Typography": "src/ui/theme.zod.ts#Typography (type)", - "TypographySchema": "src/ui/theme.zod.ts#TypographySchema (const)", "UrlNavItem": "src/ui/app.zod.ts#UrlNavItem (type)", "UrlNavItemParsed": "src/ui/app.zod.ts#UrlNavItemParsed (type)", "UrlNavItemSchema": "src/ui/app.zod.ts#UrlNavItemSchema (const)", @@ -420,7 +407,6 @@ "defineForm": "src/ui/view.zod.ts#defineForm (function)", "definePage": "src/ui/page.zod.ts#definePage (function)", "defineReport": "src/ui/report.zod.ts#defineReport (function)", - "defineTheme": "src/ui/theme.zod.ts#defineTheme (function)", "defineView": "src/ui/view.zod.ts#defineView (function)", "defineViewItem": "src/ui/view.zod.ts#defineViewItem (function)", "diagnoseViewMetadata": "src/ui/view.zod.ts#diagnoseViewMetadata (function)", diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index eb2b124e4e..6174a2db03 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -16,7 +16,6 @@ "ui/AppContextSelector", "ui/AppearanceConfig", "ui/AriaProps", - "ui/BorderRadius", "ui/BreakpointColumnMap", "ui/BreakpointName", "ui/BreakpointOrderMap", @@ -35,7 +34,6 @@ "ui/ChartInteraction", "ui/ChartSeries", "ui/ChartType", - "ui/ColorPalette", "ui/ColumnPrefix", "ui/ColumnSummary", "ui/ColumnSummaryConfig", @@ -139,14 +137,10 @@ "ui/RowColorConfig", "ui/RowHeight", "ui/SelectionConfig", - "ui/Shadow", "ui/SharingConfig", "ui/StyleMap", - "ui/Theme", - "ui/ThemeMode", "ui/TimelineConfig", "ui/TreeConfig", - "ui/Typography", "ui/UrlNavItem", "ui/UserActionsConfig", "ui/UserFilterField", diff --git a/packages/spec/scripts/build-skill-references.ts b/packages/spec/scripts/build-skill-references.ts index 429ca9a9f1..60d37b8c66 100644 --- a/packages/spec/scripts/build-skill-references.ts +++ b/packages/spec/scripts/build-skill-references.ts @@ -97,7 +97,7 @@ const SKILL_MAP: Record = { 'ui/widget.zod.ts', 'ui/component.zod.ts', 'ui/report.zod.ts', - 'ui/theme.zod.ts', + // 'ui/theme.zod.ts' — retired whole at #10485 (ADR-0049). 'ui/dataset.zod.ts', ], 'objectstack-platform': [ diff --git a/packages/spec/src/automation/sync-retirement.test.ts b/packages/spec/src/automation/sync-retirement.test.ts index 62442fff2f..65bba90f4a 100644 --- a/packages/spec/src/automation/sync-retirement.test.ts +++ b/packages/spec/src/automation/sync-retirement.test.ts @@ -204,7 +204,8 @@ describe('[#4738] sync/conflict dual-source retirement', () => { } // Anti-vacuity: the ui namespace we just probed is real and non-trivial — // otherwise a broken import would satisfy the three absences above. - expect('ThemeSchema' in ui).toBe(true); + // (`ThemeSchema` was the probe until #10485 retired it — ADR-0049.) + expect('PageSchema' in ui).toBe(true); }); it('still parses authored connector syncConfig through the renamed enum — the live path', async () => { diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index fdec3e9fc5..251b965fa5 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -145,7 +145,8 @@ export type { } from './data/authoring-key-lint'; export { defineCube } from './data/analytics.zod'; export { defineMapping } from './data/mapping.zod'; -export { defineTheme } from './ui/theme.zod'; +// `defineTheme` was removed at #10485 with `ui/theme.zod.ts` (ADR-0049) — see +// the block in `./ui/index.ts`; `app.branding` is the one colour surface. export { defineTranslationBundle } from './system/translation.zod'; export { definePage } from './ui/page.zod'; export { defineAction } from './ui/action.zod'; diff --git a/packages/spec/src/kernel/metadata-type-schemas.test.ts b/packages/spec/src/kernel/metadata-type-schemas.test.ts index cfc7f7c646..98398db71f 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.test.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.test.ts @@ -367,6 +367,8 @@ describe('#6931 — the envelope invariant also covers UNREGISTERED_KIND_SCHEMAS * sentence was prose until #10194 found its converse failure mode — two stack * collections (`themes`, `analyticsCubes`) with strict schemas and NO map * entry, so the divergence the sentence forbids simply lived outside the map. + * (#10485 later retired the `themes` carrier and `ThemeSchema` whole, and the + * `theme` binding left the map with them — the row below went too.) * * Two halves, both load-bearing: * @@ -386,7 +388,7 @@ const STACK_COLLECTION_OF: Record = { webhook: 'webhooks', connector: 'connectors', sharing_rule: 'sharingRules', - theme: 'themes', + // theme: 'themes' — retired at #10485 (ADR-0049). analytics_cube: 'analyticsCubes', }; diff --git a/packages/spec/src/kernel/metadata-type-schemas.ts b/packages/spec/src/kernel/metadata-type-schemas.ts index 8d8c7fa603..ff8100bbd3 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.ts @@ -47,7 +47,6 @@ import { DatasetSchema } from '../ui/dataset.zod'; import { FlowSchema } from '../automation/flow.zod'; import { WebhookSchema } from '../automation/webhook.zod'; -import { ThemeSchema } from '../ui/theme.zod'; import { CubeSchema } from '../data/analytics.zod'; @@ -209,8 +208,11 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> = * with `.strict()` schemas (`defineStack({ themes, analyticsCubes })`), named * by the URL-spelling contract as legal, addressable kinds — yet neither was * bound here, so `PUT /meta/theme/:name` stored ANY JSON with `success: true` - * while the stack door strictly refused the same body. They are bound now, the - * same SHAPE-check-only way. `rag_pipeline` is deliberately NOT bound: it has + * while the stack door strictly refused the same body. They were bound then, + * the same SHAPE-check-only way. (#10485 later retired the `themes` carrier + * and `ThemeSchema` whole under ADR-0049, and `theme` left the URL-spelling + * contract with it — see the entry-shaped comment below.) `rag_pipeline` is + * deliberately NOT bound: it has * no stack collection to take a schema from — that drift is recorded as #6242 * row 2 (`scripts/check-stack-collection-maps.mjs`), not solved by inventing a * schema here. @@ -261,16 +263,14 @@ const UNREGISTERED_KIND_SCHEMAS: Record = { // never refuses one. Same treatment #5271/#5312 gave their strict surfaces. sharing_rule: SharingRuleSchema, - // [#10194] `stack.zod.ts`: `themes: z.array(ThemeSchema)`. - // - // The console's own styling surface: a malformed theme stored through this - // door used to fail at RENDER, with nothing at the write point to say so. - // `ThemeSchema` declares the ADR-0010 envelope for the reason - // `sharing.zod.ts` states for its own spread — the schema is `.strict()`, - // and binding the door without the spread would aim the new 422 at the - // runtime's own `applyProtection` stamp instead of at malformed author - // input. - theme: ThemeSchema, + // [#10194 → #10485] `theme: ThemeSchema` was bound here when `themes` was a + // stack collection; #10485 retired that carrier and the schema whole + // (ADR-0049 — nothing downstream ever read a stored theme). The binding did + // NOT regress to the store-anything branch this docblock describes: with the + // `themes: 'theme'` fold gone from `PLURAL_TO_SINGULAR`, `theme` leaves the + // generated URL-spelling contract entirely, so `PUT /meta/theme/:name` now + // gets `unrecognisedMetaTypeRefusal`'s loud verdict (#8421) before any + // schema would be consulted. // [#10194] `stack.zod.ts`: `analyticsCubes: z.array(CubeSchema)`. // diff --git a/packages/spec/src/meta-spelling/meta-url-data.generated.ts b/packages/spec/src/meta-spelling/meta-url-data.generated.ts index c237b1a091..e87a0ec394 100644 --- a/packages/spec/src/meta-spelling/meta-url-data.generated.ts +++ b/packages/spec/src/meta-spelling/meta-url-data.generated.ts @@ -24,7 +24,6 @@ export const META_URL_TO_SINGULAR: Readonly> = Object.fre "reports": "report", "datasets": "dataset", "actions": "action", - "themes": "theme", "flows": "flow", "jobs": "job", "positions": "position", diff --git a/packages/spec/src/meta-spelling/metadata-url-spelling.ts b/packages/spec/src/meta-spelling/metadata-url-spelling.ts index f06ce9a697..c81d3893ec 100644 --- a/packages/spec/src/meta-spelling/metadata-url-spelling.ts +++ b/packages/spec/src/meta-spelling/metadata-url-spelling.ts @@ -55,8 +55,11 @@ * * 1. **Manifest spellings** — every key of `PLURAL_TO_SINGULAR`, verbatim. * These are spellings that already worked at the URL boundary, including - * the camelCase ones and the six that name PLUGIN-registered kinds with no - * static registry entry at all (`themes`, `webhooks`, `connectors`, …). + * the camelCase ones and the five that name PLUGIN-registered kinds with no + * static registry entry at all (`webhooks`, `connectors`, … — `themes` was + * one of them until #10485 retired the carrier, and left this limb WITH + * the `PLURAL_TO_SINGULAR` row, which is how a retired kind exits the + * spelling contract without this module changing). * Keeping this limb whole is what makes the derivation non-breaking: no * spelling that resolved before resolves differently now. * 2. **Registry-derived spellings** — {@link restPluralOfMetaType} applied to @@ -254,12 +257,15 @@ export function metaUrlSpellingRefusal( * {@link META_URL_TO_SINGULAR} rather than its keys. * * Strictly larger than `DECLARED_META_TYPES`, and that difference is the whole - * reason this set exists: limb 1 carries six kinds that NO registry derivation - * could produce — `theme`, `webhook`, `connector`, `sharing_rule`, - * `analytics_cube`, `rag_pipeline` — which are legal, addressable metadata - * kinds with no static registry entry. A refusal quantified over the registry - * alone would refuse all six, i.e. break `PUT /meta/theme/dark`, which is the - * exact operation the plugin path exists to serve. + * reason this set exists: limb 1 carries five kinds that NO registry derivation + * could produce — `webhook`, `connector`, `sharing_rule`, `analytics_cube`, + * `rag_pipeline` — which are legal, addressable metadata kinds with no static + * registry entry. A refusal quantified over the registry alone would refuse + * all five, i.e. break `PUT /meta/webhook/stripe`, which is the exact + * operation the plugin path exists to serve. (`theme` was the sixth until + * #10485 retired its carrier; dropping the `PLURAL_TO_SINGULAR` row is what + * moved `/meta/theme` from this set to `unrecognisedMetaTypeRefusal`'s + * verdict.) * * Module-internal (#8424), for the same reason `DECLARED_META_TYPES` is: it * LOOKS like a live registry of registered types and is not one. diff --git a/packages/spec/src/migrations/entries/retired-defs/18.ui__BorderRadius.ts b/packages/spec/src/migrations/entries/retired-defs/18.ui__BorderRadius.ts new file mode 100644 index 0000000000..6bdf8aded6 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.ui__BorderRadius.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #10485 — `ui/BorderRadius` (the border-radius scale sub-block) left with `ui/Theme`: +// its ONLY consumer was the retired `ThemeSchema` (the #3950 rule — an +// exported value schema with no consumer reads as a capability). See +// `18.ui__Theme.ts` for the retirement record and the ruling. +export const entry = 'ui/BorderRadius'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.ui__ColorPalette.ts b/packages/spec/src/migrations/entries/retired-defs/18.ui__ColorPalette.ts new file mode 100644 index 0000000000..4148347aec --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.ui__ColorPalette.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #10485 — `ui/ColorPalette` (the colour palette sub-block) left with `ui/Theme`: +// its ONLY consumer was the retired `ThemeSchema` (the #3950 rule — an +// exported value schema with no consumer reads as a capability). See +// `18.ui__Theme.ts` for the retirement record and the ruling. +export const entry = 'ui/ColorPalette'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.ui__Shadow.ts b/packages/spec/src/migrations/entries/retired-defs/18.ui__Shadow.ts new file mode 100644 index 0000000000..341a2a80b6 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.ui__Shadow.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #10485 — `ui/Shadow` (the shadow scale sub-block) left with `ui/Theme`: +// its ONLY consumer was the retired `ThemeSchema` (the #3950 rule — an +// exported value schema with no consumer reads as a capability). See +// `18.ui__Theme.ts` for the retirement record and the ruling. +export const entry = 'ui/Shadow'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.ui__Theme.ts b/packages/spec/src/migrations/entries/retired-defs/18.ui__Theme.ts new file mode 100644 index 0000000000..263c7d64f8 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.ui__Theme.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #10485 — `ui/theme.zod.ts` `ThemeSchema`, retired whole with its +// `defineStack({ themes })` carrier key (ADR-0049 enforce-or-remove; +// maintainer ruling 2026-08-21, disposition B: 退役授权面 — `app.branding` +// stays the one colour surface; objectui's ThemeEngine/ThemeContext and their +// unit tests are retained). The pipeline was live from the authoring gate +// through artifact ingest and stopped there: zero non-test readers of the +// stored items across core/runtime/rest/services/plugins, `theme` never a +// registered metadata type, no first-party app mounting the spec-aware +// provider, and nothing selecting an active theme — an authored theme shipped +// green and changed nothing on screen. The carrier's strict-parse rejection +// carries the prescription (stack.zod.ts `guidance`); upgraders get the D3 +// semantic entry `stack-themes-carrier-retired`. +// +// Registered under 18, not 17: v17.0.0 was cut before this landed, so the +// removal ships on the 17.x line (launch-window convention: accept-set +// narrowings ride minor releases) and the prescription lives at the major +// boundary where `migrate meta` users look (the #8586 / #8715 precedent). +export const entry = 'ui/Theme'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.ui__ThemeMode.ts b/packages/spec/src/migrations/entries/retired-defs/18.ui__ThemeMode.ts new file mode 100644 index 0000000000..26d40dd4bc --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.ui__ThemeMode.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #10485 — `ui/ThemeMode` (the theme mode enum (light/dark/auto)) left with `ui/Theme`: +// its ONLY consumer was the retired `ThemeSchema` (the #3950 rule — an +// exported value schema with no consumer reads as a capability). See +// `18.ui__Theme.ts` for the retirement record and the ruling. +export const entry = 'ui/ThemeMode'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.ui__Typography.ts b/packages/spec/src/migrations/entries/retired-defs/18.ui__Typography.ts new file mode 100644 index 0000000000..fc4a169dcb --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.ui__Typography.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #10485 — `ui/Typography` (the typography sub-block) left with `ui/Theme`: +// its ONLY consumer was the retired `ThemeSchema` (the #3950 rule — an +// exported value schema with no consumer reads as a capability). See +// `18.ui__Theme.ts` for the retirement record and the ruling. +export const entry = 'ui/Typography'; diff --git a/packages/spec/src/migrations/entries/semantic/18.stack-themes-carrier-retired.ts b/packages/spec/src/migrations/entries/semantic/18.stack-themes-carrier-retired.ts new file mode 100644 index 0000000000..5d4a640b23 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.stack-themes-carrier-retired.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// Registered as D3 SEMANTIC and deliberately NOT as a D2 conversion, on the +// D2 scope guard (lossless only — the `owd-legacy-read-aliases` / `'full'` +// precedent): an authored theme has no lossless target. `app.branding` holds +// two colour strings scoped per APP, while a theme is a stack-scoped palette +// with modes and inheritance — a stack may declare N themes and M apps, so +// which palette entry becomes which app's `primaryColor` is a judgment the +// transform cannot make, and auto-DELETING the whole authored artifact would +// silently discard content the author may want to salvage. (Mechanically, a +// full-carrier `stripKeys` conversion also cannot coexist with the published +// `theme-inert-token-scales-removed` fixture: the fixture-disjointness +// contract replays the whole table over every fixture, and that entry's +// fixture necessarily authors `themes` — a secondary observation recorded for +// the next reader, not the reason.) +export const entry: SemanticMigration = { + id: 'stack-themes-carrier-retired', + surface: 'stack `themes` (the carrier collection, and `ThemeSchema` with its sub-blocks)', + replacement: + 'delete the `themes:` key (and any `defineTheme` calls). To colour the shipped console, ' + + "set `app.branding.primaryColor` / `accentColor` — the one live colour surface (read by " + + 'objectui, driving `--primary`, `--accent` and their derived CSS variables). A palette ' + + 'value your own stylesheet consumed has no spec slot any more: move it into your own CSS.', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21 on #10485 (disposition B: ' + + '退役授权面 — objectui engine code and its unit tests are retained). The pipeline was ' + + 'live from the authoring gate (`ObjectStackDefinitionSchema.themes`, `defineTheme`) ' + + 'through artifact ingest (`ARTIFACT_FIELD_TO_TYPE.themes`) and stopped there, measured: ' + + 'zero non-test readers of `.themes` or stored `theme` items across ' + + '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 key anywhere selected an active theme. So an author (human or AI) who wrote a ' + + 'theme shipped it through every green gate and saw nothing change — the ' + + 'declared-but-unenforced shape ADR-0049 exists to delete. What colours a console today ' + + 'is `app.branding`, and that path is live and untouched.', + acceptanceCriteria: + 'No stack source authors `themes:`; a stack that still does is refused at parse with the ' + + 'prescription (unrecognized_keys carrying the #10485 guidance — pinned in ' + + '`stack-top-level-strict.test.ts`). `PUT /meta/theme/:name` gets the #8421 ' + + 'unrecognised-type refusal instead of the pre-#10194 store-anything branch (pinned in ' + + '`protocol.unrecognised-meta-type.test.ts`). Legacy stored `theme` rows are untouched: ' + + '`applyConversionsToStoredItem` passes them through, reads still answer, and DELETE ' + + 'still works, so the residue is removable. ⚠️ On-screen behaviour is deliberately ' + + 'UNCHANGED and must be verified as such: nothing ever read an authored theme, so ' + + 'removing the surface removes no behaviour — `app.branding` colours the console before ' + + 'and after.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 9bc12a62cf..1a238c339d 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5100,7 +5100,18 @@ const step18: MigrationStep = { 'conversion strips the key from old sources (pure lossless delete — it never had an ' + 'effect to lose); filter at query time with `where`, fold the condition into the ' + 'metric\'s own `sql` expression, or use an ADR-0021 dataset measure\'s structured ' + - '`filter`.', + '`filter`. ' + + 'Finally, it retires the stack `themes` carrier and `ThemeSchema` whole (#10485, ' + + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, disposition B: 退役授权面): ' + + 'the pipeline was live from the authoring gate through artifact ingest and stopped ' + + 'there — zero non-test readers of stored `theme` items, `theme` never a registered ' + + 'metadata type, no first-party app mounting the spec-aware provider, nothing ' + + 'selecting an active theme — so an authored theme shipped through every green gate ' + + 'and changed nothing on screen. `app.branding` stays the one colour surface; ' + + 'objectui\'s ThemeEngine/ThemeContext and their unit tests are retained. Semantic ' + + 'rather than mechanical: an authored palette has no lossless target (N themes vs ' + + 'M apps is a judgment), so the entry prescribes the hand move instead of deleting ' + + 'authored content silently.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -5971,6 +5982,52 @@ const step18: MigrationStep = { + '`collapsible: true` explicitly — an unset key now defers to the renderer, which does ' + 'not collapse.', }, + // Registered as D3 SEMANTIC and deliberately NOT as a D2 conversion, on the + // D2 scope guard (lossless only — the `owd-legacy-read-aliases` / `'full'` + // precedent): an authored theme has no lossless target. `app.branding` holds + // two colour strings scoped per APP, while a theme is a stack-scoped palette + // with modes and inheritance — a stack may declare N themes and M apps, so + // which palette entry becomes which app's `primaryColor` is a judgment the + // transform cannot make, and auto-DELETING the whole authored artifact would + // silently discard content the author may want to salvage. (Mechanically, a + // full-carrier `stripKeys` conversion also cannot coexist with the published + // `theme-inert-token-scales-removed` fixture: the fixture-disjointness + // contract replays the whole table over every fixture, and that entry's + // fixture necessarily authors `themes` — a secondary observation recorded for + // the next reader, not the reason.) + { + id: 'stack-themes-carrier-retired', + surface: 'stack `themes` (the carrier collection, and `ThemeSchema` with its sub-blocks)', + replacement: + 'delete the `themes:` key (and any `defineTheme` calls). To colour the shipped console, ' + + "set `app.branding.primaryColor` / `accentColor` — the one live colour surface (read by " + + 'objectui, driving `--primary`, `--accent` and their derived CSS variables). A palette ' + + 'value your own stylesheet consumed has no spec slot any more: move it into your own CSS.', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21 on #10485 (disposition B: ' + + '退役授权面 — objectui engine code and its unit tests are retained). The pipeline was ' + + 'live from the authoring gate (`ObjectStackDefinitionSchema.themes`, `defineTheme`) ' + + 'through artifact ingest (`ARTIFACT_FIELD_TO_TYPE.themes`) and stopped there, measured: ' + + 'zero non-test readers of `.themes` or stored `theme` items across ' + + '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 key anywhere selected an active theme. So an author (human or AI) who wrote a ' + + 'theme shipped it through every green gate and saw nothing change — the ' + + 'declared-but-unenforced shape ADR-0049 exists to delete. What colours a console today ' + + 'is `app.branding`, and that path is live and untouched.', + acceptanceCriteria: + 'No stack source authors `themes:`; a stack that still does is refused at parse with the ' + + 'prescription (unrecognized_keys carrying the #10485 guidance — pinned in ' + + '`stack-top-level-strict.test.ts`). `PUT /meta/theme/:name` gets the #8421 ' + + 'unrecognised-type refusal instead of the pre-#10194 store-anything branch (pinned in ' + + '`protocol.unrecognised-meta-type.test.ts`). Legacy stored `theme` rows are untouched: ' + + '`applyConversionsToStoredItem` passes them through, reads still answer, and DELETE ' + + 'still works, so the residue is removable. ⚠️ On-screen behaviour is deliberately ' + + 'UNCHANGED and must be verified as such: nothing ever read an authored theme, so ' + + 'removing the surface removes no behaviour — `app.branding` colours the console before ' + + 'and after.', + }, { id: 'stack-top-level-unknown-keys-refused', surface: 'top-level stack definition keys (`ObjectStackDefinitionSchema`) — undeclared keys', @@ -6958,6 +7015,49 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8586 / PR #8702 precedent). 'identity/ApiKey', + // #10485 — `ui/BorderRadius` (the border-radius scale sub-block) left with `ui/Theme`: + // its ONLY consumer was the retired `ThemeSchema` (the #3950 rule — an + // exported value schema with no consumer reads as a capability). See + // `18.ui__Theme.ts` for the retirement record and the ruling. + 'ui/BorderRadius', + // #10485 — `ui/ColorPalette` (the colour palette sub-block) left with `ui/Theme`: + // its ONLY consumer was the retired `ThemeSchema` (the #3950 rule — an + // exported value schema with no consumer reads as a capability). See + // `18.ui__Theme.ts` for the retirement record and the ruling. + 'ui/ColorPalette', + // #10485 — `ui/Shadow` (the shadow scale sub-block) left with `ui/Theme`: + // its ONLY consumer was the retired `ThemeSchema` (the #3950 rule — an + // exported value schema with no consumer reads as a capability). See + // `18.ui__Theme.ts` for the retirement record and the ruling. + 'ui/Shadow', + // #10485 — `ui/theme.zod.ts` `ThemeSchema`, retired whole with its + // `defineStack({ themes })` carrier key (ADR-0049 enforce-or-remove; + // maintainer ruling 2026-08-21, disposition B: 退役授权面 — `app.branding` + // stays the one colour surface; objectui's ThemeEngine/ThemeContext and their + // unit tests are retained). The pipeline was live from the authoring gate + // through artifact ingest and stopped there: zero non-test readers of the + // stored items across core/runtime/rest/services/plugins, `theme` never a + // registered metadata type, no first-party app mounting the spec-aware + // provider, and nothing selecting an active theme — an authored theme shipped + // green and changed nothing on screen. The carrier's strict-parse rejection + // carries the prescription (stack.zod.ts `guidance`); upgraders get the D3 + // semantic entry `stack-themes-carrier-retired`. + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // removal ships on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look (the #8586 / #8715 precedent). + 'ui/Theme', + // #10485 — `ui/ThemeMode` (the theme mode enum (light/dark/auto)) left with `ui/Theme`: + // its ONLY consumer was the retired `ThemeSchema` (the #3950 rule — an + // exported value schema with no consumer reads as a capability). See + // `18.ui__Theme.ts` for the retirement record and the ruling. + 'ui/ThemeMode', + // #10485 — `ui/Typography` (the typography sub-block) left with `ui/Theme`: + // its ONLY consumer was the retired `ThemeSchema` (the #3950 rule — an + // exported value schema with no consumer reads as a capability). See + // `18.ui__Theme.ts` for the retirement record and the ruling. + 'ui/Typography', // ], }; diff --git a/packages/spec/src/shared/metadata-collection.test.ts b/packages/spec/src/shared/metadata-collection.test.ts index 17d1e439d4..9339009c64 100644 --- a/packages/spec/src/shared/metadata-collection.test.ts +++ b/packages/spec/src/shared/metadata-collection.test.ts @@ -247,7 +247,7 @@ describe('MAP_SUPPORTED_FIELDS', () => { expect(MAP_SUPPORTED_FIELDS).toContain('dashboards'); expect(MAP_SUPPORTED_FIELDS).toContain('reports'); expect(MAP_SUPPORTED_FIELDS).toContain('actions'); - expect(MAP_SUPPORTED_FIELDS).toContain('themes'); + // 'themes' left the list at #10485 (ADR-0049 — carrier retired). expect(MAP_SUPPORTED_FIELDS).toContain('flows'); expect(MAP_SUPPORTED_FIELDS).toContain('positions'); expect(MAP_SUPPORTED_FIELDS).toContain('permissions'); diff --git a/packages/spec/src/shared/metadata-collection.zod.ts b/packages/spec/src/shared/metadata-collection.zod.ts index d3e5b1274d..67defd69af 100644 --- a/packages/spec/src/shared/metadata-collection.zod.ts +++ b/packages/spec/src/shared/metadata-collection.zod.ts @@ -79,7 +79,7 @@ export const MAP_SUPPORTED_FIELDS = [ 'reports', 'datasets', 'actions', - 'themes', + // `themes` left with the retired carrier key (#10485, ADR-0049). 'flows', 'jobs', 'positions', @@ -116,7 +116,13 @@ export const PLURAL_TO_SINGULAR: Record = { reports: 'report', datasets: 'dataset', actions: 'action', - themes: 'theme', + // `themes: 'theme'` was removed at #10485 (ADR-0049; the carrier key is + // retired). Its absence here is load-bearing twice over: the generated + // `META_URL_TO_SINGULAR` (gen:meta-url-spelling) loses the fold, so + // `/meta/theme` gets `unrecognisedMetaTypeRefusal`'s loud verdict instead of + // the pre-#10194 store-anything branch; and `applyConversionsToStoredItem` + // passes legacy `theme` rows through untouched rather than manufacturing a + // collection for them. flows: 'flow', jobs: 'job', positions: 'position', diff --git a/packages/spec/src/shared/metadata-url-spelling.test.ts b/packages/spec/src/shared/metadata-url-spelling.test.ts index 475d77e67e..0d020e7efe 100644 --- a/packages/spec/src/shared/metadata-url-spelling.test.ts +++ b/packages/spec/src/shared/metadata-url-spelling.test.ts @@ -46,9 +46,10 @@ describe('#7894 INVARIANT 1 — no spelling that worked before may stop working' it('folds every manifest spelling to exactly the singular it folded to before', () => { // The manifest map is the complete population of spellings that resolved at // the `/meta` boundary before this change, so quantifying over it IS the - // non-breaking proof. It includes six that name PLUGIN kinds with no static - // registry entry (`themes`, `webhooks`, `connectors`, `sharingRules`, - // `ragPipelines`, `analyticsCubes`) — a purely registry-derived map would + // non-breaking proof. It includes five that name PLUGIN kinds with no static + // registry entry (`webhooks`, `connectors`, `sharingRules`, + // `ragPipelines`, `analyticsCubes`; `themes` was the sixth until #10485 + // retired its carrier out of the map) — a purely registry-derived map would // have dropped those, which is why the derived limb is unioned rather than // substituted. for (const [plural, singular] of Object.entries(PLURAL_TO_SINGULAR)) { @@ -62,14 +63,17 @@ describe('#7894 INVARIANT 1 — no spelling that worked before may stop working' } }); - it('carries the six plugin-kind spellings that no registry derivation could produce', () => { + it('carries the five plugin-kind spellings that no registry derivation could produce', () => { // Named explicitly because losing them is the specific regression the // union-vs-pure-derivation choice was made to avoid, and a reader needs to // see the population rather than infer it. const registryTypes = new Set(DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type)); const pluginOnly = Object.entries(PLURAL_TO_SINGULAR).filter(([, s]) => !registryTypes.has(s)); + // `themes` left this list at #10485 (ADR-0049 — the carrier retired, so + // `/meta/theme` now earns `unrecognisedMetaTypeRefusal`'s verdict; pinned + // in the #8421 block at the bottom of this file). expect(pluginOnly.map(([p]) => p).sort()).toEqual( - ['analyticsCubes', 'connectors', 'ragPipelines', 'sharingRules', 'themes', 'webhooks'], + ['analyticsCubes', 'connectors', 'ragPipelines', 'sharingRules', 'webhooks'], ); for (const [plural, singular] of pluginOnly) { expect(canonicalMetaUrlType(plural)).toBe(singular); @@ -158,11 +162,15 @@ describe('#7894 — the refusal limb is narrow by construction', () => { // misspellings of DECLARED types and none of these is one. Read it as a // statement about `metaUrlSpellingRefusal`, not about the whole boundary: // `saveMetaItem` now also consults `unrecognisedMetaTypeRefusal`, under - // which the six mapped kinds below stay writable and the six unmapped + // which the five mapped kinds below stay writable and the six unmapped // names no longer are. That narrowing is pinned, deliberately visible, in - // the `#8421` block at the bottom of this file. + // the `#8421` block at the bottom of this file. (`theme` moved from this + // list to the refused side at #10485 — its carrier retired out of the map, + // so it is no longer "a spelling that worked before" for this verdict: + // `metaUrlSpellingRefusal` still answers null for it, but the #8421 + // verdict now refuses it, pinned below.) for (const kind of [ - 'theme', 'sharing_rule', 'webhook', 'rag_pipeline', 'analytics_cube', 'connector', + 'sharing_rule', 'webhook', 'rag_pipeline', 'analytics_cube', 'connector', 'my_plugin_kind', 'address', 'status', 'kudos', 'analysis', 'series', ]) { expect(metaUrlSpellingRefusal(kind), `${kind} must not be refused`).toBeNull(); @@ -216,13 +224,14 @@ describe('#8421 — the second verdict: not a metadata type AT ALL', () => { it('accepts every manifest spelling AND the singular each one folds to', () => { // The direction that matters most, and the one a registry-quantified - // refusal would get wrong: six of these singulars — `theme`, `webhook`, + // refusal would get wrong: five of these singulars — `webhook`, // `connector`, `sharing_rule`, `analytics_cube`, `rag_pipeline` — are // PLUGIN kinds with no static registry entry at all. Refusing them would - // break `PUT /meta/theme/dark`, the exact operation the plugin path exists - // to serve, which is the failure #8421's measurement disqualified option C - // for. Accepting the singular is not decoration: `themes` folds to `theme`, - // and a boundary that refused the fold's own output would be incoherent. + // break `PUT /meta/webhook/stripe`, the exact operation the plugin path + // exists to serve, which is the failure #8421's measurement disqualified + // option C for. Accepting the singular is not decoration: `webhooks` folds + // to `webhook`, and a boundary that refused the fold's own output would be + // incoherent. for (const [plural, singular] of Object.entries(PLURAL_TO_SINGULAR)) { expect(unrecognisedMetaTypeRefusal(plural), `${plural} works today`).toBeNull(); expect( @@ -232,16 +241,26 @@ describe('#8421 — the second verdict: not a metadata type AT ALL', () => { } }); - it('carries the six plugin kinds by NAME, since quantification hides them', () => { + it('carries the five plugin kinds by NAME, since quantification hides them', () => { const declared = new Set(DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type)); for (const kind of [ - 'analytics_cube', 'connector', 'rag_pipeline', 'sharing_rule', 'theme', 'webhook', + 'analytics_cube', 'connector', 'rag_pipeline', 'sharing_rule', 'webhook', ]) { expect(declared.has(kind), `${kind} must NOT be in the static registry`).toBe(false); expect(unrecognisedMetaTypeRefusal(kind), `${kind} must stay accepted anyway`).toBeNull(); } }); + it('[#10485] `theme` left the contract with its carrier — refused by name', () => { + // The retired kind's whole exit path: the `themes: 'theme'` fold left + // `PLURAL_TO_SINGULAR`, so both spellings now earn the #8421 verdict + // instead of the pre-#10194 store-anything branch. + for (const spelling of ['theme', 'themes']) { + const verdict = unrecognisedMetaTypeRefusal(spelling); + expect(verdict, `${spelling} must be refused`).not.toBeNull(); + } + }); + it('refuses the card coordinates — a name the contract does not carry', () => { for (const type of ['fieldz', 'objectt', 'viewz', 'nonsense_type']) { expect(unrecognisedMetaTypeRefusal(type)).toEqual({ type }); diff --git a/packages/spec/src/stack-top-level-strict.test.ts b/packages/spec/src/stack-top-level-strict.test.ts index 64645fd3a2..e029a20919 100644 --- a/packages/spec/src/stack-top-level-strict.test.ts +++ b/packages/spec/src/stack-top-level-strict.test.ts @@ -100,6 +100,9 @@ describe('#8687 — unknown top-level stack keys are refused at parse', () => { ['workflows', 'state_machine'], // #3464: the collection was removed outright. ['portals', '#3464'], + // #10485 (ADR-0049): the themes carrier retired; app.branding is the + // one colour surface. + ['themes', 'app.branding'], // #4212: the uninvoked lifecycle family. ['onDisable', '#4212'], ]; @@ -122,6 +125,51 @@ describe('#8687 — unknown top-level stack keys are refused at parse', () => { }); }); +describe('#10485 — the `themes` carrier is retired (ADR-0049; ruled 退役授权面 2026-08-21)', () => { + // A theme body that parsed green on 17.0 — the exact authoring the issue + // measured shipping end-to-end while changing nothing on screen. + const previouslyAcceptedThemes = [ + { name: 'corporate', label: 'Corporate', mode: 'light', colors: { primary: '#7C3AED' } }, + ]; + + it('a stack declaring `themes` is refused LOUDLY, at the key (not the value)', () => { + const result = parseTopLevel({ ...legalStack(), themes: previouslyAcceptedThemes }); + expect(result.success).toBe(false); + if (result.success) return; + const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys')!; + expect(issue, 'themes must raise unrecognized_keys').toBeDefined(); + expect(issue.path).toEqual([]); + expect((issue as unknown as { keys: string[] }).keys).toContain('themes'); + // The prescription itself — removal citation, and where colour lives now. + expect(issue.message).toContain('#10485'); + expect(issue.message).toContain('app.branding'); + expect(issue.message).toContain('primaryColor'); + // A retirement is a prescription, never a rename suggestion (finding 7). + expect(issue.message).not.toContain('Did you mean'); + }); + + it('positive control: the prescribed replacement parses green', () => { + const result = parseTopLevel({ + ...legalStack(), + apps: [{ + name: 'probe_app', + label: 'Probe', + branding: { primaryColor: '#7C3AED', accentColor: '#06B6D4' }, + }], + }); + expect(result.success, 'app.branding must stay accepted').toBe(true); + }); + + it('the schema surface is gone with the carrier: no theme export survives on ./ui', async () => { + const ui = await import('./ui/index'); + for (const name of ['ThemeSchema', 'ColorPaletteSchema', 'TypographySchema', 'BorderRadiusSchema', 'ShadowSchema', 'ThemeModeSchema', 'defineTheme']) { + expect(name in ui, `${name} must not survive on ./ui`).toBe(false); + } + // Anti-vacuity: the namespace probed is real and non-trivial. + expect('PageSchema' in ui).toBe(true); + }); +}); + describe('#8687 — the accept side does not move', () => { it('accepts every declared top-level key', () => { // Structural sweep: a declared key must never be refused as unknown. All diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 8d6a0bd582..109c8a2ba0 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -29,7 +29,6 @@ import { DashboardSchema } from './ui/dashboard.zod'; import { ReportSchema } from './ui/report.zod'; import { DatasetSchema } from './ui/dataset.zod'; import { ActionSchema, InlineActionSchema } from './ui/action.zod'; -import { ThemeSchema } from './ui/theme.zod'; // Automation Protocol import { FlowSchema } from './automation/flow.zod'; @@ -224,6 +223,13 @@ export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ portals: 'the top-level `portals` collection was removed (#3464) — nothing ever consumed it. ' + 'Author external-user UI with `apps`/`views` plus positions and permission sets.', + themes: + '`themes` was removed in @objectstack/spec 17.1 (#10485, ADR-0049) — authored themes ' + + 'were parsed and stored, but no framework package ever read them back, no first-party ' + + 'app mounted the spec-aware theme provider, and nothing selected an active theme, so ' + + 'a declared theme changed nothing on screen. Delete the key. To colour the shipped ' + + 'console, set `app.branding.primaryColor` / `accentColor` — the one live colour ' + + 'surface (it drives `--primary`, `--accent` and their derived variables).', onDisable: 'no kernel, runtime or service ever called `onDisable` (#4212 retired the uninvoked ' + 'lifecycle family), so a value written here goes nowhere. Do teardown inside the ' @@ -336,7 +342,15 @@ export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ reports: z.array(ReportSchema).optional().describe('Analytics Reports'), datasets: z.array(DatasetSchema).optional().describe('Analytics semantic-layer datasets (ADR-0021)'), actions: z.array(ActionSchema).optional().describe('Global and Object Actions'), - themes: z.array(ThemeSchema).optional().describe('UI Themes'), + // `themes` was REMOVED in 17.1 (#10485, ADR-0049 enforce-or-remove — ruled + // 退役授权面, 2026-08-21). The pipeline was live from authoring gate through + // artifact ingest and stopped there: no framework package ever read the + // stored items, `theme` was never a registered metadata type, no first-party + // app mounted the spec-aware provider, and nothing selected a theme — an + // authored theme shipped green and changed nothing. `app.branding` is the + // one colour surface. The unknown-key rejection carries the prescription via + // the `guidance` entry on this schema's `strictObject` options; the D3 + // semantic entry `stack-themes-carrier-retired` carries it to upgraders. /** * ObjectFlow: Automation Layer @@ -1788,7 +1802,8 @@ const COMPOSE_KEY_DISPOSITIONS: Record, z export type Iso698 = Assert, z.infer< typeof M165.StyleMapSchema > >>; export type Iso699 = Assert, z.infer< typeof M165.ResponsiveStylesSchema > >>; -// ui/theme.zod.ts -export type Iso700 = Assert, z.infer< typeof M166.ColorPaletteSchema > >>; -export type Iso701 = Assert, z.infer< typeof M166.TypographySchema > >>; -export type Iso702 = Assert, z.infer< typeof M166.BorderRadiusSchema > >>; -export type Iso703 = Assert, z.infer< typeof M166.ShadowSchema > >>; -export type Iso704 = Assert, z.infer< typeof M166.ThemeModeSchema > >>; +// ui/theme.zod.ts (Iso700–Iso704) left with the module at #10485 — the Iso +// numbers are positional and stay vacant. // ui/view.zod.ts export type Iso705 = Assert, z.infer< typeof M167.FormButtonConfigSchema > >>; @@ -1661,7 +1658,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 839 isomorphic pins', () => { + it('still declares all 834 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -1928,9 +1925,14 @@ describe('ADR-0122 type-alias convention', () => { // schemas or converted aliases to `Parsed` pairs; this one moved the // input/output identity into the annotation itself, where both halves // spell the same type. + // + // 839 -> 834 is #10485 — `Iso700`-`Iso704` DELETED with `ui/theme.zod.ts` + // (ADR-0049 retirement of the whole theme authoring surface): the five + // schemas they pinned no longer exist, so there is nothing left to exempt. + // The Iso numbers are positional and stay vacant. const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert { * - `NotificationTypeSchema` — `ui/notification.zod.ts` kept its presentation * enums when PR #5300 retired `NotificationActionSchema` out of it. * - `SharingConfigSchema` — `ui/sharing.zod.ts`'s live door. - * - `ThemeSchema` — the file whose own `animation` block #5021 retired; the - * name collision must not have cost it its schema. + * - (`ThemeSchema` stood here until #10485 retired the theme surface whole, + * ADR-0049 — a survivor list entry follows its subject out.) * - `PageSchema` / `PageComponentSchema` — the authoring roots the five * vocabularies would have hung off had they ever had a carrier. */ @@ -122,7 +122,6 @@ describe('[#4988] ui/ interaction config family retirement', () => { 'I18nLabelSchema', 'NotificationTypeSchema', 'SharingConfigSchema', - 'ThemeSchema', 'PageSchema', 'PageComponentSchema', ] as const; diff --git a/packages/spec/src/ui/theme.test.ts b/packages/spec/src/ui/theme.test.ts deleted file mode 100644 index cd71c6634d..0000000000 --- a/packages/spec/src/ui/theme.test.ts +++ /dev/null @@ -1,810 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - ThemeSchema, - ThemeMode, - ThemeModeSchema, - ColorPaletteSchema, - TypographySchema, - BorderRadiusSchema, - ShadowSchema, - defineTheme, - type Theme, - type ColorPalette, -} from './theme.zod'; -import { ObjectStackSchema } from '../stack.zod'; - -describe('ThemeMode', () => { - it('should accept valid theme modes', () => { - expect(() => ThemeMode.parse('light')).not.toThrow(); - expect(() => ThemeMode.parse('dark')).not.toThrow(); - expect(() => ThemeMode.parse('auto')).not.toThrow(); - }); - - it('should reject invalid theme modes', () => { - expect(() => ThemeMode.parse('custom')).toThrow(); - expect(() => ThemeMode.parse('system')).toThrow(); - }); -}); - -describe('ColorPaletteSchema', () => { - it('should accept minimal color palette with primary color', () => { - const palette: ColorPalette = { - primary: '#007BFF', - }; - - expect(() => ColorPaletteSchema.parse(palette)).not.toThrow(); - }); - - it('should accept complete color palette', () => { - const palette: ColorPalette = { - primary: '#007BFF', - secondary: '#6C757D', - accent: '#FFC107', - success: '#28A745', - warning: '#FFC107', - error: '#DC3545', - info: '#17A2B8', - background: '#FFFFFF', - surface: '#F8F9FA', - text: '#212529', - textSecondary: '#6C757D', - border: '#DEE2E6', - disabled: '#E9ECEF', - primaryLight: '#4DA3FF', - primaryDark: '#0056B3', - }; - - expect(() => ColorPaletteSchema.parse(palette)).not.toThrow(); - }); - - it('should accept colors in different formats', () => { - const palette: ColorPalette = { - primary: '#007BFF', // hex - secondary: 'rgb(108, 117, 125)', // rgb - accent: 'hsl(45, 100%, 51%)', // hsl - }; - - expect(() => ColorPaletteSchema.parse(palette)).not.toThrow(); - }); -}); - -describe('TypographySchema', () => { - it('should accept minimal typography settings', () => { - const typography = { - fontFamily: { - base: 'Inter, system-ui, sans-serif', - }, - }; - - expect(() => TypographySchema.parse(typography)).not.toThrow(); - }); - - // REPLACED at #5021 (fixture triage, disposition 3). The fixture that used to - // sit here authored all four retired scales plus `fontFamily.heading`/`.mono` - // and asserted `not.toThrow()` — i.e. it pinned exactly the limbs the - // retirement deletes. Re-spelling it was not an option (there is no canonical - // spelling to move to) and keeping it would have made the whole surviving - // block untested, so it is replaced by a fixture the narrowed schema really - // reads. The rejection side is pinned in the #5021 block at the bottom. - it('accepts the whole SURVIVING typography surface — which is `fontFamily.base`', () => { - const typography = { fontFamily: { base: 'Inter, system-ui, sans-serif' } }; - - const parsed = TypographySchema.parse(typography); - expect(parsed.fontFamily?.base).toBe('Inter, system-ui, sans-serif'); - // Nothing else is left to WRITE: `base` emits `--font-sans`, the one - // typography variable objectui actually reads. (The four retired scales are - // still in the shape as tombstones, but they accept nothing, so they never - // appear on a parsed value.) - expect(Object.keys(parsed)).toEqual(['fontFamily']); - }); -}); - -describe('BorderRadiusSchema', () => { - it('should accept border radius scale', () => { - const borderRadius = { - none: '0', - sm: '0.125rem', - base: '0.25rem', - md: '0.375rem', - lg: '0.5rem', - full: '9999px', - }; - - expect(() => BorderRadiusSchema.parse(borderRadius)).not.toThrow(); - }); -}); - -describe('ShadowSchema', () => { - it('should accept shadow definitions', () => { - const shadows = { - none: 'none', - sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', - base: '0 1px 3px 0 rgba(0, 0, 0, 0.1)', - md: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', - lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1)', - }; - - expect(() => ShadowSchema.parse(shadows)).not.toThrow(); - }); -}); - -describe('ThemeSchema', () => { - it('should accept minimal theme with required fields', () => { - const theme: Theme = { - name: 'default_theme', - label: 'Default Theme', - colors: { - primary: '#007BFF', - }, - }; - - expect(() => ThemeSchema.parse(theme)).not.toThrow(); - }); - - it('should enforce snake_case for theme name', () => { - const validNames = ['default_theme', 'dark_theme', 'custom_2023']; - validNames.forEach(name => { - const theme: Theme = { - name, - label: 'Test Theme', - colors: { primary: '#000000' }, - }; - expect(() => ThemeSchema.parse(theme)).not.toThrow(); - }); - - const invalidNames = ['DefaultTheme', 'dark-theme', '123theme']; - invalidNames.forEach(name => { - const theme = { - name, - label: 'Test Theme', - colors: { primary: '#000000' }, - }; - expect(() => ThemeSchema.parse(theme)).toThrow(); - }); - }); - - it('should accept complete theme configuration', () => { - const theme: Theme = { - name: 'enterprise_theme', - label: 'Enterprise Theme', - description: 'Professional theme for enterprise applications', - mode: 'light', - colors: { - primary: '#0066CC', - secondary: '#6C757D', - accent: '#FFC107', - success: '#28A745', - warning: '#FFC107', - error: '#DC3545', - info: '#17A2B8', - background: '#FFFFFF', - surface: '#F8F9FA', - text: '#212529', - textSecondary: '#6C757D', - border: '#DEE2E6', - }, - // #5021 fixture triage, disposition 1 (re-spell): this fixture merely - // USED `fontFamily.heading`/`.mono` and a `fontSize` scale, so it drops - // them and keeps the live `base`. It is still a "complete" theme — the - // completeness that matters is the set of blocks with live consumers. - typography: { - fontFamily: { - base: 'Inter, sans-serif', - }, - }, - borderRadius: { - base: '0.25rem', - lg: '0.5rem', - }, - shadows: { - base: '0 1px 3px 0 rgba(0, 0, 0, 0.1)', - lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1)', - }, - }; - - expect(() => ThemeSchema.parse(theme)).not.toThrow(); - }); - - it('should accept theme with custom CSS variables', () => { - const theme: Theme = { - name: 'custom_vars_theme', - label: 'Custom Variables Theme', - colors: { - primary: '#007BFF', - }, - customVars: { - '--header-height': '64px', - '--sidebar-width': '256px', - '--transition-speed': '0.3s', - }, - }; - - expect(() => ThemeSchema.parse(theme)).not.toThrow(); - }); - - it('should accept theme that extends another theme', () => { - const theme: Theme = { - name: 'dark_extended', - label: 'Dark Extended Theme', - extends: 'default_theme', - colors: { - primary: '#007BFF', - background: '#1A1A1A', - text: '#FFFFFF', - }, - }; - - expect(() => ThemeSchema.parse(theme)).not.toThrow(); - }); - - it('should apply default mode', () => { - const theme = { - name: 'test_theme', - label: 'Test Theme', - colors: { - primary: '#007BFF', - }, - }; - - const result = ThemeSchema.parse(theme); - expect(result.mode).toBe('light'); - }); - - it('should accept dark mode theme', () => { - const theme: Theme = { - name: 'dark_theme', - label: 'Dark Theme', - mode: 'dark', - colors: { - primary: '#4DA3FF', - background: '#1A1A1A', - surface: '#2D2D2D', - text: '#FFFFFF', - textSecondary: '#B0B0B0', - border: '#404040', - }, - }; - - expect(() => ThemeSchema.parse(theme)).not.toThrow(); - }); - - it('should accept auto mode theme', () => { - const theme: Theme = { - name: 'auto_theme', - label: 'Auto Theme', - mode: 'auto', - colors: { - primary: '#007BFF', - }, - }; - - expect(() => ThemeSchema.parse(theme)).not.toThrow(); - }); - - // The `zIndex` and `animation` acceptance fixtures that sat here were - // REPLACED at #5021 (fixture triage, disposition 3) — they pinned the two - // keys the retirement deletes, and an `expect(…).not.toThrow()` on a deleted - // key has no honest re-spelling. Their replacements are the layering and - // motion fixtures below, which express the SAME intent through the door that - // survived, plus the rejection pins in the #5021 block at the bottom. - - it('a layering scale is still expressible — through `customVars`, the live door', () => { - const theme: Theme = { - name: 'layered_theme', - label: 'Layered Theme', - colors: { primary: '#007BFF' }, - // The variable names are spelled out because that is what the engine used - // to derive from the `zIndex` key names. Byte for byte the same custom - // properties reach the document — which is the whole basis on which the - // retirement claims to remove no capability. - customVars: { - 'z-base': '0', - 'z-dropdown': '1000', - 'z-sticky': '1020', - 'z-fixed': '1030', - 'z-modal-backdrop': '1040', - 'z-modal': '1050', - 'z-popover': '1060', - 'z-tooltip': '1070', - }, - }; - - const parsed = ThemeSchema.parse(theme); - expect(parsed.customVars?.['z-modal']).toBe('1050'); - }); - - it('a motion scale is still expressible — same door, same variables', () => { - const theme: Theme = { - name: 'animated_theme', - label: 'Animated Theme', - colors: { primary: '#007BFF' }, - customVars: { - 'duration-fast': '150ms', - 'duration-base': '300ms', - 'duration-slow': '500ms', - 'timing-ease': 'cubic-bezier(0.4, 0, 0.2, 1)', - 'timing-ease_in': 'cubic-bezier(0.4, 0, 1, 1)', - }, - }; - - const parsed = ThemeSchema.parse(theme); - expect(parsed.customVars?.['duration-fast']).toBe('150ms'); - }); - -}); - -describe('Real-World Theme Examples', () => { - it('should accept enterprise light theme', () => { - const theme: Theme = { - name: 'enterprise_light', - label: 'Enterprise Light', - description: 'Professional light theme for enterprise applications', - mode: 'light', - colors: { - primary: '#0066CC', - secondary: '#4A5568', - accent: '#ED8936', - success: '#48BB78', - warning: '#ECC94B', - error: '#F56565', - info: '#4299E1', - background: '#FFFFFF', - surface: '#F7FAFC', - text: '#1A202C', - textSecondary: '#718096', - border: '#E2E8F0', - }, - // #5021 fixture triage, disposition 1 (re-spell): `heading` dropped, the - // live `base` kept. - typography: { - fontFamily: { - base: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif', - }, - }, - }; - - expect(() => ThemeSchema.parse(theme)).not.toThrow(); - }); - - it('should accept dark theme with extended configuration', () => { - const theme: Theme = { - name: 'professional_dark', - label: 'Professional Dark', - mode: 'dark', - colors: { - primary: '#60A5FA', - secondary: '#9CA3AF', - accent: '#FBBF24', - success: '#34D399', - warning: '#FBBF24', - error: '#F87171', - info: '#60A5FA', - background: '#0F172A', - surface: '#1E293B', - text: '#F1F5F9', - textSecondary: '#94A3B8', - border: '#334155', - }, - borderRadius: { - base: '0.375rem', - lg: '0.5rem', - xl: '0.75rem', - }, - shadows: { - base: '0 1px 3px 0 rgba(0, 0, 0, 0.3)', - md: '0 4px 6px -1px rgba(0, 0, 0, 0.3)', - lg: '0 10px 15px -3px rgba(0, 0, 0, 0.3)', - }, - }; - - expect(() => ThemeSchema.parse(theme)).not.toThrow(); - }); -}); - -// ============================================================================ -// Issue #6: Easing naming unified to snake_case in theme animation tokens -// -// RETIRED at #5021. `AnimationSchema` no longer exists, so the snake_case-vs- -// camelCase question this block settled is moot at the schema — there is no -// declared easing vocabulary left to be inconsistent about. The old fixture -// (fixture triage, disposition 3) pinned `animation.timing` directly. -// -// Kept as a pin rather than deleted, because the useful half of #6's finding -// SURVIVES the retirement and would otherwise go untested: an author who wants -// snake_case easing variables can still emit them, and now spells the variable -// name in full instead of relying on the engine's key-to-variable derivation — -// which is the one thing that actually changed for them. -// ============================================================================ -describe('easing tokens after the #5021 retirement', () => { - it('the snake_case easing vocabulary is still emittable through `customVars`', () => { - const theme = ThemeSchema.parse({ - name: 'snake_case_timing', - label: 'Snake Case Timing', - colors: { primary: '#000' }, - customVars: { - 'timing-linear': 'linear', - 'timing-ease': 'ease', - 'timing-ease_in': 'ease-in', - 'timing-ease_out': 'ease-out', - 'timing-ease_in_out': 'ease-in-out', - }, - }); - expect(theme.customVars?.['timing-ease_in']).toBe('ease-in'); - expect(theme.customVars?.['timing-ease_in_out']).toBe('ease-in-out'); - }); - - it('`animation` itself is gone — the key no longer exists on the parsed theme', () => { - const theme = ThemeSchema.parse({ - name: 'no_animation', - label: 'No Animation', - colors: { primary: '#000' }, - }); - expect(theme).not.toHaveProperty('animation'); - expect(theme).not.toHaveProperty('zIndex'); - }); -}); - -// ============================================================================ -// Issue #9: ThemeModeSchema — canonical *Schema name with deprecated alias -// ============================================================================ -describe('ThemeModeSchema (canonical name)', () => { - it('should accept valid theme modes', () => { - expect(() => ThemeModeSchema.parse('light')).not.toThrow(); - expect(() => ThemeModeSchema.parse('dark')).not.toThrow(); - expect(() => ThemeModeSchema.parse('auto')).not.toThrow(); - }); - - it('should be the same as deprecated ThemeMode alias', () => { - expect(ThemeModeSchema).toBe(ThemeMode); - }); -}); - -// ============================================================================ -// #4001 批 15 — unknown keys are REJECTED, and the rejection is fixable. -// -// These assertions are the third of the three places this batch's verdict is -// recorded (the other two: the header comment in `theme.zod.ts`, and the ui/ -// row in `docs/audits/2026-07-unknown-key-strictness-ledger.md`). They pin -// three separate things, because each can regress on its own: -// -// 1. THE DOOR. Strictness is a property of a PARSE; a strict schema nobody -// parses gates nothing (#4583). So the first block asserts the parse -// exists — `defineTheme()` and `defineStack({ themes })` — rather than -// only asserting the schema is strict. -// 2. EVERY ONE of the 14 object sites is closed. Strictness does NOT recurse -// (the 批 13 finding: a strict shell around strip sub-blocks is the -// silhouette of a closed surface, not a closed surface), so each nested -// block is probed at its own path. -// 3. The CURATION — the aliases and tombstones that make the rejection -// fixable. Each entry here was measured against a named sibling contract, -// not guessed; the comments in `theme.zod.ts` say which. -// ============================================================================ -describe('#4001 批 15 — ThemeSchema unknown-key strictness', () => { - const base = { name: 'brand_theme', label: 'Brand', colors: { primary: '#000000' } }; - const reject = (theme: unknown): string => { - const r = ThemeSchema.safeParse(theme); - expect(r.success, 'expected this theme to be REJECTED').toBe(false); - return JSON.stringify(r.error?.issues ?? []); - }; - - it('the control parses — these tests fail closed, not by rejecting everything', () => { - expect(ThemeSchema.safeParse(base).success).toBe(true); - }); - - // ---- 1. the door ---------------------------------------------------- - it('defineTheme() is a real parse door — it throws on an undeclared key', () => { - expect(() => defineTheme({ ...base, spacing: {} } as never)).toThrow(/Unrecognized key/); - }); - - it('defineStack({ themes }) is the second door — ObjectStackSchema carries ThemeSchema', () => { - const shape = (ObjectStackSchema as unknown as { _zod: { def: { shape: Record } } })._zod.def.shape; - expect(Object.keys(shape), 'the carrier key this file is reachable through').toContain('themes'); - }); - - // ---- 2. all fourteen sites, each at its own path --------------------- - it('rejects an undeclared key at the TOP level', () => { - expect(reject({ ...base, extraKey: 1 })).toContain('extraKey'); - }); - - it('rejects an undeclared key in `colors` — strictness does not stop at the shell', () => { - expect(reject({ ...base, colors: { primary: '#000', notAColor: '#fff' } })).toContain('notAColor'); - }); - - // #5021 shrank this list from fourteen sites to six. The `animation`, - // `zIndex`, `typography.fontSize`/`.fontWeight`/`.lineHeight`/ - // `.letterSpacing` and `animation.duration`/`.timing` rows were not - // re-spelled — those schemas no longer exist, so an unknown-key probe against - // them has nothing to probe. What replaced them is the retirement pin at the - // bottom of this file: writing the BLOCK is now the rejection, which is - // strictly stronger than rejecting one bad key inside it. - it.each([ - ['typography', { notATypographyKey: 1 }], - ['borderRadius', { notARadius: '1px' }], - ['shadows', { notAShadow: 'x' }], - ])('rejects an undeclared key in `%s`', (block, value) => { - expect(reject({ ...base, [block]: value })).toContain(Object.keys(value)[0]); - }); - - it('rejects an undeclared key in the nested `typography.fontFamily` block', () => { - expect(reject({ ...base, typography: { fontFamily: { notAFamily: 'x' } } })).toContain('notAFamily'); - }); - - // ---- 3. curation ---------------------------------------------------- - it('renames the shadcn colour vocabulary onto the palette keys it maps to', () => { - // MEASURED against objectui's COLOR_TO_CSS_MAP: `surface` is emitted as - // `--card`, `text` as `--foreground`, `disabled` as `--muted`, `error` as - // `--destructive`. An author reading the rendered CSS writes the shadcn - // name back, which no edit distance can reach. - const msg = reject({ ...base, colors: { primary: '#000', card: '#fff', foreground: '#111', destructive: '#f00' } }); - expect(msg).toContain('`card` → `surface`'); - expect(msg).toContain('`foreground` → `text`'); - expect(msg).toContain('`destructive` → `error`'); - }); - - // The `md` → `base` (font-size), `base` → `normal` (font-weight) and - // `easeIn` → `ease_in` (animation timing) curation tests lived here until - // #5021. All three graded aliases INSIDE a retired scale, so they went with - // their schemas rather than being re-spelled — there is no surviving surface - // on which `md` or `easeIn` means anything. This is the honest reading of the - // fixture-triage rule: a fixture whose subject was deleted is replaced by one - // the surviving rule reads, not kept alive on a technicality. - - it('renames onto the camelCase targets the distance fallback is weak on (#4990)', () => { - // `findClosestMatches` lowercases the input but not the candidates, so a - // capital costs an edit. These land through the explicit table instead. - // - // The `backdrop` → `modalBackdrop` case that used to anchor this test was - // on `zIndex` and retired with it (#5021); `radius` and `cssVars` carry the - // same property on surfaces that are still live. - expect(reject({ ...base, radius: {} })).toContain('`radius` → `borderRadius`'); - expect(reject({ ...base, cssVars: {} })).toContain('`cssVars` → `customVars`'); - expect(reject({ ...base, customProperties: {} })).toContain('`customProperties` → `customVars`'); - }); - - it('renames `inset` onto `inner` — CSS\'s word for what this scale calls inner', () => { - expect(reject({ ...base, shadows: { inset: '0 0 1px' } })).toContain('`inset` → `inner`'); - }); - - it('carries a TOMBSTONE, not a rename, for each of the eight props #3494 removed', () => { - for (const key of ['spacing', 'breakpoints', 'logo', 'density', 'wcagContrast', 'rtl', 'touchTarget', 'keyboardNavigation']) { - const msg = reject({ ...base, [key]: 'x' }); - expect(msg, `${key} must carry its own #3494 prescription`).toContain('#3494'); - expect(msg, `${key} must be named in its own prescription`).toContain('`' + key + '` was removed'); - } - }); - - it('gives each retired key its OWN sentence — a shared string prints N times (批 10)', () => { - const msg = reject({ ...base, rtl: true, density: 'compact' }); - expect(msg).toContain('text direction follows the document'); - expect(msg).toContain('compact/comfortable spacing'); - // Two keys, two DISTINCT bullets. - expect(msg.split('• ').length - 1).toBe(2); - }); - - it('never prescribes a vocabulary with no carrier key (the ledger\'s finding 7)', () => { - // `touchTarget` / `keyboardNavigation` look like they should point at - // `ui/touch.zod.ts` / `ui/keyboard.zod.ts`. 批 13 measured both as having - // NO carrier, so prescribing them would walk an author out of a loud - // rejection into a silent one. #4988 then RETIRED both modules, which makes - // this assertion stronger rather than moot: the strings they must not - // contain now name files that do not exist, so a future edit reaching for - // "a nearby-looking slot" would be prescribing a deleted module. - const msg = reject({ ...base, touchTarget: 1, keyboardNavigation: true }); - expect(msg).not.toContain('touch.zod'); - expect(msg).not.toContain('keyboard.zod'); - }); - - it('every suggestion it makes is a key the schema actually accepts', () => { - // The `triggerPhrases` lesson in `shared/strict-object.ts`: never point an - // author at a key that will reject them a second time. Walk the whole - // alias table and prove each target parses. - // - // `animation` and `zIndex` left this list at #5021 — WITH the five aliases - // that pointed at them (`animations`/`motion`/`transitions`/`layers`/ - // `stacking`). That pairing is the point of the test, not bookkeeping: had - // the aliases stayed, this assertion would be the thing that caught it. - const targets = ['colors', 'typography', 'borderRadius', 'shadows', 'customVars', 'extends', 'label', 'name', 'mode']; - const shape = (ThemeSchema as unknown as { _zod: { def: { shape: Record } } })._zod.def.shape; - for (const t of targets) expect(Object.keys(shape), `alias target "${t}" must be declared`).toContain(t); - }); -}); - -// ============================================================================ -// #5021 — the nine emitted-but-unread token groups, RETIRED (ADR-0049 D2) -// -// The measurement (objectui `main`, re-confirmed 2026-08-04): `--font-size-*`, -// `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--duration-*`, -// `--timing-*`, `--z-*`, `--font-heading` and `--font-mono` have ZERO consumers -// across objectui's `packages/**`, while `--font-sans`, `--radius*`, -// `--shadow*` and the colour variables are read — the positive controls that -// make the zero mean something. -// -// Route: STRICT REMOVAL + guidance map, not a `retiredKey()` tombstone. Both -// channels are still covered and it is worth being explicit about which does -// what, because the two routes' evidence looks different: -// * `tsc` — the key is gone from the inferred input type, so authoring one -// fails to compile. (A `retiredKey()` would type it `never`; deleting it -// from a `.strict()` shape is the same outcome by a different mechanism.) -// * the parse — `strictObject`'s `guidance` map carries the prescription, so -// the rejection is the upgrade instruction rather than a bare -// "unrecognized key". That is what these tests pin. -// ============================================================================ -describe('#5021 — retired theme token scales', () => { - const base = { name: 'demo_theme', label: 'Demo', colors: { primary: '#000' } }; - const reject = (input: unknown): string => { - const r = ThemeSchema.safeParse(input); - expect(r.success, 'fixture must be REJECTED — a passing parse means the key is still live').toBe(false); - return r.error!.issues.map((i) => i.message).join('\n'); - }; - - // ---- 1. the control: this suite fails closed ------------------------ - it('the surviving theme parses — these tests reject specific keys, not everything', () => { - expect(ThemeSchema.safeParse({ - ...base, - typography: { fontFamily: { base: 'Inter' } }, - borderRadius: { base: '0.25rem' }, - shadows: { base: '0 1px 2px rgba(0,0,0,.1)' }, - customVars: { 'z-modal': '1050' }, - }).success).toBe(true); - }); - - // ---- 2. every retired key rejects, at its own path ------------------- - it('rejects `animation` and `zIndex` at the theme top level', () => { - expect(reject({ ...base, animation: { duration: { fast: '150ms' } } })).toContain('`theme.animation` was removed'); - expect(reject({ ...base, zIndex: { modal: 1050 } })).toContain('`theme.zIndex` was removed'); - }); - - it.each(['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing'])( - 'rejects the retired `typography.%s` scale', - (key) => { - const msg = reject({ ...base, typography: { [key]: {} } }); - expect(msg).toContain('`theme.typography.' + key + '` was removed'); - }, - ); - - it.each(['heading', 'mono'])('rejects the retired `typography.fontFamily.%s`', (key) => { - const msg = reject({ ...base, typography: { fontFamily: { base: 'Inter', [key]: 'Georgia' } } }); - expect(msg).toContain('`theme.typography.fontFamily.' + key + '` was removed'); - }); - - // ---- 3. the prescription is the payload ----------------------------- - it('every prescription names `customVars`, the door measured to have real consumers', () => { - for (const [input, key] of [ - [{ ...base, animation: {} }, 'animation'], - [{ ...base, zIndex: {} }, 'zIndex'], - [{ ...base, typography: { fontSize: {} } }, 'typography.fontSize'], - [{ ...base, typography: { fontWeight: {} } }, 'typography.fontWeight'], - [{ ...base, typography: { lineHeight: {} } }, 'typography.lineHeight'], - [{ ...base, typography: { letterSpacing: {} } }, 'typography.letterSpacing'], - [{ ...base, typography: { fontFamily: { heading: 'x' } } }, 'typography.fontFamily.heading'], - [{ ...base, typography: { fontFamily: { mono: 'x' } } }, 'typography.fontFamily.mono'], - ] as const) { - const msg = reject(input); - expect(msg, `${key} must prescribe customVars`).toContain('customVars'); - expect(msg, `${key} must name the migration command`).toContain('os migrate meta --from 16'); - expect(msg, `${key} must cite the issue`).toContain('#5021'); - } - }); - - it('gives each retired key its OWN sentence — a shared string prints N times (批 10)', () => { - const r = ThemeSchema.safeParse({ ...base, animation: {}, zIndex: {} }); - expect(r.success).toBe(false); - const msg = r.error!.issues.map((i) => i.message).join('\n'); - expect(msg).toContain('every transition ran at the renderer default'); - expect(msg).toContain('still stacked by document order'); - // Two keys, two DISTINCT issues. Note the shape difference from the #4001 - // block above: a `guidance` prescription arrives as ONE unrecognized-key - // issue carrying N bullets, whereas a `retiredKey()` raises its own issue - // per key — so this counts issues, not bullets. - expect(r.error!.issues).toHaveLength(2); - }); - - // ---- 4. finding 7: no suggestion may point at a retired key ---------- - it('the five aliases that pointed at `animation`/`zIndex` are GONE, not re-pointed', () => { - // Had `layers: 'zIndex'` survived, an author writing `layers` would be told - // "did you mean `zIndex`?" and then rejected for writing `zIndex` — walked - // out of one rejection into a second. The ledger's finding 7, which this - // file's own header has now signposted three times. - for (const alias of ['animations', 'motion', 'transitions', 'layers', 'stacking']) { - const msg = reject({ ...base, [alias]: {} }); - expect(msg, `${alias} must not be renamed onto a retired key`).not.toContain('→ `animation`'); - expect(msg, `${alias} must not be renamed onto a retired key`).not.toContain('→ `zIndex`'); - } - }); - - it('the typography aliases that pointed at retired scales are GONE too', () => { - for (const alias of ['sizes', 'size', 'weights', 'weight', 'tracking', 'leading']) { - const msg = reject({ ...base, typography: { [alias]: {} } }); - for (const dead of ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing']) { - expect(msg, `${alias} must not be renamed onto retired \`${dead}\``).not.toContain('→ `' + dead + '`'); - } - } - for (const alias of ['headings', 'display', 'monospace', 'code']) { - const msg = reject({ ...base, typography: { fontFamily: { base: 'Inter', [alias]: 'x' } } }); - expect(msg, `${alias} must not be renamed onto retired \`heading\``).not.toContain('→ `heading`'); - expect(msg, `${alias} must not be renamed onto retired \`mono\``).not.toContain('→ `mono`'); - } - }); - - it('the retired keys are DECLARED-but-unwritable, not deleted — the tombstone route', () => { - // The distinction that decides how `authorable-surface.json` moves: a - // tombstoned key STAYS in the walked shape (gaining a `[RETIRED]` marker - // in the baseline) rather than vanishing from it. Assert the mechanism - // directly, so a future "tidy-up" that deletes these keys outright fails - // here rather than in `gen:schema`'s #4650 deletion check. - const shapeOf = (s: unknown) => - (s as { _zod: { def: { shape: Record } } })._zod.def.shape; - - const themeShape = shapeOf(ThemeSchema); - const typoShape = shapeOf(TypographySchema); - expect(Object.keys(themeShape)).toContain('animation'); - expect(Object.keys(themeShape)).toContain('zIndex'); - expect(Object.keys(typoShape)).toEqual( - expect.arrayContaining(['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing']), - ); - - // …and every one of them accepts NOTHING, which is what makes it a - // tombstone rather than a live key. - for (const [shape, keys] of [ - [themeShape, ['animation', 'zIndex']], - [typoShape, ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing']], - ] as const) { - for (const k of keys) { - const inner = (shape[k] as { _zod: { def: { innerType?: { _zod: { def: { type: string } } } } } }) - ._zod.def.innerType; - expect(inner?._zod.def.type, `${k} must be a never-typed tombstone`).toBe('never'); - } - } - }); - - // ---- 5. the capability survives ------------------------------------- - it('`customVars` reproduces every retired variable by name — capability is not lost', () => { - // This is the claim the whole retirement rests on, so it is pinned rather - // than asserted in prose: the engine emits `customVars` as `--: - // ` verbatim, so each retired variable has an exact spelling here. - const parsed = ThemeSchema.parse({ - ...base, - customVars: { - 'font-size-lg': '1.125rem', - 'font-weight-bold': '700', - 'line-height-relaxed': '1.75', - 'letter-spacing-wide': '0.025em', - 'duration-fast': '150ms', - 'timing-ease_in': 'cubic-bezier(0.4, 0, 1, 1)', - 'z-modal': '1050', - 'font-heading': 'Georgia, serif', - 'font-mono': 'ui-monospace, monospace', - }, - }); - // One entry per retired GROUP — all nine the issue measured. - expect(Object.keys(parsed.customVars ?? {})).toHaveLength(9); - }); - - // ---- 6. the live blocks are untouched ------------------------------- - it('the blocks with live consumers still parse — the retirement is scoped, not a sweep', () => { - const parsed = ThemeSchema.parse({ - ...base, - colors: { primary: '#7C3AED', surface: '#F8F9FA', text: '#1F2937' }, - typography: { fontFamily: { base: 'Inter, sans-serif' } }, - borderRadius: { none: '0', base: '0.25rem', full: '9999px' }, - shadows: { base: '0 1px 3px rgba(0,0,0,.1)', inner: 'inset 0 2px 4px rgba(0,0,0,.06)' }, - }); - expect(parsed.colors.surface).toBe('#F8F9FA'); - expect(parsed.typography?.fontFamily?.base).toBe('Inter, sans-serif'); - expect(parsed.borderRadius?.full).toBe('9999px'); - expect(parsed.shadows?.inner).toBe('inset 0 2px 4px rgba(0,0,0,.06)'); - }); - - // ---- 7. the authoring DOORS carry the rejection ---------------------- - it('both authoring doors reject a retired key — `defineTheme` and `defineStack`', () => { - expect(() => defineTheme({ ...base, zIndex: { modal: 1 } } as never)).toThrow(/`theme\.zIndex` was removed/s); - - const stack = ObjectStackSchema.safeParse({ - name: 'demo', label: 'Demo', - themes: [{ ...base, typography: { fontSize: { base: '1rem' } } }], - }); - expect(stack.success).toBe(false); - expect(stack.error!.issues.map((i) => i.message).join('\n')) - .toContain('`theme.typography.fontSize` was removed'); - }); -}); diff --git a/packages/spec/src/ui/theme.zod.ts b/packages/spec/src/ui/theme.zod.ts deleted file mode 100644 index 366e97f620..0000000000 --- a/packages/spec/src/ui/theme.zod.ts +++ /dev/null @@ -1,503 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; - -// --------------------------------------------------------------------------- -// CLOSED AGAINST UNKNOWN KEYS (#4001 批 15, ADR-0078) — and the door was -// MEASURED before anything was tightened, not assumed. Read this before adding -// a key, and before "finishing" any sibling file by analogy. -// -// THE DOOR (three measurements, 2026-08-03, each with controls in the run): -// -// 1. CARRIER KEY — `stack.zod.ts` declares `themes: z.array(ThemeSchema)`, -// and `defineTheme()` (exported from the package root) parses a theme -// directly. Both are doors an author writes by hand. -// 2. GRAPH — a BFS over this build's in-memory Zod graph from all 24 -// metadata-type roots (`listMetadataTypeSchemaTypes`) plus -// `ObjectStackSchema` — the closure `build-schemas.ts` uses for the #4650 -// deletion check — reaches EVERY schema in this file (`ThemeSchema` and -// its sub-blocks `root-graph`, `ColorPaletteSchema` `derived-clone`). -// Controls in the same run: `PageSchema` / `DashboardSchema` / -// `ReportSchema` / `WebhookSchema` / `StateMachineSchema` all resolve, and -// 批 13's measured no-door shapes (`TouchTargetConfigSchema`, -// `GestureConfigSchema`) came back unreachable. So "reachable" here is a -// fact about the graph, not an instrument that says yes to everything. -// (Those two negative controls were RETIRED at #4988 — the whole no-door -// interaction family went — so a re-run supplies its own, e.g. an inline -// `z.object({ a: z.string() })`. The reading above is unaffected.) -// 3. PARSE — `defineStack()` parses `ObjectStackSchema` on every app boot and -// on every `objectstack build`, so a theme key is judged on the path an -// author actually runs. -// -// `theme` is deliberately NOT in `BUILTIN_METADATA_TYPE_SCHEMAS` — it is not -// a metadata KIND (#2657's B/C decision stays open). Since #10194 it IS bound -// in `UNREGISTERED_KIND_SCHEMAS` (the #6245 map), so the runtime metadata -// write door (`PUT /meta/theme/:name`) applies the SAME shape check -// `defineStack` / `defineTheme` do, instead of storing any JSON with -// `success: true`. That is a SHAPE check only — no registry entry, no -// capability or authorization change — and this comment does not claim more -// than that. -// -// WHY EVERY SUB-BLOCK IS `strict` AND NOT `passthrough` — the #4909 question, -// asked per block rather than per file, because the theme engine reads the two -// halves of this file DIFFERENTLY (`@object-ui/core`'s `ThemeEngine.ts`): -// -// * `colors` / `borderRadius` / `shadows` / `typography.fontFamily` are read -// through FIXED maps (`COLOR_TO_CSS_MAP`, the radius and shadow maps) or by -// named property. An undeclared key there is read by nothing, ever. -// * The token SCALES that used to sit beside them — `typography.fontSize` / -// `.fontWeight` / `.lineHeight` / `.letterSpacing`, `animation.duration` / -// `.timing` and `zIndex` — were read with `Object.entries(...)`, emitting -// `--font-size-`, `--duration-`, `--z-` … for whatever they -// were handed. That WAS the #4909 open shape at the runtime. It is moot -// now: all of them were RETIRED at #5021 (see the block below). -// * And the escape hatch it might otherwise have removed already exists and -// is declared: `customVars` emits an arbitrary CSS custom property by name. -// An author who wants `--font-size-huge` writes it there. Openness in the -// token scales would only add a second, undocumented way to do the same -// thing — one whose typos are indistinguishable from intent. -// -// ✅ ANSWERED AT #5021 (ADR-0049 enforce-or-remove) — this file's own filed -// question, now closed. The block above used to end "SEPARATE, FILED, NOT -// ANSWERED HERE", because 批 15 correctly refused to decide a LIVENESS question -// inside a STRICTNESS batch: strictness makes a dropped key loud, it cannot -// make a slot live. The measurement it filed on (re-confirmed against objectui -// `main` on 2026-08-04, with `--font-sans` / `--radius` / `--shadow` / -// `--primary` as positive controls in the same run) was that `--font-size-*`, -// `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--z-*`, -// `--duration-*`, `--timing-*`, `--font-heading` and `--font-mono` have ZERO -// first-party consumers, while only the colour variables, `--radius*`, -// `--shadow*` and `--font-sans` are read. -// -// The maintainer's ruling (2026-08-04) is RETIRE, not "add consumers" and not -// "promise them as a public token surface": theme-driven typography is not a -// near-term product capability, so wiring shadcn/Tailwind to read these would -// build a feature nobody asked for, and stamping a stability guarantee onto a -// surface that changes nothing in the platform's own UI is a promise attached -// to an inert slot (the #4583 shape — a precisely validated dead slot is the -// more convincing lie). -// -// ⚠️ The counter-argument that kept these alive through #3494 was answered, not -// ignored: a CSS custom property differs from an ordinary spec key because a -// TENANT'S OWN STYLESHEET can read it once it lands on the document, so -// "zero in-repo consumers" is weaker evidence here than it is elsewhere. That -// is exactly why the prescription is `customVars` rather than a bare deletion. -// `customVars` emits `--: ` verbatim, so a tenant who really was -// reading `--z-modal` or `--font-size-lg` from their own CSS reproduces every -// one of these variables BYTE FOR BYTE. Capability lost: none. What is lost is -// a semantic vocabulary that the platform itself never honoured — which is the -// thing ADR-0049 exists to delete. -// -// `colors`, `borderRadius`, `shadows` and `typography.fontFamily.base` have -// live consumers and are UNTOUCHED. objectui's `ThemeEngine` still emits the -// retired groups until its own stop-emitting follow-up lands (filed on the -// objectui queue); that is NOT a correctness dependency in either direction — -// an emitted variable nothing reads is inert, and after this change no author -// can put a value into one, so the emitter has nothing left to emit. -// --------------------------------------------------------------------------- - -/** - * Color Palette Schema - * Defines brand colors and their variants. - */ -import { lazySchema } from '../shared/lazy-schema'; -import { retiredKey } from '../shared/retired-key'; -import { strictObject } from '../shared/strict-object'; -import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; - -// Competing vocabulary, MEASURED not guessed: objectui's `COLOR_TO_CSS_MAP` -// renames every one of these on the way out (`surface` → `--card`, `text` → -// `--foreground`, `textSecondary` → `--muted-foreground`, `disabled` → -// `--muted`, `error` → `--destructive`). An author who read the rendered CSS — -// or who knows shadcn — writes the shadcn name back into the palette, which is -// a different WORD for the same intent rather than a typo, so edit distance -// cannot reach it. `aliasProbe` lowercases and strips `-`/`_`, so each entry -// also covers the `muted-foreground` spelling. -const COLOR_ALIASES: Readonly> = { - card: 'surface', - foreground: 'text', - mutedForeground: 'textSecondary', - muted: 'disabled', - destructive: 'error', - danger: 'error', - textPrimary: 'text', - secondaryText: 'textSecondary', -}; - -export const ColorPaletteSchema = lazySchema(() => strictObject( - { - surface: 'this theme color palette', - history: - 'Until #4001 an undeclared colour key was dropped at parse and every element kept its default colour — the brand change simply did not happen, and the theme still reported valid.', - aliases: COLOR_ALIASES, - }, - { - primary: z.string().describe('Primary brand color (hex, rgb, or hsl)'), - secondary: z.string().optional().describe('Secondary brand color'), - accent: z.string().optional().describe('Accent color for highlights'), - success: z.string().optional().describe('Success state color (default: green)'), - warning: z.string().optional().describe('Warning state color (default: yellow)'), - error: z.string().optional().describe('Error state color (default: red)'), - info: z.string().optional().describe('Info state color (default: blue)'), - - // Neutral colors - background: z.string().optional().describe('Background color'), - surface: z.string().optional().describe('Surface/card background color'), - text: z.string().optional().describe('Primary text color'), - textSecondary: z.string().optional().describe('Secondary text color'), - border: z.string().optional().describe('Border color'), - disabled: z.string().optional().describe('Disabled state color'), - - // Color variants (shades) - primaryLight: z.string().optional().describe('Lighter shade of primary'), - primaryDark: z.string().optional().describe('Darker shade of primary'), - secondaryLight: z.string().optional().describe('Lighter shade of secondary'), - secondaryDark: z.string().optional().describe('Darker shade of secondary'), - }, -)); - -/** - * Typography Settings Schema - * Base font family. The size / weight / line-height / letter-spacing scales - * were retired at #5021 and are `retiredKey()` tombstones below. - * - * ⚠️ WHY `retiredKey()` AND NOT A `guidance` ENTRY, on a shape that is already - * `.strict()`. The two routes are not interchangeable here and the build is - * what settles it. `guidance` is consulted for `unrecognized_keys`, which - * requires the key to be ABSENT from the shape — and a key absent from the - * shape is absent from `authorable-surface.json`, which is a ratcheted - * baseline. Deleting a live line from it fails `gen:schema`'s #4650 deletion - * check (`the entry at was LIVE (never tombstoned)`), and rightly: the - * ratchet cannot see that THIS parent happens to be strict, and the class of - * mistake it guards — a key deleted from a non-strict shape, silently stripped - * forever after — is indistinguishable at the file level. - * - * A tombstone satisfies both: the key stays in the walked shape (so the - * baseline gains a `[RETIRED]` marker instead of losing a line), and it is - * strictly LOUDER than the strict shell, because it carries its own - * prescription instead of a generic unknown-key message. `strictObject` already - * anticipates the combination — its `acceptsNothing()` helper exists precisely - * to keep a tombstoned key out of the "did you mean" candidates. - * - * Every prescription points at `customVars`, and that is a byte-for-byte - * replacement rather than a consolation prize: the engine emits `customVars` as - * `--: ` verbatim, so `customVars: { 'font-size-lg': '1.125rem' }` - * puts the SAME `--font-size-lg` on the document this block used to. What the - * author loses is a semantic vocabulary the platform never honoured; what they - * keep is every variable they were actually shipping. - */ -export const TypographySchema = lazySchema(() => strictObject( - { - surface: 'this theme typography block', - history: - 'Until #4001 an undeclared typography key was dropped at parse and the type scale silently stayed at the defaults.', - // ⚠️ The `sizes`/`size`, `weights`/`weight`, `tracking`/`spacing` and - // `leading` aliases were DELETED at #5021, not re-pointed. They named - // Tailwind's utility words for scales this block no longer declares, so - // keeping them would answer an author with "did you mean `fontSize`?" and - // then reject `fontSize` — walking them out of one rejection into a second - // one, which is the ledger's finding 7 (this campaign signposting the way - // into the failure mode it exists to kill). A key that no longer exists - // gets no suggestion at all; the four canonical spellings still get their - // full prescription through `guidance` below. - aliases: { - fonts: 'fontFamily', font: 'fontFamily', family: 'fontFamily', fontFamilies: 'fontFamily', - }, - }, - { - fontFamily: strictObject( - { - surface: 'this theme font-family block', - history: - 'Until #4001 an undeclared font-family key was dropped at parse and the element kept the system font stack.', - // MEASURED: the engine emits `base` as `--font-sans` (the only one of - // the three that any objectui stylesheet ever read — which is why it is - // the only one of the three that survived #5021), so the rendered - // variable name and the authorable key name disagree; `sans` is what an - // author reading the output writes back, four edits away from `base`. - // - // `headings`/`display` → `heading` and `monospace`/`code` → `mono` were - // DELETED at #5021 with their targets, for the finding-7 reason spelled - // out on the parent block. - aliases: { sans: 'base', body: 'base', default: 'base' }, - }, - { - base: z.string().optional().describe('Base font family (default: system fonts)'), - - heading: retiredKey( - '`theme.typography.fontFamily.heading` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — it emitted `--font-heading`, which no objectui component or stylesheet reads, so headings always rendered in the base font stack. `base` is the ONE font-family key with a live consumer (it emits `--font-sans`) and is unchanged. Delete the key; if your own CSS reads the variable, declare it under `customVars` (`{ "font-heading": "Georgia, serif" }` emits exactly the same `--font-heading`). ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', - ), - mono: retiredKey( - '`theme.typography.fontFamily.mono` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — it emitted `--font-mono`, which no objectui component or stylesheet reads, so code always rendered in the browser default monospace. `base` is the ONE font-family key with a live consumer (it emits `--font-sans`) and is unchanged. Delete the key; if your own CSS reads the variable, declare it under `customVars` (`{ "font-mono": "ui-monospace, monospace" }` emits exactly the same `--font-mono`). ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', - ), - }, - ).optional(), - - fontSize: retiredKey( - '`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: retiredKey( - '`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: retiredKey( - '`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: retiredKey( - '`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.', - ), - }, -)); - -/** - * Border Radius Schema - * Rounded corners configuration. - */ -export const BorderRadiusSchema = lazySchema(() => strictObject( - { - surface: 'this theme border-radius scale', - history: - 'Until #4001 an undeclared radius stop was dropped at parse and the corners rendered at the default radius.', - // MEASURED: `base` is emitted as the BARE `--radius`, which is the one - // radius variable objectui's stylesheets actually read — so `radius` is - // what an author copying from the rendered CSS writes. - aliases: { radius: 'base', default: 'base', normal: 'base', pill: 'full', round: 'full', rounded: 'full' }, - }, - { - none: z.string().optional().describe('No border radius (0)'), - sm: z.string().optional().describe('Small border radius (e.g., 0.125rem)'), - base: z.string().optional().describe('Base border radius (e.g., 0.25rem)'), - md: z.string().optional().describe('Medium border radius (e.g., 0.375rem)'), - lg: z.string().optional().describe('Large border radius (e.g., 0.5rem)'), - xl: z.string().optional().describe('Extra large border radius (e.g., 0.75rem)'), - '2xl': z.string().optional().describe('2X large border radius (e.g., 1rem)'), - full: z.string().optional().describe('Full border radius (50%)'), - }, -)); - -/** - * Shadow Schema - * Box shadow effects. - */ -export const ShadowSchema = lazySchema(() => strictObject( - { - surface: 'this theme shadow scale', - history: - 'Until #4001 an undeclared shadow stop was dropped at parse and the surface rendered flat.', - // `inset` is CSS's own word for what this scale calls `inner` — and the - // key's own `.describe()` says so ("Inner shadow (inset)"). - aliases: { inset: 'inner', default: 'base', normal: 'base' }, - }, - { - none: z.string().optional().describe('No shadow'), - sm: z.string().optional().describe('Small shadow'), - base: z.string().optional().describe('Base shadow'), - md: z.string().optional().describe('Medium shadow'), - lg: z.string().optional().describe('Large shadow'), - xl: z.string().optional().describe('Extra large shadow'), - '2xl': z.string().optional().describe('2X large shadow'), - inner: z.string().optional().describe('Inner shadow (inset)'), - }, -)); - -/** - * Theme Mode Schema - */ -export const ThemeModeSchema = lazySchema(() => z.enum(['light', 'dark', 'auto'])); - -/** @deprecated Use ThemeModeSchema instead */ -export const ThemeMode = ThemeModeSchema; - -// Tombstones for the eight props #3494 removed, plus the two blocks #5021 -// retired. Each carries its OWN sentence: `guidance` prints one bullet per -// rejected key verbatim, so a shared string prints the same paragraph N times -// (批 10's `join`/`joinGateway` lesson). -// -// Two of them are deliberately worded NOT to hand the author a replacement -// slot. `touchTarget` and `keyboardNavigation` read like they should point at -// `ui/touch.zod.ts` / `ui/keyboard.zod.ts` — but 批 13 measured both of those -// vocabularies as having no carrier key at all, so prescribing them would walk -// an author out of a loud rejection and into a silent one. That is the ledger's -// finding 7, and this campaign has now signposted its own failure mode twice; -// it does not get to do it a third time. **#4988 settled it the other way**: -// both modules were RETIRED outright (ADR-0049), so the slot these two -// prescriptions declined to name no longer exists at all — the refusal was -// right, and it must stay a refusal rather than becoming a dangling pointer. -// -// The #5021 pair is the OPPOSITE case and it is worth keeping the distinction -// visible: `animation` and `zIndex` DO get a replacement slot, because -// `customVars` is measured live (the engine emits every entry verbatim) rather -// than merely plausible. That is the whole difference between a prescription -// and a signpost into a second rejection. -const THEME_RETIRED_KEY_GUIDANCE: Readonly> = { - spacing: - '`spacing` was removed in #3494 — the theme engine (objectui `generateThemeVars`) never emitted a spacing variable, so authoring it was a silent no-op. Emit your own scale through `customVars` (e.g. `{ "space-4": "1rem" }`).', - breakpoints: - '`breakpoints` was removed in #3494 — breakpoints are not theme-scoped. Author responsive behaviour per component (`page.components[].responsive`), where the protocol owns the breakpoint names.', - logo: - '`logo` was removed in #3494 — brand imagery is app-scoped, not theme-scoped: set `branding.logo` on the app (`ui/app.zod.ts`).', - density: - '`density` was removed in #3494 — no renderer read it. Express compact/comfortable spacing as your own tokens under `customVars`.', - wcagContrast: - '`wcagContrast` was removed in #3494 — it declared a check nothing ran. Contrast is measured against the palette you author (`contrastRatio` / `meetsContrastLevel` in the theme engine), never enabled by a flag.', - rtl: - '`rtl` was removed in #3494 — text direction follows the document and locale, not the theme.', - touchTarget: - '`touchTarget` was removed in #3494 — the theme engine never emitted a touch-target variable, so it changed nothing. If you need a token for it, declare one under `customVars`.', - keyboardNavigation: - '`keyboardNavigation` was removed in #3494 — keyboard behaviour is not a CSS variable and was never emitted; no theme key can switch it on or off.', -}; - -/** - * The two theme-level keys #5021 retired. - * - * These are `retiredKey()` tombstones rather than `guidance` entries — unlike - * the eight above, which predate the `authorable-surface.json` ratchet and are - * long gone from it. See the note on `TypographySchema` for why a strict shell - * does not make the tombstone redundant. - */ -const THEME_ANIMATION_RETIRED = - '`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.'; - -const THEME_ZINDEX_RETIRED = - '`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.'; - -/** - * Theme Configuration Schema - * Complete theme definition for brand customization. - * - * #3494: the aspirational props `spacing`, `breakpoints`, `logo`, `density`, - * `wcagContrast`, `rtl`, `touchTarget` and `keyboardNavigation` were removed — - * the theme engine (objectui generateThemeVars) never consumed them, so - * authoring them was a silent no-op (liveness audit #1878/#1893). Since #4001 - * writing one is a loud rejection carrying its replacement, instead of a silent - * strip that made the removal indistinguishable from the bug it fixed. - * - * #5021 removed `animation` and `zIndex` (and, inside `typography`, the - * `fontSize` / `fontWeight` / `lineHeight` / `letterSpacing` scales and - * `fontFamily.heading` / `.mono`). #3494's criterion could not reach these: - * it was "the engine never emits it", and the engine emitted every one of - * these. The criterion that reaches them is ADR-0049's — emitted, but read by - * nobody. `colors`, `borderRadius`, `shadows` and `fontFamily.base` have live - * consumers and stay. - */ -export const ThemeSchema = lazySchema(() => strictObject( - { - surface: 'this theme', - history: - 'Until #4001 an undeclared theme key was dropped at parse and the theme loaded looking complete — which is exactly how the eight props #3494 removed went on being authored after they stopped existing.', - aliases: { - palette: 'colors', colours: 'colors', colorPalette: 'colors', color: 'colors', - fonts: 'typography', font: 'typography', type: 'typography', - radius: 'borderRadius', borderRadii: 'borderRadius', radii: 'borderRadius', - shadow: 'shadows', boxShadow: 'shadows', elevation: 'shadows', - // `animations`/`motion`/`transitions` → `animation` and - // `layers`/`stacking` → `zIndex` were DELETED at #5021 rather than - // re-pointed at `customVars`: an alias renames one key to another, and - // this is not a rename — a scale object would have to become a flat - // string map with the CSS variable names spelled out and the numbers - // stringified. Suggesting the retired key would hand the author a second - // rejection (finding 7); suggesting `customVars` would imply the value - // transfers unchanged, which it does not. The prescription in `guidance` - // states the shape change; the D2 conversion DELETES the key and emits a - // notice rather than auto-populating `customVars`, because a rewrite - // would silently reconstitute ~25 variables nothing reads as - // live-looking config — the retirement would be invisible in exactly the - // way ADR-0049 objects to. - cssVars: 'customVars', variables: 'customVars', vars: 'customVars', customProperties: 'customVars', tokens: 'customVars', - extend: 'extends', parent: 'extends', inherits: 'extends', basedOn: 'extends', - title: 'label', displayName: 'label', - id: 'name', key: 'name', - darkMode: 'mode', colorScheme: 'mode', scheme: 'mode', - }, - guidance: THEME_RETIRED_KEY_GUIDANCE, - }, - { - name: SnakeCaseIdentifierSchema.describe('Unique theme identifier (snake_case)'), - label: z.string().describe('Human-readable theme name'), - description: z.string().optional().describe('Theme description'), - - /** Theme mode */ - mode: ThemeModeSchema.default('light').describe('Theme mode (light, dark, or auto)'), - - /** Color system */ - colors: ColorPaletteSchema.describe('Color palette configuration'), - - /** Typography */ - typography: TypographySchema.optional().describe('Typography settings'), - - /** Border radius */ - borderRadius: BorderRadiusSchema.optional().describe('Border radius scale'), - - /** Shadows */ - shadows: ShadowSchema.optional().describe('Box shadow effects'), - - /** @deprecated REMOVED at #5021 — see {@link THEME_ANIMATION_RETIRED}. */ - animation: retiredKey(THEME_ANIMATION_RETIRED), - - /** @deprecated REMOVED at #5021 — see {@link THEME_ZINDEX_RETIRED}. */ - zIndex: retiredKey(THEME_ZINDEX_RETIRED), - - // `AnimationSchema` and `ZIndexSchema` were DELETED outright rather than - // left standing beside these tombstones: an exported value schema with no - // consumer reads as a capability to whoever finds it (#3950), and each had - // exactly one consumer — the key now tombstoned above. Their own - // `authorable-surface` lines leave with the def, which is the #4650 - // deletion check's third proof (whole def no longer emitted) and is - // adjudicated by the json-schema.manifest.json ratchet (#2978) instead. - - /** - * Custom CSS variables. - * - * The declared door for any custom property, and — since #5021 — the ONLY - * one. Each entry is emitted verbatim as `--: `, so this is - * where a `--z-modal` or a `--font-size-lg` goes now. - */ - customVars: z.record(z.string(), z.string()).optional().describe('Custom CSS variables (key-value pairs)'), - - /** Extends another theme */ - extends: z.string().optional().describe('Base theme to extend from'), - - // ADR-0010 — runtime protection envelope (internal — set by loader). - // - // [#10194] Declared for the reason `sharing.zod.ts` states for its own - // spread: BOTH metadata load paths call `applyProtection` on EVERY type, - // so a package-loaded theme already carries these keys by the time - // anything re-parses it. This shape is `.strict()`, so until now that - // stamped envelope was not merely dropped — it was REJECTED, which stayed - // invisible only because `theme` resolved no schema at the overlay door - // and nothing parsed it there. #10194 binds that door - // (`UNREGISTERED_KIND_SCHEMAS`), so declaring the envelope is what keeps - // the new 422 aimed at malformed AUTHOR input instead of at the runtime's - // own stamp. `metadata-type-schemas.test.ts` enforces exactly this. - ...MetadataProtectionFields, - }, -)); - -export type Theme = z.input; -/** Post-parse shape of {@link Theme} — defaults applied, transforms run (ADR-0122). */ -export type ThemeParsed = z.infer; - -/** - * Type-safe factory for a UI theme. Validates at authoring time via - * `.parse()` and accepts input-shape config (optional defaults, CEL - * shorthand) — preferred over a bare `: Theme` literal. - */ -export function defineTheme(config: z.input): ThemeParsed { - return ThemeSchema.parse(config); -} -export type ColorPalette = z.input; -export type Typography = z.input; -export type BorderRadius = z.input; -export type Shadow = z.input; -// `Animation` and `ZIndex` were exported here until #5021, alongside the two -// schemas they were inferred from. -export type ThemeMode = z.input; diff --git a/packages/spec/src/ui/widget-i18n-retirement.test.ts b/packages/spec/src/ui/widget-i18n-retirement.test.ts index 6248ddca98..efdb12cb13 100644 --- a/packages/spec/src/ui/widget-i18n-retirement.test.ts +++ b/packages/spec/src/ui/widget-i18n-retirement.test.ts @@ -108,7 +108,7 @@ describe('[#5055] ui/ widget + i18n family retirement', () => { 'ResponsiveConfigSchema', 'NotificationTypeSchema', 'SharingConfigSchema', - 'ThemeSchema', + // (`ThemeSchema` stood here until #10485 retired the theme surface whole.) 'PageSchema', 'PageComponentSchema', ] as const; diff --git a/scripts/check-stack-collection-maps.mjs b/scripts/check-stack-collection-maps.mjs index f955690fbe..b1cffbc8a6 100644 --- a/scripts/check-stack-collection-maps.mjs +++ b/scripts/check-stack-collection-maps.mjs @@ -410,11 +410,13 @@ const SITES = [ waivers: [ { direction: 'extra', - keys: ['triggers', 'workflows'], + keys: ['triggers', 'workflows', 'themes'], reason: 'DRIFT, and NOT removable from here: this enum is an ACCEPTANCE face — dropping a member changes ' + 'what a published artifact may declare, a protocol-breaking change owing an ADR-0087 conversion. ' - + 'Recorded so the enum stops reading as an answerable enumeration (#6242 row 5).', + + 'Recorded so the enum stops reading as an answerable enumeration (#6242 row 5). `themes` joined ' + + 'the row at #10485 (carrier retired, ADR-0049): a previously published artifact may still ' + + 'declare the category, so the member leaves through its own reviewed diff, not this gate.', }, { direction: 'missing', @@ -455,9 +457,10 @@ const SITES = [ waivers: [ { direction: 'extra', - keys: ['workflows', 'approvals', 'roles', 'profiles', 'policies', 'ragPipelines'], + keys: ['workflows', 'approvals', 'roles', 'profiles', 'policies', 'ragPipelines', 'themes'], reason: - 'DRIFT — six kinds retired across ADR-0019 / ADR-0020 / ADR-0088 / ADR-0090 that this loop still ' + 'DRIFT — kinds retired across ADR-0019 / ADR-0020 / ADR-0088 / ADR-0090 / ADR-0049 (#10485 for ' + + '`themes`) that this loop still ' + 'iterates. Inert: `(manifest as any)[key]` is `undefined` for every one, because the schema ' + 'rejects the keys long before the loop runs. Removing them is an `engine-core` source change ' + '(#6242 row 3) and rides that lane, not this gate.', @@ -550,7 +553,7 @@ const SITES = [ { direction: 'missing', keys: [ - 'objectExtensions', 'datasourceMapping', 'datasources', 'themes', 'jobs', 'apis', 'webhooks', + 'objectExtensions', 'datasourceMapping', 'datasources', 'jobs', 'apis', 'webhooks', 'hooks', 'mappings', 'analyticsCubes', 'connectors', 'capabilities', 'datasets', ], reason: @@ -579,7 +582,7 @@ const SITES = [ waivers: [ { direction: 'missing', - keys: ['datasourceMapping', 'translations', 'themes', 'data', 'sharingRules', 'webhooks'], + keys: ['datasourceMapping', 'translations', 'data', 'sharingRules', 'webhooks'], reason: 'NOT DEMONSTRATED, and visible for the first time. This manifest tracked 3 of the non-kind ' + 'collections and — unlike its ratcheted sibling `KIND_COVERAGE` — was answerable to nothing ' diff --git a/skills/objectstack-platform/SKILL.md b/skills/objectstack-platform/SKILL.md index 07b88b555a..4a72b59604 100644 --- a/skills/objectstack-platform/SKILL.md +++ b/skills/objectstack-platform/SKILL.md @@ -176,7 +176,7 @@ export default defineStack({ `defineStack()` accepts an `ObjectStackDefinitionInput`. Each top-level key holds a collection of one metadata kind — `manifest`, `objects`, `objectExtensions`, `views`, `apps`, `pages`, `dashboards`, -`reports`, `datasets`, `actions`, `themes`, `flows`, `jobs`, +`reports`, `datasets`, `actions`, `flows`, `jobs`, `emailTemplates`, `docs`, `books`, `positions`, `permissions`, `capabilities`, `sharingRules`, `apis`, `webhooks`, `api`, `agents`, `tools`, `skills`, `hooks`, `functions`, `mappings`, `analyticsCubes`, diff --git a/skills/objectstack-ui/references/_index.md b/skills/objectstack-ui/references/_index.md index c5034313ba..01f52e6a46 100644 --- a/skills/objectstack-ui/references/_index.md +++ b/skills/objectstack-ui/references/_index.md @@ -17,7 +17,6 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/ui/dataset.zod.ts` — Analytics Dataset — the one semantic layer (ADR-0021). - `node_modules/@objectstack/spec/src/ui/page.zod.ts` — Page Region Schema - `node_modules/@objectstack/spec/src/ui/report.zod.ts` — Report Type Enum -- `node_modules/@objectstack/spec/src/ui/theme.zod.ts` — Color Palette Schema - `node_modules/@objectstack/spec/src/ui/view.zod.ts` — HTTP Method Enum & HTTP Request Schema - `node_modules/@objectstack/spec/src/ui/widget.zod.ts` — Field Widget Props Schema