diff --git a/.changeset/datasync-conflict-dual-source-c13-c15.md b/.changeset/datasync-conflict-dual-source-c13-c15.md index b2900997ed..45d34c94f1 100644 --- a/.changeset/datasync-conflict-dual-source-c13-c15.md +++ b/.changeset/datasync-conflict-dual-source-c13-c15.md @@ -31,7 +31,7 @@ import { DataSyncConfig, ConflictResolution, Sync } from '@objectstack/spec/auto ``` - 若你要的是**多源转换管道**:`import { ETLPipeline } from '@objectstack/spec/automation'`。 -- 若你要的是**客户端离线冲突策略**:`import { ConflictResolution } from '@objectstack/spec/ui'`(本次未动)。 +- 若你要的是**客户端离线冲突策略**:~~`import { ConflictResolution } from '@objectstack/spec/ui'`~~ —— ⚠️ **同一个 v17 窗口内,该形状已在 #4988 一并退役**(ADR-0049:`ui/offline.zod.ts` 同样没有承载键)。裸名 `ConflictResolution` 现在**没有任何 def 发布**;客户端离线冲突策略请在你自己的代码里就地声明该联合类型 —— 那是客户端策略,不是平台词表。 ```ts // FROM —— integration 侧旧名,编译期起以 TS2305 失败 diff --git a/.changeset/ui-interaction-config-family-retired.md b/.changeset/ui-interaction-config-family-retired.md new file mode 100644 index 0000000000..3046f3fbb5 --- /dev/null +++ b/.changeset/ui-interaction-config-family-retired.md @@ -0,0 +1,108 @@ +--- +"@objectstack/spec": major +--- + +refactor(spec)!: retire the five `ui/` interaction config modules — a documented vocabulary with no carrier key anywhere (#4988) + +`@objectstack/spec/ui` exported five interaction-configuration modules — +`touch.zod.ts`, `dnd.zod.ts`, `keyboard.zod.ts`, `animation.zod.ts` and +`offline.zod.ts` — carrying **22 `z.object` sites, 32 emitted defs and 64 +exported names**. All five are removed, and their generated reference pages with +them. + +Nothing in the protocol ever carried them. There was no `touch:` / `dnd:` / +`keyboard:` / `animation:` / `offline:` key on any schema, so no metadata +document could reach these shapes and nothing ever parsed one. + +Three measurements, each re-run on `origin/main` immediately before the removal, +each with its controls passing in the same run: + +1. **Static** — no module under `packages/spec/src` imported any of the five + except the `ui/index.ts` barrel, so no schema declared a carrier key. +2. **Graph** — a BFS over the in-memory Zod graph from all 24 metadata-type + roots plus `defineStack`'s `ObjectStackSchema` (25 roots, 4742 nodes) reached + **none** of the 21 named object shapes, while `PageSchema`, `WebhookSchema` + and `StateMachineSchema` all resolved `direct`, and injecting a synthetic + carrier flipped all 21 to `direct`. So "unreachable" was a fact about the + graph, not a broken walker. +3. **Call sites** — zero `.parse()` / `.safeParse()` in objectstack, objectui or + cloud outside these modules' own unit tests. objectui holds type re-exports + and parity ratchets, never validators, and says so (#2561). + +**The defect was on the documentation side, and that is what made it urgent.** +`authorable-surface.json` listed **109 keys** under these defs and +`content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered +them as authoring tables. An AI author reading `dnd.mdx` and writing a `dnd:` +block onto a page component was rejected by `PageComponentSchema` for an +unrecognized key — the published docs and the schema disagreeing about what the +platform does (Prime Directive #10). This was never a strictness question: +`.strict()` is a property of a parse, and there was no parse. + +Business ruling (2026-08-04): these five categories are **renderer built-in +behavior** — touch targets, drag-and-drop, focus and shortcuts, motion are +decided by the component library, not authored per page. Offline is a platform +capability whose vocabulary belongs on the sync engine that owns the queue, the +conflict policy and the cache, and that engine does not exist. Whichever of them +earns real product pull returns **with** its own vocabulary and its executor. + +FROM → TO: + +| removed | what to do instead | +|---|---| +| `TouchTargetConfig` / `GestureConfig` / `TouchInteraction` / `SwipeGestureConfig` / `PinchGestureConfig` / `LongPressGestureConfig` / `GestureType` / `SwipeDirection` | nothing to author — touch targets and gesture handling are the component library's behaviour | +| `DndConfig` / `DragItem` / `DropZone` / `DragConstraint` / `DragHandle` / `DropEffect` | nothing to author — drag-and-drop is renderer behaviour | +| `KeyboardNavigationConfig` / `KeyboardShortcut` / `FocusManagement` / `FocusTrapConfig` | nothing to author — focus order and shortcuts are renderer behaviour | +| `ComponentAnimation` / `MotionConfig` / `PageTransition` / `TransitionConfig` / `TransitionPreset` / `EasingFunction` / `AnimationTrigger` | nothing to author. (For theme-level CSS variables see `theme.customVars`; the theme `animation` block was separately retired at #5021) | +| `OfflineConfig` / `OfflineCacheConfig` / `SyncConfig` / `OfflineStrategy` / `ConflictResolution` / `PersistStorage` / `EvictionPolicy` | nothing to author — offline sync is unimplemented; its vocabulary arrives with the sync engine | + +**No metadata document needs editing.** A stack that parsed before parses +byte-for-byte the same after: none of these blocks was writable in the first +place. The break is a TypeScript one — every removed name is `TS2305` on +`@objectstack/spec` and `@objectstack/spec/ui` after upgrade. + +One name is worth checking explicitly: the bare **`ConflictResolution`**. #4738 +renamed the connector-side enum to `ConnectorConflictResolution` *because* +`ui/offline.zod.ts` owned the bare name; that owner is now gone, so the bare name +is published by **nobody**. The #4738 rename stands — freeing a word is not a +reason to spend a second breaking change renaming back — and no domain re-adopts +it. If you consumed it as a type for your own offline code, declare that union +locally; it is your client's policy, not the platform's. +`@objectstack/spec/integration`'s `ConnectorConflictResolution` (connector sync) +and `@objectstack/spec/api`'s `ConflictResolutionStrategy` (route merge policy) +are different concepts and are untouched. + +⚠️ `ui/animation.zod.ts` (`ComponentAnimation` / `MotionConfig` / +`PageTransition` / `AnimationTrigger`) is a **different surface** from the theme +`animation` block retired by #5021 — different file, different defs, different +manifest entries. That one had a carrier key and got a `retiredKey()` tombstone; +this one had none and gets deletion. + +The retirement kit: + +- **No `retiredKey()` tombstone, deliberately** — route 3 of the retirement + playbook ("nothing parses it → neither"), as used by #4834 / PR #4878 (kernel + plugin-runtime family) and #4938 / PR #5293 (`HttpServerConfig`). A tombstone + is a message to whoever writes the key; with no carrier key there is no shape + for one to sit on and no author who could ever receive it. +- **No ADR-0087 D2 conversion**, for the same reason: measured **zero** authored + instances in `examples/**` and `apps/**` — necessarily zero, since the keys + were unwritable — so there is no source for the chain to rewrite. The + registered record is the **D3 `SemanticMigration`** + `ui-interaction-config-family-retired`, with the protocol-17 step's rationale + extended. +- **Whole-file deletion was checked per file, not assumed.** Each of the five + was verified to have no surviving export with a live consumer (the #4938 + lesson: retire the shapes, keep the file when a sibling is alive). All 64 + names were in-family and unreachable, so all five files go. +- Baselines updated deliberately: `json-schema.manifest.json` (−32, the #2978 + ratchet fires first and demands each deletion), `authorable-surface.json` + (−109, adjudicated by the #4650 gate's path 3 "def no longer emitted by this + build"), `api-surface.json` (−64). Reference docs, `references/ui/meta.json` + and the strictness-ledger counts regenerated. +- **Pins are bidirectional.** `ui/interaction-config-retirement.test.ts` asserts + absence across every public entry by resolved symbol identity *and* the + survival of the neighbours a too-wide sweep would take — first among them + `ResponsiveConfigSchema`, batch 13's sixth file, which measured **reachable** + (`page.components[].responsive`) and was tightened rather than retired. + +No runtime behaviour changes. That impossibility is the reason for the removal. diff --git a/content/docs/protocol/objectql/index.mdx b/content/docs/protocol/objectql/index.mdx index 0cbd3c76ec..cbe8a5a4da 100644 --- a/content/docs/protocol/objectql/index.mdx +++ b/content/docs/protocol/objectql/index.mdx @@ -269,10 +269,12 @@ os package publish Offline sync (on-device SQLite mirror + conflict resolution) is **not yet implemented** — there is no `offline_sync` capability in the object schema, and - the offline/sync config schemas that do exist in `@objectstack/spec` - (`OfflineConfigSchema` / `SyncConfigSchema`) are not consumed by any runtime or - client code yet. The example below illustrates the intended direction, not a - shipping feature. + there is no offline/sync vocabulary in `@objectstack/spec` either: the config + schemas that used to sit there (`OfflineConfigSchema` / `SyncConfigSchema`) + had no carrier key and no consumer, and were retired in v17 (#4988, ADR-0049 + enforce-or-remove). When offline sync is built, its vocabulary arrives with + the sync engine that owns the queue, the conflict policy and the cache. The + example below illustrates the intended direction, not a shipping feature. **Intended ObjectQL Solution:** because objects are defined once and the same diff --git a/content/docs/references/ui/animation.mdx b/content/docs/references/ui/animation.mdx deleted file mode 100644 index 37b5ac6e4d..0000000000 --- a/content/docs/references/ui/animation.mdx +++ /dev/null @@ -1,152 +0,0 @@ ---- -title: Animation -description: Animation protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -Transition Preset Schema - -Common animation transition presets. - - -**Source:** `packages/spec/src/ui/animation.zod.ts` - - -## TypeScript Usage - -```typescript -import { AnimationTriggerSchema, ComponentAnimationSchema, EasingFunctionSchema, MotionConfigSchema, PageTransitionSchema, TransitionConfigSchema, TransitionPresetSchema } from '@objectstack/spec/ui'; -import type { AnimationTrigger, ComponentAnimation, EasingFunction, MotionConfig, PageTransition, TransitionConfig, TransitionPreset } from '@objectstack/spec/ui'; - -// Validate data -const result = AnimationTriggerSchema.parse(data); -``` - ---- - -## AnimationTrigger - -Event that triggers the animation - -### Allowed Values - -* `on_mount` -* `on_unmount` -* `on_hover` -* `on_focus` -* `on_click` -* `on_scroll` -* `on_visible` - - ---- - -## ComponentAnimation - -Component-level animation configuration - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **label** | `string` | optional | Descriptive label for this animation configuration | -| **enter** | `{ preset?: Enum<'fade' \| 'slide_up' \| 'slide_down' \| 'slide_left' \| 'slide_right' \| 'scale' \| 'rotate' \| 'flip' \| 'none'>; duration?: number; easing?: Enum<'linear' \| 'ease' \| 'ease_in' \| 'ease_out' \| 'ease_in_out' \| 'spring'>; delay?: number; … }` | optional | Enter/mount animation | -| **exit** | `{ preset?: Enum<'fade' \| 'slide_up' \| 'slide_down' \| 'slide_left' \| 'slide_right' \| 'scale' \| 'rotate' \| 'flip' \| 'none'>; duration?: number; easing?: Enum<'linear' \| 'ease' \| 'ease_in' \| 'ease_out' \| 'ease_in_out' \| 'spring'>; delay?: number; … }` | optional | Exit/unmount animation | -| **hover** | `{ preset?: Enum<'fade' \| 'slide_up' \| 'slide_down' \| 'slide_left' \| 'slide_right' \| 'scale' \| 'rotate' \| 'flip' \| 'none'>; duration?: number; easing?: Enum<'linear' \| 'ease' \| 'ease_in' \| 'ease_out' \| 'ease_in_out' \| 'spring'>; delay?: number; … }` | optional | Hover state animation | -| **trigger** | `Enum<'on_mount' \| 'on_unmount' \| 'on_hover' \| 'on_focus' \| 'on_click' \| 'on_scroll' \| 'on_visible'>` | optional | When to trigger the animation | -| **reducedMotion** | `Enum<'respect' \| 'disable' \| 'alternative'>` | ✅ | Accessibility: how to handle prefers-reduced-motion | -| **ariaLabel** | `string` | optional | Accessible label for screen readers (WAI-ARIA aria-label) | -| **ariaDescribedBy** | `string` | optional | ID of element providing additional description (WAI-ARIA aria-describedby) | -| **role** | `string` | optional | WAI-ARIA role attribute (e.g., "dialog", "navigation", "alert") | - - ---- - -## EasingFunction - -Animation easing function - -### Allowed Values - -* `linear` -* `ease` -* `ease_in` -* `ease_out` -* `ease_in_out` -* `spring` - - ---- - -## MotionConfig - -Top-level motion and animation design configuration - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **label** | `string` | optional | Descriptive label for the motion configuration | -| **defaultTransition** | `{ preset?: Enum<'fade' \| 'slide_up' \| 'slide_down' \| 'slide_left' \| 'slide_right' \| 'scale' \| 'rotate' \| 'flip' \| 'none'>; duration?: number; easing?: Enum<'linear' \| 'ease' \| 'ease_in' \| 'ease_out' \| 'ease_in_out' \| 'spring'>; delay?: number; … }` | optional | Default transition applied to all animations | -| **pageTransitions** | `{ type: Enum<'fade' \| 'slide_up' \| 'slide_down' \| 'slide_left' \| 'slide_right' \| 'scale' \| 'rotate' \| 'flip' \| 'none'>; duration: number; easing: Enum<'linear' \| 'ease' \| 'ease_in' \| 'ease_out' \| 'ease_in_out' \| 'spring'>; crossFade: boolean }` | optional | Page navigation transition settings | -| **componentAnimations** | `Record` | optional | Component name to animation configuration mapping | -| **reducedMotion** | `boolean` | ✅ | When true, respect prefers-reduced-motion and suppress animations globally | -| **enabled** | `boolean` | ✅ | Enable or disable all animations globally | - - ---- - -## PageTransition - -Page-level transition configuration - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `Enum<'fade' \| 'slide_up' \| 'slide_down' \| 'slide_left' \| 'slide_right' \| 'scale' \| 'rotate' \| 'flip' \| 'none'>` | ✅ | Page transition type | -| **duration** | `number` | ✅ | Transition duration in milliseconds | -| **easing** | `Enum<'linear' \| 'ease' \| 'ease_in' \| 'ease_out' \| 'ease_in_out' \| 'spring'>` | ✅ | Easing function for the transition | -| **crossFade** | `boolean` | ✅ | Whether to cross-fade between pages | - - ---- - -## TransitionConfig - -Animation transition configuration - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **preset** | `Enum<'fade' \| 'slide_up' \| 'slide_down' \| 'slide_left' \| 'slide_right' \| 'scale' \| 'rotate' \| 'flip' \| 'none'>` | optional | Transition preset to apply | -| **duration** | `number` | optional | Transition duration in milliseconds | -| **easing** | `Enum<'linear' \| 'ease' \| 'ease_in' \| 'ease_out' \| 'ease_in_out' \| 'spring'>` | optional | Easing function for the transition | -| **delay** | `number` | optional | Delay before transition starts in milliseconds | -| **customKeyframes** | `string` | optional | CSS @keyframes name for custom animations | -| **themeToken** | `string` | optional | Reference to a theme animation token (e.g. "animation.duration.fast") | - - ---- - -## TransitionPreset - -Transition preset type - -### Allowed Values - -* `fade` -* `slide_up` -* `slide_down` -* `slide_left` -* `slide_right` -* `scale` -* `rotate` -* `flip` -* `none` - - ---- - diff --git a/content/docs/references/ui/dnd.mdx b/content/docs/references/ui/dnd.mdx deleted file mode 100644 index 7d14451eb6..0000000000 --- a/content/docs/references/ui/dnd.mdx +++ /dev/null @@ -1,128 +0,0 @@ ---- -title: Dnd -description: Dnd protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -Drag Handle Schema - -Defines how a drag interaction is initiated on an element. - - -**Source:** `packages/spec/src/ui/dnd.zod.ts` - - -## TypeScript Usage - -```typescript -import { DndConfigSchema, DragConstraintSchema, DragHandleSchema, DragItemSchema, DropEffectSchema, DropZoneSchema } from '@objectstack/spec/ui'; -import type { DndConfig, DragConstraint, DragHandle, DragItem, DropEffect, DropZone } from '@objectstack/spec/ui'; - -// Validate data -const result = DndConfigSchema.parse(data); -``` - ---- - -## DndConfig - -Drag and drop interaction configuration - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable drag and drop | -| **dragItem** | `{ type: string; label?: string; handle: Enum<'element' \| 'handle' \| 'grip_icon'>; constraint?: object; … }` | optional | Configuration for draggable item | -| **dropZone** | `{ label?: string; accept: string[]; maxItems?: number; highlightOnDragOver: boolean; … }` | optional | Configuration for drop target | -| **sortable** | `boolean` | ✅ | Enable sortable list behavior | -| **autoScroll** | `boolean` | ✅ | Auto-scroll during drag near edges | -| **touchDelay** | `number` | ✅ | Delay in ms before drag starts on touch devices | - - ---- - -## DragConstraint - -Drag movement constraints - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **axis** | `Enum<'x' \| 'y' \| 'both'>` | ✅ | Constrain drag axis | -| **bounds** | `Enum<'parent' \| 'viewport' \| 'none'>` | ✅ | Constrain within bounds | -| **grid** | `any[]` | optional | Snap to grid [x, y] in pixels | - - ---- - -## DragHandle - -Drag initiation method - -### Allowed Values - -* `element` -* `handle` -* `grip_icon` - - ---- - -## DragItem - -Draggable item configuration - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `string` | ✅ | Drag item type identifier for matching with drop zones | -| **label** | `string` | optional | Accessible label describing the draggable item | -| **handle** | `Enum<'element' \| 'handle' \| 'grip_icon'>` | ✅ | How to initiate drag | -| **constraint** | `{ axis: Enum<'x' \| 'y' \| 'both'>; bounds: Enum<'parent' \| 'viewport' \| 'none'>; grid?: any[] }` | optional | Drag movement constraints | -| **preview** | `Enum<'element' \| 'custom' \| 'none'>` | ✅ | Drag preview type | -| **disabled** | `boolean` | ✅ | Disable dragging | -| **ariaLabel** | `string` | optional | Accessible label for screen readers (WAI-ARIA aria-label) | -| **ariaDescribedBy** | `string` | optional | ID of element providing additional description (WAI-ARIA aria-describedby) | -| **role** | `string` | optional | WAI-ARIA role attribute (e.g., "dialog", "navigation", "alert") | - - ---- - -## DropEffect - -Drop operation effect - -### Allowed Values - -* `move` -* `copy` -* `link` -* `none` - - ---- - -## DropZone - -Drop zone configuration - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **label** | `string` | optional | Accessible label for the drop zone | -| **accept** | `string[]` | ✅ | Accepted drag item types | -| **maxItems** | `number` | optional | Maximum items allowed in drop zone | -| **highlightOnDragOver** | `boolean` | ✅ | Highlight drop zone when dragging over | -| **dropEffect** | `Enum<'move' \| 'copy' \| 'link' \| 'none'>` | ✅ | Visual effect on drop | -| **ariaLabel** | `string` | optional | Accessible label for screen readers (WAI-ARIA aria-label) | -| **ariaDescribedBy** | `string` | optional | ID of element providing additional description (WAI-ARIA aria-describedby) | -| **role** | `string` | optional | WAI-ARIA role attribute (e.g., "dialog", "navigation", "alert") | - - ---- - diff --git a/content/docs/references/ui/index.mdx b/content/docs/references/ui/index.mdx index bb696a8110..321864cba9 100644 --- a/content/docs/references/ui/index.mdx +++ b/content/docs/references/ui/index.mdx @@ -7,24 +7,19 @@ This section contains all protocol schemas for the ui layer of ObjectStack. - - - - - diff --git a/content/docs/references/ui/keyboard.mdx b/content/docs/references/ui/keyboard.mdx deleted file mode 100644 index de96242905..0000000000 --- a/content/docs/references/ui/keyboard.mdx +++ /dev/null @@ -1,94 +0,0 @@ ---- -title: Keyboard -description: Keyboard protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -Focus Trap Configuration Schema - -Constrains keyboard focus within a specific container (e.g., modals, dialogs). - - -**Source:** `packages/spec/src/ui/keyboard.zod.ts` - - -## TypeScript Usage - -```typescript -import { FocusManagementSchema, FocusTrapConfigSchema, KeyboardNavigationConfigSchema, KeyboardShortcutSchema } from '@objectstack/spec/ui'; -import type { FocusManagement, FocusTrapConfig, KeyboardNavigationConfig, KeyboardShortcut } from '@objectstack/spec/ui'; - -// Validate data -const result = FocusManagementSchema.parse(data); -``` - ---- - -## FocusManagement - -Focus and tab navigation management - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **tabOrder** | `Enum<'auto' \| 'manual'>` | ✅ | Tab order strategy: auto (DOM order) or manual (explicit tabIndex) | -| **skipLinks** | `boolean` | ✅ | Provide skip-to-content navigation links | -| **focusVisible** | `boolean` | ✅ | Show visible focus indicators for keyboard users | -| **focusTrap** | `{ enabled: boolean; initialFocus?: string; returnFocus: boolean; escapeDeactivates: boolean }` | optional | Focus trap settings | -| **arrowNavigation** | `boolean` | ✅ | Enable arrow key navigation between focusable items | - - ---- - -## FocusTrapConfig - -Focus trap configuration for modal-like containers - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable focus trapping within this container | -| **initialFocus** | `string` | optional | CSS selector for the element to focus on activation | -| **returnFocus** | `boolean` | ✅ | Return focus to trigger element on deactivation | -| **escapeDeactivates** | `boolean` | ✅ | Allow Escape key to deactivate the focus trap | - - ---- - -## KeyboardNavigationConfig - -Keyboard navigation and shortcut configuration - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **shortcuts** | `{ key: string; action: string; description?: string; scope: Enum<'global' \| 'view' \| 'form' \| 'modal' \| 'list'> }[]` | optional | Registered keyboard shortcuts | -| **focusManagement** | `{ tabOrder: Enum<'auto' \| 'manual'>; skipLinks: boolean; focusVisible: boolean; focusTrap?: object; … }` | optional | Focus and tab order management | -| **rovingTabindex** | `boolean` | ✅ | Enable roving tabindex pattern for composite widgets | -| **ariaLabel** | `string` | optional | Accessible label for screen readers (WAI-ARIA aria-label) | -| **ariaDescribedBy** | `string` | optional | ID of element providing additional description (WAI-ARIA aria-describedby) | -| **role** | `string` | optional | WAI-ARIA role attribute (e.g., "dialog", "navigation", "alert") | - - ---- - -## KeyboardShortcut - -Keyboard shortcut binding - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **key** | `string` | ✅ | Key combination (e.g., "Ctrl+S", "Alt+N", "Escape") | -| **action** | `string` | ✅ | Action identifier to invoke when shortcut is triggered | -| **description** | `string` | optional | Human-readable description of what the shortcut does | -| **scope** | `Enum<'global' \| 'view' \| 'form' \| 'modal' \| 'list'>` | ✅ | Scope in which this shortcut is active | - - ---- - diff --git a/content/docs/references/ui/meta.json b/content/docs/references/ui/meta.json index 6b286829f4..95980719b4 100644 --- a/content/docs/references/ui/meta.json +++ b/content/docs/references/ui/meta.json @@ -14,13 +14,8 @@ "report", "widget", "---Interaction & Layout---", - "animation", - "dnd", - "keyboard", - "offline", "responsive", "theme", - "touch", "---Platform---", "http", "i18n", diff --git a/content/docs/references/ui/offline.mdx b/content/docs/references/ui/offline.mdx deleted file mode 100644 index b5de5aad95..0000000000 --- a/content/docs/references/ui/offline.mdx +++ /dev/null @@ -1,134 +0,0 @@ ---- -title: Offline -description: Offline protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -Offline Strategy Schema - -Determines how data is fetched when connectivity is limited. - - -**Source:** `packages/spec/src/ui/offline.zod.ts` - - -## TypeScript Usage - -```typescript -import { ConflictResolutionSchema, EvictionPolicySchema, OfflineCacheConfigSchema, OfflineConfigSchema, OfflineStrategySchema, PersistStorageSchema, SyncConfigSchema } from '@objectstack/spec/ui'; -import type { ConflictResolution, EvictionPolicy, OfflineCacheConfig, OfflineConfig, OfflineStrategy, PersistStorage, SyncConfig } from '@objectstack/spec/ui'; - -// Validate data -const result = ConflictResolutionSchema.parse(data); -``` - ---- - -## ConflictResolution - -How to resolve conflicts when syncing offline changes - -### Allowed Values - -* `client_wins` -* `server_wins` -* `manual` -* `last_write_wins` - - ---- - -## EvictionPolicy - -Cache eviction policy - -### Allowed Values - -* `lru` -* `lfu` -* `fifo` - - ---- - -## OfflineCacheConfig - -Client-side offline cache configuration - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **maxSize** | `number` | optional | Maximum cache size in bytes | -| **ttl** | `number` | optional | Time-to-live for cached entries in milliseconds | -| **persistStorage** | `Enum<'indexeddb' \| 'localstorage' \| 'sqlite'>` | ✅ | Storage backend | -| **evictionPolicy** | `Enum<'lru' \| 'lfu' \| 'fifo'>` | ✅ | Cache eviction policy when full | - - ---- - -## OfflineConfig - -Offline support configuration - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable offline support | -| **strategy** | `Enum<'cache_first' \| 'network_first' \| 'stale_while_revalidate' \| 'network_only' \| 'cache_only'>` | ✅ | Default offline fetch strategy | -| **cache** | `{ maxSize?: number; ttl?: number; persistStorage: Enum<'indexeddb' \| 'localstorage' \| 'sqlite'>; evictionPolicy: Enum<'lru' \| 'lfu' \| 'fifo'> }` | optional | Cache settings for offline data | -| **sync** | `{ strategy: Enum<'cache_first' \| 'network_first' \| 'stale_while_revalidate' \| 'network_only' \| 'cache_only'>; conflictResolution: Enum<'client_wins' \| 'server_wins' \| 'manual' \| 'last_write_wins'>; retryInterval?: number; maxRetries?: number; … }` | optional | Sync settings for offline mutations | -| **offlineIndicator** | `boolean` | ✅ | Show a visual indicator when offline | -| **offlineMessage** | `string` | optional | Customizable offline status message shown to users | -| **queueMaxSize** | `number` | optional | Maximum number of queued offline mutations | - - ---- - -## OfflineStrategy - -Data fetching strategy for offline/online transitions - -### Allowed Values - -* `cache_first` -* `network_first` -* `stale_while_revalidate` -* `network_only` -* `cache_only` - - ---- - -## PersistStorage - -Client-side storage backend for offline cache - -### Allowed Values - -* `indexeddb` -* `localstorage` -* `sqlite` - - ---- - -## SyncConfig - -Offline-to-online synchronization configuration - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **strategy** | `Enum<'cache_first' \| 'network_first' \| 'stale_while_revalidate' \| 'network_only' \| 'cache_only'>` | ✅ | Sync fetch strategy | -| **conflictResolution** | `Enum<'client_wins' \| 'server_wins' \| 'manual' \| 'last_write_wins'>` | ✅ | Conflict resolution policy | -| **retryInterval** | `number` | optional | Retry interval in milliseconds between sync attempts | -| **maxRetries** | `number` | optional | Maximum number of sync retry attempts | -| **batchSize** | `number` | optional | Number of mutations to sync per batch | - - ---- - diff --git a/content/docs/references/ui/touch.mdx b/content/docs/references/ui/touch.mdx deleted file mode 100644 index 9e0b6ef5b9..0000000000 --- a/content/docs/references/ui/touch.mdx +++ /dev/null @@ -1,151 +0,0 @@ ---- -title: Touch -description: Touch protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -Touch Target Configuration Schema - -Ensures touch targets meet WCAG 2.5.5 minimum size requirements (44x44px). - - -**Source:** `packages/spec/src/ui/touch.zod.ts` - - -## TypeScript Usage - -```typescript -import { GestureConfigSchema, GestureTypeSchema, LongPressGestureConfigSchema, PinchGestureConfigSchema, SwipeDirectionSchema, SwipeGestureConfigSchema, TouchInteractionSchema, TouchTargetConfigSchema } from '@objectstack/spec/ui'; -import type { GestureConfig, GestureType, LongPressGestureConfig, PinchGestureConfig, SwipeDirection, SwipeGestureConfig, TouchInteraction, TouchTargetConfig } from '@objectstack/spec/ui'; - -// Validate data -const result = GestureConfigSchema.parse(data); -``` - ---- - -## GestureConfig - -Per-gesture configuration - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `Enum<'swipe' \| 'pinch' \| 'long_press' \| 'double_tap' \| 'drag' \| 'rotate' \| 'pan'>` | ✅ | Gesture type to configure | -| **label** | `string` | optional | Descriptive label for the gesture action | -| **enabled** | `boolean` | ✅ | Whether this gesture is active | -| **swipe** | `{ direction: Enum<'up' \| 'down' \| 'left' \| 'right'>[]; threshold?: number; velocity?: number }` | optional | Swipe gesture settings (when type is swipe) | -| **pinch** | `{ minScale?: number; maxScale?: number }` | optional | Pinch gesture settings (when type is pinch) | -| **longPress** | `{ duration: number; moveTolerance?: number }` | optional | Long press settings (when type is long_press) | - - ---- - -## GestureType - -Touch gesture type - -### Allowed Values - -* `swipe` -* `pinch` -* `long_press` -* `double_tap` -* `drag` -* `rotate` -* `pan` - - ---- - -## LongPressGestureConfig - -Long press gesture recognition settings - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **duration** | `number` | ✅ | Hold duration in milliseconds to trigger long press | -| **moveTolerance** | `number` | optional | Max movement in pixels allowed during press | - - ---- - -## PinchGestureConfig - -Pinch/zoom gesture recognition settings - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **minScale** | `number` | optional | Minimum scale factor (e.g., 0.5 for 50%) | -| **maxScale** | `number` | optional | Maximum scale factor (e.g., 3.0 for 300%) | - - ---- - -## SwipeDirection - -### Allowed Values - -* `up` -* `down` -* `left` -* `right` - - ---- - -## SwipeGestureConfig - -Swipe gesture recognition settings - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **direction** | `Enum<'up' \| 'down' \| 'left' \| 'right'>[]` | ✅ | Allowed swipe directions | -| **threshold** | `number` | optional | Minimum distance in pixels to recognize swipe | -| **velocity** | `number` | optional | Minimum velocity (px/ms) to trigger swipe | - - ---- - -## TouchInteraction - -Touch and gesture interaction configuration - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **gestures** | `{ type: Enum<'swipe' \| 'pinch' \| 'long_press' \| 'double_tap' \| 'drag' \| 'rotate' \| 'pan'>; label?: string; enabled: boolean; swipe?: object; … }[]` | optional | Configured gesture recognizers | -| **touchTarget** | `{ minWidth: number; minHeight: number; padding?: number; hitSlop?: object }` | optional | Touch target sizing and hit area | -| **hapticFeedback** | `boolean` | optional | Enable haptic feedback on touch interactions | -| **ariaLabel** | `string` | optional | Accessible label for screen readers (WAI-ARIA aria-label) | -| **ariaDescribedBy** | `string` | optional | ID of element providing additional description (WAI-ARIA aria-describedby) | -| **role** | `string` | optional | WAI-ARIA role attribute (e.g., "dialog", "navigation", "alert") | - - ---- - -## TouchTargetConfig - -Touch target sizing configuration (WCAG accessible) - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **minWidth** | `number` | ✅ | Minimum touch target width in pixels (WCAG 2.5.5: 44px) | -| **minHeight** | `number` | ✅ | Minimum touch target height in pixels (WCAG 2.5.5: 44px) | -| **padding** | `number` | optional | Additional padding around touch target in pixels | -| **hitSlop** | `{ top?: number; right?: number; bottom?: number; left?: number }` | optional | Invisible hit area extension beyond the visible bounds | - - ---- - diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 16f2db9079..adf9aeed31 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,9 +21,9 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 476 | -| Still-open (strip) sites | 217 | -| Files carrying at least one | 34 | +| Object sites in them | 454 | +| Still-open (strip) sites | 195 | +| Files carrying at least one | 29 | Remaining strip sites by class: @@ -32,7 +32,7 @@ Remaining strip sites by class: | authorable — the ruling's forced scope | 11 | | unresolved — needs a per-schema verdict | 33 | | wire / open — out of forced scope | 106 | -| no door — no carrier, ADR-0049 territory | 36 | +| no door — no carrier, ADR-0049 territory | 14 | | no gate — carrier live, no parse | 31 | ## Posture, per triaged directory @@ -43,12 +43,12 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 192 | 116 | 5 | 0 | 71 | +| `ui/` | 170 | 116 | 5 | 0 | 49 | | `data/` | 162 | 54 | 1 | 0 | 107 | | `automation/` | 75 | 49 | 0 | 0 | 26 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **476** | **253** | **6** | **0** | **217** | +| **total** | **454** | **253** | **6** | **0** | **195** | ## File-level triage — site counts @@ -61,26 +61,21 @@ classify and is not listed (it becomes reportable the day it grows its first sit | File | Sites | |---|---| | `action.zod.ts` | 8 | -| `animation.zod.ts` | 4 | | `app.zod.ts` | 18 | | `bulk-action.zod.ts` | 3 | | `chart.zod.ts` | 8 | | `component.zod.ts` | 29 | | `dashboard.zod.ts` | 11 | | `dataset.zod.ts` | 4 | -| `dnd.zod.ts` | 4 | | `i18n.zod.ts` | 6 | -| `keyboard.zod.ts` | 4 | -| `offline.zod.ts` | 3 | | `page.zod.ts` | 7 | | `report.zod.ts` | 3 | | `responsive.zod.ts` | 4 | | `sharing.zod.ts` | 1 | | `theme.zod.ts` | 6 | -| `touch.zod.ts` | 7 | | `view.zod.ts` | 53 | | `widget.zod.ts` | 9 | -| **total** | **192** | +| **total** | **170** | ### `data/` — sites @@ -160,29 +155,24 @@ over it is here. ### `ui/` — open -**71 strip of 192**, in 11 file(s). +**49 strip of 170**, in 6 file(s). | File | Strip | Sites | |---|---|---| -| `animation.zod.ts` | 4 | 4 | | `app.zod.ts` | 1 | 18 | | `chart.zod.ts` | 2 | 8 | | `component.zod.ts` | 29 | 29 | -| `dnd.zod.ts` | 4 | 4 | | `i18n.zod.ts` | 5 | 6 | -| `keyboard.zod.ts` | 4 | 4 | -| `offline.zod.ts` | 3 | 3 | -| `touch.zod.ts` | 7 | 7 | | `view.zod.ts` | 3 | 53 | | `widget.zod.ts` | 9 | 9 | -| **total** | **71** | **192** | +| **total** | **49** | **170** | | Bucket | Sites | |---|---| | authorable — the ruling's forced scope | 2 | | unresolved — needs a per-schema verdict | 0 | | wire / open — out of forced scope | 2 | -| no door — no carrier, ADR-0049 territory | 36 | +| no door — no carrier, ADR-0049 territory | 14 | | no gate — carrier live, no parse | 31 | ### `data/` — open diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index babb61d50d..e37f8cc728 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -628,7 +628,7 @@ sites left to be a verdict about. | `view.zod.ts` | authorable | partially strict (ADR-0089); long tail of sub-blocks. `bulkActionDefs` left this file in #4457 — see the row below | | `bulk-action.zod.ts` | authorable | **strict as of #4457** — `BulkActionDefSchema` (the def itself). It was `z.array(z.record(z.string(), z.any()))` inline in `view.zod.ts`: a selection-bar button with **no shape at all**, so `opeartion` / `excution: 'aggregate'` parsed and shipped as a button that ran the default behaviour. Its two other sites are `BulkActionParamSchema` and that param's `options` entry, both deliberately **open** and both now `.passthrough()` — the param because objectui's `BulkActionParam` declares a `[key: string]: unknown` catch-all for widget config (min/max/step/format), so passthrough is the honest mirror and strictness would reject valid config (same call as `dashboard.zod.ts`'s widget `config`); the OPTION ENTRY on separate measured evidence, since its objectui type is closed and only the runtime path is open — `bulkParamToField` spreads each entry (`plugin-grid/src/components/bulkParamToField.ts:131`) into `SelectOptionMetadata` (`types/src/field-types.ts:288`), which declares and reads `color` / `icon` / `disabled` / `visibleWhen`. **This row said "both deliberately open" while only the parent was `passthrough`** — one intent, two postures, caught by the 2026-08-03 re-measure and closed by the ruling's verdict A (make the code match the prose). The lesson is the campaign's own: prose in this ledger is not a posture reading, which is why the remaining-strip map is gated and this column is not. The def also refuses the combinations the executor never reads (`patch` outside an update, `execution` outside a custom, `batchSize` on an aggregate) and a hand-written `actionDef`, which is renderer-attached | | `component.zod.ts` | ~~authorable (p)~~ **no gate** | **no parse anywhere (measured, #4001 批 17)** — the `(p)` resolved NEGATIVE, and this is the campaign's largest single reclassification. The standing warning said to verify objectui's React-prop open slots first; doing so found the question was moot one level up. **The carrier is live but it is an open bag**: `PageComponentSchema.properties` is `z.record(z.string(), z.unknown())`, and although `PageComponentSchema` has been `.strict()` since ADR-0089 D3a, **strictness does not recurse** — it closes the component node's own keys and leaves everything under `properties` unchecked. Nothing dispatches `ComponentPropsMap` by `type`. Three measurements on 2026-08-04, controls green in the same run: (1) a BFS from all 24 metadata-type roots plus `ObjectStackSchema`, over a 6899-node closure built with `build-schemas.ts`'s own `zodChildSchemas`/`zodShapeOf` (the #4650 walk), returns **UNREACHABLE for all 52 targets** (21 exported schemas + every one of `ComponentPropsMap`'s 31 entries), while `PageSchema`/`PageComponentSchema`/`PageRegionSchema`/`ThemeSchema`/`ChartConfigSchema`/`ResponsiveConfigSchema` all resolve `root-graph` and 批 13's no-door shapes stay unreachable — the walk stops dead at `properties`. ⚠️ The #5056 bridge defect does not touch this row: it makes the derived-clone bridge report dead shapes as REACHABLE, the opposite direction, and nothing here rests on that bridge — all six positive controls resolve `root-graph` and all 52 targets miss BOTH `root-graph` and `derived-clone`; (2) across `objectstack`, `objectui` and `cloud`, every `.parse()`/`.safeParse()` on anything in this file is inside the file's own unit tests — objectui mirrors the props as hand-written React interfaces and imports only the inferred TYPES, `cloud` references none, and `react-blocks.ts` uses `Object.keys(ComponentPropsMap)` for type NAMES only (its `REACT_BLOCKS[].schema` entries all point at view/chart schemas); (3) empirically through the live door — `definePage()` IS `PageSchema.parse()` — an undeclared key written inside `components[].properties` parses clean and is RETAINED on 10/10 example-corpus pages, while the same key one level out is rejected on 10/10 (the negative control that makes the first number mean anything). ⚠️ **`no gate`, not `no door`** — the vocabulary is ALIVE and must not be retired: objectui's `SchemaRenderer` hoists `properties` onto the node and spreads every key not on its fixed deny-list straight into the React component, so a misspelled key is neither rejected nor dropped — it reaches the renderer and is ignored there, the ADR-0078 failure mode one layer below where this ratchet reaches. That IS the #4909 open-slot shape, but `.passthrough()` would be exactly as vacuous as `.strict()` on a schema nothing parses, so no posture change was made. The fix is to wire the parse at the carrier's own gate — a `packages/lint`/carrier change, filed as **#5068**, which also records the two constraints that stop it being a drive-by: `type` is an open union (`z.union([PageComponentType, z.string()])`, so `record:line_items`-style unregistered types are authored in the wild) and real pages already author shapes these schemas do not declare (`record:details` `sections[].fields[]`/`hideFields[]`, the record picker's `labelField` — `packages/lint/src/validate-page-field-bindings.ts` has documented the untyped bag all along). **Do not reschedule this as strictness work** — that is what the `(p)` was for, and it has been answered. Recorded in three places (file header, `component.test.ts` pin incl. a standing assertion that goes red the day `properties` gets a typed dispatch, this row) | -| `theme.zod.ts` | authorable | **strict as of #4001 批 15** — all 14 sites. The `(p)` resolved to authorable on two doors, both measured: `stack.zod.ts` declares `themes: z.array(ThemeSchema)` (so `defineStack()` parses every theme on boot and on `objectstack build`), and `defineTheme()` parses one directly. A BFS from all 24 metadata-type roots plus `ObjectStackSchema` reaches every schema in the file, with `PageSchema`/`DashboardSchema`/`ReportSchema`/`WebhookSchema`/`StateMachineSchema` passing as positive controls and 批 13's no-door shapes failing as negative controls **in the same run**. Note what is NOT claimed: `theme` is deliberately absent from `BUILTIN_METADATA_TYPE_SCHEMAS`, so a stored theme row is not validated by the metadata REST door — the gate is the authoring one, and the file says so rather than implying reach it lacks. **The `passthrough` question was asked per BLOCK, not per file**, and the answer split: objectui's `ThemeEngine` reads `colors`/`borderRadius`/`shadows`/`typography.fontFamily` through FIXED maps (an extra key is read by nothing, ever), but spreads `fontSize`/`fontWeight`/`lineHeight`/`letterSpacing`/`duration`/`timing`/`zIndex` with `Object.entries` into `--font-size-` … — 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 (#4988) — prescribing them would walk an author out of a loud rejection into a silent one, the ledger's finding 7. ⚠️ **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 | +| `theme.zod.ts` | authorable | **strict as of #4001 批 15** — all 14 sites. The `(p)` resolved to authorable on two doors, both measured: `stack.zod.ts` declares `themes: z.array(ThemeSchema)` (so `defineStack()` parses every theme on boot and on `objectstack build`), and `defineTheme()` parses one directly. A BFS from all 24 metadata-type roots plus `ObjectStackSchema` reaches every schema in the file, with `PageSchema`/`DashboardSchema`/`ReportSchema`/`WebhookSchema`/`StateMachineSchema` passing as positive controls and 批 13's no-door shapes failing as negative controls **in the same run**. Note what is NOT claimed: `theme` is deliberately absent from `BUILTIN_METADATA_TYPE_SCHEMAS`, so a stored theme row is not validated by the metadata REST door — the gate is the authoring one, and the file says so rather than implying reach it lacks. **The `passthrough` question was asked per BLOCK, not per file**, and the answer split: objectui's `ThemeEngine` reads `colors`/`borderRadius`/`shadows`/`typography.fontFamily` through FIXED maps (an extra key is read by nothing, ever), but spreads `fontSize`/`fontWeight`/`lineHeight`/`letterSpacing`/`duration`/`timing`/`zIndex` with `Object.entries` into `--font-size-` … — 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` | @@ -637,12 +637,32 @@ sites left to be a verdict about. | `i18n.zod.ts` | **split** | **`i18n` SPLITS across two classes (measured, #4001 批 16)** and is the file this table's standing warning was about. The warning said "label shapes are wide-open records by design"; measurement says something more useful. `AriaPropsSchema` is a **real door and is closed** — carried as `aria:` on ~30 live shapes under six metadata-type roots (`ListViewSchema`, `PageSchema`, `PageComponentSchema`, `DashboardWidgetSchema`, `ChartConfigSchema`, `ActionSchema`, 20 SDUI component defs) and directly BFS-reachable. It was stripping in the wild: through the `view` root, `aria: { label: 'Accounts', describedBy: 'x' }` parsed CLEAN and returned `aria: {}`, so the accessible name existed in the source file and nowhere else. The other five (`I18nObject`, `PluralRule`, `NumberFormat`, `DateFormat`, `LocaleConfig`) are **no door** — no carrier, unreachable, zero parse in all three repos; ADR-0049 is #5055. Note `NumberFormat` / `DateFormat` DO have a carrier (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier is itself doorless, so the subtree is `no door`, not `no gate`. And the warning's own subject — the wide-open **record** level — was never one of the six sites: `I18nObject.params` is a `z.record` interpolation bag whose key space is whatever the message template names, so openness there is the contract and there was nothing to close. Pinned in `i18n.zod.ts`'s header, in `i18n.test.ts`, and here | | `responsive.zod.ts` | authorable | **strict as of #4001 批 13** — all four sites (`ResponsiveConfig`, `ResponsiveStyles`, and the two per-breakpoint maps). This is the one file of batch 13's six whose `(p)` resolved POSITIVE, and it resolved on the graph rather than on the file's face: `page.components[].responsive` / `.responsiveStyles` put both shapes inside the `page` metadata-type root (`dashboard.widgets[].responsive` was the second carrier until #4876 retired it, same day). What the closure bought is the batch's whole argument in one parse — **`PageComponentSchema` has been `.strict()` since ADR-0089 D3a and that never reached these blocks**, so `{ type:'element:text', responsiveStyles: { lg: {…} }, responsive: { colums: {…}, hideOn: [] } }` parsed CLEAN and returned `responsiveStyles: {}, responsive: {}` — every styling and layout instruction the author wrote, gone, reported valid. A strict shell over strip-mode children is a closed surface's silhouette, not a closed surface. The curation is the file's real hazard rather than typos: it carries TWO breakpoint vocabularies sixteen lines apart on the same component (`responsiveStyles`' `large`/`medium`/`small`/`xsmall`, ADR-0065, against `responsive`'s Tailwind `xs`…`2xl`), so the aliases run BOTH ways between them and are anchored to the named sibling, not to edit distance — batch 12's method, and the only thing that can answer `lg` → `large`. Two entries had to be measured rather than reasoned: `{ columns: { large: 4, lg: 3 } }` used to keep HALF the map (the node laid out, at the wrong width, on breakpoints the author never named — worse than a total loss, which is at least visible); and `hideOn` → `hiddenOn` needed a hand-written alias because the distance fallback provably cannot reach it — it lowercases the input but not the candidates, so a capital in a declared key costs an extra edit against a budget of 2, and the all-lowercase `hiddenon` resolves while the correctly-cased `hideOn` does not. That asymmetry is general to camelCase keys, i.e. to most of the spec, and is filed as **#4990**. `StyleMapSchema` stays deliberately OPEN (its key space is every CSS property; objectui's `declarations()` emits whatever it is handed) — recorded in the schema JSDoc, in a test pin, and in this row | | `dataset.zod.ts` | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DatasetSchema` was strict from the ADR-0021 cutover while the two shapes carrying the actual semantic contract — `DatasetDimension`, `DatasetMeasure` (+ `.derived`) — were not. Curated against the sibling this module's own header names, `data/analytics.zod.ts`'s Cube layer: a Cube metric's `type` IS its aggregation, so `{ name: 'revenue', type: 'sum', field: 'amount' }` parsed clean and computed a `count`; `sql` gets guidance rather than an alias, because aiming `SUM(amount)` at `field` is finding 7's trap | -| `animation.zod.ts` / `dnd.zod.ts` / `keyboard.zod.ts` / `touch.zod.ts` / `offline.zod.ts` | ~~authorable (p)~~ **no door** | **no authoring door (measured, #4001 批 13)** — the `(p)` resolved NEGATIVE and the row is kept only so the arithmetic stays complete. Three independent measurements on 2026-08-03: (1) nothing under `packages/spec/src` imports these modules except the `ui/index.ts` barrel, so no schema anywhere declares a carrier key for them; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` — the closure `build-schemas.ts` uses for the #4650 deletion check — reaches none of the 22 sites, while its three positive controls (`PageSchema`, batch 11's `WebhookSchema`, batch 10's `StateMachineSchema`) all resolve `root-graph` in the same run; (3) no `.parse()` / `.safeParse()` on any of them exists in `objectstack`, `objectui` or the example apps outside their own unit tests — objectui re-exports the inferred TYPES only and says so (#2561). `.strict()` is a property of a PARSE and there is no parse, so closing them would enforce nothing and would spend a v17 breaking change to leave *"a precisely validated dead slot — the more convincing lie"* (the #4583 row below). The live question is ADR-0049 enforce-or-remove, filed as **#4988**; each file's header comment and its test file carry the same verdict (the batch 12 three-places standard). **Do not reschedule these as strictness work** — that is what the `(p)` was for, and it has been answered | | `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 | `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` / +`dnd.zod.ts` / `keyboard.zod.ts` / `touch.zod.ts` / `offline.zod.ts` were the +first `no door` verdicts this ledger ever recorded, and they are the first to be +acted on: ADR-0049 enforce-or-remove resolved **RETIRE** (maintainer ruling +2026-08-04), so all five files were deleted whole — 22 `z.object` sites, 32 +emitted defs, 64 exported names, 109 `authorable-surface.json` keys and the five +generated `content/docs/references/ui/*` pages. The rows are gone because the +files are gone, not because anything was tightened; the measurement that +produced the verdict is preserved in the retirement's own record +(`ui/interaction-config-retirement.test.ts`, the +`ui-interaction-config-family-retired` ADR-0087 D3 entry and the changeset). + +⚠️ **The class held.** The 批 13 note said `.strict()` here would spend a v17 +breaking change to leave *"a precisely validated dead slot — the more convincing +lie"*, and it named the live question as ADR-0049 rather than strictness. That +refusal is what made the question answerable a batch later, and the answer went +the destructive way — which is exactly why the `no door` → ADR-0049 → +maintainer-ruling path exists instead of a sweep. **This retirement moves no +strictness debt**: the sites were `strip`, never `strict`, so the `strict` +column does not move and the `strip` column falls by the count of what left. + ### `data/` — file-level triage | File | Class | Note | @@ -827,12 +847,7 @@ next person to open that file will look. | `view.zod.ts` | mixed · 1 authorable, 2 wire | **15 of 20 closed at #4001 批 18**, a sixteenth (`UserFiltersSchema`) at **#5073** once its protocol blocker was adjudicated, a seventeenth — `ViewFilterRuleSchema`, closed by an EARLIER wave — reopened at **#5114**, and then the file's last authoring debt cleared at **#5074**, which closed `ViewItemSchema` (×2 arms), `ListView.sort` AND `ViewFilterRuleSchema` in one structural change. **The strip count went 5 → 3, and the arithmetic is the finding, not the number: FOUR sites closed and TWO were ADDED** — the two arms of the new `ViewItemWireSchema`, which are strip BY DESIGN. That is why this row's Class cell is now a split (`1 authorable, 2 wire`) rather than a smaller `authorable` count: the wire contract that used to live on "the member nobody closed" now has a name, and this map measures posture, not intent. Closed: `ViewDataSchema`'s four provider arms, `UserFilterField.options`, `GanttQuickFilter.options`, `GanttConfig.tooltipFields`, `ListView.conditionalFormatting` / `.emptyState`, `FormFieldBase.keyField`, `FormView.subforms`, and `submitBehavior`'s four arms. Reachability was measured, not assumed: a BFS from all 24 metadata-type roots plus `ObjectStackSchema` resolves every one `root-graph`, with `ViewSchema`/`FormViewSchema`/`ViewItemSchema`/`PageSchema` as positive controls and 批 13's no-door shapes UNREACHABLE **in the same run** — and the instrument had to be fixed first: `lazySchema` returns a Proxy, but a carrier writes `X.optional()`, which RESOLVES it, so the closure holds the real instance and comparing the Proxy alone false-negatived `ViewDataSchema` (caught by cross-checking its two literal carrier keys, not by trusting the reading). ⚠️ **Re-checked against #5056**: every 批 18 target is `root-graph` by **identity**, so **none** of the fifteen rests on the `derived-clone` bridge that 批 16 found can mark a dead shape reachable. The one `derived-clone` verdict in the run is `ListViewSchema` — a positive CONTROL, not a target, and independently identity-reachable via `ObjectListViewSchema`. Every closed shape also has a literal carrier key in this file and a named parse door (`defineView` / `defineViewItem` / the `view` metadata-type schema / objectui's `GanttConfigSchema.safeParse` at `plugin-gantt/src/ObjectGantt.tsx:408`) — the strong-evidence class #5056 leaves standing. ⚠️ **`ListView.sort` was closed, REVERTED, and closed again at #5074 — the round trip is the file's most useful finding.** It carried `direction → order`, the #4721 alias for the identical tuple (`{field, direction:'desc'}` parsed to `{field, order:'asc'}` — a silently REVERSED sort). The full suite then failed one case: `view-metadata-schema.test.ts` pins `sort: [{ id, field, order }]` as the exact body a console column-sort PUT persists, and objectui stamps that `id` per row (`components/src/custom/sort-builder.tsx:68`/`:94`, `crypto.randomUUID()`). **The mechanism governs every nested block in this file and is the opposite of what the union's own comment implies: `.strip()` does NOT recurse.** `ViewMetadataSchema` rescues Studio's round-trip keys by making its flattened members `.strip()`, but that re-opens the TOP level only — a nested block closed inside `ListViewSchema` is still reached through that member, so a console-stamped key inside it becomes a 422 regardless. `id` was deliberately NOT declared to silence it: it is a React list key, and declaring it would put a UI artifact on the authorable surface and tell an AI author to emit one. **#5074 supplied the missing half and the shape is now CLOSED**: the write door removes the declared decoration vocabulary (`VIEW_CONSOLE_ROW_DECORATIONS` / `stripViewConsoleDecorations`, the mirror of `stripReadDecorations`) BEFORE the union runs, so the opening is recursive-effective where a member-level `.strip()` can never be, and the authoring surface never grew the key. The `direction → order` alias came back with it. Curation on what DID close is anchored to named siblings: an option `count` gets a wrong-layer pointer to `showCount` because objectui COMPUTES it per render; and a bare `name` on the `object` data source is deliberately NOT aliased — it is a real key on the view ITEM, so a rename would be finding 7 again. `submitBehavior` became a `discriminatedUnion` on the `kind` literal it already required: as a plain union of four strict members the rejection is an `invalid_union` whose prescription #5014 measured the renderers flattening away. ⚠️ **`GanttConfigSchema` / `TreeConfigSchema` are `strictObject(…).passthrough()`** — open at the parent by design, and this ledger's own counter used to read them as `strict`, because `postureOf` returned early on the `strictObject` idiom instead of walking the chain. **Fixed at #5072**: the idiom now seeds the initial posture and the chain always runs, so the two read `passthrough` and the directory's strict count drops by 2. The strip count was never affected — neither posture is strip — so this row's numbers do not move. **`UserFiltersSchema` is CLOSED as of #5073, and it is the one site in this file whose blocker was never a strictness question.** Closing it would have 422'd `allowAddTab` — a key objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; because `saveMetaItem` validates but persists the ORIGINAL body, the stripped key still reached the renderer, so the capability WORKED and closing would have removed it rather than making a silent failure loud. 批 18 stopped and filed rather than guessing, and the maintainer adjudicated **promote, then close, in one PR** (2026-08-04): `allowAddTab` is now DECLARED here, so the capability is discoverable from the contract (JSON Schema / Studio SchemaForm / an AI author) instead of living in one React file, and the shape closes behind it with no intermediate state. The rejected option was `SANCTIONED_LOCAL` in objectui, which would have made spec and objectui two sources of truth for one contract — the fork #2231's derive-by-reference exists to prevent (PD#12) — and would have taught authors to delete a working key with a rejection that was itself "correct" (finding 7). Two details the close is worth remembering for. **(a)** The promotion is scoped to what the renderer really does: the add-tab button objectui renders carries no click handler, so `allowAddTab` declares that the affordance RENDERS and deliberately says nothing about creating presets — a `.describe()` promising more would be PD#10's advertise-what-you-don't-deliver, and the renderer gap is filed as **#5236**. **(b)** The 批 6e reliance question resolved exactly as predicted — `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flipped from "drops" to "rejects", which is wanted (the CLI lint `validate-list-view-mode.ts` was already reporting these) — but inheriting the posture also inherits the base's ERROR MAP, whose `knownKeys` were read from the base shape and therefore still listed the omitted keys. Measured on the flip: `tab` was answered *"Did you mean `tab` → `tabs`?"*, steering the author at the one key that surface refuses — finding 7 produced by the fix for finding 7. So the object variant now carries its own map built over the OMITTED shape (the shape still derived by `.omit()`, so #2231 holds), with `guidance` pointing all three page-only keys at `listViews`. **⚠️ #5074 — the authoring/wire SPLIT, and the row's headline.** `ViewItemSchema` wore two contracts: the authoring gate (`defineViewItem`, objectui's view-create form, which validates `createBuildBody`'s output against the real spec schema) and member 1 of `ViewMetadataSchema`, the union `saveMetaItem` validates every persisted `view` body against. The wire role was measured, not inferred — objectui's pin control PUTs `{...storedItem, isPinned}` (`ObjectView.tsx:882` → `data-objectstack/src/index.ts:2801`); a stored ViewItem record carries `viewKind` AND `config`, so the merged body lands on member 1 (the flattened members are excluded by their `config: z.undefined()` guard) and closing the one schema would have 422'd pinning a saved view. The maintainer ruled **split** (2026-08-04), and the two-axis reasoning is worth keeping: `defineViewItem({name, object, viewKind, confg: {…}})` — one letter — used to strip the typo and hand back a ViewItem with **no view configuration at all**, parsed clean, which is #1535's `workflows: [...]` replayed on the file's densest authoring surface. `ViewItemSchema` is now `strictObject` on both arms; `ViewItemWireSchema` is the `.strip()` wire variant, built from the SAME `viewItemArmShape()` (derive-by-reference, #2231 — a `discriminatedUnion` cannot be `.extend()`ed, so sharing the shape factory is what keeps one contract from becoming two transcriptions), and `isPinned`/`sortOrder` are DECLARED on it — an explicit home, instead of surviving because nobody closed the member. **The scope addendum's hard requirement was recursive-effective openness, and that is the part a posture flip could not deliver.** `.strip()` re-opens a member's TOP level only, so the two console-decorated NESTED blocks (`ListView.sort[].id`, `ViewFilterRule.id`) were still reached at full strictness through it. The route taken is the addendum's second sanctioned one: a declared decoration vocabulary stripped before validation, at the wire door, reaching every carrier at every depth — including ones added later, which a hand-maintained parallel wire tree would not. It is deliberately NOT a second schema tree (PD#12's fork) and deliberately NOT a declared `id` (批 18 Q1's two-axis rejection: a React list key on the authoring surface teaches AI authors to emit UUIDs). Two landmines were named in the ruling and both are pinned in `view-authoring-wire-split.test.ts` §5: `z.toJSONSchema()` must still emit a four-member `anyOf` (the `/api/v1/meta/types/view` endpoint feeds Studio's SchemaForm from it — it does; a pipe converts to its output side, asserted in BOTH io directions), and the `lazySchema` Proxy's ADR-0089 D3a crash (`Cannot set properties of undefined (setting 'ref')`) must not recur under a pipe-rooted lazy schema — it does not, and each new schema is converted directly rather than only through its parent. **One real hazard the change surfaced, fixed in the same PR:** a `z.preprocess` at a registered root put TWO gate walkers into the exact blind spot #4488 had already found and fixed in `check-liveness.mts` — `metadata-authoring-lint.ts` and `metadata-form-zod-reconciliation.test.ts` both unwrapped a pipe via `def.in`, which for a preprocess is the TRANSFORM, so each reported `view` as *not key-bearing* and silently stopped covering it. Caught by their own coverage assertions (`lintables.length >= 1`, `root schema is not key-bearing`), which is precisely what those assertions exist for; both now prefer whichever side is not the transform. **A gate going quiet is worse than a gate failing** — and the pattern will recur on the next preprocess-rooted registration, so it is recorded here rather than only in the diff. **Still open, one site, measured:** `FormFieldBaseSchema` — a module-private BASE whose sole consumer already applies `.strict()` plus the ADR-0089 `strictVisibilityError` map; the door is closed, the ledger counts the base. The two remaining strip sites beyond it are `ViewItemWireSchema`'s arms, which are `wire` by design and are not debt. `ViewFilterRuleSchema` — **the same wire contamination, one block over, and it was already LIVE on `main`** (#5114): closed by an earlier wave, while objectui's filter builder stamps `id: crypto.randomUUID()` on every row it writes (`components/src/custom/filter-builder.tsx:228`, re-stamped on read-back at `plugin-view/src/config/view-config-utils.ts:146`/`:160`), and `saveMetaItem` persists the AUTHORED body verbatim — so saving a filter from the console 422'd, on all three paths including the flattened overlay that is the body actually PUT. Reopened as a p1 hotfix; `id` deliberately NOT declared, for the reason given for `sort` above. **That reopen was explicitly PROVISIONAL — "pending #5074" — and #5074 retired it rather than leaving it standing: the shape is CLOSED again, by the same decoration strip that closed `sort`, so the authoring gate rejects `id` by name while the console's own three paths still parse.** Its pin file now asserts the split per door, and the direction is the INVERTED one worth flagging to the next reader: probes 1/3 and 2/3 were GREEN before #5074 and are RED after (that IS the close), while 3/3 — the body the console actually PUTs — is green on BOTH sides and must stay so; a file that only asserted "the console body parses" would have passed unchanged through a change that quietly declared `id` as authorable. Two details worth keeping: the overlay path's rejection surfaces as `invalid_union` / *"Invalid input"* — the #5014 flattening, so the key that caused it is not in the message the author sees, which is why this sat on `main` unnoticed; and the reopening was verified in BOTH directions (re-close it and 7 assertions in `view-filter-rule-wire-id.test.ts` go red, while that file's two mechanism CONTROLS — top-level aux key rides, nested `emptyState` still rejects — stay green either way, which is what makes them controls). #5074's scope addendum named this site; the gate it was waiting on — a wire opening that REACHES a nested block — landed with it. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` / `view-filter-rule-wire-id.test.ts` + this row) | | `widget.zod.ts` | **no door** | ⛔ **not strictness work** — the whole file measured unreachable from every authoring root (#4001 批 16), with no carrier key and zero parse in all three repos. ADR-0049 triage is **#5055**. See the triage row above, including why the campaign's own BFS said otherwise first (**#5056**) | | `chart.zod.ts` | **no gate** | `ChartAggregateSchema` + `ChartGroupBySchema`'s object arm. Config / axis / series / annotation / interaction closed at 批 15; these two are NOT unfinished work — their carrier (``) is live but nothing parses them, so closing them would gate nothing (#4583). Blocked on wiring the react-page publish gate to parse the schema instead of re-deriving it — see the triage row. **#5022 added an eighth site to this file, and it is the one worth copying**: `ChartDrillDownSchema` arrived with its gate already wired — `packages/lint/src/validate-react-page-props.ts` PARSES it against a static `drillDown={{…}}` literal instead of re-deriving the rules the way `CHART_FUNCTIONS` does for `aggregate` beside it. That is exactly the fix this row is blocked on, demonstrated on one key; the two sites here are unchanged because their prop is `aggregate`, not `drillDown` | -| `touch.zod.ts` | **no door** | ⛔ **not strictness work** — measured unreachable from every authoring root (#4001 批 13); ADR-0049 triage is #4988. See the triage row above | | `i18n.zod.ts` | **split** · 5 no door | **批 16 closed the one real door**: `AriaPropsSchema` (`strictObject`, carried as `aria:` on ~30 shapes under six metadata-type roots — it was returning `aria: {}` for a legacy-spelled block). The 5 left are `I18nObject` / `PluralRule` / `NumberFormat` / `DateFormat` / `LocaleConfig`, all **no door** (#5055) — ⛔ **do not close them**. This row shrinks without disappearing, the third such in the ledger after `flow` (批 11) and `etl` (批 12): the reverse pin fires on ZERO, so a row parked at a deliberate floor looks exactly like a row nobody finished, and only the `Class` column separates them | -| `animation.zod.ts` | **no door** | ⛔ same as `touch` — #4988 | -| `dnd.zod.ts` | **no door** | ⛔ same as `touch` — #4988 | -| `keyboard.zod.ts` | **no door** | ⛔ same as `touch` — #4988 | -| `offline.zod.ts` | **no door** | ⛔ same as `touch` — #4988 | | `app.zod.ts` | verify | **批 19 ran the check and it came back NEGATIVE — no posture change, and the row's `Class` is held at `verify` deliberately (see below).** `BaseNavItemSchema`. The instruction here was to confirm the members' strictness was not already covering it before touching; it is, and the premise this row carried was wrong twice. (1) **The members do not `.extend()` the base — they spread `...BaseNavItemSchema.shape`.** That is a different mechanism, and the difference is the whole of finding 16: `.extend()` clones INHERIT the base's posture (which is how closing two `view` authoring schemas silently closed the Studio round-trip overlay), while a `...shape` spread copies the per-key schemas into a FRESH `z.object` whose posture is its own. Measured in both directions rather than read off the source, because *"closing the base closes the members"* and *"closing the base is a no-op"* are opposite claims: `strictBase.extend({…})` rejects an unknown key, `z.object({...strictBase.shape})` accepts it, `z.object({...openBase.shape}).strict()` rejects it. (2) **All nine branches already apply their own `.strict()`** with the curated `navItemUnknownKeyError` — asserted per branch through the real door (`AppSchema.navigation`, a `discriminatedUnion` on `type`), with a positive control (every base-contributed key, incl. `requiresService` which no branch declares itself, is ACCEPTED) and a negative control (an undeclared key is REJECTED) in the same run. The base is also module-private and has zero `.parse()` anywhere, so `.strict()` here would be a property of a parse that does not exist. Closing it is therefore a guaranteed no-op, and #4583 is explicit that a no-op closure is not neutral. ⚠️ **The open question is the VOCABULARY, not the measurement** — which is why the `Class` cell was not changed, since it is machine-read and a guess here would be published as a confident subtotal. The two-axis table above resolves carrier-absent + parse-absent to `no door`, whose prescribed follow-up is ADR-0049 retirement — and that prescription is *destructive* here: the vocabulary is fully ALIVE and fully GATED at nine consumers, so retiring the base would delete nine branches' shared keys. `no gate` is wrong for the mirror reason (the gate exists, at the members). `authorable` is the `FormFieldBaseSchema` precedent one row over in `view.zod.ts` — but that base really is `.extend()`ed, so closing it WOULD change behaviour, and calling this one `authorable` invites exactly the later sweep that "finishes the job" on a shape nothing parses. None of the eight enumerated verdicts is honest for a shape that is neither a door nor dead, and adding a ninth changes a machine-read contract — so the decision is the maintainer's (**#5249**). Recorded in three places (the `BaseNavItemSchema` JSDoc + `app-strictness-batch19.test.ts` + this row); the pin includes a guard that fails if any branch ever stops rejecting unknown keys, which is the one change that would make this verdict need re-taking | `sharing.zod.ts` and `notification.zod.ts` left this table at **#5015** by a route no other row has taken: not by being CLOSED, but by having their remaining sites REMOVED. Both were `no door` — ADR-0049 territory, explicitly out of this ratchet's scope — and the enforce-or-remove call came back REMOVE, so `EmbedConfigSchema` and `NotificationActionSchema` are gone rather than strict. Read the reverse pin carefully here, because it fires on zero either way and cannot tell the two routes apart: the `sharing.zod.ts` row said in as many words that it *"shrinks without disappearing — the first `no door` floor"*, and that was true right up until the floor was retired out from under it. A deliberate floor and a retired one look identical from the count; only the `Class` column and this paragraph separate them. `sharing.zod.ts` keeps its TRIAGE row above, because `SharingConfigSchema` is still there and still strict — the file is closed, not empty. `notification.zod.ts` keeps no row anywhere: it has zero object sites left. @@ -841,7 +856,22 @@ next person to open that file will look. — it reached 0 strip, the gate went red on the row still being there, and the row was deleted. `action.zod.ts`, `report.zod.ts`, `dataset.zod.ts` and `dashboard.zod.ts` left it the same way at **批 14**, and `theme.zod.ts` at -**批 15**. Header and subtotal are +**批 15**. + +**Five more rows left at #4988, and their destination is not "closed" — it is +"deleted".** `touch.zod.ts`, `animation.zod.ts`, `dnd.zod.ts`, +`keyboard.zod.ts` and `offline.zod.ts` reached 0 strip sites because the FILES +were retired (ADR-0049 enforce-or-remove; 22 sites, 32 defs, 64 exported names, +reference docs deleted with them). The reverse pin fires on zero either way and +cannot tell the two apart — the same blind spot PR #5300 recorded for +`sharing.zod.ts` — so it is written down here instead: these five rows did not +graduate, they were retired, and their `no door` verdict was the evidence for +the retirement rather than a worklist item anyone finished. `i18n.zod.ts`'s +five `no door` sites and `widget.zod.ts` are the remaining rows parked at a +deliberate floor for the same reason; do not read their survival as unfinished +work. + +Header and subtotal are **recomputed from the surviving rows**, never decremented by any batch's own count. That is not pedantry: it happened four times in one day in `automation/` — each branch's arithmetic was right against itself, git merged the rows cleanly @@ -943,13 +973,21 @@ about this directory now, and it should be read before any further `ui/` strictness batch is scheduled — the ratchet is very nearly done here, and what remains open is overwhelmingly work for OTHER issues: -- **`no door`** — `touch`, `animation`, `dnd`, `keyboard` and `offline` from - 批 13; `widget.zod.ts` plus `i18n.zod.ts`'s remainder from 批 16 (#4988, - #5055). 批 14's two — `sharing.zod.ts`'s `EmbedConfig` and - `notification.zod.ts`'s `NotificationAction` — are **no longer open**: #5015 - answered their ADR-0049 call REMOVE and both shapes are gone, which is what a - `no door` row is supposed to end in. They are the first entries in this list - to be closed by retirement rather than by a batch. +- **`no door`** — ~~`touch`, `animation`, `dnd`, `keyboard` and `offline` from + 批 13~~ **RETIRED at #4988** (all five files deleted whole; see the destination + note under the `ui/` triage table), and ~~`sharing.zod.ts`'s `EmbedConfig` and + `notification.zod.ts`'s `NotificationAction` from 批 14~~ **RETIRED at #5015** + (two shapes, both host files kept — one file, two verdicts). Still open: + `widget.zod.ts` plus `i18n.zod.ts`'s remainder from 批 16 (#5055). + **Seven of this class's nine entries were closed by RETIREMENT inside one + release window, none of them by a strictness batch** — which is what `no door` + was added to make possible. Read the two retirements' shapes as a pair, since + they are the class's worked examples in both directions: #5015 removed two + shapes out of two files that stay (`SharingConfigSchema` and the notification + presentation enums are live), while #4988 removed five files entire because + every export in them was in-family and unreachable. The question is always + asked per SCHEMA; the file is only the answer's unit when every schema in it + answers the same way. - **`no gate`** — `chart.zod.ts`'s remaining pair from 批 15, plus **all of `component.zod.ts` from 批 17** (#5068). That single row is the campaign's largest reclassification and the reason this subtotal fell by 29 without one diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 46740c233b..6c744ecf45 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -204,6 +204,8 @@ Finally, the theme token scales retire (#5021, ADR-0049): `typography.fontSize`, It closes the enforce-or-remove line with two `./ui` vocabulary shapes that never had a key to be written into (#5015): `NotificationActionSchema` / `NotificationAction` and `EmbedConfigSchema` / `EmbedConfig`. This is the class BELOW a declared-but-unread key — there was no key at all. No schema anywhere declared a carrier, a BFS from all 24 metadata-type roots plus `ObjectStackSchema` reached neither (with `Page` / `Action` / `DashboardWidget` / `Webhook` / `SharingConfig` as positive controls in the same run, and a synthetic carrier flipping both), and no repo parsed either outside its own unit test. Each was left behind by an earlier retirement one level up: the notification action by #4610, which deleted the two wrapper shapes that could have carried it, and the embed config by 17.0.0's own `App.embed` tombstone. #4001 批 14 measured both and deliberately declined to close them with `.strict()`, because strictness on a shape nothing parses enforces nothing and only makes a dead slot look load-bearing (#4583); #5015 is that deferred call, answered REMOVE. Nothing is applied for you and nothing needs to be — there is no key in any source to rewrite; the change is visible only as TS2305 on an import. ⚠️ Read the scope precisely, because one of the two modules SPLITS: `ui/sharing.zod` keeps `SharingConfigSchema` and it stays LIVE — `FormView.sharing` carries it and `rest-server.ts` mounts the anonymous form routes on `allowAnonymous` + `publicLink`, so public form sharing is untouched — and `ui/notification.zod` keeps `NotificationType` / `NotificationSeverity` / `NotificationPosition`. Only the two named shapes go. +The same is true of the protocol-17 retirement that closes this list, and the pair is worth reading together (#4988, ADR-0049): the five `@objectstack/spec/ui` interaction-config modules — `touch.zod.ts`, `dnd.zod.ts`, `keyboard.zod.ts`, `animation.zod.ts` and `offline.zod.ts`, 22 `z.object` sites and 64 exported names — are deleted whole, with their reference docs. They were never reachable: no schema in the protocol declared a `touch:` / `dnd:` / `keyboard:` / `animation:` / `offline:` key, so no metadata document could carry one and none needs rewriting now. The defect was on the DOCUMENTATION side, which is the half that made it urgent — `authorable-surface.json` carried 109 keys under these defs and the generated `references/ui/*` pages rendered them as authoring tables, so an AI author reading `dnd.mdx` wrote a `dnd:` block that `PageComponentSchema` then rejected as an unknown key. That is a published capability the runtime does not deliver (Prime Directive #10), not a strictness gap: closing the shapes would have validated a slot nobody can reach. Business reading behind the ruling: these five are RENDERER BUILT-IN behaviour, decided by the component library rather than authored per page; offline is a platform capability whose vocabulary belongs on a sync engine that has not been built. ⚠️ The `animation` here is `ui/animation.zod.ts` (`ComponentAnimation` / `MotionConfig` / `PageTransition` / `AnimationTrigger`), a DIFFERENT surface from the theme `animation` block retired above by #5021 — that one had a carrier key and got a tombstone; this one had none and gets deletion. The one name worth checking on upgrade is the bare `ConflictResolution` type: it left with `ui/offline.zod.ts` and is now published by nobody. `ConnectorConflictResolution` (`@objectstack/spec/integration`, connector sync) and `ConflictResolutionStrategy` (`@objectstack/spec/api`, route merge policy) are different concepts under their own names and are untouched. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -322,6 +324,9 @@ It closes the enforce-or-remove line with two `./ui` vocabulary shapes that neve - **`declarative-apis-endpoints-live`** — `stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)` → the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }` - Why not automatic: This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is "did the author of this endpoint mean for the internet to reach it?" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call. - Done when: You have READ every entry of every `apis:` block, not just the ones that fail to publish. Concretely: (1) each declared `path` is `/api/v1/apps//` and the stack declares that `manifest.namespace` explicitly; (2) every entry declaring `authRequired: false` is one you INTEND to be reachable without a session, and each carries `rateLimit: { enabled: true, windowMs, maxRequests }` — entries that were not intended to be anonymous have the key removed so the safe default (`true`) applies; (3) `objectstack validate` passes, which also proves no endpoint declares a shape 17.x cannot execute (`type: script` / `proxy`, mapping `transform`, an `object_operation` missing `objectParams`, `cacheTtl` on a non-GET method, `inputMapping` on find/get/delete, or two endpoints claiming one METHOD + path); and (4) after publishing, each endpoint answers as you expect — an anonymous request to a session-only endpoint returns 401 rather than data. +- **`ui-interaction-config-family-retired`** — `ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)` → (removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor, the #4910 way, not by un-retiring a declaration) + - Why not automatic: Five `@objectstack/spec/ui` modules declared a full interaction-configuration vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol carried them. This is the ADR-0049 false-compliance shape in its most inviting form for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: `authorable-surface.json` listed 109 keys under these defs and `content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a page component was rejected by `PageComponentSchema` for an unrecognized key — the docs and the schema disagreeing about the platform (Prime Directive #10). Three independent measurements, each with its controls passing in the same run: (1) no module under `packages/spec/src` imported any of the five except the `ui/index.ts` barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` (25 roots, 4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, `WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic carrier flipped all 21 — so unreachability was a fact about the graph, not a broken walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these modules' own unit tests. objectui holds TYPE re-exports and parity ratchets, never validators, and says so (#2561). The 2026-08-04 ruling weighed wiring a carrier key (option B) and rejected it: that is a feature with a renderer behind it, not ledger clean-up. It also weighed tightening the shapes to `strictObject` and rejected that explicitly — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave "a precisely validated dead slot, the more convincing lie" (#4583). Because there was no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 as #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ Not to be confused with #5021, which retired the THEME `animation` block — a different file, different defs, and that one did have a carrier key and therefore a tombstone. ADR-0049, #4988. + - Done when: No code imports any of the 64 retired names from `@objectstack/spec` or `@objectstack/spec/ui` — `TouchTargetConfig(Schema)`, `GestureType(Schema)`, `SwipeDirection(Schema)`, `SwipeGestureConfig(Schema)`, `PinchGestureConfig(Schema)`, `LongPressGestureConfig(Schema)`, `GestureConfig(Schema)`, `TouchInteraction(Schema)`, `TransitionPreset(Schema)`, `EasingFunction(Schema)`, `TransitionConfig(Schema)`, `AnimationTrigger(Schema)`, `ComponentAnimation(Schema)`, `PageTransition(Schema)`, `MotionConfig(Schema)`, `DragHandle(Schema)`, `DropEffect(Schema)`, `DragConstraint(Schema)`, `DropZone(Schema)`, `DragItem(Schema)`, `DndConfig(Schema)`, `FocusTrapConfig(Schema)`, `KeyboardShortcut(Schema)`, `FocusManagement(Schema)`, `KeyboardNavigationConfig(Schema)`, `OfflineStrategy(Schema)`, `ConflictResolution(Schema)`, `SyncConfig(Schema)`, `PersistStorage(Schema)`, `EvictionPolicy(Schema)`, `OfflineCacheConfig(Schema)`, `OfflineConfig(Schema)` — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `ui/interaction-config-retirement.test.ts`). No metadata document needs editing, because none could ever carry one of these blocks: a stack that parsed before parses byte-for-byte the same after. If you consumed the bare `ConflictResolution` from `@objectstack/spec/ui` as a TYPE for your own offline code, declare that union locally — it is your client's policy, not the platform's. `@objectstack/spec/integration`'s `ConnectorConflictResolution` (connector sync) and `@objectstack/spec/api`'s `ConflictResolutionStrategy` (route merge policy) are different concepts and are untouched. - **`ui-notification-action-embed-config-retired`** — `ui.notificationAction / ui.embedConfig` → (removed — there is no replacement shape, because there was never a key to write either into. Delete the import and the value. Notification presentation is still described by the surviving `NotificationType` / `NotificationSeverity` / `NotificationPosition` vocabulary; public access to a form is granted by the LIVE `FormView.sharing` block (`SharingConfig`), which is untouched. Notification action buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 through a new ADR — carrier key and renderer first, vocabulary second) - Why not automatic: Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. #4001 批 14 measured them three ways on 2026-08-03 and this retirement re-ran all three against `origin/main` before removing anything, each with a positive control that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared a key of either type (`ui/notification.zod`'s only non-test importer was the barrel; `ui/sharing.zod`'s were the barrel and `ui/view.zod.ts`, which names its SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than substring-matching, because the repo holds two `sharing.zod` modules and a substring test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema`, over `build-schemas.ts`'s own walk including its derived-clone bridge, never reached either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` itself all resolved `root-graph` in the same run and an injected synthetic carrier flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside their own unit tests. So nobody could author one and nothing ever validated one: the #3950 shape, an exported schema with no consumer read as a capability, and the ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle as proof the platform serves iframes. Neither is stored metadata and neither has a carrier, so no `sys_metadata` row can hold one and there is no source for the D2 chain to rewrite; this entry is the D3 record. 批 14 deliberately did NOT close them with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing parses buys only "a precisely-validated dead slot, the more convincing lie" (#4583) — and filed the disposition as #5015, ruled REMOVE on 2026-08-04. Each was orphaned by an earlier retirement one level up: `NotificationAction` lost its wrappers at #4610 (`NotificationSchema` / `NotificationConfigSchema`, deleted for zero consumers), and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 liveness audit retired `App.embed` (no iframe route ever read it) — that key still stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already meets a prescription; this removes the value shape that outlived it. ⚠️ The retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount the anonymous form routes, and `ui/notification.zod` keeps its three presentation enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY (never a parse) to pin its own hand-written `NotificationActionButton` interface — which is exactly why "has a consumer" never meant "has an authoring door" here; that pin is adapted objectui-side when it refreshes this dependency. ADR-0049, #5015. - Done when: No code imports `NotificationActionSchema`, `NotificationAction`, `EmbedConfigSchema` or `EmbedConfig` from `@objectstack/spec` or `@objectstack/spec/ui` — both are TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `notification-embed-retirement.test.ts`). The same pin asserts the SURVIVORS in the same run, and that half is equally load-bearing: `NotificationTypeSchema` / `NotificationSeveritySchema` / `NotificationPositionSchema` and `SharingConfigSchema` must still be exported from `./ui`, and both modules must still load — a retirement that deleted either file would satisfy the absence half while destroying working surface. Nothing regresses at runtime, because nothing ever ran: no notification action was ever parsed from metadata and no iframe route ever read an embed config. Public form sharing is unaffected — `FormView.sharing` still gates the anonymous endpoints on `allowAnonymous` + `publicLink`. diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 2bea64727c..f902ec0006 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3160,8 +3160,6 @@ "ActionType (const)", "AddRecordConfig (type)", "AddRecordConfigSchema (const)", - "AnimationTrigger (type)", - "AnimationTriggerSchema (const)", "App (type)", "AppBranding (type)", "AppBrandingSchema (const)", @@ -3217,15 +3215,11 @@ "ColumnSummaryConfig (type)", "ColumnSummaryConfigSchema (const)", "ColumnSummarySchema (const)", - "ComponentAnimation (type)", - "ComponentAnimationSchema (const)", "ComponentNavItem (type)", "ComponentNavItemSchema (const)", "ComponentProps (type)", "ComponentPropsInput (type)", "ComponentPropsMap (const)", - "ConflictResolution (type)", - "ConflictResolutionSchema (const)", "Dashboard (type)", "DashboardHeader (type)", "DashboardHeaderAction (type)", @@ -3252,20 +3246,6 @@ "DateFormatSchema (const)", "DerivedMeasureOp (const)", "DerivedMeasureOpValue (type)", - "DndConfig (type)", - "DndConfigSchema (const)", - "DragConstraint (type)", - "DragConstraintSchema (const)", - "DragHandle (type)", - "DragHandleSchema (const)", - "DragItem (type)", - "DragItemSchema (const)", - "DropEffect (type)", - "DropEffectSchema (const)", - "DropZone (type)", - "DropZoneSchema (const)", - "EasingFunction (type)", - "EasingFunctionSchema (const)", "ElementButtonPropsSchema (const)", "ElementDataSource (type)", "ElementDataSourceSchema (const)", @@ -3277,16 +3257,10 @@ "ElementRecordPickerPropsSchema (const)", "ElementTextInputPropsSchema (const)", "ElementTextPropsSchema (const)", - "EvictionPolicy (type)", - "EvictionPolicySchema (const)", "ExpandViewResult (interface)", "ExpandedViewItem (interface)", "FieldWidgetProps (type)", "FieldWidgetPropsSchema (const)", - "FocusManagement (type)", - "FocusManagementSchema (const)", - "FocusTrapConfig (type)", - "FocusTrapConfigSchema (const)", "FormButtonConfig (type)", "FormButtonConfigSchema (const)", "FormField (type)", @@ -3300,10 +3274,6 @@ "GalleryConfigSchema (const)", "GanttConfigSchema (const)", "GanttQuickFilterSchema (const)", - "GestureConfig (type)", - "GestureConfigSchema (const)", - "GestureType (type)", - "GestureTypeSchema (const)", "GlobalFilter (type)", "GlobalFilterOptionsFrom (type)", "GlobalFilterOptionsFromSchema (const)", @@ -3330,10 +3300,6 @@ "JoinedReportBlockInput (type)", "JoinedReportBlockSchema (const)", "KanbanConfigSchema (const)", - "KeyboardNavigationConfig (type)", - "KeyboardNavigationConfigSchema (const)", - "KeyboardShortcut (type)", - "KeyboardShortcutSchema (const)", "ListChartConfig (type)", "ListChartConfigSchema (const)", "ListColumn (type)", @@ -3342,10 +3308,6 @@ "ListViewSchema (const)", "LocaleConfig (type)", "LocaleConfigSchema (const)", - "LongPressGestureConfig (type)", - "LongPressGestureConfigSchema (const)", - "MotionConfig (type)", - "MotionConfigSchema (const)", "NavigationArea (type)", "NavigationAreaSchema (const)", "NavigationConfig (type)", @@ -3369,12 +3331,6 @@ "ObjectNavItem (type)", "ObjectNavItemSchema (const)", "ObjectUserFiltersSchema (const)", - "OfflineCacheConfig (type)", - "OfflineCacheConfigSchema (const)", - "OfflineConfig (type)", - "OfflineConfigSchema (const)", - "OfflineStrategy (type)", - "OfflineStrategySchema (const)", "PAGE_TYPE_ROADMAP (const)", "Page (type)", "PageAccordionProps (const)", @@ -3390,18 +3346,12 @@ "PageRegionSchema (const)", "PageSchema (const)", "PageTabsProps (const)", - "PageTransition (type)", - "PageTransitionSchema (const)", "PageType (type)", "PageTypeSchema (const)", "PageVariable (type)", "PageVariableSchema (const)", "PaginationConfig (type)", "PaginationConfigSchema (const)", - "PersistStorage (type)", - "PersistStorageSchema (const)", - "PinchGestureConfig (type)", - "PinchGestureConfigSchema (const)", "PluralRule (type)", "PluralRuleSchema (const)", "REACT_BLOCKS (const)", @@ -3448,12 +3398,6 @@ "SharingConfigSchema (const)", "StyleMap (type)", "StyleMapSchema (const)", - "SwipeDirection (type)", - "SwipeDirectionSchema (const)", - "SwipeGestureConfig (type)", - "SwipeGestureConfigSchema (const)", - "SyncConfig (type)", - "SyncConfigSchema (const)", "Theme (type)", "ThemeInput (type)", "ThemeMode (type)", @@ -3461,14 +3405,6 @@ "ThemeSchema (const)", "TimelineConfig (type)", "TimelineConfigSchema (const)", - "TouchInteraction (type)", - "TouchInteractionSchema (const)", - "TouchTargetConfig (type)", - "TouchTargetConfigSchema (const)", - "TransitionConfig (type)", - "TransitionConfigSchema (const)", - "TransitionPreset (type)", - "TransitionPresetSchema (const)", "TreeConfigSchema (const)", "Typography (type)", "TypographySchema (const)", diff --git a/packages/spec/authorable-surface.base.json b/packages/spec/authorable-surface.base.json index 4f4285e3f7..21bdc8db49 100644 --- a/packages/spec/authorable-surface.base.json +++ b/packages/spec/authorable-surface.base.json @@ -1,6 +1,6 @@ { "description": "In-tree anchor for the authorable-surface deletion gate (#4650, #5235): a verbatim copy of the keys in authorable-surface.json as they stood at `baseRev`, a commit on origin/main. A build that CAN reach origin/main anchors on the merge base instead, and re-verifies this file against `baseRev` — so a PR that edits it to hide a deletion goes red wherever the network exists. A build that CANNOT reach GitHub (image-build stages, air-gapped, fork, historical-tag reproduction) anchors here instead of failing. Written only by `gen:schema`, only from a git-resolved baseline — never from the build that is being checked. See #5235.", - "baseRev": "26e1029f5cdf7f63719e3f58eb661b19f65b78fe", + "baseRev": "1c3da1f6f0899f7299d4b28294e049b0b75d398d", "keys": [ "ai/AIModelConfig:maxTokens", "ai/AIModelConfig:model", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 400095e829..cb210d6d05 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -7077,15 +7077,6 @@ "ui/ColumnPrefix:type", "ui/ColumnSummaryConfig:field", "ui/ColumnSummaryConfig:type", - "ui/ComponentAnimation:ariaDescribedBy", - "ui/ComponentAnimation:ariaLabel", - "ui/ComponentAnimation:enter", - "ui/ComponentAnimation:exit", - "ui/ComponentAnimation:hover", - "ui/ComponentAnimation:label", - "ui/ComponentAnimation:reducedMotion", - "ui/ComponentAnimation:role", - "ui/ComponentAnimation:trigger", "ui/ComponentNavItem:badge", "ui/ComponentNavItem:badgeVariant", "ui/ComponentNavItem:componentRef", @@ -7198,32 +7189,6 @@ "ui/DateFormat:hour12", "ui/DateFormat:timeStyle", "ui/DateFormat:timeZone", - "ui/DndConfig:autoScroll", - "ui/DndConfig:dragItem", - "ui/DndConfig:dropZone", - "ui/DndConfig:enabled", - "ui/DndConfig:sortable", - "ui/DndConfig:touchDelay", - "ui/DragConstraint:axis", - "ui/DragConstraint:bounds", - "ui/DragConstraint:grid", - "ui/DragItem:ariaDescribedBy", - "ui/DragItem:ariaLabel", - "ui/DragItem:constraint", - "ui/DragItem:disabled", - "ui/DragItem:handle", - "ui/DragItem:label", - "ui/DragItem:preview", - "ui/DragItem:role", - "ui/DragItem:type", - "ui/DropZone:accept", - "ui/DropZone:ariaDescribedBy", - "ui/DropZone:ariaLabel", - "ui/DropZone:dropEffect", - "ui/DropZone:highlightOnDragOver", - "ui/DropZone:label", - "ui/DropZone:maxItems", - "ui/DropZone:role", "ui/ElementButtonProps:action", "ui/ElementButtonProps:aria", "ui/ElementButtonProps:disabled", @@ -7289,15 +7254,6 @@ "ui/ElementTextProps:aria", "ui/ElementTextProps:content", "ui/ElementTextProps:variant", - "ui/FocusManagement:arrowNavigation", - "ui/FocusManagement:focusTrap", - "ui/FocusManagement:focusVisible", - "ui/FocusManagement:skipLinks", - "ui/FocusManagement:tabOrder", - "ui/FocusTrapConfig:enabled", - "ui/FocusTrapConfig:escapeDeactivates", - "ui/FocusTrapConfig:initialFocus", - "ui/FocusTrapConfig:returnFocus", "ui/FormButtonConfig:label", "ui/FormButtonConfig:show", "ui/FormField:colSpan", @@ -7389,12 +7345,6 @@ "ui/GanttQuickFilter:field", "ui/GanttQuickFilter:label", "ui/GanttQuickFilter:options", - "ui/GestureConfig:enabled", - "ui/GestureConfig:label", - "ui/GestureConfig:longPress", - "ui/GestureConfig:pinch", - "ui/GestureConfig:swipe", - "ui/GestureConfig:type", "ui/GlobalFilter:defaultValue", "ui/GlobalFilter:field", "ui/GlobalFilter:label", @@ -7472,16 +7422,6 @@ "ui/KanbanConfig:columns", "ui/KanbanConfig:groupByField", "ui/KanbanConfig:summarizeField", - "ui/KeyboardNavigationConfig:ariaDescribedBy", - "ui/KeyboardNavigationConfig:ariaLabel", - "ui/KeyboardNavigationConfig:focusManagement", - "ui/KeyboardNavigationConfig:role", - "ui/KeyboardNavigationConfig:rovingTabindex", - "ui/KeyboardNavigationConfig:shortcuts", - "ui/KeyboardShortcut:action", - "ui/KeyboardShortcut:description", - "ui/KeyboardShortcut:key", - "ui/KeyboardShortcut:scope", "ui/ListChartConfig:chartType", "ui/ListChartConfig:dataset", "ui/ListChartConfig:dimensions", @@ -7553,14 +7493,6 @@ "ui/LocaleConfig:direction", "ui/LocaleConfig:fallbackChain", "ui/LocaleConfig:numberFormat", - "ui/LongPressGestureConfig:duration", - "ui/LongPressGestureConfig:moveTolerance", - "ui/MotionConfig:componentAnimations", - "ui/MotionConfig:defaultTransition", - "ui/MotionConfig:enabled", - "ui/MotionConfig:label", - "ui/MotionConfig:pageTransitions", - "ui/MotionConfig:reducedMotion", "ui/NavigationArea:description", "ui/NavigationArea:icon", "ui/NavigationArea:id", @@ -7648,17 +7580,6 @@ "ui/ObjectNavItem:visible", "ui/ObjectUserFilters:element", "ui/ObjectUserFilters:fields", - "ui/OfflineCacheConfig:evictionPolicy", - "ui/OfflineCacheConfig:maxSize", - "ui/OfflineCacheConfig:persistStorage", - "ui/OfflineCacheConfig:ttl", - "ui/OfflineConfig:cache", - "ui/OfflineConfig:enabled", - "ui/OfflineConfig:offlineIndicator", - "ui/OfflineConfig:offlineMessage", - "ui/OfflineConfig:queueMaxSize", - "ui/OfflineConfig:strategy", - "ui/OfflineConfig:sync", "ui/Page:_lock", "ui/Page:_lockDocsUrl", "ui/Page:_lockReason", @@ -7731,18 +7652,12 @@ "ui/PageTabsProps:items", "ui/PageTabsProps:position", "ui/PageTabsProps:type", - "ui/PageTransition:crossFade", - "ui/PageTransition:duration", - "ui/PageTransition:easing", - "ui/PageTransition:type", "ui/PageVariable:defaultValue", "ui/PageVariable:name", "ui/PageVariable:source", "ui/PageVariable:type", "ui/PaginationConfig:pageSize", "ui/PaginationConfig:pageSizeOptions", - "ui/PinchGestureConfig:maxScale", - "ui/PinchGestureConfig:minScale", "ui/PluralRule:few", "ui/PluralRule:key", "ui/PluralRule:many", @@ -7865,14 +7780,6 @@ "ui/SharingConfig:expiresAt", "ui/SharingConfig:password", "ui/SharingConfig:publicLink", - "ui/SwipeGestureConfig:direction", - "ui/SwipeGestureConfig:threshold", - "ui/SwipeGestureConfig:velocity", - "ui/SyncConfig:batchSize", - "ui/SyncConfig:conflictResolution", - "ui/SyncConfig:maxRetries", - "ui/SyncConfig:retryInterval", - "ui/SyncConfig:strategy", "ui/Theme:animation [RETIRED]", "ui/Theme:borderRadius", "ui/Theme:colors", @@ -7891,22 +7798,6 @@ "ui/TimelineConfig:scale", "ui/TimelineConfig:startDateField", "ui/TimelineConfig:titleField", - "ui/TouchInteraction:ariaDescribedBy", - "ui/TouchInteraction:ariaLabel", - "ui/TouchInteraction:gestures", - "ui/TouchInteraction:hapticFeedback", - "ui/TouchInteraction:role", - "ui/TouchInteraction:touchTarget", - "ui/TouchTargetConfig:hitSlop", - "ui/TouchTargetConfig:minHeight", - "ui/TouchTargetConfig:minWidth", - "ui/TouchTargetConfig:padding", - "ui/TransitionConfig:customKeyframes", - "ui/TransitionConfig:delay", - "ui/TransitionConfig:duration", - "ui/TransitionConfig:easing", - "ui/TransitionConfig:preset", - "ui/TransitionConfig:themeToken", "ui/TreeConfig:defaultExpandedDepth", "ui/TreeConfig:fields", "ui/TreeConfig:labelField", diff --git a/packages/spec/docs/SYNC_ARCHITECTURE.md b/packages/spec/docs/SYNC_ARCHITECTURE.md index 1f0420e3d9..e9faef7fb5 100644 --- a/packages/spec/docs/SYNC_ARCHITECTURE.md +++ b/packages/spec/docs/SYNC_ARCHITECTURE.md @@ -38,9 +38,14 @@ live declarations in `integration/connector.zod.ts` and `ui/offline.zod.ts` (the (strategy, direction, schedule, `conflictResolution`, batching, delete mode). - **Transformation pipelines** — `ETLPipeline` (`automation/etl.zod.ts`) for multi-source, multi-stage data movement. -- **Client offline sync** — `SyncConfigSchema` / `ConflictResolution` - (`ui/offline.zod.ts`): a *different* concept (client/server conflict handling) - that now owns the bare `ConflictResolution` name package-wide. +- **Client offline sync** — ~~`SyncConfigSchema` / `ConflictResolution` + (`ui/offline.zod.ts`)~~ **also retired, at #4988** (ADR-0049). That vocabulary + had no carrier key either: no schema in the protocol declared an `offline:` + slot, so nothing ever parsed it. Offline sync is a platform capability, and + when it is built its vocabulary arrives on the sync engine that owns the + queue, the conflict policy and the cache — not as a standalone `ui/` config + shape. The bare `ConflictResolution` name is consequently published by no def + at all; `ConnectorConflictResolution` above is the connector-sync one. --- diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 5d7bf97be0..1fa6bc8de4 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1461,7 +1461,6 @@ "ui/ActionParam", "ui/ActionType", "ui/AddRecordConfig", - "ui/AnimationTrigger", "ui/App", "ui/AppBranding", "ui/AppContextSelector", @@ -1490,9 +1489,7 @@ "ui/ColumnPrefix", "ui/ColumnSummary", "ui/ColumnSummaryConfig", - "ui/ComponentAnimation", "ui/ComponentNavItem", - "ui/ConflictResolution", "ui/Dashboard", "ui/DashboardHeader", "ui/DashboardHeaderAction", @@ -1504,13 +1501,6 @@ "ui/DatasetMeasure", "ui/DateFormat", "ui/DerivedMeasureOp", - "ui/DndConfig", - "ui/DragConstraint", - "ui/DragHandle", - "ui/DragItem", - "ui/DropEffect", - "ui/DropZone", - "ui/EasingFunction", "ui/ElementButtonProps", "ui/ElementDataSource", "ui/ElementFilterProps", @@ -1521,9 +1511,6 @@ "ui/ElementRecordPickerProps", "ui/ElementTextInputProps", "ui/ElementTextProps", - "ui/EvictionPolicy", - "ui/FocusManagement", - "ui/FocusTrapConfig", "ui/FormButtonConfig", "ui/FormField", "ui/FormSection", @@ -1531,8 +1518,6 @@ "ui/GalleryConfig", "ui/GanttConfig", "ui/GanttQuickFilter", - "ui/GestureConfig", - "ui/GestureType", "ui/GlobalFilter", "ui/GlobalFilterOptionsFrom", "ui/GroupNavItem", @@ -1546,14 +1531,10 @@ "ui/InterfacePageConfig", "ui/JoinedReportBlock", "ui/KanbanConfig", - "ui/KeyboardNavigationConfig", - "ui/KeyboardShortcut", "ui/ListChartConfig", "ui/ListColumn", "ui/ListView", "ui/LocaleConfig", - "ui/LongPressGestureConfig", - "ui/MotionConfig", "ui/NavigationArea", "ui/NavigationConfig", "ui/NavigationContribution", @@ -1566,9 +1547,6 @@ "ui/ObjectListView", "ui/ObjectNavItem", "ui/ObjectUserFilters", - "ui/OfflineCacheConfig", - "ui/OfflineConfig", - "ui/OfflineStrategy", "ui/Page", "ui/PageAccordionProps", "ui/PageCardProps", @@ -1578,12 +1556,9 @@ "ui/PageNavItem", "ui/PageRegion", "ui/PageTabsProps", - "ui/PageTransition", "ui/PageType", "ui/PageVariable", "ui/PaginationConfig", - "ui/PersistStorage", - "ui/PinchGestureConfig", "ui/PluralRule", "ui/RecordActivityProps", "ui/RecordChatterProps", @@ -1605,16 +1580,9 @@ "ui/Shadow", "ui/SharingConfig", "ui/StyleMap", - "ui/SwipeDirection", - "ui/SwipeGestureConfig", - "ui/SyncConfig", "ui/Theme", "ui/ThemeMode", "ui/TimelineConfig", - "ui/TouchInteraction", - "ui/TouchTargetConfig", - "ui/TransitionConfig", - "ui/TransitionPreset", "ui/TreeConfig", "ui/Typography", "ui/UrlNavItem", diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts index 35437582aa..6d4f7cc46b 100644 --- a/packages/spec/scripts/build-docs.ts +++ b/packages/spec/scripts/build-docs.ts @@ -588,7 +588,13 @@ const SECTION_GROUPS: Record ui: [ { section: 'Apps & Navigation', pages: ['app', 'page', 'view', 'action'] }, { section: 'Visualization', pages: ['chart', 'dashboard', 'dataset', 'report', 'widget', 'component'] }, - { section: 'Interaction & Layout', pages: ['animation', 'dnd', 'keyboard', 'touch', 'responsive', 'theme', 'offline'] }, + // `animation` / `dnd` / `keyboard` / `touch` / `offline` left this section at + // #4988: the five `ui/` interaction config modules were retired whole + // (ADR-0049 — no carrier key, nothing parsed them), and their generated + // pages went with them. `buildCategoryPages` filters by what was emitted, so + // leaving the names here would have been silently harmless — which is why + // they are removed deliberately instead. + { section: 'Interaction & Layout', pages: ['responsive', 'theme'] }, { section: 'Platform', pages: ['i18n', 'notification', 'sharing', 'http'] }, ], }; diff --git a/packages/spec/scripts/lib/renamed-defs.ts b/packages/spec/scripts/lib/renamed-defs.ts index 924a18c344..b59daaf987 100644 --- a/packages/spec/scripts/lib/renamed-defs.ts +++ b/packages/spec/scripts/lib/renamed-defs.ts @@ -89,9 +89,14 @@ export const RENAMED_DEFS: Readonly> = { // def, no authorable properties). The automation side was retired outright // with the rest of `automation/sync.zod.ts` in the same change (deliberate // manifest removal, NOT carried here — a real retirement must never ride the - // rename table). `ui/ConflictResolution` keeps the bare name: it is a - // distinct concept (client/server offline sync) and the only side with - // cross-repo consumers (objectui useOffline + re-export + parity ratchet). + // rename table). `ui/ConflictResolution` kept the bare name at #4738 — a + // distinct concept (client/server offline sync). #4988 then RETIRED + // `ui/offline.zod.ts` whole (ADR-0049; deliberate manifest deletion, not + // carried here either), so the bare name is now emitted by no def at all. + // This entry is unaffected and STAYS: its source is still unemitted, its + // target still emitted, and `ConnectorConflictResolution` remains the + // connector vocabulary's real name — a freed word is not a reason to rename + // back, which would be a second breaking change carrying no keys. 'integration/ConflictResolution': 'integration/ConnectorConflictResolution', // #4737 / ADR-0112 D9a — `ActionLocation` was published by ./studio AND ./ui diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 67aa753e37..22b86446fb 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -553,6 +553,13 @@ "toMajor": 17, "rationale": "This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is \"did the author of this endpoint mean for the internet to reach it?\" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call." }, + { + "surface": "ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)", + "replacement": "(removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor, the #4910 way, not by un-retiring a declaration)", + "migrationId": "ui-interaction-config-family-retired", + "toMajor": 17, + "rationale": "Five `@objectstack/spec/ui` modules declared a full interaction-configuration vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol carried them. This is the ADR-0049 false-compliance shape in its most inviting form for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: `authorable-surface.json` listed 109 keys under these defs and `content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a page component was rejected by `PageComponentSchema` for an unrecognized key — the docs and the schema disagreeing about the platform (Prime Directive #10). Three independent measurements, each with its controls passing in the same run: (1) no module under `packages/spec/src` imported any of the five except the `ui/index.ts` barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` (25 roots, 4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, `WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic carrier flipped all 21 — so unreachability was a fact about the graph, not a broken walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these modules' own unit tests. objectui holds TYPE re-exports and parity ratchets, never validators, and says so (#2561). The 2026-08-04 ruling weighed wiring a carrier key (option B) and rejected it: that is a feature with a renderer behind it, not ledger clean-up. It also weighed tightening the shapes to `strictObject` and rejected that explicitly — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (#4583). Because there was no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 as #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ Not to be confused with #5021, which retired the THEME `animation` block — a different file, different defs, and that one did have a carrier key and therefore a tombstone. ADR-0049, #4988." + }, { "surface": "ui.notificationAction / ui.embedConfig", "replacement": "(removed — there is no replacement shape, because there was never a key to write either into. Delete the import and the value. Notification presentation is still described by the surviving `NotificationType` / `NotificationSeverity` / `NotificationPosition` vocabulary; public access to a form is granted by the LIVE `FormView.sharing` block (`SharingConfig`), which is untouched. Notification action buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 through a new ADR — carrier key and renderer first, vocabulary second)", @@ -1171,6 +1178,13 @@ "toMajor": 17, "rationale": "This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is \"did the author of this endpoint mean for the internet to reach it?\" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call." }, + { + "surface": "ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)", + "replacement": "(removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor, the #4910 way, not by un-retiring a declaration)", + "migrationId": "ui-interaction-config-family-retired", + "toMajor": 17, + "rationale": "Five `@objectstack/spec/ui` modules declared a full interaction-configuration vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol carried them. This is the ADR-0049 false-compliance shape in its most inviting form for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: `authorable-surface.json` listed 109 keys under these defs and `content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a page component was rejected by `PageComponentSchema` for an unrecognized key — the docs and the schema disagreeing about the platform (Prime Directive #10). Three independent measurements, each with its controls passing in the same run: (1) no module under `packages/spec/src` imported any of the five except the `ui/index.ts` barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` (25 roots, 4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, `WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic carrier flipped all 21 — so unreachability was a fact about the graph, not a broken walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these modules' own unit tests. objectui holds TYPE re-exports and parity ratchets, never validators, and says so (#2561). The 2026-08-04 ruling weighed wiring a carrier key (option B) and rejected it: that is a feature with a renderer behind it, not ledger clean-up. It also weighed tightening the shapes to `strictObject` and rejected that explicitly — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (#4583). Because there was no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 as #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ Not to be confused with #5021, which retired the THEME `animation` block — a different file, different defs, and that one did have a carrier key and therefore a tombstone. ADR-0049, #4988." + }, { "surface": "ui.notificationAction / ui.embedConfig", "replacement": "(removed — there is no replacement shape, because there was never a key to write either into. Delete the import and the value. Notification presentation is still described by the surviving `NotificationType` / `NotificationSeverity` / `NotificationPosition` vocabulary; public access to a form is granted by the LIVE `FormView.sharing` block (`SharingConfig`), which is untouched. Notification action buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 through a new ADR — carrier key and renderer first, vocabulary second)", diff --git a/packages/spec/src/api/router.zod.ts b/packages/spec/src/api/router.zod.ts index 712dff406d..e5a565fc65 100644 --- a/packages/spec/src/api/router.zod.ts +++ b/packages/spec/src/api/router.zod.ts @@ -37,11 +37,13 @@ export type RouteCategory = z.infer; * read it). This enum survives that removal deliberately and is NOT a * re-introduction of the registry: it is pinned as a `@objectstack/spec/api` * export by two independent ratchets — `spec/src/automation/sync-retirement.test.ts` - * (#4738: it is the FOURTH relative of the `ConflictResolution` family and must - * never collapse into the `ui` declaration) and, cross-repo, objectui's - * `offline-nav-performance-spec-parity.test.ts`, whose `useOffline` hook renamed - * its own symbol precisely because this name was taken. Route conflicts are a - * router concern, so the router module is where it belongs now. + * (#4738: it is the FOURTH relative of the `ConflictResolution` family; since + * #4988 retired `ui/offline.zod.ts` the bare name is published by nobody, and + * what that pin now asserts is that this relative keeps its OWN name and its + * `src/api/` home rather than drifting into the freed word) and, cross-repo, + * objectui's `offline-nav-performance-spec-parity.test.ts`, whose `useOffline` + * hook renamed its own symbol precisely because this name was taken. Route + * conflicts are a router concern, so the router module is where it belongs now. */ export const ConflictResolutionStrategy = z.enum([ 'error', // Throw error on conflict (safest, default) diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index 1821c74908..d625af7836 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -29,8 +29,12 @@ export * from './time-relative-trigger.zod'; // unreachable from the metadata-type roots (#4650 gate). Connector-attached // sync config is `ConnectorSchema.syncConfig` (integration/connector.zod.ts, // the live parse path); multi-step transformation is `etl.zod.ts`. The bare -// `ConflictResolution` name now belongs solely to `@objectstack/spec/ui` -// (offline sync), which objectui consumes. +// `ConflictResolution` name went to `@objectstack/spec/ui` (offline sync) at +// #4738 — and left the package entirely at #4988, which retired +// `ui/offline.zod.ts` under ADR-0049. The connector vocabulary keeps its +// `ConnectorConflictResolution` name; a freed word is not a reason to rename +// back, and no domain may re-adopt the bare one (pinned in +// `sync-retirement.test.ts`). export * from './state-machine.zod'; export * from './node-executor.zod'; export * from './flow-node-expression-paths'; diff --git a/packages/spec/src/automation/sync-retirement.test.ts b/packages/spec/src/automation/sync-retirement.test.ts index c413d8c2e2..f791ab6f45 100644 --- a/packages/spec/src/automation/sync-retirement.test.ts +++ b/packages/spec/src/automation/sync-retirement.test.ts @@ -27,12 +27,19 @@ import { describe, it, expect } from 'vitest'; // (ADR-0112 D9a prefixing, RENAMED_DEFS carry). `DataSyncConfig` stays // integration-owned under its bare name — it is on the live parse path // (`ConnectorSchema.syncConfig`). -// - ui keeps the bare `ConflictResolution(Schema)` UNTOUCHED: a distinct -// concept (offline client/server sync) and the only side with cross-repo -// consumers (objectui useOffline.ts + types re-export + a parity ratchet -// that pins "must stay a spec export"). Renaming the ui side would replay -// the objectui#3235 downstream breakage — that "tidy-up" is the wrong-case -// this pin exists to catch. +// - ui kept the bare `ConflictResolution(Schema)` UNTOUCHED at #4738: a +// distinct concept (offline client/server sync) and the only side with +// cross-repo consumers. Renaming the ui side would have replayed the +// objectui#3235 downstream breakage — that "tidy-up" is the wrong-case this +// pin exists to catch. +// +// ⚠️ #4988 SUPERSEDED that half. `ui/offline.zod.ts` was retired whole +// under ADR-0049 enforce-or-remove — it had no carrier key in the protocol, +// no `.parse()` in any of the three repos, and objectui's references were +// type re-exports and parity ratchets rather than runtime consumers. So the +// bare name is now published by NOBODY, and sections 3 and 5 below were +// rewritten to pin that instead. The #4738 rename stands: freeing a word is +// not a reason to rename the connector vocabulary back. // - `@objectstack/spec/api`'s `ConflictResolutionStrategy` (route conflicts) // is a FOURTH relative under a different name; it is outside the baseline // and must not be touched by any of this. @@ -141,18 +148,31 @@ describe('[#4738] sync/conflict dual-source retirement', () => { } } - // 3. The bare `ConflictResolution(Schema)` now has exactly ONE owner: ./ui, - // declared in ui/offline.zod.ts. Not just "same declaration everywhere" - // — NO other entry may export the bare name at all. A re-export from - // ./integration or ./automation would share the declaration (green to - // the dual-source gate) while telling connector authors the offline - // client/server vocabulary is a connector sync strategy — the C14 - // lesson: a re-export can lie about the domain even when the symbol is - // honest. + // 3. The bare `ConflictResolution(Schema)` is now published by NOBODY. + // + // ⚠️ REWRITTEN AT #4988, and the direction of this assertion INVERTED. + // When this file was written the bare name had exactly one owner — + // `./ui`, declared in `ui/offline.zod.ts` — and that ownership is why + // #4738 renamed the connector side instead of the ui side. #4988 then + // retired `ui/offline.zod.ts` whole (ADR-0049 enforce-or-remove: no + // carrier key, no parse, in any of the three repos), so the word is + // FREE rather than re-homed. + // + // Left as it was, this assertion would have gone red for the right + // reason and been "fixed" the wrong way — by pointing it at whichever + // entry still had a `ConflictResolution` — which is exactly the domain + // lie the C14 lesson names. The invariant that actually survives its + // owner is: no domain may quietly adopt a freed bare name. #4738's + // rename is NOT undone by the retirement (`ConnectorConflictResolution` + // is the connector vocabulary's real name now, pinned in 2 above; giving + // it back the bare word would be a second breaking change to gain + // nothing), and nothing else may claim it either. for (const name of ['ConflictResolution', 'ConflictResolutionSchema']) { const holders = holdersOf(name); - expect(holders.map((h) => h.sub), `${name} must be owned by ./ui alone`).toEqual(['./ui']); - expect(holders[0].origin).toMatch(/^src\/ui\/offline\.zod\.ts:\d+$/); + expect( + holders.map((h) => `${h.sub} (${h.origin})`), + `${name} was retired with ui/offline.zod.ts at #4988 — no entry may re-adopt the bare name`, + ).toEqual([]); } // 4. `DataSyncConfig(Schema)` likewise: ./integration alone, declared in @@ -165,16 +185,20 @@ describe('[#4738] sync/conflict dual-source retirement', () => { } // 5. The fourth relative is untouched: `ConflictResolutionStrategy` (route - // conflict handling) still exists on ./api under its own distinct name, - // and is a DIFFERENT declaration from ui's ConflictResolution. objectui's - // parity ratchet (offline-nav-performance-spec-parity.test.ts) pins the - // same pair from the consumer side. + // conflict handling) still exists on ./api under its own distinct name. + // + // ⚠️ Also rewritten at #4988. The old form proved distinctness by + // comparing this declaration's origin against ui's `ConflictResolution` + // origin — and with that owner retired, `holdersOf(…)[0]` throws before + // any assertion runs. The surviving, stronger statement is that this + // relative kept its OWN name and stayed in `api/`: the retirement freed + // a word, and the nearest neighbour is the most likely shape to drift + // into it. const strategyHolders = holdersOf('ConflictResolutionStrategy'); expect(strategyHolders.length, './api must still export ConflictResolutionStrategy').toBeGreaterThan(0); expect(strategyHolders.map((h) => h.sub)).toContain('./api'); - const uiOrigin = holdersOf('ConflictResolution')[0].origin; for (const h of strategyHolders) { - expect(h.origin, 'ConflictResolutionStrategy must not collapse into the ui declaration').not.toBe(uiOrigin); + expect(h.origin, 'ConflictResolutionStrategy must stay declared under src/api/').toMatch(/^src\/api\//); } }); @@ -199,12 +223,15 @@ describe('[#4738] sync/conflict dual-source retirement', () => { expect(() => integration.ConnectorConflictResolutionSchema.parse('destination_wins')).toThrow(); expect(() => integration.ConnectorConflictResolutionSchema.parse('merge')).toThrow(); - // ui side — untouched, and still the offline client/server vocabulary. - expect('ConflictResolutionSchema' in ui).toBe(true); - expect(() => ui.ConflictResolutionSchema.parse('client_wins')).not.toThrow(); - expect(() => ui.ConflictResolutionSchema.parse('last_write_wins')).not.toThrow(); - expect(() => ui.ConflictResolutionSchema.parse('target_wins')).toThrow(); - expect(() => ui.ConflictResolutionSchema.parse('source_wins')).toThrow(); + // ui side — RETIRED at #4988 with `ui/offline.zod.ts`. The runtime half of + // section 3: the bare name is absent from all three namespaces rather than + // having moved to one of them. + for (const [label, ns] of [['ui', ui], ['integration', integration], ['automation', automation]] as const) { + expect('ConflictResolutionSchema' in ns, `${label} must not export ConflictResolutionSchema`).toBe(false); + } + // 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); }); it('still parses authored connector syncConfig through the renamed enum — the live path', async () => { diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index afb6a5b647..6901359205 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -171,9 +171,12 @@ export type SyncStrategy = z.infer; * Renamed from `ConflictResolution` (#4738, ADR-0112 D9a — the C9/C12 * prefixing lineage): that bare name was published by three entry points for * three different declarations (#4411 trap). The connector-sync strategy takes - * the domain prefix; the bare `ConflictResolution` now belongs solely to - * `@objectstack/spec/ui` (offline client/server sync — a different concept - * with a disjoint vocabulary, and the only side with cross-repo consumers). + * the domain prefix; the bare `ConflictResolution` went to + * `@objectstack/spec/ui` (offline client/server sync — a different concept with + * a disjoint vocabulary). #4988 then retired `ui/offline.zod.ts` whole under + * ADR-0049, so the bare name is now published by nobody. This name STAYS as it + * is: it is the connector vocabulary's real name, and un-renaming it to reclaim + * a freed word would be a second breaking change for no gain. * The enum VALUES here are unchanged — authored `syncConfig.conflictResolution` * metadata parses byte-for-byte the same. Note `@objectstack/spec/api` also * exports `ConflictResolutionStrategy` (route conflicts) — a fourth, distinct diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 1d32c2de89..8c1fd62ce9 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -931,7 +931,31 @@ const step17: MigrationStep = { + 'carries it and `rest-server.ts` mounts the anonymous form routes on ' + '`allowAnonymous` + `publicLink`, so public form sharing is untouched — and ' + '`ui/notification.zod` keeps `NotificationType` / `NotificationSeverity` / ' - + '`NotificationPosition`. Only the two named shapes go.', + + '`NotificationPosition`. Only the two named shapes go.\n\n' + + 'The same is true of the protocol-17 retirement that closes this list, and the pair is ' + + 'worth reading together (#4988, ADR-0049): the five `@objectstack/spec/ui` interaction-config ' + + 'modules — `touch.zod.ts`, `dnd.zod.ts`, `keyboard.zod.ts`, `animation.zod.ts` and ' + + '`offline.zod.ts`, 22 `z.object` sites and 64 exported names — are deleted whole, with ' + + 'their reference docs. They were never reachable: no schema in the protocol declared a ' + + '`touch:` / `dnd:` / `keyboard:` / `animation:` / `offline:` key, so no metadata document ' + + 'could carry one and none needs rewriting now. The defect was on the DOCUMENTATION side, ' + + 'which is the half that made it urgent — `authorable-surface.json` carried 109 keys under ' + + 'these defs and the generated `references/ui/*` pages rendered them as authoring tables, ' + + 'so an AI author reading `dnd.mdx` wrote a `dnd:` block that `PageComponentSchema` then ' + + 'rejected as an unknown key. That is a published capability the runtime does not deliver ' + + '(Prime Directive #10), not a strictness gap: closing the shapes would have validated a ' + + 'slot nobody can reach. Business reading behind the ruling: these five are RENDERER ' + + 'BUILT-IN behaviour, decided by the component library rather than authored per page; ' + + 'offline is a platform capability whose vocabulary belongs on a sync engine that has not ' + + 'been built. ⚠️ The `animation` here is `ui/animation.zod.ts` ' + + '(`ComponentAnimation` / `MotionConfig` / `PageTransition` / `AnimationTrigger`), a ' + + 'DIFFERENT surface from the theme `animation` block retired above by #5021 — that one had ' + + 'a carrier key and got a tombstone; this one had none and gets deletion. The one name ' + + 'worth checking on upgrade is the bare `ConflictResolution` type: it left with ' + + '`ui/offline.zod.ts` and is now published by nobody. `ConnectorConflictResolution` ' + + '(`@objectstack/spec/integration`, connector sync) and `ConflictResolutionStrategy` ' + + '(`@objectstack/spec/api`, route merge policy) are different concepts under their own ' + + 'names and are untouched.', conversionIds: [ 'action-execute-to-target', 'field-conditionalRequired-to-requiredWhen', @@ -1651,6 +1675,76 @@ const step17: MigrationStep = { + '(4) after publishing, each endpoint answers as you expect — an anonymous request to ' + 'a session-only endpoint returns 401 rather than data.', }, + { + id: 'ui-interaction-config-family-retired', + surface: + 'ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig ' + + '/ ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig ' + + '(the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ' + + 'ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)', + replacement: + '(removed — there is no replacement key, because there was never a key. Touch targets, ' + + 'drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN ' + + 'behaviour: the component library decides them, not a per-page metadata author. ' + + 'Offline is a platform capability, and its vocabulary belongs on the sync engine that ' + + 'owns the queue, the conflict policy and the cache — none of which exists yet. Delete ' + + 'the import and the value. Whichever of these earns real product pull returns WITH its ' + + 'own vocabulary and its executor, the #4910 way, not by un-retiring a declaration)', + reason: + 'Five `@objectstack/spec/ui` modules declared a full interaction-configuration ' + + 'vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, ' + + 'focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol ' + + 'carried them. This is the ADR-0049 false-compliance shape in its most inviting form ' + + 'for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: ' + + '`authorable-surface.json` listed 109 keys under these defs and ' + + '`content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them ' + + 'as authoring tables, so the published documentation advertised a vocabulary with no ' + + 'carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a ' + + 'page component was rejected by `PageComponentSchema` for an unrecognized key — the ' + + 'docs and the schema disagreeing about the platform (Prime Directive #10). Three ' + + 'independent measurements, each with its controls passing in the same run: (1) no ' + + 'module under `packages/spec/src` imported any of the five except the `ui/index.ts` ' + + 'barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph ' + + 'from all 24 metadata-type roots plus `defineStack`\'s `ObjectStackSchema` (25 roots, ' + + '4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, ' + + '`WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic ' + + 'carrier flipped all 21 — so unreachability was a fact about the graph, not a broken ' + + 'walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud ' + + 'outside these modules\' own unit tests. objectui holds TYPE re-exports and parity ' + + 'ratchets, never validators, and says so (#2561). The 2026-08-04 ruling weighed ' + + 'wiring a carrier key (option B) and rejected it: that is a feature with a renderer ' + + 'behind it, not ledger clean-up. It also weighed tightening the shapes to ' + + '`strictObject` and rejected that explicitly — strictness is a property of a PARSE and ' + + 'there is no parse, so it would spend a breaking change to leave "a precisely ' + + 'validated dead slot, the more convincing lie" (#4583). Because there was no carrier ' + + 'key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 ' + + 'conversion to rewrite: this entry is the D3 record, the same route 3 as #4834 (kernel ' + + 'plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ Not to be confused with ' + + '#5021, which retired the THEME `animation` block — a different file, different defs, ' + + 'and that one did have a carrier key and therefore a tombstone. ADR-0049, #4988.', + acceptanceCriteria: + 'No code imports any of the 64 retired names from `@objectstack/spec` or ' + + '`@objectstack/spec/ui` — `TouchTargetConfig(Schema)`, `GestureType(Schema)`, ' + + '`SwipeDirection(Schema)`, `SwipeGestureConfig(Schema)`, `PinchGestureConfig(Schema)`, ' + + '`LongPressGestureConfig(Schema)`, `GestureConfig(Schema)`, `TouchInteraction(Schema)`, ' + + '`TransitionPreset(Schema)`, `EasingFunction(Schema)`, `TransitionConfig(Schema)`, ' + + '`AnimationTrigger(Schema)`, `ComponentAnimation(Schema)`, `PageTransition(Schema)`, ' + + '`MotionConfig(Schema)`, `DragHandle(Schema)`, `DropEffect(Schema)`, ' + + '`DragConstraint(Schema)`, `DropZone(Schema)`, `DragItem(Schema)`, `DndConfig(Schema)`, ' + + '`FocusTrapConfig(Schema)`, `KeyboardShortcut(Schema)`, `FocusManagement(Schema)`, ' + + '`KeyboardNavigationConfig(Schema)`, `OfflineStrategy(Schema)`, ' + + '`ConflictResolution(Schema)`, `SyncConfig(Schema)`, `PersistStorage(Schema)`, ' + + '`EvictionPolicy(Schema)`, `OfflineCacheConfig(Schema)`, `OfflineConfig(Schema)` — ' + + 'every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol ' + + 'identity in `ui/interaction-config-retirement.test.ts`). No metadata document needs ' + + 'editing, because none could ever carry one of these blocks: a stack that parsed ' + + 'before parses byte-for-byte the same after. If you consumed the bare ' + + '`ConflictResolution` from `@objectstack/spec/ui` as a TYPE for your own offline code, ' + + 'declare that union locally — it is your client\'s policy, not the platform\'s. ' + + '`@objectstack/spec/integration`\'s `ConnectorConflictResolution` (connector sync) and ' + + '`@objectstack/spec/api`\'s `ConflictResolutionStrategy` (route merge policy) are ' + + 'different concepts and are untouched.', + }, { id: 'ui-notification-action-embed-config-retired', surface: 'ui.notificationAction / ui.embedConfig', diff --git a/packages/spec/src/ui/animation.test.ts b/packages/spec/src/ui/animation.test.ts deleted file mode 100644 index 23a4c60a85..0000000000 --- a/packages/spec/src/ui/animation.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - TransitionPresetSchema, - EasingFunctionSchema, - TransitionConfigSchema, - AnimationTriggerSchema, - ComponentAnimationSchema, - PageTransitionSchema, - MotionConfigSchema, - type TransitionPreset, - type EasingFunction, - type TransitionConfig, - type AnimationTrigger, - type ComponentAnimation, - type PageTransition, - type MotionConfig, -} from './animation.zod'; - -describe('TransitionPresetSchema', () => { - it('should accept all valid presets', () => { - const presets = ['fade', 'slide_up', 'slide_down', 'slide_left', 'slide_right', 'scale', 'rotate', 'flip', 'none'] as const; - presets.forEach(preset => { - expect(() => TransitionPresetSchema.parse(preset)).not.toThrow(); - }); - }); - - it('should reject invalid presets', () => { - expect(() => TransitionPresetSchema.parse('dissolve')).toThrow(); - expect(() => TransitionPresetSchema.parse('')).toThrow(); - }); -}); - -describe('EasingFunctionSchema', () => { - it('should accept all valid easing functions', () => { - const easings = ['linear', 'ease', 'ease_in', 'ease_out', 'ease_in_out', 'spring'] as const; - easings.forEach(easing => { - expect(() => EasingFunctionSchema.parse(easing)).not.toThrow(); - }); - }); - - it('should reject invalid easing functions', () => { - expect(() => EasingFunctionSchema.parse('bounce')).toThrow(); - expect(() => EasingFunctionSchema.parse('')).toThrow(); - }); -}); - -describe('TransitionConfigSchema', () => { - it('should accept empty config', () => { - const result = TransitionConfigSchema.parse({}); - expect(result).toEqual({}); - }); - - it('should accept full config with all fields', () => { - const config: TransitionConfig = { - preset: 'fade', - duration: 200, - easing: 'ease_in_out', - delay: 50, - customKeyframes: 'bounce-in', - }; - const result = TransitionConfigSchema.parse(config); - expect(result.preset).toBe('fade'); - expect(result.duration).toBe(200); - expect(result.easing).toBe('ease_in_out'); - expect(result.delay).toBe(50); - expect(result.customKeyframes).toBe('bounce-in'); - }); - - it('should leave optional fields undefined when not provided', () => { - const result = TransitionConfigSchema.parse({ duration: 100 }); - expect(result.duration).toBe(100); - expect(result.preset).toBeUndefined(); - expect(result.easing).toBeUndefined(); - expect(result.delay).toBeUndefined(); - expect(result.customKeyframes).toBeUndefined(); - }); -}); - -describe('AnimationTriggerSchema', () => { - it('should accept all valid triggers', () => { - const triggers = ['on_mount', 'on_unmount', 'on_hover', 'on_focus', 'on_click', 'on_scroll', 'on_visible'] as const; - triggers.forEach(trigger => { - expect(() => AnimationTriggerSchema.parse(trigger)).not.toThrow(); - }); - }); - - it('should reject invalid triggers', () => { - expect(() => AnimationTriggerSchema.parse('on_drag')).toThrow(); - expect(() => AnimationTriggerSchema.parse('')).toThrow(); - }); -}); - -describe('ComponentAnimationSchema', () => { - it('should apply default reducedMotion for empty config', () => { - const result = ComponentAnimationSchema.parse({}); - expect(result.reducedMotion).toBe('respect'); - }); - - it('should accept full config with enter/exit/hover/trigger/reducedMotion', () => { - const config: ComponentAnimation = { - enter: { preset: 'slide_up', duration: 300, easing: 'ease_out' }, - exit: { preset: 'fade', duration: 200 }, - hover: { preset: 'scale', duration: 150 }, - trigger: 'on_visible', - reducedMotion: 'alternative', - }; - const result = ComponentAnimationSchema.parse(config); - expect(result.enter?.preset).toBe('slide_up'); - expect(result.exit?.preset).toBe('fade'); - expect(result.hover?.preset).toBe('scale'); - expect(result.trigger).toBe('on_visible'); - expect(result.reducedMotion).toBe('alternative'); - }); - - it('should accept disable for reducedMotion', () => { - const result = ComponentAnimationSchema.parse({ reducedMotion: 'disable' }); - expect(result.reducedMotion).toBe('disable'); - }); -}); - -describe('PageTransitionSchema', () => { - it('should apply defaults for empty config', () => { - const result = PageTransitionSchema.parse({}); - expect(result.type).toBe('fade'); - expect(result.duration).toBe(300); - expect(result.easing).toBe('ease_in_out'); - expect(result.crossFade).toBe(false); - }); - - it('should accept full config overriding defaults', () => { - const config: PageTransition = { - type: 'slide_left', - duration: 500, - easing: 'spring', - crossFade: true, - }; - const result = PageTransitionSchema.parse(config); - expect(result.type).toBe('slide_left'); - expect(result.duration).toBe(500); - expect(result.easing).toBe('spring'); - expect(result.crossFade).toBe(true); - }); -}); - -describe('MotionConfigSchema', () => { - it('should apply defaults for empty config', () => { - const result = MotionConfigSchema.parse({}); - expect(result.enabled).toBe(true); - expect(result.reducedMotion).toBe(false); - }); - - it('should accept full config with componentAnimations record', () => { - const config: MotionConfig = { - defaultTransition: { preset: 'fade', duration: 250, easing: 'ease' }, - pageTransitions: { type: 'slide_right', duration: 400, easing: 'ease_in_out', crossFade: false }, - componentAnimations: { - card: { enter: { preset: 'scale', duration: 200 }, reducedMotion: 'respect' }, - modal: { enter: { preset: 'slide_up' }, exit: { preset: 'fade' }, reducedMotion: 'disable' }, - }, - reducedMotion: true, - enabled: false, - }; - const result = MotionConfigSchema.parse(config); - expect(result.defaultTransition?.preset).toBe('fade'); - expect(result.pageTransitions?.type).toBe('slide_right'); - expect(result.componentAnimations?.card.enter?.preset).toBe('scale'); - expect(result.componentAnimations?.modal.reducedMotion).toBe('disable'); - expect(result.reducedMotion).toBe(true); - expect(result.enabled).toBe(false); - }); - - it('should leave optional fields undefined when not provided', () => { - const result = MotionConfigSchema.parse({}); - expect(result.defaultTransition).toBeUndefined(); - expect(result.pageTransitions).toBeUndefined(); - expect(result.componentAnimations).toBeUndefined(); - }); -}); - -describe('Type exports', () => { - it('should have valid type exports', () => { - const preset: TransitionPreset = 'fade'; - const easing: EasingFunction = 'linear'; - const transition: TransitionConfig = {}; - const trigger: AnimationTrigger = 'on_mount'; - const component: ComponentAnimation = { reducedMotion: 'respect' }; - const page: PageTransition = { type: 'fade', duration: 300, easing: 'ease_in_out', crossFade: false }; - const motion: MotionConfig = { reducedMotion: false, enabled: true }; - expect(preset).toBeDefined(); - expect(easing).toBeDefined(); - expect(transition).toBeDefined(); - expect(trigger).toBeDefined(); - expect(component).toBeDefined(); - expect(page).toBeDefined(); - expect(motion).toBeDefined(); - }); -}); - -describe('I18n and ARIA integration', () => { - it('should reject I18n label on ComponentAnimationSchema', () => { - expect(() => ComponentAnimationSchema.parse({ - label: { key: 'animations.card_enter', defaultValue: 'Card Enter' }, - })).toThrow(); - }); - - it('should accept plain string label on ComponentAnimationSchema', () => { - const result = ComponentAnimationSchema.parse({ label: 'Slide In' }); - expect(result.label).toBe('Slide In'); - }); - - it('should accept ARIA props on ComponentAnimationSchema', () => { - const result = ComponentAnimationSchema.parse({ - ariaLabel: 'Animated card', - ariaDescribedBy: 'card-desc', - role: 'presentation', - }); - expect(result.ariaLabel).toBe('Animated card'); - expect(result.ariaDescribedBy).toBe('card-desc'); - expect(result.role).toBe('presentation'); - }); - - it('should reject I18n label on MotionConfigSchema', () => { - expect(() => MotionConfigSchema.parse({ - label: { key: 'motion.global', defaultValue: 'Global Motion Config' }, - })).toThrow(); - }); - - it('should leave I18n/ARIA fields undefined when not provided', () => { - const result = ComponentAnimationSchema.parse({}); - expect(result.label).toBeUndefined(); - expect(result.ariaLabel).toBeUndefined(); - expect(result.ariaDescribedBy).toBeUndefined(); - expect(result.role).toBeUndefined(); - }); -}); - -// ============================================================================ -// Issue #6: TransitionConfigSchema themeToken support -// ============================================================================ -describe('TransitionConfigSchema - themeToken', () => { - it('should accept transition with themeToken reference', () => { - const result = TransitionConfigSchema.parse({ - themeToken: 'animation.duration.fast', - }); - expect(result.themeToken).toBe('animation.duration.fast'); - }); - - it('should accept transition combining themeToken with explicit values', () => { - const result = TransitionConfigSchema.parse({ - preset: 'fade', - duration: 200, - easing: 'ease_in_out', - themeToken: 'animation.timing.ease_in_out', - }); - expect(result.themeToken).toBe('animation.timing.ease_in_out'); - expect(result.duration).toBe(200); - }); - - it('should leave themeToken undefined when not provided', () => { - const result = TransitionConfigSchema.parse({ duration: 300 }); - expect(result.themeToken).toBeUndefined(); - }); -}); - -// --------------------------------------------------------------------------- -// #4001 batch 13 -- THIS FILE IS DELIBERATELY NOT `.strict()`, on a measurement. -// -// The strictness ledger scheduled these 4 sites as `authorable (p)`. Resolving -// the `(p)` found no authoring door at all: nothing under `packages/spec/src` -// imports this module except the `ui/index.ts` barrel, a BFS from all 24 -// metadata-type roots plus `defineStack`'s `ObjectStackSchema` never reaches -// these schemas (`PageSchema` / `WebhookSchema` / `StateMachineSchema` pass as -// positive controls in the same run), and no `.parse()` on any of them exists -// in `objectstack`, `objectui` or the example apps outside this test file. -// `.strict()` is a property of a PARSE, and there is no parse to gate. -// -// So the strip pinned below is not an unfinished row -- it is the recorded -// verdict. The open question is ADR-0049 enforce-or-remove, filed as #4988. -// These assertions exist so the next sweep stops and reads instead of reaching -// for `strictObject` and shipping a precisely-validated dead slot (#4583). The -// header comment in `animation.zod.ts` and this file's ledger row carry the same verdict. -// --------------------------------------------------------------------------- -describe('unknown-key posture is an open question, not an omission (#4001 batch 13 -> #4988)', () => { - it('TransitionConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = TransitionConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - it('ComponentAnimationSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = ComponentAnimationSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - it('PageTransitionSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = PageTransitionSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - it('MotionConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = MotionConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - // The standing half of measurement 1, so the verdict cannot go stale in - // silence: the day someone gives this vocabulary a carrier they will add an - // import, and this is where they are told to revisit #4988 and the ledger. - it('is still imported by nothing but the ui/ barrel', async () => { - const fs = await import('node:fs'); - const path = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); - const importers: string[] = []; - const walk = (dir: string) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') - && full !== path.join(root, 'ui', 'animation.zod.ts')) { - if (/(?:import|export)[^;]*['"][^'"]*\/animation\.zod['"]/.test(fs.readFileSync(full, 'utf-8'))) { - importers.push(path.relative(root, full)); - } - } - } - }; - walk(root); - expect(importers, 'a new importer means this vocabulary got a carrier -- re-read #4988') - .toEqual(['ui/index.ts']); - }); -}); diff --git a/packages/spec/src/ui/animation.zod.ts b/packages/spec/src/ui/animation.zod.ts deleted file mode 100644 index a1424cd378..0000000000 --- a/packages/spec/src/ui/animation.zod.ts +++ /dev/null @@ -1,162 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; -import { lazySchema } from '../shared/lazy-schema'; - -// --------------------------------------------------------------------------- -// NOT CLOSED AGAINST UNKNOWN KEYS -- AND THAT IS THE MEASURED VERDICT -// (#4001 batch 13 / 批 13, ADR-0078). Read this before "finishing" the file. -// -// The strictness ledger scheduled this file's 4 object sites as `authorable -// (p)` -- provisional. #4001's own rule is verify-before-tightening, and here -// the verification came back NEGATIVE: no metadata document is ever parsed -// against these shapes, because nothing in the protocol carries them. -// -// Three independent measurements, 2026-08-03: -// -// 1. STATIC -- nothing under `packages/spec/src` imports this module except -// the `ui/index.ts` barrel. No schema anywhere declares a `component.animation / app.motion` -// slot, so there is no key an author can write to reach these shapes. -// 2. GRAPH -- a BFS over this build's in-memory Zod graph from all 24 -// metadata-type roots (`listMetadataTypeSchemaTypes`) plus -// `ObjectStackSchema` (`defineStack`) -- the closure `build-schemas.ts` -// uses for the #4650 deletion check -- reaches none of them. Its three -// positive controls resolve `root-graph` in the same run: `PageSchema`, -// `WebhookSchema` (batch 11's `defineStack({ webhooks })` door) and -// `StateMachineSchema` (batch 10's `agent.lifecycle` door). So -// "unreachable" is a fact about the graph, not a broken instrument. -// 3. CALL SITES -- no `.parse()` / `.safeParse()` on any schema here exists -// in `objectstack`, `objectui` or the example apps, outside this file's -// own unit test. objectui re-exports the inferred TYPES only and says so -// (`@object-ui/types`, the #2561 note: the validators are deliberately -// NOT re-exported). -// -// `.strict()` would therefore gate nothing -- strictness is a property of a -// PARSE, and there is no parse. Adding it would spend a v17 breaking change to -// make this file LOOK finished, and leave behind the artefact the ledger -// itself warns about: "a *precisely validated* dead slot is the more -// convincing lie" (#4583). The real question is ADR-0049 enforce-or-remove -- -// retire this vocabulary or give it a carrier -- filed as #4988, with the same -// verdict recorded in this file's ledger row. -// -// DO NOT convert these sites to `strictObject` before #4988 is decided: a -// strict shape reads as load-bearing and makes the retirement harder, which is -// the opposite of what the measurement asks for. -// --------------------------------------------------------------------------- - -/** - * Transition Preset Schema - * Common animation transition presets. - */ -export const TransitionPresetSchema = lazySchema(() => z.enum([ - 'fade', - 'slide_up', - 'slide_down', - 'slide_left', - 'slide_right', - 'scale', - 'rotate', - 'flip', - 'none', -]).describe('Transition preset type')); - -export type TransitionPreset = z.infer; - -/** - * Easing Function Schema - * Supported animation easing/timing functions. - */ -export const EasingFunctionSchema = lazySchema(() => z.enum([ - 'linear', - 'ease', - 'ease_in', - 'ease_out', - 'ease_in_out', - 'spring', -]).describe('Animation easing function')); - -export type EasingFunction = z.infer; - -/** - * Transition Configuration Schema - * Defines a single animation transition with timing and easing options. - */ -export const TransitionConfigSchema = lazySchema(() => z.object({ - preset: TransitionPresetSchema.optional().describe('Transition preset to apply'), - duration: z.number().optional().describe('Transition duration in milliseconds'), - easing: EasingFunctionSchema.optional().describe('Easing function for the transition'), - delay: z.number().optional().describe('Delay before transition starts in milliseconds'), - customKeyframes: z.string().optional().describe('CSS @keyframes name for custom animations'), - themeToken: z.string().optional().describe('Reference to a theme animation token (e.g. "animation.duration.fast")'), -}).describe('Animation transition configuration')); - -export type TransitionConfig = z.infer; - -/** - * Animation Trigger Schema - * Events that can trigger an animation. - */ -export const AnimationTriggerSchema = lazySchema(() => z.enum([ - 'on_mount', - 'on_unmount', - 'on_hover', - 'on_focus', - 'on_click', - 'on_scroll', - 'on_visible', -]).describe('Event that triggers the animation')); - -export type AnimationTrigger = z.infer; - -/** - * Component Animation Schema - * Animation configuration for an individual UI component. - */ -export const ComponentAnimationSchema = lazySchema(() => z.object({ - label: I18nLabelSchema.optional().describe('Descriptive label for this animation configuration'), - enter: TransitionConfigSchema.optional().describe('Enter/mount animation'), - exit: TransitionConfigSchema.optional().describe('Exit/unmount animation'), - hover: TransitionConfigSchema.optional().describe('Hover state animation'), - trigger: AnimationTriggerSchema.optional().describe('When to trigger the animation'), - reducedMotion: z.enum(['respect', 'disable', 'alternative']).default('respect') - .describe('Accessibility: how to handle prefers-reduced-motion'), -}).merge(AriaPropsSchema.partial()) - // `.strip()` is LOAD-BEARING (#4001 批 16): `AriaPropsSchema` became `strictObject` and - // `.merge()` adopts the incoming schema's unknown-key posture, so without this the shape - // would silently become `.strict()` — with zod's generic message, not the campaign's — and - // would contradict this file's measured `no door` verdict (#4988: nothing parses it, so a - // strict shell enforces nothing). Keep it until #4988 says what happens to this file. - .strip() - .describe('Component-level animation configuration')); - -export type ComponentAnimation = z.infer; - -/** - * Page Transition Schema - * Defines the animation used when navigating between pages. - */ -export const PageTransitionSchema = lazySchema(() => z.object({ - type: TransitionPresetSchema.default('fade').describe('Page transition type'), - duration: z.number().default(300).describe('Transition duration in milliseconds'), - easing: EasingFunctionSchema.default('ease_in_out').describe('Easing function for the transition'), - crossFade: z.boolean().default(false).describe('Whether to cross-fade between pages'), -}).describe('Page-level transition configuration')); - -export type PageTransition = z.infer; - -/** - * Motion Configuration Schema - * Top-level animation and motion design configuration. - */ -export const MotionConfigSchema = lazySchema(() => z.object({ - label: I18nLabelSchema.optional().describe('Descriptive label for the motion configuration'), - defaultTransition: TransitionConfigSchema.optional().describe('Default transition applied to all animations'), - pageTransitions: PageTransitionSchema.optional().describe('Page navigation transition settings'), - componentAnimations: z.record(z.string(), ComponentAnimationSchema).optional() - .describe('Component name to animation configuration mapping'), - reducedMotion: z.boolean().default(false).describe('When true, respect prefers-reduced-motion and suppress animations globally'), - enabled: z.boolean().default(true).describe('Enable or disable all animations globally'), -}).describe('Top-level motion and animation design configuration')); - -export type MotionConfig = z.infer; diff --git a/packages/spec/src/ui/chart.zod.ts b/packages/spec/src/ui/chart.zod.ts index d97843bace..484ff9991d 100644 --- a/packages/spec/src/ui/chart.zod.ts +++ b/packages/spec/src/ui/chart.zod.ts @@ -30,7 +30,10 @@ import { strictObject } from '../shared/strict-object'; // `root-graph`. Controls in the same run: `PageSchema` / // `DashboardSchema` / `ReportSchema` / `WebhookSchema` / // `StateMachineSchema` resolve; 批 13's measured no-door shapes -// (`TouchTargetConfigSchema`, `GestureConfigSchema`) do not. +// (`TouchTargetConfigSchema`, `GestureConfigSchema`) did not. (Those two +// negative controls are gone as of #4988, which retired the five no-door +// interaction modules outright; a re-run needs a fresh negative control — +// an inline `z.object({ a: z.string() })` is the cheapest one.) // 3. PARSE — `getMetadataTypeSchema('dashboard' | 'report')` is what // `MetadataManager.validate`, `GET /api/v1/meta` and the Studio form all // go through, so a chart key is judged on the stored-metadata path. diff --git a/packages/spec/src/ui/dnd.test.ts b/packages/spec/src/ui/dnd.test.ts deleted file mode 100644 index 4b815583ab..0000000000 --- a/packages/spec/src/ui/dnd.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - DragHandleSchema, - DropEffectSchema, - DragConstraintSchema, - DropZoneSchema, - DragItemSchema, - DndConfigSchema, - type DragHandle, - type DropEffect, - type DragConstraint, - type DropZone, - type DragItem, - type DndConfig, -} from './dnd.zod'; - -describe('DragHandleSchema', () => { - it('should accept all valid drag handle values', () => { - const handles = ['element', 'handle', 'grip_icon'] as const; - handles.forEach(handle => { - expect(() => DragHandleSchema.parse(handle)).not.toThrow(); - }); - }); - - it('should reject invalid drag handle values', () => { - expect(() => DragHandleSchema.parse('button')).toThrow(); - expect(() => DragHandleSchema.parse('')).toThrow(); - }); -}); - -describe('DropEffectSchema', () => { - it('should accept all valid drop effect values', () => { - const effects = ['move', 'copy', 'link', 'none'] as const; - effects.forEach(effect => { - expect(() => DropEffectSchema.parse(effect)).not.toThrow(); - }); - }); - - it('should reject invalid drop effect values', () => { - expect(() => DropEffectSchema.parse('delete')).toThrow(); - expect(() => DropEffectSchema.parse('')).toThrow(); - }); -}); - -describe('DragConstraintSchema', () => { - it('should apply defaults for empty config', () => { - const result = DragConstraintSchema.parse({}); - expect(result.axis).toBe('both'); - expect(result.bounds).toBe('none'); - }); - - it('should accept grid tuple', () => { - const result = DragConstraintSchema.parse({ grid: [10, 10] }); - expect(result.grid).toEqual([10, 10]); - }); - - it('should accept all valid axis values', () => { - const axes = ['x', 'y', 'both'] as const; - axes.forEach(axis => { - expect(() => DragConstraintSchema.parse({ axis })).not.toThrow(); - }); - }); - - it('should reject invalid axis value', () => { - expect(() => DragConstraintSchema.parse({ axis: 'z' })).toThrow(); - }); - - it('should accept all valid bounds values', () => { - const bounds = ['parent', 'viewport', 'none'] as const; - bounds.forEach(b => { - expect(() => DragConstraintSchema.parse({ bounds: b })).not.toThrow(); - }); - }); -}); - -describe('DropZoneSchema', () => { - it('should accept valid config with accept array', () => { - const config: DropZone = { accept: ['card', 'item'], highlightOnDragOver: true, dropEffect: 'move' }; - const result = DropZoneSchema.parse(config); - expect(result.accept).toEqual(['card', 'item']); - }); - - it('should reject missing accept', () => { - expect(() => DropZoneSchema.parse({})).toThrow(); - }); - - it('should apply defaults for optional fields', () => { - const result = DropZoneSchema.parse({ accept: ['task'] }); - expect(result.highlightOnDragOver).toBe(true); - expect(result.dropEffect).toBe('move'); - }); - - it('should accept maxItems', () => { - const result = DropZoneSchema.parse({ accept: ['card'], maxItems: 5 }); - expect(result.maxItems).toBe(5); - }); -}); - -describe('DragItemSchema', () => { - it('should accept valid config with type', () => { - const result = DragItemSchema.parse({ type: 'card' }); - expect(result.type).toBe('card'); - }); - - it('should apply defaults for handle, preview, and disabled', () => { - const result = DragItemSchema.parse({ type: 'task' }); - expect(result.handle).toBe('element'); - expect(result.preview).toBe('element'); - expect(result.disabled).toBe(false); - }); - - it('should reject missing type', () => { - expect(() => DragItemSchema.parse({})).toThrow(); - }); - - it('should accept constraint configuration', () => { - const result = DragItemSchema.parse({ - type: 'widget', - constraint: { axis: 'x', bounds: 'parent', grid: [20, 20] }, - }); - expect(result.constraint?.axis).toBe('x'); - expect(result.constraint?.bounds).toBe('parent'); - expect(result.constraint?.grid).toEqual([20, 20]); - }); - - it('should accept custom preview', () => { - const result = DragItemSchema.parse({ type: 'item', preview: 'custom' }); - expect(result.preview).toBe('custom'); - }); -}); - -describe('DndConfigSchema', () => { - it('should accept empty config with defaults', () => { - const result = DndConfigSchema.parse({}); - expect(result.enabled).toBe(false); - expect(result.sortable).toBe(false); - expect(result.autoScroll).toBe(true); - expect(result.touchDelay).toBe(200); - }); - - it('should accept full config with dragItem and dropZone', () => { - const config: DndConfig = { - enabled: true, - dragItem: { type: 'card', handle: 'handle', preview: 'custom', disabled: false }, - dropZone: { accept: ['card'], maxItems: 10, highlightOnDragOver: true, dropEffect: 'copy' }, - sortable: true, - autoScroll: false, - touchDelay: 300, - }; - const result = DndConfigSchema.parse(config); - expect(result.enabled).toBe(true); - expect(result.dragItem?.type).toBe('card'); - expect(result.dropZone?.accept).toEqual(['card']); - expect(result.sortable).toBe(true); - expect(result.autoScroll).toBe(false); - expect(result.touchDelay).toBe(300); - }); - - it('should leave dragItem and dropZone undefined when not provided', () => { - const result = DndConfigSchema.parse({}); - expect(result.dragItem).toBeUndefined(); - expect(result.dropZone).toBeUndefined(); - }); - - it('should accept config with only dragItem', () => { - const result = DndConfigSchema.parse({ - enabled: true, - dragItem: { type: 'row' }, - }); - expect(result.dragItem?.type).toBe('row'); - expect(result.dropZone).toBeUndefined(); - }); -}); - -describe('Type exports', () => { - it('should have valid type exports', () => { - const handle: DragHandle = 'grip_icon'; - const effect: DropEffect = 'copy'; - const constraint: DragConstraint = { axis: 'both', bounds: 'none' }; - const zone: DropZone = { accept: ['card'], highlightOnDragOver: true, dropEffect: 'move' }; - const item: DragItem = { type: 'card', handle: 'element', preview: 'element', disabled: false }; - const config: DndConfig = { enabled: false, sortable: false, autoScroll: true, touchDelay: 200 }; - expect(handle).toBeDefined(); - expect(effect).toBeDefined(); - expect(constraint).toBeDefined(); - expect(zone).toBeDefined(); - expect(item).toBeDefined(); - expect(config).toBeDefined(); - }); -}); - -describe('I18n and ARIA integration', () => { - it('should reject I18n label on DropZoneSchema', () => { - expect(() => DropZoneSchema.parse({ - accept: ['card'], - label: { key: 'dnd.drop_zone', defaultValue: 'Drop items here' }, - })).toThrow(); - }); - - it('should accept ARIA props on DropZoneSchema', () => { - const result = DropZoneSchema.parse({ - accept: ['task'], - ariaLabel: 'Task drop zone', - role: 'region', - }); - expect(result.ariaLabel).toBe('Task drop zone'); - expect(result.role).toBe('region'); - }); - - it('should accept I18n label on DragItemSchema', () => { - const result = DragItemSchema.parse({ - type: 'card', - label: 'Drag this card', - }); - expect(result.label).toBe('Drag this card'); - }); - - it('should reject ARIA props on DragItemSchema', () => { - expect(() => DragItemSchema.parse({ - type: 'row', - ariaLabel: { key: 'dnd.drag_row', defaultValue: 'Draggable row' }, - ariaDescribedBy: 'row-desc', - })).toThrow(); - }); - - it('should leave I18n/ARIA fields undefined when not provided', () => { - const zone = DropZoneSchema.parse({ accept: ['item'] }); - expect(zone.label).toBeUndefined(); - expect(zone.ariaLabel).toBeUndefined(); - const item = DragItemSchema.parse({ type: 'card' }); - expect(item.label).toBeUndefined(); - expect(item.ariaLabel).toBeUndefined(); - }); -}); - -// --------------------------------------------------------------------------- -// #4001 batch 13 -- THIS FILE IS DELIBERATELY NOT `.strict()`, on a measurement. -// -// The strictness ledger scheduled these 4 sites as `authorable (p)`. Resolving -// the `(p)` found no authoring door at all: nothing under `packages/spec/src` -// imports this module except the `ui/index.ts` barrel, a BFS from all 24 -// metadata-type roots plus `defineStack`'s `ObjectStackSchema` never reaches -// these schemas (`PageSchema` / `WebhookSchema` / `StateMachineSchema` pass as -// positive controls in the same run), and no `.parse()` on any of them exists -// in `objectstack`, `objectui` or the example apps outside this test file. -// `.strict()` is a property of a PARSE, and there is no parse to gate. -// -// So the strip pinned below is not an unfinished row -- it is the recorded -// verdict. The open question is ADR-0049 enforce-or-remove, filed as #4988. -// These assertions exist so the next sweep stops and reads instead of reaching -// for `strictObject` and shipping a precisely-validated dead slot (#4583). The -// header comment in `dnd.zod.ts` and this file's ledger row carry the same verdict. -// --------------------------------------------------------------------------- -describe('unknown-key posture is an open question, not an omission (#4001 batch 13 -> #4988)', () => { - it('DragConstraintSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = DragConstraintSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - it('DropZoneSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = DropZoneSchema.parse({ accept: ['card'], aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - it('DragItemSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = DragItemSchema.parse({ type: 'card', aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - it('DndConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = DndConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - // The standing half of measurement 1, so the verdict cannot go stale in - // silence: the day someone gives this vocabulary a carrier they will add an - // import, and this is where they are told to revisit #4988 and the ledger. - it('is still imported by nothing but the ui/ barrel', async () => { - const fs = await import('node:fs'); - const path = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); - const importers: string[] = []; - const walk = (dir: string) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') - && full !== path.join(root, 'ui', 'dnd.zod.ts')) { - if (/(?:import|export)[^;]*['"][^'"]*\/dnd\.zod['"]/.test(fs.readFileSync(full, 'utf-8'))) { - importers.push(path.relative(root, full)); - } - } - } - }; - walk(root); - expect(importers, 'a new importer means this vocabulary got a carrier -- re-read #4988') - .toEqual(['ui/index.ts']); - }); -}); diff --git a/packages/spec/src/ui/dnd.zod.ts b/packages/spec/src/ui/dnd.zod.ts deleted file mode 100644 index eddd86f0db..0000000000 --- a/packages/spec/src/ui/dnd.zod.ts +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; -import { lazySchema } from '../shared/lazy-schema'; - -// --------------------------------------------------------------------------- -// NOT CLOSED AGAINST UNKNOWN KEYS -- AND THAT IS THE MEASURED VERDICT -// (#4001 batch 13 / 批 13, ADR-0078). Read this before "finishing" the file. -// -// The strictness ledger scheduled this file's 4 object sites as `authorable -// (p)` -- provisional. #4001's own rule is verify-before-tightening, and here -// the verification came back NEGATIVE: no metadata document is ever parsed -// against these shapes, because nothing in the protocol carries them. -// -// Three independent measurements, 2026-08-03: -// -// 1. STATIC -- nothing under `packages/spec/src` imports this module except -// the `ui/index.ts` barrel. No schema anywhere declares a `component.dnd / view.dnd` -// slot, so there is no key an author can write to reach these shapes. -// 2. GRAPH -- a BFS over this build's in-memory Zod graph from all 24 -// metadata-type roots (`listMetadataTypeSchemaTypes`) plus -// `ObjectStackSchema` (`defineStack`) -- the closure `build-schemas.ts` -// uses for the #4650 deletion check -- reaches none of them. Its three -// positive controls resolve `root-graph` in the same run: `PageSchema`, -// `WebhookSchema` (batch 11's `defineStack({ webhooks })` door) and -// `StateMachineSchema` (batch 10's `agent.lifecycle` door). So -// "unreachable" is a fact about the graph, not a broken instrument. -// 3. CALL SITES -- no `.parse()` / `.safeParse()` on any schema here exists -// in `objectstack`, `objectui` or the example apps, outside this file's -// own unit test. objectui re-exports the inferred TYPES only and says so -// (`@object-ui/types`, the #2561 note: the validators are deliberately -// NOT re-exported). -// -// `.strict()` would therefore gate nothing -- strictness is a property of a -// PARSE, and there is no parse. Adding it would spend a v17 breaking change to -// make this file LOOK finished, and leave behind the artefact the ledger -// itself warns about: "a *precisely validated* dead slot is the more -// convincing lie" (#4583). The real question is ADR-0049 enforce-or-remove -- -// retire this vocabulary or give it a carrier -- filed as #4988, with the same -// verdict recorded in this file's ledger row. -// -// DO NOT convert these sites to `strictObject` before #4988 is decided: a -// strict shape reads as load-bearing and makes the retirement harder, which is -// the opposite of what the measurement asks for. -// --------------------------------------------------------------------------- - -/** - * Drag Handle Schema - * Defines how a drag interaction is initiated on an element. - */ -export const DragHandleSchema = lazySchema(() => z.enum([ - 'element', - 'handle', - 'grip_icon', -]).describe('Drag initiation method')); - -export type DragHandle = z.infer; - -/** - * Drop Effect Schema - * Visual feedback indicating the result of a drop operation. - */ -export const DropEffectSchema = lazySchema(() => z.enum([ - 'move', - 'copy', - 'link', - 'none', -]).describe('Drop operation effect')); - -export type DropEffect = z.infer; - -/** - * Drag Constraint Schema - * Constrains drag movement along axes, within bounds, or to a grid. - */ -export const DragConstraintSchema = lazySchema(() => z.object({ - axis: z.enum(['x', 'y', 'both']).default('both').describe('Constrain drag axis'), - bounds: z.enum(['parent', 'viewport', 'none']).default('none').describe('Constrain within bounds'), - grid: z.tuple([z.number(), z.number()]).optional().describe('Snap to grid [x, y] in pixels'), -}).describe('Drag movement constraints')); - -export type DragConstraint = z.infer; - -/** - * Drop Zone Schema - * Configures a container that accepts dragged items. - */ -export const DropZoneSchema = lazySchema(() => z.object({ - label: I18nLabelSchema.optional().describe('Accessible label for the drop zone'), - accept: z.array(z.string()).describe('Accepted drag item types'), - maxItems: z.number().optional().describe('Maximum items allowed in drop zone'), - highlightOnDragOver: z.boolean().default(true).describe('Highlight drop zone when dragging over'), - dropEffect: DropEffectSchema.default('move').describe('Visual effect on drop'), -}).merge(AriaPropsSchema.partial()) - // `.strip()` is LOAD-BEARING (#4001 批 16): `AriaPropsSchema` became `strictObject` and - // `.merge()` adopts the incoming schema's unknown-key posture, so without this the shape - // would silently become `.strict()` — with zod's generic message, not the campaign's — and - // would contradict this file's measured `no door` verdict (#4988: nothing parses it, so a - // strict shell enforces nothing). Keep it until #4988 says what happens to this file. - .strip() - .describe('Drop zone configuration')); - -export type DropZone = z.infer; - -/** - * Drag Item Schema - * Configures a draggable element including handle, constraints, and preview. - */ -export const DragItemSchema = lazySchema(() => z.object({ - type: z.string().describe('Drag item type identifier for matching with drop zones'), - label: I18nLabelSchema.optional().describe('Accessible label describing the draggable item'), - handle: DragHandleSchema.default('element').describe('How to initiate drag'), - constraint: DragConstraintSchema.optional().describe('Drag movement constraints'), - preview: z.enum(['element', 'custom', 'none']).default('element').describe('Drag preview type'), - disabled: z.boolean().default(false).describe('Disable dragging'), -}).merge(AriaPropsSchema.partial()) - // `.strip()` is LOAD-BEARING (#4001 批 16): `AriaPropsSchema` became `strictObject` and - // `.merge()` adopts the incoming schema's unknown-key posture, so without this the shape - // would silently become `.strict()` — with zod's generic message, not the campaign's — and - // would contradict this file's measured `no door` verdict (#4988: nothing parses it, so a - // strict shell enforces nothing). Keep it until #4988 says what happens to this file. - .strip() - .describe('Draggable item configuration')); - -export type DragItem = z.infer; - -/** - * Drag and Drop Configuration Schema - * Top-level drag-and-drop interaction configuration for a component. - */ -export const DndConfigSchema = lazySchema(() => z.object({ - enabled: z.boolean().default(false).describe('Enable drag and drop'), - dragItem: DragItemSchema.optional().describe('Configuration for draggable item'), - dropZone: DropZoneSchema.optional().describe('Configuration for drop target'), - sortable: z.boolean().default(false).describe('Enable sortable list behavior'), - autoScroll: z.boolean().default(true).describe('Auto-scroll during drag near edges'), - touchDelay: z.number().default(200).describe('Delay in ms before drag starts on touch devices'), -}).describe('Drag and drop interaction configuration')); - -export type DndConfig = z.infer; diff --git a/packages/spec/src/ui/i18n.test.ts b/packages/spec/src/ui/i18n.test.ts index e591fdb9c7..35ebe0d3a6 100644 --- a/packages/spec/src/ui/i18n.test.ts +++ b/packages/spec/src/ui/i18n.test.ts @@ -17,10 +17,6 @@ import { import { measureDoors } from './door-reachability.testkit'; import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; import { PageSchema } from './page.zod'; -import { ComponentAnimationSchema } from './animation.zod'; -import { DropZoneSchema, DragItemSchema } from './dnd.zod'; -import { KeyboardNavigationConfigSchema } from './keyboard.zod'; -import { TouchInteractionSchema } from './touch.zod'; describe('I18nObjectSchema', () => { it('should accept valid i18n object with key only', () => { @@ -367,17 +363,54 @@ describe('#4001 批 16 — AriaPropsSchema is closed (the door is real)', () => expect(reject(objectuiAria as never, { ariaLabel: 'x', bogus: 1 })).toContain('these ARIA attributes'); }); - it('does NOT ride `.merge()` into the four no-door files — they stay open', () => { - // `X.merge(AriaPropsSchema.partial())` adopts the INCOMING posture, so - // closing this shape silently closed `animation` / `dnd` / `keyboard` / - // `touch` too — with zod's generic message, no changeset, and against - // #4988's measured verdict that nothing parses them. The explicit `.strip()` - // in those four files is what holds this line; this is its pin. - expect(ComponentAnimationSchema.safeParse({ name: 'a', notAnAnimationKey: 1 }).success).toBe(true); - expect(DropZoneSchema.safeParse({ accept: ['card'], notADropZoneKey: 1 }).success).toBe(true); - expect(DragItemSchema.safeParse({ type: 'card', notADraggableKey: 1 }).success).toBe(true); - expect(KeyboardNavigationConfigSchema.safeParse({ notAKeyboardKey: 1 }).success).toBe(true); - expect(TouchInteractionSchema.safeParse({ notATouchKey: 1 }).success).toBe(true); + it('has no `.merge()` riders left at all — the four it had were retired at #4988', async () => { + // REPLACED WHOLESALE at #4988, not re-spelled. This test used to name five + // shapes in `animation` / `dnd` / `keyboard` / `touch` and assert each still + // accepted an undeclared key, because `X.merge(AriaPropsSchema.partial())` + // adopts the INCOMING posture and closing `AriaPropsSchema` would otherwise + // have closed them silently. Those five modules are gone (ADR-0049 + // enforce-or-remove, `interaction-config-retirement.test.ts`), and they were + // the ONLY `.merge(AriaPropsSchema…)` sites in the package. + // + // Simply dropping the five assertions would have left an empty test that + // passes because nothing is produced rather than because the logic holds — + // the PR #5046 trap. So the concern is re-pinned at the level it actually + // lives on: the merge-rider count is zero, and a future rider has to make + // the posture decision explicitly instead of inheriting `strictObject` by + // accident. + const fs = await import('node:fs'); + const path = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const srcRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + + const riders: string[] = []; + let scanned = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.zod.ts')) { + scanned++; + const src = fs.readFileSync(full, 'utf-8'); + if (/\.merge\(\s*AriaPropsSchema/.test(src)) riders.push(path.relative(srcRoot, full)); + } + } + }; + walk(srcRoot); + + // Anti-vacuity, both halves: the walk really visited the package, and the + // matcher really fires — proven against a synthetic source, not by trusting + // a zero. + expect(scanned, 'the walk must have visited the schema files').toBeGreaterThan(100); + expect(/\.merge\(\s*AriaPropsSchema\.partial\(\)\)/.test('X.merge(AriaPropsSchema.partial())')).toBe(true); + + expect(riders, 'a new `.merge(AriaPropsSchema…)` rider inherits strictObject silently — decide the posture in the file').toEqual([]); + + // The other direction stays live: `.optional()` CARRIERS of the shared + // shape are the normal, intended pattern and must not have been swept away + // with the riders. + const pageShape = (PageSchema as never as { shape: Record }).shape; + expect(Object.keys(pageShape)).toContain('aria'); }); }); diff --git a/packages/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts index ef6c555fd0..2ce3d3e26e 100644 --- a/packages/spec/src/ui/index.ts +++ b/packages/spec/src/ui/index.ts @@ -35,19 +35,37 @@ export * from './widget.zod'; export * from './component.zod'; export * from './react-blocks'; export * from './theme.zod'; -export * from './touch.zod'; -export * from './offline.zod'; -export * from './keyboard.zod'; -export * from './animation.zod'; // `notification.zod` still exports the three presentation enums // (`NotificationType` / `NotificationSeverity` / `NotificationPosition`); // `NotificationActionSchema` / `NotificationAction` were REMOVED at #5015 per // ADR-0049 enforce-or-remove — no carrier key, unreachable from every metadata // root, zero parse. See the block in that module. export * from './notification.zod'; -export * from './dnd.zod'; // `sharing.zod` still exports the LIVE `SharingConfigSchema` (carried by // `FormViewSchema.sharing`; `rest-server.ts` gates the anonymous form routes on // it). `EmbedConfigSchema` / `EmbedConfig` were REMOVED at #5015 per ADR-0049 — // one file, two verdicts. See the block in that module. export * from './sharing.zod'; + +// --------------------------------------------------------------------------- +// RETIRED in v17 (#4988, ADR-0049 enforce-or-remove): the five interaction +// config modules that used to be re-exported from here — +// `touch.zod` / `dnd.zod` / `keyboard.zod` / `animation.zod` / `offline.zod` +// (22 `z.object` sites, 32 emitted defs, 64 exported names) — were deleted with +// their reference docs. +// +// They had NO carrier key anywhere in the protocol: nothing under +// `packages/spec/src` imported them except this barrel, so no metadata document +// could reach them and no `.parse()` existed for them in objectstack, objectui +// or the example apps. What they described is RENDERER BUILT-IN BEHAVIOR +// (touch targets, drag-and-drop, focus/shortcuts, motion), not per-page author +// metadata; offline is a platform capability whose vocabulary belongs on a sync +// engine that does not exist yet. Whichever of them earns real product pull +// returns WITH its own vocabulary and its executor, the #4910 way — not by +// un-retiring a declaration. +// +// ⚠️ Do NOT re-add an `export *` here "to unblock a consumer". An exported +// schema with no consumer is read as a capability (#3950), and a precisely +// validated dead slot is the more convincing lie (#4583). Absence and survival +// are both pinned in `interaction-config-retirement.test.ts`. +// --------------------------------------------------------------------------- diff --git a/packages/spec/src/ui/interaction-config-retirement.test.ts b/packages/spec/src/ui/interaction-config-retirement.test.ts new file mode 100644 index 0000000000..1714ae218a --- /dev/null +++ b/packages/spec/src/ui/interaction-config-retirement.test.ts @@ -0,0 +1,252 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +// ─── [#4988] the five ui/ interaction config modules are RETIRED ──────────── +// +// ADR-0049 enforce-or-remove, maintainer ruling 2026-08-04: `ui/touch.zod.ts`, +// `ui/dnd.zod.ts`, `ui/keyboard.zod.ts`, `ui/animation.zod.ts` and +// `ui/offline.zod.ts` (22 `z.object` sites, 32 emitted defs, 64 exported names) +// are deleted whole, reference docs with them. +// +// The measurement that decided it, re-run on `origin/main` before the removal, +// with its controls passing in the SAME run: +// +// 1. STATIC — no module under `packages/spec/src` imported any of the five +// except the `ui/index.ts` barrel, so no schema declared a carrier key and +// no author could write a path that reached these shapes. +// 2. GRAPH — a BFS from all 24 metadata-type roots plus `defineStack`'s +// `ObjectStackSchema` (25 roots, 4742 nodes) reached NONE of the 21 named +// object shapes, while `PageSchema` / `WebhookSchema` / `StateMachineSchema` +// all resolved `direct`. Injecting a synthetic carrier flipped all 21 to +// `direct` — so "unreachable" was a fact about the graph, not a broken +// walker (`door-reachability.testkit.ts`). +// 3. CALL SITES — zero `.parse()` / `.safeParse()` on any of them in +// objectstack / objectui / cloud outside their own unit tests. +// +// Business reading, which is what the ruling turned on: these five categories +// are RENDERER BUILT-IN BEHAVIOR, not per-page author metadata. Offline is a +// platform capability whose vocabulary belongs on a future sync engine. +// +// ## Why route 3, and why there is nothing to tombstone +// +// The retirement playbook's route table forks on what parses the key. Here +// NOTHING did: with no carrier key there is no shape on which a `retiredKey()` +// tombstone could sit and no author document for an ADR-0087 D2 conversion to +// rewrite. That is route 3 ("nothing parses it → neither"), the shape #4834 / +// PR #4878 used for the kernel plugin-runtime family and PR #5293 for +// `HttpServerConfig`. The migration channel is the D3 `SemanticMigration` +// `ui-interaction-config-family-retired` plus `api-surface.json`. +// +// ## This pin is BIDIRECTIONAL and both halves carry weight +// +// Absence alone is satisfiable by deleting far too much — a sweep that took +// `ui/i18n.zod.ts` or `ui/responsive.zod.ts` with it would pass every +// `not.toContain` below. The SURVIVAL half is what catches that, and it is not +// hypothetical: `ui/responsive.zod.ts` was the sixth file of the same #4001 +// batch 13 and measured REACHABLE (`page.components[].responsive`), so it was +// tightened rather than retired. It must still be here. +// +// Form follows #4834 / PR #5300: resolved symbol identity over every public +// entry in `package.json`'s exports map. #4642 established that a compile-time +// conditional-type pin in this package is a no-op (tsconfig excludes +// `**/*.test.ts`; vitest never enables `typecheck`), so the compiler-API walk +// with anti-vacuity guards is the load-bearing instrument. +describe('[#4988] ui/ interaction config family retirement', () => { + /** Every name the five modules exported (32 schema/enum consts + 32 types). */ + const RETIRED_NAMES = [ + // touch.zod.ts + 'TouchTargetConfig', 'TouchTargetConfigSchema', + 'GestureType', 'GestureTypeSchema', + 'SwipeDirection', 'SwipeDirectionSchema', + 'SwipeGestureConfig', 'SwipeGestureConfigSchema', + 'PinchGestureConfig', 'PinchGestureConfigSchema', + 'LongPressGestureConfig', 'LongPressGestureConfigSchema', + 'GestureConfig', 'GestureConfigSchema', + 'TouchInteraction', 'TouchInteractionSchema', + // animation.zod.ts — NOTE: distinct from the theme `animation` block #5021 + // retired; different file, different defs, different manifest entries. + 'TransitionPreset', 'TransitionPresetSchema', + 'EasingFunction', 'EasingFunctionSchema', + 'TransitionConfig', 'TransitionConfigSchema', + 'AnimationTrigger', 'AnimationTriggerSchema', + 'ComponentAnimation', 'ComponentAnimationSchema', + 'PageTransition', 'PageTransitionSchema', + 'MotionConfig', 'MotionConfigSchema', + // dnd.zod.ts + 'DragHandle', 'DragHandleSchema', + 'DropEffect', 'DropEffectSchema', + 'DragConstraint', 'DragConstraintSchema', + 'DropZone', 'DropZoneSchema', + 'DragItem', 'DragItemSchema', + 'DndConfig', 'DndConfigSchema', + // keyboard.zod.ts + 'FocusTrapConfig', 'FocusTrapConfigSchema', + 'KeyboardShortcut', 'KeyboardShortcutSchema', + 'FocusManagement', 'FocusManagementSchema', + 'KeyboardNavigationConfig', 'KeyboardNavigationConfigSchema', + // offline.zod.ts + 'OfflineStrategy', 'OfflineStrategySchema', + 'ConflictResolution', 'ConflictResolutionSchema', + 'SyncConfig', 'SyncConfigSchema', + 'PersistStorage', 'PersistStorageSchema', + 'EvictionPolicy', 'EvictionPolicySchema', + 'OfflineCacheConfig', 'OfflineCacheConfigSchema', + 'OfflineConfig', 'OfflineConfigSchema', + ] as const; + + /** + * Names that must SURVIVE on `./ui`, one per neighbouring concern a + * too-wide sweep would plausibly take: + * + * - `ResponsiveConfigSchema` — batch 13's sixth file, measured REACHABLE and + * tightened instead of retired. The single most likely over-deletion. + * - `AriaPropsSchema` / `I18nLabelSchema` — the two shapes the five retired + * modules imported; deleting a consumer must not take its dependency. + * - `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. + * - `PageSchema` / `PageComponentSchema` — the authoring roots the five + * vocabularies would have hung off had they ever had a carrier. + */ + const MUST_SURVIVE = [ + 'ResponsiveConfigSchema', + 'AriaPropsSchema', + 'I18nLabelSchema', + 'NotificationTypeSchema', + 'SharingConfigSchema', + 'ThemeSchema', + 'PageSchema', + 'PageComponentSchema', + ] as const; + + it('every retired name has ZERO holders on any public entry; the survivors still stand on ./ui', async () => { + const ts = (await import('typescript')).default; + const { resolve, dirname } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const { readFileSync } = await import('node:fs'); + + const specDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + // Read the entry list from package.json's exports map so a future entry + // cannot silently escape the absence assertions below (the PR #5300 form). + const pkg = JSON.parse(readFileSync(resolve(specDir, 'package.json'), 'utf8')) as { + exports: Record; + }; + const entries: Record = {}; + for (const sub of Object.keys(pkg.exports)) { + if (sub === '.') entries[sub] = resolve(specDir, 'src/index.ts'); + else if (/^\.\/[a-z-]+$/.test(sub)) entries[sub] = resolve(specDir, `src/${sub.slice(2)}/index.ts`); + } + // Anti-vacuity: the enumeration must have found the real surface. + for (const needed of ['.', './ui', './automation', './integration', './api']) { + expect(Object.keys(entries), `exports map must include ${needed}`).toContain(needed); + } + expect(Object.keys(entries).length).toBeGreaterThan(10); + + const program = ts.createProgram(Object.values(entries), { + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + skipLibCheck: true, + noEmit: true, + }); + const checker = program.getTypeChecker(); + + const exportNamesOf = (sub: string) => { + const sf = program.getSourceFile(entries[sub]); + const moduleSym = sf && checker.getSymbolAtLocation(sf); + // Without this guard a resolution failure makes every absence assertion + // pass vacuously — exactly how a gate goes dormant (#4642). + expect(moduleSym, `${sub} module symbol must resolve`).toBeTruthy(); + return checker.getExportsOfModule(moduleSym!).map((s) => s.getName()); + }; + + const byEntry = new Map(); + for (const sub of Object.keys(entries)) byEntry.set(sub, exportNamesOf(sub)); + + // Anti-vacuity: `./ui` is a large, real surface — so `not.toContain` on it + // means something. + expect(byEntry.get('./ui')!.length, './ui must export a non-trivial surface').toBeGreaterThan(100); + + // ── ABSENCE (every entry, not just ./ui) ────────────────────────────── + for (const name of RETIRED_NAMES) { + const holders = [...byEntry.entries()].filter(([, names]) => names.includes(name)).map(([sub]) => sub); + expect(holders, `${name} must have zero holders after #4988`).toEqual([]); + } + + // ── SURVIVAL (on ./ui, where they live) ─────────────────────────────── + const uiNames = byEntry.get('./ui')!; + for (const name of MUST_SURVIVE) { + expect(uiNames, `${name} must SURVIVE this retirement`).toContain(name); + } + }); + + it('the five modules are gone from disk, and nothing imports them any more', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const srcRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + + const RETIRED_MODULES = ['touch', 'dnd', 'keyboard', 'animation', 'offline'] as const; + for (const m of RETIRED_MODULES) { + expect(fs.existsSync(path.join(srcRoot, 'ui', `${m}.zod.ts`)), `ui/${m}.zod.ts must be deleted`).toBe(false); + } + // Anti-vacuity for the existence probe: the sibling that was MEASURED + // reachable and kept must still be on disk, so "false" above cannot mean + // "this test is looking in the wrong directory". + expect(fs.existsSync(path.join(srcRoot, 'ui', 'responsive.zod.ts'))).toBe(true); + + const importers: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.ts')) { + const src = fs.readFileSync(full, 'utf-8'); + for (const m of RETIRED_MODULES) { + if (new RegExp(`(?:import|export)[^;]*['"][^'"]*/${m}\\.zod['"]`).test(src)) { + importers.push(`${path.relative(srcRoot, full)} → ${m}.zod`); + } + } + } + } + }; + walk(srcRoot); + expect(importers, 'a resurrected import means the retirement is being undone — re-read #4988').toEqual([]); + }); + + it('runtime namespace agrees with the compiler view', async () => { + const ui = await import('./index'); + for (const name of RETIRED_NAMES) { + expect(name in ui, `ui must not export ${name}`).toBe(false); + } + // Survival at runtime too — the const half of MUST_SURVIVE. + for (const name of MUST_SURVIVE) { + expect(name in ui, `${name} must SURVIVE at runtime`).toBe(true); + } + }); + + it('the bare `ConflictResolution` name is now published by NOBODY — #4738 left it to ./ui alone', async () => { + // #4738 renamed the connector-side enum to `ConnectorConflictResolution` + // BECAUSE `ui/offline.zod.ts` owned the bare name. That owner is gone, so + // the bare name is unowned rather than re-homed: the rename is not undone + // (`ConnectorConflictResolution` is the connector vocabulary's real name + // now, and un-renaming it would be a second breaking change to hand a + // freed word back), and no other domain may quietly adopt it. + const ui = await import('./index'); + const integration = await import('../integration/index'); + const automation = await import('../automation/index'); + for (const ns of [ui, integration, automation]) { + expect('ConflictResolution' in ns).toBe(false); + expect('ConflictResolutionSchema' in ns).toBe(false); + } + // The connector vocabulary itself is untouched, byte for byte. + expect('ConnectorConflictResolutionSchema' in integration).toBe(true); + expect(() => integration.ConnectorConflictResolutionSchema.parse('target_wins')).not.toThrow(); + // And the FOURTH relative — `./api`'s route-merge policy — keeps its own + // distinct name and is unaffected by any of this. + const api = await import('../api/index'); + expect('ConflictResolutionStrategy' in api).toBe(true); + }); +}); diff --git a/packages/spec/src/ui/keyboard.test.ts b/packages/spec/src/ui/keyboard.test.ts deleted file mode 100644 index cbc35519c8..0000000000 --- a/packages/spec/src/ui/keyboard.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - FocusTrapConfigSchema, - KeyboardShortcutSchema, - FocusManagementSchema, - KeyboardNavigationConfigSchema, - type FocusTrapConfig, - type KeyboardShortcut, - type FocusManagement, - type KeyboardNavigationConfig, -} from './keyboard.zod'; - -describe('FocusTrapConfigSchema', () => { - it('should apply defaults for empty config', () => { - const result = FocusTrapConfigSchema.parse({}); - expect(result.enabled).toBe(false); - expect(result.returnFocus).toBe(true); - expect(result.escapeDeactivates).toBe(true); - }); - - it('should accept enabled with initialFocus selector', () => { - const config: FocusTrapConfig = { - enabled: true, - initialFocus: '#first-input', - returnFocus: false, - escapeDeactivates: false, - }; - const result = FocusTrapConfigSchema.parse(config); - expect(result.enabled).toBe(true); - expect(result.initialFocus).toBe('#first-input'); - expect(result.returnFocus).toBe(false); - }); - - it('should leave initialFocus undefined when not provided', () => { - const result = FocusTrapConfigSchema.parse({}); - expect(result.initialFocus).toBeUndefined(); - }); -}); - -describe('KeyboardShortcutSchema', () => { - it('should accept a valid shortcut', () => { - const shortcut: KeyboardShortcut = { - key: 'Ctrl+S', - action: 'save', - description: 'Save the current form', - scope: 'form', - }; - const result = KeyboardShortcutSchema.parse(shortcut); - expect(result.key).toBe('Ctrl+S'); - expect(result.action).toBe('save'); - expect(result.scope).toBe('form'); - }); - - it('should default scope to global', () => { - const result = KeyboardShortcutSchema.parse({ key: 'Escape', action: 'close' }); - expect(result.scope).toBe('global'); - }); - - it('should accept all valid scopes', () => { - const scopes = ['global', 'view', 'form', 'modal', 'list'] as const; - scopes.forEach(scope => { - expect(() => KeyboardShortcutSchema.parse({ key: 'a', action: 'test', scope })).not.toThrow(); - }); - }); - - it('should reject invalid scope', () => { - expect(() => KeyboardShortcutSchema.parse({ key: 'a', action: 'test', scope: 'page' })).toThrow(); - }); - - it('should reject missing key or action', () => { - expect(() => KeyboardShortcutSchema.parse({ action: 'save' })).toThrow(); - expect(() => KeyboardShortcutSchema.parse({ key: 'Ctrl+S' })).toThrow(); - }); -}); - -describe('FocusManagementSchema', () => { - it('should apply defaults for empty config', () => { - const result = FocusManagementSchema.parse({}); - expect(result.tabOrder).toBe('auto'); - expect(result.skipLinks).toBe(false); - expect(result.focusVisible).toBe(true); - expect(result.arrowNavigation).toBe(false); - }); - - it('should accept manual tab order with skipLinks', () => { - const config: FocusManagement = { - tabOrder: 'manual', - skipLinks: true, - focusVisible: true, - }; - const result = FocusManagementSchema.parse(config); - expect(result.tabOrder).toBe('manual'); - expect(result.skipLinks).toBe(true); - }); - - it('should accept nested focusTrap', () => { - const result = FocusManagementSchema.parse({ - focusTrap: { enabled: true, initialFocus: '.modal-body' }, - }); - expect(result.focusTrap?.enabled).toBe(true); - expect(result.focusTrap?.initialFocus).toBe('.modal-body'); - }); - - it('should reject invalid tabOrder', () => { - expect(() => FocusManagementSchema.parse({ tabOrder: 'random' })).toThrow(); - }); -}); - -describe('KeyboardNavigationConfigSchema', () => { - it('should accept empty config', () => { - expect(() => KeyboardNavigationConfigSchema.parse({})).not.toThrow(); - }); - - it('should default rovingTabindex to false', () => { - const result = KeyboardNavigationConfigSchema.parse({}); - expect(result.rovingTabindex).toBe(false); - }); - - it('should accept full config with shortcuts and focus management', () => { - const config: KeyboardNavigationConfig = { - shortcuts: [ - { key: 'Ctrl+S', action: 'save', scope: 'form' }, - { key: 'Ctrl+Z', action: 'undo', scope: 'global' }, - ], - focusManagement: { - tabOrder: 'manual', - skipLinks: true, - focusVisible: true, - focusTrap: { enabled: true, returnFocus: true, escapeDeactivates: true }, - arrowNavigation: true, - }, - rovingTabindex: true, - }; - const result = KeyboardNavigationConfigSchema.parse(config); - expect(result.shortcuts).toHaveLength(2); - expect(result.focusManagement?.arrowNavigation).toBe(true); - expect(result.rovingTabindex).toBe(true); - }); -}); - -describe('Type exports', () => { - it('should have valid type exports', () => { - const trap: FocusTrapConfig = { enabled: false, returnFocus: true, escapeDeactivates: true }; - const shortcut: KeyboardShortcut = { key: 'Ctrl+N', action: 'new', scope: 'global' }; - const focus: FocusManagement = { tabOrder: 'auto', skipLinks: false, focusVisible: true, arrowNavigation: false }; - const nav: KeyboardNavigationConfig = { rovingTabindex: false }; - expect(trap).toBeDefined(); - expect(shortcut).toBeDefined(); - expect(focus).toBeDefined(); - expect(nav).toBeDefined(); - }); -}); - -describe('I18n and ARIA integration', () => { - it('should reject I18n description on KeyboardShortcutSchema', () => { - expect(() => KeyboardShortcutSchema.parse({ - key: 'Ctrl+S', - action: 'save', - description: { key: 'shortcuts.save', defaultValue: 'Save the current document' }, - })).toThrow(); - }); - - it('should accept plain string description on KeyboardShortcutSchema', () => { - const result = KeyboardShortcutSchema.parse({ - key: 'Ctrl+Z', - action: 'undo', - description: 'Undo last action', - }); - expect(result.description).toBe('Undo last action'); - }); - - it('should accept ARIA props on KeyboardNavigationConfigSchema', () => { - const result = KeyboardNavigationConfigSchema.parse({ - ariaLabel: 'Keyboard navigation region', - ariaDescribedBy: 'nav-help', - role: 'navigation', - }); - expect(result.ariaLabel).toBe('Keyboard navigation region'); - expect(result.ariaDescribedBy).toBe('nav-help'); - expect(result.role).toBe('navigation'); - }); - - it('should leave I18n/ARIA fields undefined when not provided', () => { - const shortcut = KeyboardShortcutSchema.parse({ key: 'Escape', action: 'close' }); - expect(shortcut.description).toBeUndefined(); - const nav = KeyboardNavigationConfigSchema.parse({}); - expect(nav.ariaLabel).toBeUndefined(); - }); -}); - -// --------------------------------------------------------------------------- -// #4001 batch 13 -- THIS FILE IS DELIBERATELY NOT `.strict()`, on a measurement. -// -// The strictness ledger scheduled these 4 sites as `authorable (p)`. Resolving -// the `(p)` found no authoring door at all: nothing under `packages/spec/src` -// imports this module except the `ui/index.ts` barrel, a BFS from all 24 -// metadata-type roots plus `defineStack`'s `ObjectStackSchema` never reaches -// these schemas (`PageSchema` / `WebhookSchema` / `StateMachineSchema` pass as -// positive controls in the same run), and no `.parse()` on any of them exists -// in `objectstack`, `objectui` or the example apps outside this test file. -// `.strict()` is a property of a PARSE, and there is no parse to gate. -// -// So the strip pinned below is not an unfinished row -- it is the recorded -// verdict. The open question is ADR-0049 enforce-or-remove, filed as #4988. -// These assertions exist so the next sweep stops and reads instead of reaching -// for `strictObject` and shipping a precisely-validated dead slot (#4583). The -// header comment in `keyboard.zod.ts` and this file's ledger row carry the same verdict. -// --------------------------------------------------------------------------- -describe('unknown-key posture is an open question, not an omission (#4001 batch 13 -> #4988)', () => { - it('FocusTrapConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = FocusTrapConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - it('KeyboardShortcutSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = KeyboardShortcutSchema.parse({ key: 'Ctrl+S', action: 'save', aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - it('FocusManagementSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = FocusManagementSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - it('KeyboardNavigationConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = KeyboardNavigationConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - // The standing half of measurement 1, so the verdict cannot go stale in - // silence: the day someone gives this vocabulary a carrier they will add an - // import, and this is where they are told to revisit #4988 and the ledger. - it('is still imported by nothing but the ui/ barrel', async () => { - const fs = await import('node:fs'); - const path = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); - const importers: string[] = []; - const walk = (dir: string) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') - && full !== path.join(root, 'ui', 'keyboard.zod.ts')) { - if (/(?:import|export)[^;]*['"][^'"]*\/keyboard\.zod['"]/.test(fs.readFileSync(full, 'utf-8'))) { - importers.push(path.relative(root, full)); - } - } - } - }; - walk(root); - expect(importers, 'a new importer means this vocabulary got a carrier -- re-read #4988') - .toEqual(['ui/index.ts']); - }); -}); diff --git a/packages/spec/src/ui/keyboard.zod.ts b/packages/spec/src/ui/keyboard.zod.ts deleted file mode 100644 index 23d01a31b1..0000000000 --- a/packages/spec/src/ui/keyboard.zod.ts +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; -import { lazySchema } from '../shared/lazy-schema'; - -// --------------------------------------------------------------------------- -// NOT CLOSED AGAINST UNKNOWN KEYS -- AND THAT IS THE MEASURED VERDICT -// (#4001 batch 13 / 批 13, ADR-0078). Read this before "finishing" the file. -// -// The strictness ledger scheduled this file's 4 object sites as `authorable -// (p)` -- provisional. #4001's own rule is verify-before-tightening, and here -// the verification came back NEGATIVE: no metadata document is ever parsed -// against these shapes, because nothing in the protocol carries them. -// -// Three independent measurements, 2026-08-03: -// -// 1. STATIC -- nothing under `packages/spec/src` imports this module except -// the `ui/index.ts` barrel. No schema anywhere declares a `component.keyboard / app.keyboard` -// slot, so there is no key an author can write to reach these shapes. -// 2. GRAPH -- a BFS over this build's in-memory Zod graph from all 24 -// metadata-type roots (`listMetadataTypeSchemaTypes`) plus -// `ObjectStackSchema` (`defineStack`) -- the closure `build-schemas.ts` -// uses for the #4650 deletion check -- reaches none of them. Its three -// positive controls resolve `root-graph` in the same run: `PageSchema`, -// `WebhookSchema` (batch 11's `defineStack({ webhooks })` door) and -// `StateMachineSchema` (batch 10's `agent.lifecycle` door). So -// "unreachable" is a fact about the graph, not a broken instrument. -// 3. CALL SITES -- no `.parse()` / `.safeParse()` on any schema here exists -// in `objectstack`, `objectui` or the example apps, outside this file's -// own unit test. objectui re-exports the inferred TYPES only and says so -// (`@object-ui/types`, the #2561 note: the validators are deliberately -// NOT re-exported). -// -// `.strict()` would therefore gate nothing -- strictness is a property of a -// PARSE, and there is no parse. Adding it would spend a v17 breaking change to -// make this file LOOK finished, and leave behind the artefact the ledger -// itself warns about: "a *precisely validated* dead slot is the more -// convincing lie" (#4583). The real question is ADR-0049 enforce-or-remove -- -// retire this vocabulary or give it a carrier -- filed as #4988, with the same -// verdict recorded in this file's ledger row. -// -// DO NOT convert these sites to `strictObject` before #4988 is decided: a -// strict shape reads as load-bearing and makes the retirement harder, which is -// the opposite of what the measurement asks for. -// --------------------------------------------------------------------------- - -/** - * Focus Trap Configuration Schema - * Constrains keyboard focus within a specific container (e.g., modals, dialogs). - */ -export const FocusTrapConfigSchema = lazySchema(() => z.object({ - enabled: z.boolean().default(false).describe('Enable focus trapping within this container'), - initialFocus: z.string().optional().describe('CSS selector for the element to focus on activation'), - returnFocus: z.boolean().default(true).describe('Return focus to trigger element on deactivation'), - escapeDeactivates: z.boolean().default(true).describe('Allow Escape key to deactivate the focus trap'), -}).describe('Focus trap configuration for modal-like containers')); - -export type FocusTrapConfig = z.infer; - -/** - * Keyboard Shortcut Schema - * Defines a single keyboard shortcut binding. - */ -export const KeyboardShortcutSchema = lazySchema(() => z.object({ - key: z.string().describe('Key combination (e.g., "Ctrl+S", "Alt+N", "Escape")'), - action: z.string().describe('Action identifier to invoke when shortcut is triggered'), - description: I18nLabelSchema.optional().describe('Human-readable description of what the shortcut does'), - scope: z.enum(['global', 'view', 'form', 'modal', 'list']).default('global') - .describe('Scope in which this shortcut is active'), -}).describe('Keyboard shortcut binding')); - -export type KeyboardShortcut = z.infer; - -/** - * Focus Management Schema - * Controls tab order, focus visibility, and navigation behavior. - */ -export const FocusManagementSchema = lazySchema(() => z.object({ - tabOrder: z.enum(['auto', 'manual']).default('auto') - .describe('Tab order strategy: auto (DOM order) or manual (explicit tabIndex)'), - skipLinks: z.boolean().default(false).describe('Provide skip-to-content navigation links'), - focusVisible: z.boolean().default(true).describe('Show visible focus indicators for keyboard users'), - focusTrap: FocusTrapConfigSchema.optional().describe('Focus trap settings'), - arrowNavigation: z.boolean().default(false) - .describe('Enable arrow key navigation between focusable items'), -}).describe('Focus and tab navigation management')); - -export type FocusManagement = z.infer; - -/** - * Keyboard Navigation Configuration Schema - * Top-level keyboard navigation and shortcut configuration. - */ -export const KeyboardNavigationConfigSchema = lazySchema(() => z.object({ - shortcuts: z.array(KeyboardShortcutSchema).optional().describe('Registered keyboard shortcuts'), - focusManagement: FocusManagementSchema.optional().describe('Focus and tab order management'), - rovingTabindex: z.boolean().default(false) - .describe('Enable roving tabindex pattern for composite widgets'), -}).merge(AriaPropsSchema.partial()) - // `.strip()` is LOAD-BEARING (#4001 批 16): `AriaPropsSchema` became `strictObject` and - // `.merge()` adopts the incoming schema's unknown-key posture, so without this the shape - // would silently become `.strict()` — with zod's generic message, not the campaign's — and - // would contradict this file's measured `no door` verdict (#4988: nothing parses it, so a - // strict shell enforces nothing). Keep it until #4988 says what happens to this file. - .strip() - .describe('Keyboard navigation and shortcut configuration')); - -export type KeyboardNavigationConfig = z.infer; diff --git a/packages/spec/src/ui/offline.test.ts b/packages/spec/src/ui/offline.test.ts deleted file mode 100644 index ade99c572a..0000000000 --- a/packages/spec/src/ui/offline.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - OfflineStrategySchema, - ConflictResolutionSchema, - SyncConfigSchema, - PersistStorageSchema, - EvictionPolicySchema, - OfflineCacheConfigSchema, - OfflineConfigSchema, - type OfflineStrategy, - type ConflictResolution, - type SyncConfig, - type PersistStorage, - type EvictionPolicy, - type OfflineCacheConfig, - type OfflineConfig, -} from './offline.zod'; - -describe('OfflineStrategySchema', () => { - it('should accept all valid strategies', () => { - const strategies = ['cache_first', 'network_first', 'stale_while_revalidate', 'network_only', 'cache_only'] as const; - strategies.forEach(s => { - expect(() => OfflineStrategySchema.parse(s)).not.toThrow(); - }); - }); - - it('should reject invalid strategies', () => { - expect(() => OfflineStrategySchema.parse('offline_first')).toThrow(); - expect(() => OfflineStrategySchema.parse('')).toThrow(); - }); -}); - -describe('ConflictResolutionSchema', () => { - it('should accept all valid resolutions', () => { - const resolutions = ['client_wins', 'server_wins', 'manual', 'last_write_wins'] as const; - resolutions.forEach(r => { - expect(() => ConflictResolutionSchema.parse(r)).not.toThrow(); - }); - }); -}); - -describe('SyncConfigSchema', () => { - it('should apply defaults for strategy and conflictResolution', () => { - const result = SyncConfigSchema.parse({}); - expect(result.strategy).toBe('network_first'); - expect(result.conflictResolution).toBe('last_write_wins'); - }); - - it('should accept full sync config', () => { - const config: SyncConfig = { - strategy: 'cache_first', - conflictResolution: 'manual', - retryInterval: 5000, - maxRetries: 3, - batchSize: 10, - }; - const result = SyncConfigSchema.parse(config); - expect(result.retryInterval).toBe(5000); - expect(result.maxRetries).toBe(3); - expect(result.batchSize).toBe(10); - }); -}); - -describe('OfflineCacheConfigSchema', () => { - it('should apply default storage and eviction policy', () => { - const result = OfflineCacheConfigSchema.parse({}); - expect(result.persistStorage).toBe('indexeddb'); - expect(result.evictionPolicy).toBe('lru'); - }); - - it('should accept all storage backends', () => { - const backends = ['indexeddb', 'localstorage', 'sqlite'] as const; - backends.forEach(b => { - expect(() => OfflineCacheConfigSchema.parse({ persistStorage: b })).not.toThrow(); - }); - }); - - it('should accept all eviction policies', () => { - const policies = ['lru', 'lfu', 'fifo'] as const; - policies.forEach(p => { - expect(() => OfflineCacheConfigSchema.parse({ evictionPolicy: p })).not.toThrow(); - }); - }); - - it('should accept maxSize and ttl', () => { - const config: OfflineCacheConfig = { - maxSize: 50_000_000, - ttl: 86400000, - persistStorage: 'sqlite', - evictionPolicy: 'fifo', - }; - const result = OfflineCacheConfigSchema.parse(config); - expect(result.maxSize).toBe(50_000_000); - expect(result.ttl).toBe(86400000); - }); - - it('should reject invalid storage backend', () => { - expect(() => OfflineCacheConfigSchema.parse({ persistStorage: 'redis' })).toThrow(); - }); -}); - -describe('OfflineConfigSchema', () => { - it('should accept minimal config (just enabled)', () => { - const result = OfflineConfigSchema.parse({ enabled: true }); - expect(result.enabled).toBe(true); - expect(result.strategy).toBe('network_first'); - expect(result.offlineIndicator).toBe(true); - }); - - it('should apply defaults for empty object', () => { - const result = OfflineConfigSchema.parse({}); - expect(result.enabled).toBe(false); - expect(result.offlineIndicator).toBe(true); - }); - - it('should accept full offline config', () => { - const config: OfflineConfig = { - enabled: true, - strategy: 'cache_first', - cache: { - maxSize: 10_000_000, - ttl: 3600000, - persistStorage: 'indexeddb', - evictionPolicy: 'lru', - }, - sync: { - strategy: 'stale_while_revalidate', - conflictResolution: 'server_wins', - retryInterval: 10000, - maxRetries: 5, - }, - offlineIndicator: false, - queueMaxSize: 100, - }; - const result = OfflineConfigSchema.parse(config); - expect(result.cache?.maxSize).toBe(10_000_000); - expect(result.sync?.conflictResolution).toBe('server_wins'); - expect(result.queueMaxSize).toBe(100); - }); - - it('should reject invalid strategy in offline config', () => { - expect(() => OfflineConfigSchema.parse({ strategy: 'bad' })).toThrow(); - }); -}); - -describe('Type exports', () => { - it('should have valid type exports', () => { - const strategy: OfflineStrategy = 'cache_first'; - const conflict: ConflictResolution = 'manual'; - const sync: SyncConfig = { strategy: 'network_first', conflictResolution: 'last_write_wins' }; - const storage: PersistStorage = 'indexeddb'; - const eviction: EvictionPolicy = 'lru'; - const cache: OfflineCacheConfig = { persistStorage: 'indexeddb', evictionPolicy: 'lru' }; - const config: OfflineConfig = { enabled: true, strategy: 'network_first', offlineIndicator: true }; - expect(strategy).toBeDefined(); - expect(conflict).toBeDefined(); - expect(sync).toBeDefined(); - expect(storage).toBeDefined(); - expect(eviction).toBeDefined(); - expect(cache).toBeDefined(); - expect(config).toBeDefined(); - }); -}); - -describe('I18n integration', () => { - it('should reject I18n offlineMessage on OfflineConfigSchema', () => { - expect(() => OfflineConfigSchema.parse({ - offlineMessage: { key: 'offline.status', defaultValue: 'You are offline' }, - })).toThrow(); - }); - - it('should accept plain string offlineMessage', () => { - const result = OfflineConfigSchema.parse({ offlineMessage: 'No internet connection' }); - expect(result.offlineMessage).toBe('No internet connection'); - }); - - it('should leave offlineMessage undefined when not provided', () => { - const result = OfflineConfigSchema.parse({}); - expect(result.offlineMessage).toBeUndefined(); - }); -}); - -// --------------------------------------------------------------------------- -// #4001 batch 13 -- THIS FILE IS DELIBERATELY NOT `.strict()`, on a measurement. -// -// The strictness ledger scheduled these 3 sites as `authorable (p)`. Resolving -// the `(p)` found no authoring door at all: nothing under `packages/spec/src` -// imports this module except the `ui/index.ts` barrel, a BFS from all 24 -// metadata-type roots plus `defineStack`'s `ObjectStackSchema` never reaches -// these schemas (`PageSchema` / `WebhookSchema` / `StateMachineSchema` pass as -// positive controls in the same run), and no `.parse()` on any of them exists -// in `objectstack`, `objectui` or the example apps outside this test file. -// `.strict()` is a property of a PARSE, and there is no parse to gate. -// -// So the strip pinned below is not an unfinished row -- it is the recorded -// verdict. The open question is ADR-0049 enforce-or-remove, filed as #4988. -// These assertions exist so the next sweep stops and reads instead of reaching -// for `strictObject` and shipping a precisely-validated dead slot (#4583). The -// header comment in `offline.zod.ts` and this file's ledger row carry the same verdict. -// --------------------------------------------------------------------------- -describe('unknown-key posture is an open question, not an omission (#4001 batch 13 -> #4988)', () => { - it('SyncConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = SyncConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - it('OfflineCacheConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = OfflineCacheConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - it('OfflineConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = OfflineConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - // The standing half of measurement 1, so the verdict cannot go stale in - // silence: the day someone gives this vocabulary a carrier they will add an - // import, and this is where they are told to revisit #4988 and the ledger. - it('is still imported by nothing but the ui/ barrel', async () => { - const fs = await import('node:fs'); - const path = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); - const importers: string[] = []; - const walk = (dir: string) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') - && full !== path.join(root, 'ui', 'offline.zod.ts')) { - if (/(?:import|export)[^;]*['"][^'"]*\/offline\.zod['"]/.test(fs.readFileSync(full, 'utf-8'))) { - importers.push(path.relative(root, full)); - } - } - } - }; - walk(root); - expect(importers, 'a new importer means this vocabulary got a carrier -- re-read #4988') - .toEqual(['ui/index.ts']); - }); -}); diff --git a/packages/spec/src/ui/offline.zod.ts b/packages/spec/src/ui/offline.zod.ts deleted file mode 100644 index af452f98d2..0000000000 --- a/packages/spec/src/ui/offline.zod.ts +++ /dev/null @@ -1,137 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { I18nLabelSchema } from './i18n.zod'; -import { lazySchema } from '../shared/lazy-schema'; - -// --------------------------------------------------------------------------- -// NOT CLOSED AGAINST UNKNOWN KEYS -- AND THAT IS THE MEASURED VERDICT -// (#4001 batch 13 / 批 13, ADR-0078). Read this before "finishing" the file. -// -// The strictness ledger scheduled this file's 3 object sites as `authorable -// (p)` -- provisional. #4001's own rule is verify-before-tightening, and here -// the verification came back NEGATIVE: no metadata document is ever parsed -// against these shapes, because nothing in the protocol carries them. -// -// Three independent measurements, 2026-08-03: -// -// 1. STATIC -- nothing under `packages/spec/src` imports this module except -// the `ui/index.ts` barrel. No schema anywhere declares a `app.offline / page.offline` -// slot, so there is no key an author can write to reach these shapes. -// 2. GRAPH -- a BFS over this build's in-memory Zod graph from all 24 -// metadata-type roots (`listMetadataTypeSchemaTypes`) plus -// `ObjectStackSchema` (`defineStack`) -- the closure `build-schemas.ts` -// uses for the #4650 deletion check -- reaches none of them. Its three -// positive controls resolve `root-graph` in the same run: `PageSchema`, -// `WebhookSchema` (batch 11's `defineStack({ webhooks })` door) and -// `StateMachineSchema` (batch 10's `agent.lifecycle` door). So -// "unreachable" is a fact about the graph, not a broken instrument. -// 3. CALL SITES -- no `.parse()` / `.safeParse()` on any schema here exists -// in `objectstack`, `objectui` or the example apps, outside this file's -// own unit test. objectui re-exports the inferred TYPES only and says so -// (`@object-ui/types`, the #2561 note: the validators are deliberately -// NOT re-exported). -// -// `.strict()` would therefore gate nothing -- strictness is a property of a -// PARSE, and there is no parse. Adding it would spend a v17 breaking change to -// make this file LOOK finished, and leave behind the artefact the ledger -// itself warns about: "a *precisely validated* dead slot is the more -// convincing lie" (#4583). The real question is ADR-0049 enforce-or-remove -- -// retire this vocabulary or give it a carrier -- filed as #4988, with the same -// verdict recorded in this file's ledger row. -// -// DO NOT convert these sites to `strictObject` before #4988 is decided: a -// strict shape reads as load-bearing and makes the retirement harder, which is -// the opposite of what the measurement asks for. -// --------------------------------------------------------------------------- - -/** - * Offline Strategy Schema - * Determines how data is fetched when connectivity is limited. - */ -export const OfflineStrategySchema = lazySchema(() => z.enum([ - 'cache_first', - 'network_first', - 'stale_while_revalidate', - 'network_only', - 'cache_only', -]).describe('Data fetching strategy for offline/online transitions')); - -export type OfflineStrategy = z.infer; - -/** - * Conflict Resolution Strategy Enum - */ -export const ConflictResolutionSchema = lazySchema(() => z.enum([ - 'client_wins', - 'server_wins', - 'manual', - 'last_write_wins', -]).describe('How to resolve conflicts when syncing offline changes')); - -export type ConflictResolution = z.infer; - -/** - * Sync Configuration Schema - * Controls how offline mutations are synchronized with the server. - */ -export const SyncConfigSchema = lazySchema(() => z.object({ - strategy: OfflineStrategySchema.default('network_first').describe('Sync fetch strategy'), - conflictResolution: ConflictResolutionSchema.default('last_write_wins').describe('Conflict resolution policy'), - retryInterval: z.number().optional().describe('Retry interval in milliseconds between sync attempts'), - maxRetries: z.number().optional().describe('Maximum number of sync retry attempts'), - batchSize: z.number().optional().describe('Number of mutations to sync per batch'), -}).describe('Offline-to-online synchronization configuration')); - -export type SyncConfig = z.infer; - -/** - * Persist Storage Backend Enum - */ -export const PersistStorageSchema = lazySchema(() => z.enum([ - 'indexeddb', - 'localstorage', - 'sqlite', -]).describe('Client-side storage backend for offline cache')); - -export type PersistStorage = z.infer; - -/** - * Eviction Policy Enum - */ -export const EvictionPolicySchema = lazySchema(() => z.enum([ - 'lru', - 'lfu', - 'fifo', -]).describe('Cache eviction policy')); - -export type EvictionPolicy = z.infer; - -/** - * Offline Cache Configuration Schema - * Controls how data is persisted on the client for offline access. - */ -export const OfflineCacheConfigSchema = lazySchema(() => z.object({ - maxSize: z.number().optional().describe('Maximum cache size in bytes'), - ttl: z.number().optional().describe('Time-to-live for cached entries in milliseconds'), - persistStorage: PersistStorageSchema.default('indexeddb').describe('Storage backend'), - evictionPolicy: EvictionPolicySchema.default('lru').describe('Cache eviction policy when full'), -}).describe('Client-side offline cache configuration')); - -export type OfflineCacheConfig = z.infer; - -/** - * Offline Configuration Schema - * Top-level offline support configuration for an application or component. - */ -export const OfflineConfigSchema = lazySchema(() => z.object({ - enabled: z.boolean().default(false).describe('Enable offline support'), - strategy: OfflineStrategySchema.default('network_first').describe('Default offline fetch strategy'), - cache: OfflineCacheConfigSchema.optional().describe('Cache settings for offline data'), - sync: SyncConfigSchema.optional().describe('Sync settings for offline mutations'), - offlineIndicator: z.boolean().default(true).describe('Show a visual indicator when offline'), - offlineMessage: I18nLabelSchema.optional().describe('Customizable offline status message shown to users'), - queueMaxSize: z.number().optional().describe('Maximum number of queued offline mutations'), -}).describe('Offline support configuration')); - -export type OfflineConfig = z.infer; diff --git a/packages/spec/src/ui/theme.test.ts b/packages/spec/src/ui/theme.test.ts index 64214defa8..cd71c6634d 100644 --- a/packages/spec/src/ui/theme.test.ts +++ b/packages/spec/src/ui/theme.test.ts @@ -584,8 +584,11 @@ describe('#4001 批 15 — ThemeSchema unknown-key strictness', () => { 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 (#4988), so prescribing them would walk an author out of a - // loud rejection into a silent one. + // 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'); diff --git a/packages/spec/src/ui/theme.zod.ts b/packages/spec/src/ui/theme.zod.ts index 26714eb622..3f97853e85 100644 --- a/packages/spec/src/ui/theme.zod.ts +++ b/packages/spec/src/ui/theme.zod.ts @@ -34,8 +34,11 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; // Controls in the same run: `PageSchema` / `DashboardSchema` / // `ReportSchema` / `WebhookSchema` / `StateMachineSchema` all resolve, and // 批 13's measured no-door shapes (`TouchTargetConfigSchema`, -// `GestureConfigSchema`) come back unreachable. So "reachable" here is a +// `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. @@ -321,10 +324,13 @@ export const ThemeMode = ThemeModeSchema; // 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 (#4988), 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. +// 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 diff --git a/packages/spec/src/ui/touch.test.ts b/packages/spec/src/ui/touch.test.ts deleted file mode 100644 index 56c774da5c..0000000000 --- a/packages/spec/src/ui/touch.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - TouchTargetConfigSchema, - GestureTypeSchema, - SwipeGestureConfigSchema, - PinchGestureConfigSchema, - LongPressGestureConfigSchema, - GestureConfigSchema, - TouchInteractionSchema, - type TouchTargetConfig, - type GestureType, - type SwipeGestureConfig, - type PinchGestureConfig, - type LongPressGestureConfig, - type GestureConfig, - type TouchInteraction, -} from './touch.zod'; - -describe('TouchTargetConfigSchema', () => { - it('should apply default 44x44 values', () => { - const result = TouchTargetConfigSchema.parse({}); - expect(result.minWidth).toBe(44); - expect(result.minHeight).toBe(44); - }); - - it('should accept custom dimensions', () => { - const config: TouchTargetConfig = { minWidth: 48, minHeight: 56, padding: 8 }; - const result = TouchTargetConfigSchema.parse(config); - expect(result.minWidth).toBe(48); - expect(result.minHeight).toBe(56); - expect(result.padding).toBe(8); - }); - - it('should accept hitSlop configuration', () => { - const result = TouchTargetConfigSchema.parse({ - hitSlop: { top: 10, right: 10, bottom: 10, left: 10 }, - }); - expect(result.hitSlop?.top).toBe(10); - }); -}); - -describe('GestureTypeSchema', () => { - it('should accept all valid gesture types', () => { - const types = ['swipe', 'pinch', 'long_press', 'double_tap', 'drag', 'rotate', 'pan'] as const; - types.forEach(type => { - expect(() => GestureTypeSchema.parse(type)).not.toThrow(); - }); - }); - - it('should reject invalid gesture types', () => { - expect(() => GestureTypeSchema.parse('flick')).toThrow(); - expect(() => GestureTypeSchema.parse('')).toThrow(); - }); -}); - -describe('SwipeGestureConfigSchema', () => { - it('should accept valid swipe config with directions', () => { - const config: SwipeGestureConfig = { direction: ['left', 'right'] }; - const result = SwipeGestureConfigSchema.parse(config); - expect(result.direction).toEqual(['left', 'right']); - }); - - it('should accept all four directions', () => { - const result = SwipeGestureConfigSchema.parse({ - direction: ['up', 'down', 'left', 'right'], - threshold: 50, - velocity: 0.3, - }); - expect(result.direction).toHaveLength(4); - expect(result.threshold).toBe(50); - }); - - it('should reject missing direction', () => { - expect(() => SwipeGestureConfigSchema.parse({})).toThrow(); - }); -}); - -describe('PinchGestureConfigSchema', () => { - it('should accept min and max scale', () => { - const config: PinchGestureConfig = { minScale: 0.5, maxScale: 3.0 }; - const result = PinchGestureConfigSchema.parse(config); - expect(result.minScale).toBe(0.5); - expect(result.maxScale).toBe(3.0); - }); - - it('should accept empty config', () => { - expect(() => PinchGestureConfigSchema.parse({})).not.toThrow(); - }); -}); - -describe('LongPressGestureConfigSchema', () => { - it('should apply default duration of 500ms', () => { - const result = LongPressGestureConfigSchema.parse({}); - expect(result.duration).toBe(500); - }); - - it('should accept custom duration and tolerance', () => { - const config: LongPressGestureConfig = { duration: 800, moveTolerance: 10 }; - const result = LongPressGestureConfigSchema.parse(config); - expect(result.duration).toBe(800); - expect(result.moveTolerance).toBe(10); - }); -}); - -describe('GestureConfigSchema', () => { - it('should accept a swipe gesture config', () => { - const config: GestureConfig = { - type: 'swipe', - swipe: { direction: ['left'] }, - }; - const result = GestureConfigSchema.parse(config); - expect(result.type).toBe('swipe'); - expect(result.enabled).toBe(true); - }); - - it('should accept a long press gesture with enabled false', () => { - const result = GestureConfigSchema.parse({ - type: 'long_press', - enabled: false, - longPress: { duration: 1000 }, - }); - expect(result.enabled).toBe(false); - }); - - it('should reject missing type', () => { - expect(() => GestureConfigSchema.parse({})).toThrow(); - }); -}); - -describe('TouchInteractionSchema', () => { - it('should accept empty config', () => { - expect(() => TouchInteractionSchema.parse({})).not.toThrow(); - }); - - it('should accept full interaction config', () => { - const config: TouchInteraction = { - gestures: [ - { type: 'swipe', swipe: { direction: ['up', 'down'] } }, - { type: 'pinch', pinch: { minScale: 1, maxScale: 4 } }, - ], - touchTarget: { minWidth: 48, minHeight: 48 }, - hapticFeedback: true, - }; - const result = TouchInteractionSchema.parse(config); - expect(result.gestures).toHaveLength(2); - expect(result.hapticFeedback).toBe(true); - }); -}); - -describe('Type exports', () => { - it('should have valid type exports', () => { - const target: TouchTargetConfig = { minWidth: 44, minHeight: 44 }; - const gesture: GestureType = 'swipe'; - const swipe: SwipeGestureConfig = { direction: ['left'] }; - const pinch: PinchGestureConfig = {}; - const longPress: LongPressGestureConfig = {}; - const gestureConfig: GestureConfig = { type: 'drag' }; - const interaction: TouchInteraction = {}; - expect(target).toBeDefined(); - expect(gesture).toBeDefined(); - expect(swipe).toBeDefined(); - expect(pinch).toBeDefined(); - expect(longPress).toBeDefined(); - expect(gestureConfig).toBeDefined(); - expect(interaction).toBeDefined(); - }); -}); - -describe('I18n and ARIA integration', () => { - it('should reject I18n label on GestureConfigSchema', () => { - expect(() => GestureConfigSchema.parse({ - type: 'swipe', - label: { key: 'gestures.swipe_left', defaultValue: 'Swipe to delete' }, - })).toThrow(); - }); - - it('should accept plain string label on GestureConfigSchema', () => { - const result = GestureConfigSchema.parse({ type: 'pinch', label: 'Pinch to zoom' }); - expect(result.label).toBe('Pinch to zoom'); - }); - - it('should accept ARIA props on TouchInteractionSchema', () => { - const result = TouchInteractionSchema.parse({ - ariaLabel: 'Touch-enabled area', - role: 'application', - }); - expect(result.ariaLabel).toBe('Touch-enabled area'); - expect(result.role).toBe('application'); - }); - - it('should leave I18n/ARIA fields undefined when not provided', () => { - const gesture = GestureConfigSchema.parse({ type: 'drag' }); - expect(gesture.label).toBeUndefined(); - const interaction = TouchInteractionSchema.parse({}); - expect(interaction.ariaLabel).toBeUndefined(); - }); -}); - -// --------------------------------------------------------------------------- -// #4001 batch 13 -- THIS FILE IS DELIBERATELY NOT `.strict()`, on a measurement. -// -// The strictness ledger scheduled these 7 sites as `authorable (p)`. Resolving -// the `(p)` found no authoring door at all: nothing under `packages/spec/src` -// imports this module except the `ui/index.ts` barrel, a BFS from all 24 -// metadata-type roots plus `defineStack`'s `ObjectStackSchema` never reaches -// these schemas (`PageSchema` / `WebhookSchema` / `StateMachineSchema` pass as -// positive controls in the same run), and no `.parse()` on any of them exists -// in `objectstack`, `objectui` or the example apps outside this test file. -// `.strict()` is a property of a PARSE, and there is no parse to gate. -// -// So the strip pinned below is not an unfinished row -- it is the recorded -// verdict. The open question is ADR-0049 enforce-or-remove, filed as #4988. -// These assertions exist so the next sweep stops and reads instead of reaching -// for `strictObject` and shipping a precisely-validated dead slot (#4583). The -// header comment in `touch.zod.ts` and this file's ledger row carry the same verdict. -// --------------------------------------------------------------------------- -describe('unknown-key posture is an open question, not an omission (#4001 batch 13 -> #4988)', () => { - it('TouchTargetConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = TouchTargetConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - it('TouchTargetConfigSchema.hitSlop (the nested site) strips too -- pending #4988', () => { - const parsed = TouchTargetConfigSchema.parse({ hitSlop: { top: 4, aKeyThisShapeDoesNotDeclare: 1 } }); - expect((parsed.hitSlop as Record).aKeyThisShapeDoesNotDeclare).toBeUndefined(); - expect(parsed.hitSlop?.top).toBe(4); - }); - - it('SwipeGestureConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = SwipeGestureConfigSchema.parse({ direction: ['left'], aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - it('PinchGestureConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = PinchGestureConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - it('LongPressGestureConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = LongPressGestureConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - it('GestureConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = GestureConfigSchema.parse({ type: 'swipe', aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - it('TouchInteractionSchema still strips rather than rejecting -- deliberate, pending #4988', () => { - const parsed = TouchInteractionSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; - expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); - }); - - // The standing half of measurement 1, so the verdict cannot go stale in - // silence: the day someone gives this vocabulary a carrier they will add an - // import, and this is where they are told to revisit #4988 and the ledger. - it('is still imported by nothing but the ui/ barrel', async () => { - const fs = await import('node:fs'); - const path = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); - const importers: string[] = []; - const walk = (dir: string) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') - && full !== path.join(root, 'ui', 'touch.zod.ts')) { - if (/(?:import|export)[^;]*['"][^'"]*\/touch\.zod['"]/.test(fs.readFileSync(full, 'utf-8'))) { - importers.push(path.relative(root, full)); - } - } - } - }; - walk(root); - expect(importers, 'a new importer means this vocabulary got a carrier -- re-read #4988') - .toEqual(['ui/index.ts']); - }); -}); diff --git a/packages/spec/src/ui/touch.zod.ts b/packages/spec/src/ui/touch.zod.ts deleted file mode 100644 index c50f03a986..0000000000 --- a/packages/spec/src/ui/touch.zod.ts +++ /dev/null @@ -1,152 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; -import { lazySchema } from '../shared/lazy-schema'; - -// --------------------------------------------------------------------------- -// NOT CLOSED AGAINST UNKNOWN KEYS -- AND THAT IS THE MEASURED VERDICT -// (#4001 batch 13 / 批 13, ADR-0078). Read this before "finishing" the file. -// -// The strictness ledger scheduled this file's 7 object sites as `authorable -// (p)` -- provisional. #4001's own rule is verify-before-tightening, and here -// the verification came back NEGATIVE: no metadata document is ever parsed -// against these shapes, because nothing in the protocol carries them. -// -// Three independent measurements, 2026-08-03: -// -// 1. STATIC -- nothing under `packages/spec/src` imports this module except -// the `ui/index.ts` barrel. No schema anywhere declares a `component.touch / page.touch` -// slot, so there is no key an author can write to reach these shapes. -// 2. GRAPH -- a BFS over this build's in-memory Zod graph from all 24 -// metadata-type roots (`listMetadataTypeSchemaTypes`) plus -// `ObjectStackSchema` (`defineStack`) -- the closure `build-schemas.ts` -// uses for the #4650 deletion check -- reaches none of them. Its three -// positive controls resolve `root-graph` in the same run: `PageSchema`, -// `WebhookSchema` (batch 11's `defineStack({ webhooks })` door) and -// `StateMachineSchema` (batch 10's `agent.lifecycle` door). So -// "unreachable" is a fact about the graph, not a broken instrument. -// 3. CALL SITES -- no `.parse()` / `.safeParse()` on any schema here exists -// in `objectstack`, `objectui` or the example apps, outside this file's -// own unit test. objectui re-exports the inferred TYPES only and says so -// (`@object-ui/types`, the #2561 note: the validators are deliberately -// NOT re-exported). -// -// `.strict()` would therefore gate nothing -- strictness is a property of a -// PARSE, and there is no parse. Adding it would spend a v17 breaking change to -// make this file LOOK finished, and leave behind the artefact the ledger -// itself warns about: "a *precisely validated* dead slot is the more -// convincing lie" (#4583). The real question is ADR-0049 enforce-or-remove -- -// retire this vocabulary or give it a carrier -- filed as #4988, with the same -// verdict recorded in this file's ledger row. -// -// DO NOT convert these sites to `strictObject` before #4988 is decided: a -// strict shape reads as load-bearing and makes the retirement harder, which is -// the opposite of what the measurement asks for. -// --------------------------------------------------------------------------- - -/** - * Touch Target Configuration Schema - * Ensures touch targets meet WCAG 2.5.5 minimum size requirements (44x44px). - */ -export const TouchTargetConfigSchema = lazySchema(() => z.object({ - minWidth: z.number().default(44).describe('Minimum touch target width in pixels (WCAG 2.5.5: 44px)'), - minHeight: z.number().default(44).describe('Minimum touch target height in pixels (WCAG 2.5.5: 44px)'), - padding: z.number().optional().describe('Additional padding around touch target in pixels'), - hitSlop: z.object({ - top: z.number().optional().describe('Extra hit area above the element'), - right: z.number().optional().describe('Extra hit area to the right of the element'), - bottom: z.number().optional().describe('Extra hit area below the element'), - left: z.number().optional().describe('Extra hit area to the left of the element'), - }).optional().describe('Invisible hit area extension beyond the visible bounds'), -}).describe('Touch target sizing configuration (WCAG accessible)')); - -export type TouchTargetConfig = z.infer; - -/** - * Gesture Type Enum - * Supported touch gesture types. - */ -export const GestureTypeSchema = lazySchema(() => z.enum([ - 'swipe', - 'pinch', - 'long_press', - 'double_tap', - 'drag', - 'rotate', - 'pan', -]).describe('Touch gesture type')); - -export type GestureType = z.infer; - -/** - * Swipe Direction Enum - */ -export const SwipeDirectionSchema = lazySchema(() => z.enum(['up', 'down', 'left', 'right'])); - -export type SwipeDirection = z.infer; - -/** - * Swipe Gesture Configuration Schema - */ -export const SwipeGestureConfigSchema = lazySchema(() => z.object({ - direction: z.array(SwipeDirectionSchema).describe('Allowed swipe directions'), - threshold: z.number().optional().describe('Minimum distance in pixels to recognize swipe'), - velocity: z.number().optional().describe('Minimum velocity (px/ms) to trigger swipe'), -}).describe('Swipe gesture recognition settings')); - -export type SwipeGestureConfig = z.infer; - -/** - * Pinch Gesture Configuration Schema - */ -export const PinchGestureConfigSchema = lazySchema(() => z.object({ - minScale: z.number().optional().describe('Minimum scale factor (e.g., 0.5 for 50%)'), - maxScale: z.number().optional().describe('Maximum scale factor (e.g., 3.0 for 300%)'), -}).describe('Pinch/zoom gesture recognition settings')); - -export type PinchGestureConfig = z.infer; - -/** - * Long Press Gesture Configuration Schema - */ -export const LongPressGestureConfigSchema = lazySchema(() => z.object({ - duration: z.number().default(500).describe('Hold duration in milliseconds to trigger long press'), - moveTolerance: z.number().optional().describe('Max movement in pixels allowed during press'), -}).describe('Long press gesture recognition settings')); - -export type LongPressGestureConfig = z.infer; - -/** - * Gesture Configuration Schema - * Unified configuration for all supported gesture types. - */ -export const GestureConfigSchema = lazySchema(() => z.object({ - type: GestureTypeSchema.describe('Gesture type to configure'), - label: I18nLabelSchema.optional().describe('Descriptive label for the gesture action'), - enabled: z.boolean().default(true).describe('Whether this gesture is active'), - swipe: SwipeGestureConfigSchema.optional().describe('Swipe gesture settings (when type is swipe)'), - pinch: PinchGestureConfigSchema.optional().describe('Pinch gesture settings (when type is pinch)'), - longPress: LongPressGestureConfigSchema.optional().describe('Long press settings (when type is long_press)'), -}).describe('Per-gesture configuration')); - -export type GestureConfig = z.infer; - -/** - * Touch Interaction Schema - * Top-level touch and gesture interaction configuration for a component. - */ -export const TouchInteractionSchema = lazySchema(() => z.object({ - gestures: z.array(GestureConfigSchema).optional().describe('Configured gesture recognizers'), - touchTarget: TouchTargetConfigSchema.optional().describe('Touch target sizing and hit area'), - hapticFeedback: z.boolean().optional().describe('Enable haptic feedback on touch interactions'), -}).merge(AriaPropsSchema.partial()) - // `.strip()` is LOAD-BEARING (#4001 批 16): `AriaPropsSchema` became `strictObject` and - // `.merge()` adopts the incoming schema's unknown-key posture, so without this the shape - // would silently become `.strict()` — with zod's generic message, not the campaign's — and - // would contradict this file's measured `no door` verdict (#4988: nothing parses it, so a - // strict shell enforces nothing). Keep it until #4988 says what happens to this file. - .strip() - .describe('Touch and gesture interaction configuration')); - -export type TouchInteraction = z.infer;