From ed4316f1d3dd6dc338b7a5691c9d22c15b688800 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:49:08 +0000 Subject: [PATCH 1/7] refactor!: retire nine zero-consumer modules from core, react and collaboration core: builder/schema-builder and the protocols/ trio (Dnd, Keyboard, Sharing). react: useFocusTrap, useFocusManagement, usePageTransition, useSchemaPersistence. collaboration: LiveCursors. Each had no importer outside its own barrel line and its own tests. Claude-Session: https://claude.ai/code/session_011yfYeuX2ptMSCcYZiq61tS Co-authored-by: Claude --- packages/collaboration/README.md | 17 +- packages/collaboration/src/LiveCursors.tsx | 143 ------ packages/collaboration/src/index.ts | 6 - .../builder/__tests__/schema-builder.test.ts | 241 ---------- packages/core/src/builder/schema-builder.ts | 451 ------------------ packages/core/src/index.ts | 8 +- packages/core/src/protocols/DndProtocol.ts | 237 --------- .../core/src/protocols/KeyboardProtocol.ts | 222 --------- .../core/src/protocols/SharingProtocol.ts | 212 -------- packages/core/src/protocols/index.ts | 28 -- .../useETagCache.configAlloc.test.tsx | 5 +- ...SchemaPersistence.adapterTracking.test.tsx | 172 ------- ...seSchemaPersistence.callableGuard.test.tsx | 233 --------- packages/react/src/hooks/index.ts | 4 - packages/react/src/hooks/useAnimation.ts | 6 +- packages/react/src/hooks/useETagCache.ts | 4 +- .../react/src/hooks/useFocusManagement.ts | 248 ---------- packages/react/src/hooks/useFocusTrap.ts | 138 ------ packages/react/src/hooks/usePageTransition.ts | 187 -------- .../react/src/hooks/useSchemaPersistence.ts | 373 --------------- .../__tests__/crud-retirement-5373.test.ts | 4 +- packages/types/src/index.ts | 7 +- scripts/check-doc-example-types.mjs | 6 - 23 files changed, 20 insertions(+), 2932 deletions(-) delete mode 100644 packages/collaboration/src/LiveCursors.tsx delete mode 100644 packages/core/src/builder/__tests__/schema-builder.test.ts delete mode 100644 packages/core/src/builder/schema-builder.ts delete mode 100644 packages/core/src/protocols/DndProtocol.ts delete mode 100644 packages/core/src/protocols/KeyboardProtocol.ts delete mode 100644 packages/core/src/protocols/SharingProtocol.ts delete mode 100644 packages/core/src/protocols/index.ts delete mode 100644 packages/react/src/hooks/__tests__/useSchemaPersistence.adapterTracking.test.tsx delete mode 100644 packages/react/src/hooks/__tests__/useSchemaPersistence.callableGuard.test.tsx delete mode 100644 packages/react/src/hooks/useFocusManagement.ts delete mode 100644 packages/react/src/hooks/useFocusTrap.ts delete mode 100644 packages/react/src/hooks/usePageTransition.ts delete mode 100644 packages/react/src/hooks/useSchemaPersistence.ts diff --git a/packages/collaboration/README.md b/packages/collaboration/README.md index 720c1f4ce0..29b0ea103a 100644 --- a/packages/collaboration/README.md +++ b/packages/collaboration/README.md @@ -1,10 +1,9 @@ # @object-ui/collaboration -Real-time collaboration for Object UI β€” live cursors, presence tracking, comment threads, and conflict resolution. +Real-time collaboration for Object UI β€” presence tracking, comment threads, and conflict resolution. ## Features -- πŸ–±οΈ **Live Cursors** - Display remote user cursors in real time with `LiveCursors` - πŸ‘₯ **Presence Avatars** - Show active users with `PresenceAvatars` - πŸ’¬ **Comment Threads** - Threaded comments with @mentions via `CommentThread` - πŸ”„ **Realtime Subscriptions** - WebSocket data subscriptions with `useRealtimeSubscription` @@ -27,7 +26,6 @@ npm install @object-ui/collaboration import { usePresence, useRealtimeSubscription, - LiveCursors, PresenceAvatars, CommentThread, type Comment, @@ -50,7 +48,6 @@ function CollaborativeEditor() { return (
-
updateCursor({ x: event.clientX, y: event.clientY })}> {lastMessage ? lastMessage.channel : 'waiting for updates'}
@@ -101,18 +98,6 @@ import { useConflictResolution } from '@object-ui/collaboration'; const { versions, conflicts, resolveConflict } = useConflictResolution('user-1', 'Alice'); ``` -### LiveCursors - -Displays remote user cursors on the page: - -```tsx -import { LiveCursors, type PresenceUser } from '@object-ui/collaboration'; - -declare const presenceUsers: PresenceUser[]; - -const cursors = ; -``` - ### PresenceAvatars Shows avatar badges for active users: diff --git a/packages/collaboration/src/LiveCursors.tsx b/packages/collaboration/src/LiveCursors.tsx deleted file mode 100644 index 66fee93e91..0000000000 --- a/packages/collaboration/src/LiveCursors.tsx +++ /dev/null @@ -1,143 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import React, { useMemo } from 'react'; -import type { PresenceUser } from './usePresence.js'; - -export interface LiveCursorsProps { - /** Other users' presence data */ - users: PresenceUser[]; - /** Container ref for relative positioning */ - containerRef?: React.RefObject; - /** Whether to show user names next to cursors */ - showNames?: boolean; - /** Whether to show user avatars */ - showAvatars?: boolean; - /** Cursor size in pixels (default: 20) */ - cursorSize?: number; - /** Fade out idle cursors */ - fadeIdle?: boolean; - /** Additional className */ - className?: string; -} - -const cursorStyles = { - container: { - position: 'absolute' as const, - inset: 0, - pointerEvents: 'none' as const, - overflow: 'hidden', - zIndex: 9999, - }, - cursor: { - position: 'absolute' as const, - pointerEvents: 'none' as const, - transition: 'transform 120ms ease-out, opacity 300ms ease', - willChange: 'transform, opacity', - }, - label: { - position: 'absolute' as const, - left: '16px', - top: '16px', - fontSize: '11px', - fontWeight: 500, - fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', - color: '#fff', - padding: '1px 6px', - borderRadius: '3px', - whiteSpace: 'nowrap' as const, - lineHeight: '18px', - }, - avatar: { - position: 'absolute' as const, - left: '18px', - top: '-4px', - width: '18px', - height: '18px', - borderRadius: '50%', - border: '2px solid #fff', - objectFit: 'cover' as const, - }, -} as const; - -function CursorSvg({ color, size }: { color: string; size: number }): React.ReactElement { - return React.createElement('svg', { - width: size, - height: size, - viewBox: '0 0 24 24', - fill: 'none', - xmlns: 'http://www.w3.org/2000/svg', - style: { display: 'block' }, - }, - React.createElement('path', { - d: 'M5.65376 12.3673H5.46026L5.31717 12.4976L0.500002 16.8829L0.500002 1.19841L11.7841 12.3673H5.65376Z', - fill: color, - stroke: '#fff', - strokeWidth: '1', - }), - ); -} - -/** - * Live cursors component displaying other users' cursor positions. - * - * Renders absolutely-positioned cursor SVGs with smooth CSS transitions, - * user name labels, and fade-out for idle users. - */ -export function LiveCursors({ - users, - containerRef: _containerRef, - showNames = true, - showAvatars = false, - cursorSize = 20, - fadeIdle = true, - className, -}: LiveCursorsProps): React.ReactElement { - const visibleUsers = useMemo( - () => users.filter(u => u.cursor), - [users], - ); - - return React.createElement('div', { - style: cursorStyles.container, - className, - 'aria-hidden': 'true', - }, - visibleUsers.map(user => { - const { cursor } = user; - if (!cursor) return null; - - const opacity = fadeIdle - ? user.status === 'active' ? 1 : user.status === 'idle' ? 0.5 : 0.2 - : 1; - - return React.createElement('div', { - key: user.userId, - style: { - ...cursorStyles.cursor, - transform: `translate(${cursor.x}px, ${cursor.y}px)`, - opacity, - }, - 'data-user-id': user.userId, - }, - React.createElement(CursorSvg, { color: user.color, size: cursorSize }), - showAvatars && user.avatar && React.createElement('img', { - src: user.avatar, - alt: user.userName, - style: cursorStyles.avatar, - }), - showNames && React.createElement('span', { - style: { - ...cursorStyles.label, - backgroundColor: user.color, - }, - }, user.userName), - ); - }), - ); -} diff --git a/packages/collaboration/src/index.ts b/packages/collaboration/src/index.ts index 12a066ae84..2a4ac2f713 100644 --- a/packages/collaboration/src/index.ts +++ b/packages/collaboration/src/index.ts @@ -14,7 +14,6 @@ * - usePresence hook for live cursor and presence tracking * - useConflictResolution hook for version history and conflict management * - CommentThread component for threaded comments with @mentions - * - LiveCursors component for displaying remote user cursors * - PresenceAvatars component for showing active user avatars * * @packageDocumentation @@ -76,11 +75,6 @@ export { type CommentSearchReturn, } from './useCommentSearch.js'; -export { - LiveCursors, - type LiveCursorsProps, -} from './LiveCursors.js'; - export { PresenceAvatars, type PresenceAvatarsProps, diff --git a/packages/core/src/builder/__tests__/schema-builder.test.ts b/packages/core/src/builder/__tests__/schema-builder.test.ts deleted file mode 100644 index f9c111fb4b..0000000000 --- a/packages/core/src/builder/__tests__/schema-builder.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as builder from '../../builder/schema-builder'; -import { form, button, input, card, grid, flex } from '../../builder/schema-builder'; -import { StrictAnyComponentSchema, safeValidateSchema } from '@object-ui/types/zod'; - -describe('SchemaBuilder', () => { - describe('form()', () => { - it('creates a basic form schema', () => { - const schema = form().id('test-form').build(); - expect(schema).toBeDefined(); - expect(schema.type).toBe('form'); - expect(schema.id).toBe('test-form'); - }); - - it('supports field definitions', () => { - const schema = form() - .field({ name: 'email', label: 'Email', type: 'string' }) - .build(); - expect(schema.fields).toHaveLength(1); - expect(schema.fields![0].name).toBe('email'); - }); - - it('supports bulk fields', () => { - const schema = form() - .fields([ - { name: 'first_name', label: 'First Name', type: 'string' }, - { name: 'last_name', label: 'Last Name', type: 'string' }, - ]) - .build(); - expect(schema.fields).toHaveLength(2); - }); - - it('supports default values', () => { - const schema = form() - .defaultValues({ status: 'active' }) - .build(); - expect(schema.defaultValues).toEqual({ status: 'active' }); - }); - - it('supports submit label', () => { - const schema = form() - .submitLabel('Create') - .build(); - expect(schema.submitLabel).toBe('Create'); - }); - - it('supports layout option', () => { - const schema = form() - .layout('vertical') - .build(); - expect(schema.layout).toBe('vertical'); - }); - - it('supports columns', () => { - const schema = form() - .columns(2) - .build(); - expect(schema.columns).toBe(2); - }); - - it('supports chaining', () => { - const schema = form() - .id('chained') - .field({ name: 'name', label: 'Name', type: 'string' }) - .submitLabel('Submit') - .layout('horizontal') - .columns(3) - .build(); - expect(schema.id).toBe('chained'); - expect(schema.fields).toHaveLength(1); - expect(schema.submitLabel).toBe('Submit'); - expect(schema.layout).toBe('horizontal'); - expect(schema.columns).toBe(3); - }); - }); - - describe('button()', () => { - it('creates a button schema', () => { - const schema = button().id('btn').build(); - expect(schema).toBeDefined(); - expect(schema.type).toBe('button'); - expect(schema.id).toBe('btn'); - }); - - it('supports label', () => { - const schema = button() - .label('Click Me') - .build(); - expect(schema.label).toBe('Click Me'); - }); - - it('supports variant', () => { - const schema = button() - .variant('destructive') - .build(); - expect(schema.variant).toBe('destructive'); - }); - }); - - describe('input()', () => { - it('creates an input schema', () => { - const schema = input().id('my-input').build(); - expect(schema).toBeDefined(); - expect(schema.type).toBe('input'); - expect(schema.id).toBe('my-input'); - }); - - it('supports label and placeholder', () => { - const schema = input() - .label('Username') - .placeholder('Enter username') - .build(); - expect(schema.label).toBe('Username'); - expect(schema.placeholder).toBe('Enter username'); - }); - }); - - describe('card()', () => { - it('creates a card schema', () => { - const schema = card().id('my-card').build(); - expect(schema).toBeDefined(); - expect(schema.type).toBe('card'); - expect(schema.id).toBe('my-card'); - }); - }); - - describe('grid()', () => { - it('creates a grid schema', () => { - const schema = grid().id('my-grid').build(); - expect(schema).toBeDefined(); - expect(schema.type).toBe('grid'); - expect(schema.id).toBe('my-grid'); - }); - - it('takes a column count the grid renderer maps, and nothing else (objectui#11491)', () => { - const schema = grid().columns(12).build(); - expect(schema.columns).toBe(12); - expect(safeValidateSchema(schema).success).toBe(true); - expect(StrictAnyComponentSchema.safeParse(schema).success).toBe(true); - // @ts-expect-error β€” `grid` maps no 13th column; `tsc` refuses it at the builder. - const thirteen = grid().columns(13).build(); - expect(safeValidateSchema(thirteen).success).toBe(false); - }); - }); - - describe('flex()', () => { - it('creates a flex schema', () => { - const schema = flex().id('my-flex').build(); - expect(schema).toBeDefined(); - expect(schema.type).toBe('flex'); - expect(schema.id).toBe('my-flex'); - }); - - it('supports direction', () => { - const schema = flex() - .direction('row') - .build(); - expect(schema.properties.direction).toBe('row'); - }); - - // objectui#11276 (the maintainer's ruling A on objectui#11300): the builder - // emits the AUTHORED spelling β€” every `flex` prop, the child list included, - // in the `properties` bag β€” so what it builds passes the authoring faces, - // which refuse those props written flat on the node by name. - it('emits its props in the properties bag, which both authoring faces accept (objectui#11276)', () => { - const schema = flex() - .id('row') - .className('p-4') - .direction('col') - .justify('between') - .align('center') - .gap(4) - .child({ type: 'text', content: 'a' }) - .child({ type: 'text', content: 'b' }) - .build(); - - expect(schema).toEqual({ - type: 'flex', - id: 'row', - className: 'p-4', - properties: { - direction: 'col', - justify: 'between', - align: 'center', - gap: 4, - children: [ - { type: 'text', content: 'a' }, - { type: 'text', content: 'b' }, - ], - }, - }); - for (const key of ['direction', 'justify', 'align', 'gap', 'children']) { - expect(schema, key).not.toHaveProperty(key); - } - expect(safeValidateSchema(schema).success).toBe(true); - expect(StrictAnyComponentSchema.safeParse(schema).success).toBe(true); - }); - }); - - describe('common builder properties', () => { - it('supports className', () => { - const schema = button() - .className('my-class') - .build(); - expect(schema.className).toBe('my-class'); - }); - - it('supports visible', () => { - const schema = button() - .visible(false) - .build(); - expect(schema.visible).toBe(false); - }); - - it('supports disabled', () => { - const schema = button() - .disabled(true) - .build(); - expect(schema.disabled).toBe(true); - }); - - it('supports testId', () => { - const schema = button() - .testId('test-btn') - .build(); - expect(schema.testId).toBe('test-btn'); - }); - }); -}); - -describe('the retired `crud` builder (objectui#5373)', () => { - it('exports no `crud` factory β€” the ADR-0049 retirement removed CRUDBuilder', () => { - // Counter-probe in the SAME assertion pair, so this cannot pass by the - // module failing to load or by the names being read off the wrong object: - // the surviving factories must still be there. - expect(builder).not.toHaveProperty('crud'); - expect(builder).not.toHaveProperty('CRUDBuilder'); - expect(typeof builder.form).toBe('function'); - expect(typeof builder.grid).toBe('function'); - }); -}); diff --git a/packages/core/src/builder/schema-builder.ts b/packages/core/src/builder/schema-builder.ts deleted file mode 100644 index e8a6d72312..0000000000 --- a/packages/core/src/builder/schema-builder.ts +++ /dev/null @@ -1,451 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * @object-ui/core - Schema Builder - * - * Fluent API for building schemas programmatically. - * Provides type-safe builder functions for common schema patterns. - * - * @module builder - * @packageDocumentation - */ - -import type { - BaseSchema, - DeclaredNode, - FormSchema, - FormField, - ButtonSchema, - InputSchema, - CardSchema, - GridSchema, - FlexLayoutProps -} from '@object-ui/types'; - -/** - * Base builder class - */ -class SchemaBuilder { - protected schema: any; - - constructor(type: string) { - this.schema = { type }; - } - - /** - * Set the ID - */ - id(id: string): this { - this.schema.id = id; - return this; - } - - /** - * Set the className - */ - className(className: string): this { - this.schema.className = className; - return this; - } - - /** - * Set visibility - */ - visible(visible: boolean): this { - this.schema.visible = visible; - return this; - } - - /** - * Set conditional visibility - */ - visibleOn(expression: string): this { - this.schema.visibleOn = expression; - return this; - } - - /** - * Set disabled state - */ - disabled(disabled: boolean): this { - this.schema.disabled = disabled; - return this; - } - - /** - * Set test ID - */ - testId(testId: string): this { - this.schema.testId = testId; - return this; - } - - /** - * Build the final schema - */ - build(): T { - return this.schema as T; - } -} - -/** - * Form builder - */ -export class FormBuilder extends SchemaBuilder { - constructor() { - super('form'); - this.schema.fields = []; - } - - /** - * Add a field to the form - */ - field(field: FormField): this { - this.schema.fields = [...(this.schema.fields || []), field]; - return this; - } - - /** - * Add multiple fields - */ - fields(fields: FormField[]): this { - this.schema.fields = fields; - return this; - } - - /** - * Set default values - */ - defaultValues(values: Record): this { - this.schema.defaultValues = values; - return this; - } - - /** - * Set submit label - */ - submitLabel(label: string): this { - this.schema.submitLabel = label; - return this; - } - - /** - * Set form layout - */ - layout(layout: 'vertical' | 'horizontal'): this { - this.schema.layout = layout; - return this; - } - - /** - * Set number of columns - */ - columns(columns: number): this { - this.schema.columns = columns; - return this; - } - - /** - * Set submit handler - */ - onSubmit(handler: (data: Record) => void | Promise): this { - this.schema.onSubmit = handler; - return this; - } -} - -/** - * Button builder - */ -export class ButtonBuilder extends SchemaBuilder { - constructor() { - super('button'); - } - - /** - * Set button label - */ - label(label: string): this { - this.schema.label = label; - return this; - } - - /** - * Set button variant - */ - variant(variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'): this { - this.schema.variant = variant; - return this; - } - - /** - * Set button size - */ - size(size: 'default' | 'sm' | 'lg' | 'icon'): this { - this.schema.size = size; - return this; - } - - /** - * Set button icon - */ - icon(icon: string): this { - this.schema.icon = icon; - return this; - } - - /** - * Set click handler - */ - onClick(handler: () => void | Promise): this { - this.schema.onClick = handler; - return this; - } - - /** - * Set loading state - */ - loading(loading: boolean): this { - this.schema.loading = loading; - return this; - } -} - -/** - * Input builder - */ -export class InputBuilder extends SchemaBuilder { - constructor() { - super('input'); - } - - /** - * Set field name - */ - name(name: string): this { - this.schema.name = name; - return this; - } - - /** - * Set label - */ - label(label: string): this { - this.schema.label = label; - return this; - } - - /** - * Set placeholder - */ - placeholder(placeholder: string): this { - this.schema.placeholder = placeholder; - return this; - } - - /** - * Set input type - */ - inputType(type: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url'): this { - this.schema.inputType = type; - return this; - } - - /** - * Mark as required - */ - required(required: boolean = true): this { - this.schema.required = required; - return this; - } - - /** - * Set default value - */ - defaultValue(value: string | number): this { - this.schema.defaultValue = value; - return this; - } -} - -/** - * Card builder - */ -export class CardBuilder extends SchemaBuilder { - constructor() { - super('card'); - } - - /** - * Set card title - */ - title(title: string): this { - this.schema.title = title; - return this; - } - - /** - * Set card description - */ - description(description: string): this { - this.schema.description = description; - return this; - } - - /** - * Set card content: a node of a declared type, or a list of them - * (objectui#11466). Each node is checked against its own type's keys. - */ - content(content: DeclaredNode | DeclaredNode[]): this { - this.schema.content = content; - return this; - } - - /** - * Set card variant - */ - variant(variant: 'default' | 'outline' | 'ghost'): this { - this.schema.variant = variant; - return this; - } - - /** - * Make card hoverable - */ - hoverable(hoverable: boolean = true): this { - this.schema.hoverable = hoverable; - return this; - } -} - -/** - * Grid builder - */ -export class GridBuilder extends SchemaBuilder { - constructor() { - super('grid'); - this.schema.children = []; - } - - /** - * Set number of columns β€” one of the counts the `grid` renderer maps - * (objectui#11491); the parameter is the declaration's own bare-count arm, - * so `tsc` refuses any other number. - */ - columns(columns: Extract, number>): this { - this.schema.columns = columns; - return this; - } - - /** - * Set gap β€” one of the steps the `grid` renderer maps (objectui#11474); the - * parameter is the declaration's own set, so `tsc` refuses any other number. - */ - gap(gap: NonNullable): this { - this.schema.gap = gap; - return this; - } - - /** - * Add a child: a node of a declared type (`DeclaredNode`, objectui#11466), - * checked against its own type's keys like a child written inline. - */ - child(child: DeclaredNode): this { - const children = Array.isArray(this.schema.children) ? this.schema.children : []; - this.schema.children = [...children, child]; - return this; - } - - /** - * Set all children: nodes of declared types (objectui#11466). - */ - children(children: DeclaredNode[]): this { - this.schema.children = children; - return this; - } -} - -/** - * Flex builder - * - * Builds an AUTHORED `flex` node: its props in the `properties` bag - * (objectui#11276, the maintainer's ruling A on objectui#11300) β€” - * `{ type: 'flex', properties: { direction, justify, align, gap, children } }`, - * the spelling `@objectstack/spec`'s page component declares and - * `@object-ui/types`' authoring faces accept. A `flex` prop written flat on the - * node is refused there by name, so the builder never writes one. The base - * setters (`id`, `className`, `visible`, …) stay on the node. The bag's members - * are `FlexLayoutProps`; `SchemaRenderer` hoists them onto the node before the - * `flex` renderer reads them. - */ -export class FlexBuilder extends SchemaBuilder { - constructor() { - super('flex'); - this.schema.properties = { children: [] }; - } - - /** - * Set flex direction - */ - direction(direction: 'row' | 'col' | 'row-reverse' | 'col-reverse'): this { - this.schema.properties.direction = direction; - return this; - } - - /** - * Set justify content - */ - justify(justify: 'start' | 'end' | 'center' | 'between' | 'around' | 'evenly'): this { - this.schema.properties.justify = justify; - return this; - } - - /** - * Set align items - */ - align(align: 'start' | 'end' | 'center' | 'baseline' | 'stretch'): this { - this.schema.properties.align = align; - return this; - } - - /** - * Set gap β€” one of the steps the `flex` renderer maps (objectui#11474); the - * parameter is the declaration's own set, so `tsc` refuses any other number. - */ - gap(gap: NonNullable): this { - this.schema.properties.gap = gap; - return this; - } - - /** - * Add a child: a node of a declared type (`DeclaredNode`, objectui#11466), - * checked against its own type's keys like a child written inline. - */ - child(child: DeclaredNode): this { - const children = Array.isArray(this.schema.properties.children) ? this.schema.properties.children : []; - this.schema.properties.children = [...children, child]; - return this; - } - - /** - * Set all children: nodes of declared types (objectui#11466). - */ - children(children: DeclaredNode[]): this { - this.schema.properties.children = children; - return this; - } -} - -// Export factory functions -export const form = () => new FormBuilder(); -export const button = () => new ButtonBuilder(); -export const input = () => new InputBuilder(); -export const card = () => new CardBuilder(); -export const grid = () => new GridBuilder(); -export const flex = () => new FlexBuilder(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 919fb6738b..c0819204e5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,7 +14,8 @@ export * from './registry/PluginSystem.js'; export * from './registry/PluginScopeImpl.js'; export * from './registry/WidgetRegistry.js'; export * from './validation/index.js'; -export * from './builder/schema-builder.js'; +// `builder/schema-builder` (the `form()` / `button()` / `FormBuilder` fluent +// builders) retired in objectui#13453: nothing outside its own test imported it. // The DOM pass-through whitelist of the SDUI widget prop contract // (objectui#4425 phase 2): a registered widget's host element receives only // what `toDomProps` passes β€” everything else is consumed or dropped. @@ -71,7 +72,10 @@ export * from './utils/debug-collector.js'; export * from './utils/freeze-schema.js'; // The shared key/value-bag check (objectui#13458); locals switch to it when touched. export * from './utils/is-plain-object.js'; -export * from './protocols/index.js'; +// `protocols/` retired whole in objectui#13453. Its three remaining bridges β€” +// `DndProtocol`, `KeyboardProtocol`, `SharingProtocol` β€” re-homed vocabulary +// the spec had already deleted, and had zero importers in this repo, the same +// disposition `NotificationProtocol` and `ResponsiveProtocol` got before them. export * from './styling/scoped-styles.js'; export * from './runtime/capabilities.js'; diff --git a/packages/core/src/protocols/DndProtocol.ts b/packages/core/src/protocols/DndProtocol.ts deleted file mode 100644 index c0bfed34c8..0000000000 --- a/packages/core/src/protocols/DndProtocol.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * @object-ui/core - DnD Protocol Bridge - * - * Converts spec-aligned DnD configuration schemas into runtime-usable - * component props and CSS constraint styles for drag-and-drop interactions. - * - * @module protocols/DndProtocol - * @packageDocumentation - */ - -// ============================================================================ -// DnD Vocabulary (formerly `@objectstack/spec/ui`) -// ============================================================================ -// `@objectstack/spec` 17.0.0-rc.3 deleted the whole `ui/dnd` module along with -// the four other interaction-config modules (objectstack#4988, PR -// objectstack#5321): none of them had an authoring door, so no metadata -// document could ever carry a dnd block and the platform stopped publishing -// vocabulary nothing could author. -// -// The declarations below are that vocabulary moved here verbatim β€” same keys, -// same members, same optionality as the retired `z.infer` types this module -// used to import through `@object-ui/types`. This bridge is the only consumer -// of the semantics, so it is now their owner, which is the remediation the -// spec's retirement ledger prescribes for a client that consumed these as -// types. Nothing about the resolver behaviour changes. -// -// NOTE for objectui#3363: that card recorded `DndProtocol.ts` / -// `KeyboardProtocol.ts` as same-name LOCAL declarations that never imported the -// spec ("ε‘½εε·§εˆ,δΈζ˜―θ€¦εˆ,不动"). That measurement was taken against direct -// `@objectstack/spec` imports only β€” both files reached the very same spec types -// INDIRECTLY, through `@object-ui/types`' re-export block, and so went red on -// this upgrade. Corrected here rather than left for the card. - -/** How a drag is initiated. */ -export type DragHandle = 'element' | 'handle' | 'grip_icon'; - -/** The `dropEffect` advertised to assistive technology and DnD libraries. */ -export type DropEffect = 'none' | 'link' | 'move' | 'copy'; - -/** Movement constraints applied to a drag. */ -export interface DragConstraint { - axis: 'x' | 'y' | 'both'; - bounds: 'none' | 'parent' | 'viewport'; - grid?: [number, number]; -} - -/** A draggable item's declaration. */ -export interface DragItem { - type: string; - label?: string; - handle: DragHandle; - constraint?: DragConstraint; - preview: 'custom' | 'none' | 'element'; - disabled: boolean; - ariaLabel?: string; - ariaDescribedBy?: string; - role?: string; -} - -/** A drop target's declaration. */ -export interface DropZone { - label?: string; - accept: string[]; - maxItems?: number; - highlightOnDragOver: boolean; - dropEffect: DropEffect; - ariaLabel?: string; - ariaDescribedBy?: string; - role?: string; -} - -/** Top-level drag-and-drop configuration. */ -export interface DndConfig { - enabled: boolean; - dragItem?: DragItem; - dropZone?: DropZone; - sortable: boolean; - autoScroll: boolean; - touchDelay: number; -} - -// ============================================================================ -// Resolved Types -// ============================================================================ - -/** Fully resolved DnD configuration with all defaults applied. */ -export interface ResolvedDndConfig { - enabled: boolean; - sortable: boolean; - autoScroll: boolean; - touchDelay: number; - dragItem?: DragItem; - dropZone?: DropZone; -} - -/** Component props for a draggable element. */ -export interface DragItemProps { - draggable: boolean; - 'aria-roledescription': string; - 'aria-label'?: string; - 'aria-describedby'?: string; - role: string; - 'data-drag-type': string; - 'data-drag-handle': string; - 'data-drag-disabled': string; -} - -/** Component props for a droppable area. */ -export interface DropZoneProps { - 'aria-dropeffect': string; - 'aria-label'?: string; - 'aria-describedby'?: string; - role: string; - 'data-drop-accept': string; - 'data-drop-max-items'?: number; - 'data-drop-highlight': string; -} - -/** CSS constraint styles for drag movement. */ -export interface DragConstraintStyles { - position: string; - touchAction: string; - [key: string]: string | number | undefined; -} - -// ============================================================================ -// DnD Config Resolution -// ============================================================================ - -/** - * Resolve a DnD configuration by applying spec defaults to missing fields. - * - * @param config - Partial DnD configuration from the spec - * @returns Fully resolved DnD configuration - */ -export function resolveDndConfig(config: DndConfig): ResolvedDndConfig { - return { - enabled: config.enabled ?? false, - sortable: config.sortable ?? false, - autoScroll: config.autoScroll ?? true, - touchDelay: config.touchDelay ?? 200, - dragItem: config.dragItem, - dropZone: config.dropZone, - }; -} - -// ============================================================================ -// Drag Item Props -// ============================================================================ - -/** - * Convert a spec DragItem to component props suitable for a draggable element. - * Produces `draggable`, ARIA attributes, and data attributes for DnD libraries. - * - * @param item - DragItem configuration from the spec - * @returns Component props object for a draggable element - */ -export function createDragItemProps(item: DragItem): DragItemProps { - return { - draggable: !(item.disabled ?? false), - 'aria-roledescription': 'draggable', - 'aria-label': item.ariaLabel ?? item.label, - 'aria-describedby': item.ariaDescribedBy, - role: item.role ?? 'listitem', - 'data-drag-type': item.type, - 'data-drag-handle': item.handle ?? 'element', - 'data-drag-disabled': String(item.disabled ?? false), - }; -} - -// ============================================================================ -// Drop Zone Props -// ============================================================================ - -/** - * Convert a spec DropZone to component props suitable for a droppable area. - * Produces ARIA attributes and data attributes for DnD libraries. - * - * @param zone - DropZone configuration from the spec - * @returns Component props object for a droppable area - */ -export function createDropZoneProps(zone: DropZone): DropZoneProps { - return { - 'aria-dropeffect': zone.dropEffect ?? 'move', - 'aria-label': zone.ariaLabel ?? zone.label, - 'aria-describedby': zone.ariaDescribedBy, - role: zone.role ?? 'list', - 'data-drop-accept': zone.accept.join(','), - 'data-drop-max-items': zone.maxItems, - 'data-drop-highlight': String(zone.highlightOnDragOver ?? true), - }; -} - -// ============================================================================ -// Drag Constraint Styles -// ============================================================================ - -/** - * Resolve a DragConstraint into CSS style properties that restrict - * drag movement along an axis or within bounds. - * - * @param constraint - DragConstraint configuration from the spec - * @returns CSS styles object for constraining drag movement - */ -export function resolveDragConstraints(constraint: DragConstraint): DragConstraintStyles { - const styles: DragConstraintStyles = { - position: 'relative', - touchAction: 'none', - }; - - const axis = constraint.axis ?? 'both'; - if (axis === 'x') { - styles.touchAction = 'pan-y'; - } else if (axis === 'y') { - styles.touchAction = 'pan-x'; - } - - const bounds = constraint.bounds ?? 'none'; - if (bounds === 'parent') { - styles.overflow = 'hidden'; - } - - if (constraint.grid) { - styles['--drag-grid-x'] = `${constraint.grid[0]}px`; - styles['--drag-grid-y'] = `${constraint.grid[1]}px`; - } - - return styles; -} diff --git a/packages/core/src/protocols/KeyboardProtocol.ts b/packages/core/src/protocols/KeyboardProtocol.ts deleted file mode 100644 index 0af044cff6..0000000000 --- a/packages/core/src/protocols/KeyboardProtocol.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * @object-ui/core - Keyboard Protocol Bridge - * - * Converts spec-aligned keyboard navigation and focus management - * schemas into runtime-usable configurations, shortcut parsers, - * and focus trap settings. - * - * @module protocols/KeyboardProtocol - * @packageDocumentation - */ - -// ============================================================================ -// Keyboard / Focus Vocabulary (formerly `@objectstack/spec/ui`) -// ============================================================================ -// `@objectstack/spec` 17.0.0-rc.3 deleted the whole `ui/keyboard` module with -// the four other interaction-config modules (objectstack#4988, PR -// objectstack#5321) β€” none had an authoring door, so no metadata document could -// ever carry a keyboard block. The declarations below are that vocabulary moved -// here verbatim (same keys, same members, same optionality as the retired -// `z.infer` types this module imported through `@object-ui/types`). This bridge -// is the only consumer of the semantics and is now their owner; resolver -// behaviour is unchanged. See `DndProtocol.ts` for the objectui#3363 premise -// correction that applies to this file too. - -/** A single keyboard shortcut binding. */ -export interface KeyboardShortcut { - key: string; - action: string; - description?: string; - scope: 'global' | 'form' | 'modal' | 'view' | 'list'; -} - -/** Focus trap declaration for a modal-like region. */ -export interface FocusTrapConfig { - enabled: boolean; - initialFocus?: string; - returnFocus: boolean; - escapeDeactivates: boolean; -} - -/** Focus management declaration. */ -export interface FocusManagement { - tabOrder: 'manual' | 'auto'; - skipLinks: boolean; - focusVisible: boolean; - focusTrap?: FocusTrapConfig; - arrowNavigation: boolean; -} - -/** Top-level keyboard navigation configuration. */ -export interface KeyboardNavigationConfig { - shortcuts?: KeyboardShortcut[]; - focusManagement?: FocusManagement; - rovingTabindex: boolean; - ariaLabel?: string; - ariaDescribedBy?: string; - role?: string; -} - -// ============================================================================ -// Resolved Types -// ============================================================================ - -/** Fully resolved keyboard navigation configuration. */ -export interface ResolvedKeyboardConfig { - shortcuts: KeyboardShortcut[]; - focusManagement: ResolvedFocusManagement; - rovingTabindex: boolean; - ariaLabel?: string; - ariaDescribedBy?: string; - role?: string; -} - -/** Fully resolved focus management configuration. */ -export interface ResolvedFocusManagement { - tabOrder: 'auto' | 'manual'; - skipLinks: boolean; - focusVisible: boolean; - focusTrap?: ResolvedFocusTrapConfig; - arrowNavigation: boolean; -} - -/** Fully resolved focus trap configuration. */ -export interface ResolvedFocusTrapConfig { - enabled: boolean; - initialFocus?: string; - returnFocus: boolean; - escapeDeactivates: boolean; -} - -/** Parsed keyboard shortcut descriptor. */ -export interface ParsedShortcut { - key: string; - ctrlOrMeta: boolean; - shift: boolean; - alt: boolean; -} - -/** Minimal keyboard event shape for matching (no React dependency). */ -export interface KeyboardEventLike { - key: string; - ctrlKey?: boolean; - metaKey?: boolean; - shiftKey?: boolean; - altKey?: boolean; -} - -// ============================================================================ -// Keyboard Config Resolution -// ============================================================================ - -/** - * Resolve a keyboard navigation configuration by applying spec defaults. - * - * @param config - KeyboardNavigationConfig from the spec - * @returns Fully resolved keyboard navigation configuration - */ -export function resolveKeyboardConfig(config: KeyboardNavigationConfig): ResolvedKeyboardConfig { - return { - shortcuts: config.shortcuts ?? [], - focusManagement: resolveFocusManagement(config.focusManagement), - rovingTabindex: config.rovingTabindex ?? false, - ariaLabel: config.ariaLabel, - ariaDescribedBy: config.ariaDescribedBy, - role: config.role, - }; -} - -/** - * Resolve focus management configuration with defaults. - */ -function resolveFocusManagement(fm?: FocusManagement): ResolvedFocusManagement { - return { - tabOrder: fm?.tabOrder ?? 'auto', - skipLinks: fm?.skipLinks ?? false, - focusVisible: fm?.focusVisible ?? true, - focusTrap: fm?.focusTrap ? createFocusTrapConfig(fm.focusTrap) : undefined, - arrowNavigation: fm?.arrowNavigation ?? false, - }; -} - -// ============================================================================ -// Shortcut Parsing -// ============================================================================ - -/** - * Parse a shortcut string like "Ctrl+Shift+S" into a structured descriptor. - * Recognises Ctrl, Meta, Cmd (mapped to ctrlOrMeta), Shift, and Alt modifiers. - * The last segment is treated as the key. - * - * Keys are normalised to lowercase for case-insensitive matching. The Shift - * modifier is tracked separately, so "Shift+A" and "Shift+a" are equivalent - * (both represent the same physical key combination). - * - * @param shortcut - Shortcut string (e.g. "Ctrl+S", "Alt+Shift+N", "Escape") - * @returns Parsed shortcut descriptor - */ -export function parseShortcutKey(shortcut: string): ParsedShortcut { - const parts = shortcut.split('+').map(p => p.trim()); - const modifiers = new Set(parts.slice(0, -1).map(m => m.toLowerCase())); - const key = (parts[parts.length - 1] ?? '').toLowerCase(); - - return { - key, - ctrlOrMeta: modifiers.has('ctrl') || modifiers.has('meta') || modifiers.has('cmd'), - shift: modifiers.has('shift'), - alt: modifiers.has('alt'), - }; -} - -// ============================================================================ -// Shortcut Matching -// ============================================================================ - -/** - * Test whether a keyboard event matches a shortcut string. - * Comparison is case-insensitive; the Shift modifier is checked separately. - * - * @param event - The keyboard event (or a plain object with key + modifier flags) - * @param shortcut - Shortcut string (e.g. "Ctrl+S") - * @returns `true` if the event matches the shortcut - */ -export function matchesShortcut(event: KeyboardEventLike, shortcut: string): boolean { - const parsed = parseShortcutKey(shortcut); - - if (event.key.toLowerCase() !== parsed.key) return false; - - const eventCtrlOrMeta = !!(event.ctrlKey || event.metaKey); - if (parsed.ctrlOrMeta !== eventCtrlOrMeta) return false; - - if (parsed.shift !== !!(event.shiftKey)) return false; - if (parsed.alt !== !!(event.altKey)) return false; - - return true; -} - -// ============================================================================ -// Focus Trap Configuration -// ============================================================================ - -/** - * Create a resolved focus trap configuration from a spec FocusTrapConfig. - * - * @param config - FocusTrapConfig from the spec - * @returns Resolved focus trap configuration with defaults applied - */ -export function createFocusTrapConfig(config: FocusTrapConfig): ResolvedFocusTrapConfig { - return { - enabled: config.enabled ?? false, - initialFocus: config.initialFocus, - returnFocus: config.returnFocus ?? true, - escapeDeactivates: config.escapeDeactivates ?? true, - }; -} diff --git a/packages/core/src/protocols/SharingProtocol.ts b/packages/core/src/protocols/SharingProtocol.ts deleted file mode 100644 index 0f5fd2c332..0000000000 --- a/packages/core/src/protocols/SharingProtocol.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * @object-ui/core - Sharing Protocol Bridge - * - * Converts spec-aligned SharingConfig and EmbedConfig schemas into - * runtime-usable configurations. Provides embed code generation and - * configuration validation. - * - * @module protocols/SharingProtocol - * @packageDocumentation - */ - -import type { SharingConfig } from '@object-ui/types'; - -// ============================================================================ -// Embed Vocabulary (formerly `@objectstack/spec/ui`) -// ============================================================================ -// `EmbedConfig` / `EmbedConfigSchema` were RETIRED in `@objectstack/spec` -// 17.0.0-rc.3 (objectstack#5015, PR objectstack#5300): they were published -// `ui` vocabulary with NO AUTHORING DOOR, and no iframe route ever read an -// embed config, so nothing ran to regress. `SharingConfig` is the SURVIVOR and -// is still the spec's β€” public form sharing is unaffected and `FormView.sharing` -// still gates the anonymous endpoints on `allowAnonymous` + `publicLink`. -// -// The declaration below is the retired shape moved here verbatim, so that -// `resolveEmbedConfig` / `generateEmbedCode` keep the exact input contract they -// had. Whether this bridge should exist at all now that the platform has -// withdrawn the vocabulary is a product question for objectui#3362, not a -// green-restoring one β€” this change deliberately keeps the surface intact. - -/** An iframe embed declaration. */ -export interface EmbedConfig { - enabled: boolean; - allowedOrigins?: string[]; - width?: string; - height?: string; - showHeader?: boolean; - showNavigation?: boolean; - responsive?: boolean; -} - -// ============================================================================ -// Resolved Types -// ============================================================================ - -/** Fully resolved sharing configuration. */ -export interface ResolvedSharingConfig { - enabled: boolean; - publicLink?: string; - password?: string; - allowedDomains: string[]; - expiresAt?: string; - allowAnonymous: boolean; -} - -/** Fully resolved embed configuration. */ -export interface ResolvedEmbedConfig { - enabled: boolean; - allowedOrigins: string[]; - width: string; - height: string; - showHeader: boolean; - showNavigation: boolean; - responsive: boolean; -} - -/** Validation result for sharing configuration. */ -export interface SharingValidationResult { - valid: boolean; - errors: string[]; -} - -// ============================================================================ -// Sharing Config Resolution -// ============================================================================ - -/** - * Resolve a sharing configuration by applying spec defaults. - * - * @param config - Partial SharingConfig from the spec - * @returns Fully resolved sharing configuration - */ -export function resolveSharingConfig(config: Partial): ResolvedSharingConfig { - return { - enabled: config.enabled ?? false, - publicLink: config.publicLink, - password: config.password, - allowedDomains: config.allowedDomains ?? [], - expiresAt: config.expiresAt, - allowAnonymous: config.allowAnonymous ?? false, - }; -} - -// ============================================================================ -// Embed Config Resolution -// ============================================================================ - -/** - * Resolve an embed configuration by applying spec defaults. - * - * @param config - Partial EmbedConfig from the spec - * @returns Fully resolved embed configuration - */ -export function resolveEmbedConfig(config: Partial): ResolvedEmbedConfig { - return { - enabled: config.enabled ?? false, - allowedOrigins: config.allowedOrigins ?? [], - width: config.width ?? '100%', - height: config.height ?? '600px', - showHeader: config.showHeader ?? true, - showNavigation: config.showNavigation ?? false, - responsive: config.responsive ?? true, - }; -} - -// ============================================================================ -// Embed Code Generation -// ============================================================================ - -/** - * Generate an HTML iframe embed snippet from an EmbedConfig and URL. - * - * @param config - EmbedConfig from the spec - * @param url - The URL to embed - * @returns HTML string containing an iframe element - */ -export function generateEmbedCode(config: EmbedConfig, url: string): string { - const resolved = resolveEmbedConfig(config); - const sanitizedUrl = escapeHtmlAttr(url); - const title = 'Embedded content'; - - const parts = [ - ``); - - return parts.join('\n'); -} - -/** - * Escape a string for safe use in an HTML attribute value. - */ -function escapeHtmlAttr(value: string): string { - return value - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(//g, '>'); -} - -// ============================================================================ -// Sharing Config Validation -// ============================================================================ - -/** - * Validate a sharing configuration and return any errors. - * - * @param config - SharingConfig to validate - * @returns Validation result with `valid` flag and error messages - */ -export function validateSharingConfig(config: SharingConfig): SharingValidationResult { - const errors: string[] = []; - - if (config.enabled && !config.publicLink) { - errors.push('A public link is required when sharing is enabled.'); - } - - if (config.expiresAt) { - const expiryDate = new Date(config.expiresAt); - if (isNaN(expiryDate.getTime())) { - errors.push('expiresAt must be a valid ISO 8601 date string.'); - } - } - - if (config.allowedDomains) { - for (const domain of config.allowedDomains) { - if (!domain || domain.trim().length === 0) { - errors.push('allowedDomains contains an empty or whitespace-only entry.'); - break; - } - } - } - - if (config.password !== undefined && config.password.length === 0) { - errors.push('Password must not be an empty string when provided.'); - } - - return { - valid: errors.length === 0, - errors, - }; -} diff --git a/packages/core/src/protocols/index.ts b/packages/core/src/protocols/index.ts deleted file mode 100644 index 36b2885ad2..0000000000 --- a/packages/core/src/protocols/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -export * from './DndProtocol.js'; -export * from './KeyboardProtocol.js'; -// `NotificationProtocol` removed in the 17.0.0-rc.2 uptake: it bridged -// `@objectstack/spec/ui`'s `Notification` / `NotificationConfig`, which -// objectstack#4610 deleted with no successor. The bridge had zero consumers in -// this repo β€” the live notification config resolution is -// `resolveNotificationConfig` in `@object-ui/react`'s `NotificationContext`, -// which declares its own `NotificationSystemConfig` and is what every surface -// reads. Re-typing the bridge would have meant re-declaring a vocabulary the -// spec had just retired (AGENTS.md #0.1). -// `ResponsiveProtocol` removed in the objectstack#11027 uptake (objectui#7580): -// it bridged `@objectstack/spec/ui`'s `ResponsiveConfig`, which that retirement -// deleted with no successor, and it had zero importers in this repo (measured on -// objectui#4773; `docs/audits/2901-spec-enum-renderer-coverage.md` had already -// recorded it as one of the zero-importer protocols). Re-typing the bridge would -// have meant re-declaring a vocabulary the spec had just retired, for a reader -// that does not exist β€” AGENTS.md #0.1, and the same disposition -// `NotificationProtocol` got above. The layout vocabulary that DOES have a -// renderer is re-homed instead, in `@object-ui/types` and `@object-ui/layout`. -export * from './SharingProtocol.js'; diff --git a/packages/react/src/hooks/__tests__/useETagCache.configAlloc.test.tsx b/packages/react/src/hooks/__tests__/useETagCache.configAlloc.test.tsx index 821d4ae7e7..314016dc09 100644 --- a/packages/react/src/hooks/__tests__/useETagCache.configAlloc.test.tsx +++ b/packages/react/src/hooks/__tests__/useETagCache.configAlloc.test.tsx @@ -8,8 +8,9 @@ * * `useRef` evaluates that argument on EVERY render and keeps only the first * result, so every later render allocated a five-key object that was thrown - * away. It is the same shape PR objectui#6796 repaired in `useSchemaPersistence` - * (`useRef(createLocalStorageAdapter())`), with one difference that is the whole + * away. It is the same shape PR objectui#6796 repaired in the since-retired + * `useSchemaPersistence` (`useRef(createLocalStorageAdapter())`), with one + * difference that is the whole * reason this needed its own card: clearing that one's `react-hooks/refs` * warning REQUIRED the change, and clearing this one's did not β€” so the lint * rule structurally cannot see this half of the class, and only a test can. diff --git a/packages/react/src/hooks/__tests__/useSchemaPersistence.adapterTracking.test.tsx b/packages/react/src/hooks/__tests__/useSchemaPersistence.adapterTracking.test.tsx deleted file mode 100644 index d0c00dc607..0000000000 --- a/packages/react/src/hooks/__tests__/useSchemaPersistence.adapterTracking.test.tsx +++ /dev/null @@ -1,172 +0,0 @@ -/** - * ObjectUI β€” useSchemaPersistence adapter-tracking pins (objectui#6745) - * Copyright (c) 2024-present ObjectStack Inc. - * - * The hook keeps the live adapter in a ref so that `save`/`load`/`list`/ - * `remove` can be created once (`[]` deps) and still reach the newest adapter - * at call time. That ref used to be written in the RENDER BODY: - * - * const adapterRef = useRef(adapter ?? defaultAdapter.current); - * adapterRef.current = adapter ?? defaultAdapter.current; // during render - * - * which `react-hooks/refs` flags: a render React discards or replays still - * performed the write, so a save could be routed through an adapter belonging - * to a render that never committed. - * - * Moving that write to `useEffect` would have been a BEHAVIOUR change, not a - * cleanup β€” the assignment would land after paint, so anything calling `save()` - * earlier in the same commit would reach the PREVIOUS adapter. It now lives in - * `useInsertionEffect`, which runs in the mutation phase: before every layout - * effect in the tree, before paint, and before any event handler can fire. - * - * These pins hold BOTH halves. Pin 2 is the one that discriminates: it fails - * against a `useEffect` write (fires after paint) and against a - * `useLayoutEffect` write (a child's layout effect runs before its parent's), - * and passes only while the write happens no later than the mutation phase. - */ - -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { useLayoutEffect } from 'react'; -import { render, renderHook, act } from '@testing-library/react'; -import { - useSchemaPersistence, - type SchemaPersistenceAdapter, -} from '../useSchemaPersistence'; - -const SCHEMA = { type: 'page:list' } as const; -const KEY = 'objectui-schema:design-1'; - -/** An adapter that records every id handed to `save`, tagged with its name. */ -function recordingAdapter(name: string, log: string[]): SchemaPersistenceAdapter { - return { - save: vi.fn(async (id: string) => { - log.push(`${name}:${id}`); - return id; - }), - load: vi.fn(async () => null), - list: vi.fn(async () => []), - delete: vi.fn(async () => {}), - }; -} - -beforeEach(() => { - localStorage.clear(); -}); - -describe('useSchemaPersistence β€” adapter tracking (#6745)', () => { - // ---- pin 1: a changed `adapter` prop is what the next save reaches -------- - it('routes a save through the adapter from the latest render, not the first', async () => { - const log: string[] = []; - const first = recordingAdapter('first', log); - const second = recordingAdapter('second', log); - - const hook = renderHook(({ adapter }) => useSchemaPersistence(adapter), { - initialProps: { adapter: first }, - }); - - await act(async () => { - await hook.result.current.save('before-swap', { ...SCHEMA }); - }); - - hook.rerender({ adapter: second }); - - await act(async () => { - await hook.result.current.save('after-swap', { ...SCHEMA }); - }); - - expect(log).toEqual(['first:before-swap', 'second:after-swap']); - expect(first.save).toHaveBeenCalledTimes(1); - expect(second.save).toHaveBeenCalledTimes(1); - }); - - // ---- pin 2: the swap is in place BEFORE that commit's layout effects ------ - // - // The child's layout effect runs inside the very commit that introduced the - // new adapter, and before the parent's own layout effects. Whatever it calls - // must already be routed to the new adapter β€” that is the timing the old - // render-body write gave, and the reason this write is an insertion effect. - it('has the new adapter in place before a child layout effect of the same commit', async () => { - const log: string[] = []; - const first = recordingAdapter('first', log); - const second = recordingAdapter('second', log); - - type Save = (id: string, schema: Record) => Promise; - - function Child({ save, token }: { save: Save; token: string }) { - // `save` is created once (`[]` deps) and never changes identity, so this - // fires exactly once per adapter β€” on mount, and again in the commit that - // swapped it. (Depending on a fresh closure instead re-fires on every - // commit, and `save`'s own `setLoading` then loops the test forever.) - useLayoutEffect(() => { - void save(`from-layout-${token}`, { ...SCHEMA }); - }, [token, save]); - return null; - } - - function Host({ adapter, token }: { adapter: SchemaPersistenceAdapter; token: string }) { - const persistence = useSchemaPersistence(adapter); - return ; - } - - const view = render(); - await act(async () => {}); - - expect(log).toEqual(['first:from-layout-first']); - - await act(async () => { - view.rerender(); - }); - - // Under a `useEffect` or `useLayoutEffect` write this second entry reads - // `first:from-layout-second` β€” the call is routed to the stale adapter. - expect(log).toEqual(['first:from-layout-first', 'second:from-layout-second']); - }); - - // ---- pin 3: the default localStorage adapter still works across renders --- - // - // The default adapter moved from `useRef(createLocalStorageAdapter())` (which - // re-ran the factory on every render and threw the result away) to a - // `useMemo`. It must still be the adapter a save lands in when no `adapter` - // prop is given, including after a re-render. - it('keeps persisting through the default adapter after a re-render', async () => { - const hook = renderHook(() => useSchemaPersistence()); - - hook.rerender(); - hook.rerender(); - - await act(async () => { - await hook.result.current.save('design-1', { ...SCHEMA }); - }); - - const raw = localStorage.getItem(KEY); - expect(raw).not.toBeNull(); - expect(JSON.parse(raw as string).schema).toEqual({ ...SCHEMA }); - }); - - // ---- pin 4: an explicit adapter takes over from the default -------------- - it('switches from the default adapter to an explicit one handed in later', async () => { - const log: string[] = []; - const explicit = recordingAdapter('explicit', log); - - const hook = renderHook( - ({ adapter }: { adapter?: SchemaPersistenceAdapter }) => useSchemaPersistence(adapter), - { initialProps: { adapter: undefined as SchemaPersistenceAdapter | undefined } }, - ); - - await act(async () => { - await hook.result.current.save('design-1', { ...SCHEMA }); - }); - expect(localStorage.getItem(KEY)).not.toBeNull(); - - hook.rerender({ adapter: explicit }); - - await act(async () => { - await hook.result.current.save('design-2', { ...SCHEMA }); - }); - - expect(log).toEqual(['explicit:design-2']); - // The explicit adapter does not write localStorage, so the default's entry - // is the only one there. - expect(localStorage.getItem('objectui-schema:design-2')).toBeNull(); - }); -}); diff --git a/packages/react/src/hooks/__tests__/useSchemaPersistence.callableGuard.test.tsx b/packages/react/src/hooks/__tests__/useSchemaPersistence.callableGuard.test.tsx deleted file mode 100644 index d1b6f88d53..0000000000 --- a/packages/react/src/hooks/__tests__/useSchemaPersistence.callableGuard.test.tsx +++ /dev/null @@ -1,233 +0,0 @@ -/** - * ObjectUI β€” useSchemaPersistence callable-guard pins (objectui#6658) - * Copyright (c) 2024-present ObjectStack Inc. - * - * The designer save door used to hand the schema straight to `JSON.stringify`, - * which drops a function-valued property SILENTLY: `save()` resolved with the - * id, `error` stayed null, `lastSavedAt` advanced, and the stored entry had - * quietly lost the handler. Measured on `faac0d935` before the guard landed: - * - * stored raw = {"schema":{"type":"page:list","columns":[{"name":"a"}, - * {"name":"b"},{"name":"c"}]},"updatedAt":"..."} - * save() -> "repro-6658" error -> null lastSavedAt -> set - * - * Both the top-level `onSelectionChange` and the array-nested `columns[2].cell` - * were gone, and every observable signal reported success. - * - * Per the 2026-08-29 maintainer ruling the door now REFUSES such a save. The - * guard sits at the hook layer, not in the default adapter, so a host-injected - * or REST adapter is covered too β€” that placement is the ruling, not a - * preference, and the host-adapter pin below is what holds it there. - */ - -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; -import { - useSchemaPersistence, - type SchemaPersistenceAdapter, -} from '../useSchemaPersistence'; - -const KEY = 'objectui-schema:design-1'; - -/** Drive `save()` through the hook and hand back the value it resolved with. */ -async function drive( - schema: Record, - adapter?: SchemaPersistenceAdapter, - id = 'design-1', -) { - const hook = renderHook(() => useSchemaPersistence(adapter)); - let returned: string | null | undefined; - await act(async () => { - returned = await hook.result.current.save(id, schema); - }); - return { hook, returned }; -} - -beforeEach(() => { - localStorage.clear(); -}); - -describe('useSchemaPersistence β€” callable guard (#6658)', () => { - // ---- pin 1: a NESTED function value is refused, loudly ------------------- - it('refuses a save whose schema carries a nested function value', async () => { - const schema: Record = { - type: 'page:list', - toolbar: { actions: { onExport: () => 'boom' } }, - }; - - const { hook, returned } = await drive(schema); - - expect(returned).toBeNull(); - expect(hook.result.current.error).toBeInstanceOf(Error); - // lastSavedAt untouched β€” the ruling's pin, and the signal that used to lie - expect(hook.result.current.lastSavedAt).toBeNull(); - // nothing was written at all - expect(localStorage.getItem(KEY)).toBeNull(); - expect(localStorage.getItem('objectui-schema:__index__')).toBeNull(); - }); - - it('names the exact offending key path, not just "a function was found"', async () => { - const schema: Record = { - type: 'page:list', - toolbar: { actions: { onExport: () => 'boom' } }, - }; - - const { hook } = await drive(schema); - - expect(hook.result.current.error?.message).toContain('toolbar.actions.onExport'); - }); - - it('lists EVERY offending path when a schema carries several', async () => { - const schema: Record = { - type: 'page:list', - onSelectionChange: () => 'a', - detail: { onCardMove: () => 'b' }, - }; - - const { hook } = await drive(schema); - const message = hook.result.current.error?.message ?? ''; - - expect(message).toContain('onSelectionChange'); - expect(message).toContain('detail.onCardMove'); - expect(message).toContain('2 function-valued'); - }); - - it('names both escapes, so the error says what to DO', async () => { - const { hook } = await drive({ onClick: () => 'x' }); - const message = hook.result.current.error?.message ?? ''; - - // escape 1: strip the callables before saving - expect(message).toContain('strip the callables before saving'); - // escape 2: use the declarative form - expect(message).toContain('declarative form'); - }); - - // ---- pin 4: a function nested inside an ARRAY element is caught too ------ - it('catches a function nested inside an array element, path spelled by index', async () => { - const schema: Record = { - type: 'page:list', - columns: [{ name: 'a' }, { name: 'b' }, { name: 'c', cell: () => 'x' }], - }; - - const { hook, returned } = await drive(schema); - - expect(returned).toBeNull(); - expect(hook.result.current.error?.message).toContain('columns[2].cell'); - expect(localStorage.getItem(KEY)).toBeNull(); - }); - - // ---- pin 3: a HOST-INJECTED adapter is covered, not just localStorage ---- - it('refuses before a host-injected adapter is ever reached', async () => { - // The whole reason the ruling put the guard at the hook layer: a REST or - // host adapter has the identical `JSON.stringify` shape, so a guard living - // in the default localStorage adapter would leave this one unprotected. - const hostSave = vi.fn(async () => 'design-1'); - const hostAdapter: SchemaPersistenceAdapter = { - save: hostSave, - load: async () => null, - }; - - const { hook, returned } = await drive( - { type: 'page:list', columns: [{ name: 'c', cell: () => 'x' }] }, - hostAdapter, - ); - - expect(hostSave).not.toHaveBeenCalled(); - expect(returned).toBeNull(); - expect(hook.result.current.error?.message).toContain('columns[0].cell'); - expect(hook.result.current.lastSavedAt).toBeNull(); - }); - - it('still lets a host-injected adapter save a declarative schema', async () => { - const hostSave = vi.fn(async () => 'design-1'); - const hostAdapter: SchemaPersistenceAdapter = { - save: hostSave, - load: async () => null, - }; - const schema = { type: 'page:list', columns: [{ name: 'c' }] }; - - const { hook, returned } = await drive(schema, hostAdapter); - - expect(hostSave).toHaveBeenCalledWith('design-1', schema); - expect(returned).toBe('design-1'); - expect(hook.result.current.error).toBeNull(); - expect(hook.result.current.lastSavedAt).not.toBeNull(); - }); - - // ---- pin 2: a declarative schema saves EXACTLY as today ------------------ - it('saves a fully declarative schema byte-identically to the pre-guard door', async () => { - const schema: Record = { - type: 'page:list', - title: 'Contacts', - columns: [{ name: 'a' }, { name: 'b', width: 120 }], - filters: { status: ['open', 'closed'] }, - }; - - const { hook, returned } = await drive(schema); - - expect(returned).toBe('design-1'); - expect(hook.result.current.error).toBeNull(); - expect(hook.result.current.lastSavedAt).not.toBeNull(); - expect(hook.result.current.isDirty).toBe(false); - - const raw = localStorage.getItem(KEY); - expect(raw).not.toBeNull(); - // Byte-identical to what the pre-guard door produced: the guard must not - // rewrite, re-order or strip anything on the way through. Rebuilt here from - // the entry's own `updatedAt` (the only non-deterministic part). - const entry = JSON.parse(raw as string); - expect(raw).toBe(JSON.stringify({ schema, updatedAt: entry.updatedAt })); - expect(entry.schema).toEqual(schema); - // and the index the adapter maintains is untouched in shape - expect(JSON.parse(localStorage.getItem('objectui-schema:__index__') as string)).toEqual([ - 'design-1', - ]); - }); - - it('round-trips a declarative schema through save + load unchanged', async () => { - const schema: Record = { - type: 'page:list', - columns: [{ name: 'a' }, { name: 'b' }], - }; - - const { hook } = await drive(schema); - - let loaded: Record | null = null; - await act(async () => { - loaded = await hook.result.current.load('design-1'); - }); - - expect(loaded).toEqual(schema); - }); - - it('does not mistake a declarative schema for a callable one (no over-broad guard)', async () => { - // Strings that merely LOOK like handlers, plus nulls and nested empties β€” - // an over-broad guard trips on these and breaks every existing flow. - const schema: Record = { - type: 'page:list', - onClick: 'action:submit', - cell: { kind: 'badge' }, - empty: {}, - list: [], - nothing: null, - count: 0, - flag: false, - }; - - const { hook, returned } = await drive(schema); - - expect(returned).toBe('design-1'); - expect(hook.result.current.error).toBeNull(); - expect(JSON.parse(localStorage.getItem(KEY) as string).schema).toEqual(schema); - }); - - it('does not hang on a cyclic schema before the guard can report', async () => { - const schema: Record = { type: 'page:list', onClick: () => 'x' }; - schema.self = schema; - - const { hook, returned } = await drive(schema); - - expect(returned).toBeNull(); - expect(hook.result.current.error?.message).toContain('onClick'); - }); -}); diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 9bac804df8..5203f55271 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -21,8 +21,6 @@ export * from './useViewData.js'; export * from './useElementDataSource.js'; export * from './useDynamicApp.js'; export * from './useDiscovery.js'; -export * from './useFocusTrap.js'; -export * from './useFocusManagement.js'; export * from './useKeyboardShortcuts.js'; export * from './useCrudShortcuts.js'; export * from './useReducedMotion.js'; @@ -32,10 +30,8 @@ export * from './useViewSharing.js'; export * from './useOffline.js'; export * from './usePerformance.js'; export * from './usePerformanceBudget.js'; -export * from './usePageTransition.js'; export * from './useViewTransition.js'; export * from './useETagCache.js'; -export * from './useSchemaPersistence.js'; export * from './useGlobalUndo.js'; export * from './useDebugMode.js'; export * from './useActionEngine.js'; diff --git a/packages/react/src/hooks/useAnimation.ts b/packages/react/src/hooks/useAnimation.ts index 6c1ccb7feb..5dadbd0810 100644 --- a/packages/react/src/hooks/useAnimation.ts +++ b/packages/react/src/hooks/useAnimation.ts @@ -105,9 +105,9 @@ function normalizePreset(preset: string): string { } // Keyed by the SPEC spellings (`ui/animation.zod.ts`). Class values for the -// shared presets are unchanged; `rotate`/`flip` follow `usePageTransition`'s -// enter classes (spin-in / fade+zoom), the sibling hook that always carried -// the full spec vocabulary. +// shared presets are unchanged; `rotate`/`flip` follow the enter classes +// (spin-in / fade+zoom) of the since-retired `usePageTransition`, the sibling +// hook that always carried the full spec vocabulary. const EASING_MAP: Record = { linear: 'linear', ease: 'ease', diff --git a/packages/react/src/hooks/useETagCache.ts b/packages/react/src/hooks/useETagCache.ts index 496af5bf13..0f77c5120a 100644 --- a/packages/react/src/hooks/useETagCache.ts +++ b/packages/react/src/hooks/useETagCache.ts @@ -213,8 +213,8 @@ export function useETagCache(userConfig: ETagCacheConfig = {}): ETagCacheResult // places that consume it. `useRef({ ... })` evaluates its argument on EVERY // render and keeps only the first result, so every later render allocated a // five-key object that was thrown away (objectui#6817) β€” the same shape PR - // objectui#6796 repaired in `useSchemaPersistence`. Its identity is - // unobservable from outside the hook: the ref is private and every reader + // objectui#6796 repaired in the since-retired `useSchemaPersistence`. Its + // identity is unobservable from outside the hook: the ref is private and every reader // only reads fields off `.current`, so a memo React chooses to discard is // harmless β€” it rebuilds an equal object, which is what every render used // to do unconditionally. diff --git a/packages/react/src/hooks/useFocusManagement.ts b/packages/react/src/hooks/useFocusManagement.ts deleted file mode 100644 index 190ebb2357..0000000000 --- a/packages/react/src/hooks/useFocusManagement.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { useEffect, useRef, useCallback, useState } from 'react'; - -// ─── useFocusReturn ──────────────────────────────────────────────────────────── - -export interface FocusReturnOptions { - /** Whether focus restoration is enabled. @default true */ - enabled?: boolean; - /** Whether to focus the previously active element on unmount. @default true */ - restoreOnUnmount?: boolean; -} - -/** - * Hook that captures the currently focused element on mount and restores - * focus to it when the component unmounts. Useful for modals, drawers, - * and overlays that temporarily steal focus. - * - * @example - * ```tsx - * function Drawer({ onClose }: { onClose: () => void }) { - * const { returnFocus } = useFocusReturn(); - * - * const handleClose = () => { - * returnFocus(); - * onClose(); - * }; - * - * return ; - * } - * ``` - */ -export function useFocusReturn(options: FocusReturnOptions = {}): { - /** Manually restore focus to the previously focused element. */ - returnFocus: () => void; -} { - const { enabled = true, restoreOnUnmount = true } = options; - const previousFocusRef = useRef(null); - - // Capture focused element on mount - useEffect(() => { - if (!enabled) return; - previousFocusRef.current = document.activeElement as HTMLElement | null; - }, [enabled]); - - const returnFocus = useCallback(() => { - if (!enabled) return; - const el = previousFocusRef.current; - if (el && typeof el.focus === 'function' && document.body.contains(el)) { - el.focus(); - } - }, [enabled]); - - // Restore on unmount - useEffect(() => { - if (!enabled || !restoreOnUnmount) return; - return () => { - const el = previousFocusRef.current; - if (el && typeof el.focus === 'function' && document.body.contains(el)) { - el.focus(); - } - }; - // Only run cleanup on unmount β€” intentionally stable deps - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return { returnFocus }; -} - -// ─── useRovingFocus ──────────────────────────────────────────────────────────── - -export type RovingOrientation = 'horizontal' | 'vertical' | 'both'; - -export interface RovingFocusOptions { - /** Whether roving focus is enabled. @default true */ - enabled?: boolean; - /** Navigation orientation. @default 'vertical' */ - orientation?: RovingOrientation; - /** Whether focus wraps from last to first item and vice-versa. @default true */ - loop?: boolean; - /** Callback fired when the focused index changes. */ - onFocusChange?: (index: number) => void; -} - -export interface RovingFocusReturn { - /** Ref to attach to the container element. */ - containerRef: React.RefObject; - /** The index of the currently focused item (-1 if none). */ - focusedIndex: number; - /** Programmatically focus a specific item by index. */ - setFocusedIndex: (index: number) => void; -} - -/** - * Hook that implements roving tabindex / arrow-key navigation for a list of - * focusable items inside a container (sidebar items, tab lists, menu items). - * - * Only the currently active item participates in the tab order (tabindex 0); - * all other items are removed from the tab order (tabindex -1). Arrow keys - * move focus between items. - * - * @example - * ```tsx - * function SidebarNav({ items }: { items: string[] }) { - * const { containerRef, focusedIndex } = useRovingFocus({ - * orientation: 'vertical', - * loop: true, - * }); - * - * return ( - *
    - * {items.map((item, i) => ( - *
  • - * {item} - *
  • - * ))} - *
- * ); - * } - * ``` - */ -export function useRovingFocus( - options: RovingFocusOptions = {} -): RovingFocusReturn { - const { - enabled = true, - orientation = 'vertical', - loop = true, - onFocusChange, - } = options; - - const containerRef = useRef(null); - const [focusedIndex, setFocusedIndexState] = useState(-1); - const onFocusChangeRef = useRef(onFocusChange); - useEffect(() => { - onFocusChangeRef.current = onFocusChange; - }, [onFocusChange]); - - const getItems = useCallback((): HTMLElement[] => { - if (!containerRef.current) return []; - return Array.from( - containerRef.current.querySelectorAll('[data-roving-item]') - ).filter((el) => { - // Exclude hidden or disabled elements - if (el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true') { - return false; - } - return el.offsetParent !== null; // visible check - }); - }, []); - - const setFocusedIndex = useCallback( - (index: number) => { - const items = getItems(); - if (items.length === 0) return; - - const clamped = Math.max(0, Math.min(index, items.length - 1)); - setFocusedIndexState(clamped); - items[clamped]?.focus(); - onFocusChangeRef.current?.(clamped); - }, - [getItems] - ); - - useEffect(() => { - if (!enabled || !containerRef.current) return; - - const handleKeyDown = (event: KeyboardEvent) => { - const items = getItems(); - if (items.length === 0) return; - - const currentIndex = items.findIndex((el) => el === document.activeElement); - if (currentIndex === -1) return; - - const prevKeys = - orientation === 'horizontal' ? ['ArrowLeft'] : - orientation === 'vertical' ? ['ArrowUp'] : - ['ArrowUp', 'ArrowLeft']; - - const nextKeys = - orientation === 'horizontal' ? ['ArrowRight'] : - orientation === 'vertical' ? ['ArrowDown'] : - ['ArrowDown', 'ArrowRight']; - - let nextIndex: number | null = null; - - if (prevKeys.includes(event.key)) { - event.preventDefault(); - if (currentIndex > 0) { - nextIndex = currentIndex - 1; - } else if (loop) { - nextIndex = items.length - 1; - } - } else if (nextKeys.includes(event.key)) { - event.preventDefault(); - if (currentIndex < items.length - 1) { - nextIndex = currentIndex + 1; - } else if (loop) { - nextIndex = 0; - } - } else if (event.key === 'Home') { - event.preventDefault(); - nextIndex = 0; - } else if (event.key === 'End') { - event.preventDefault(); - nextIndex = items.length - 1; - } - - if (nextIndex !== null) { - setFocusedIndexState(nextIndex); - items[nextIndex].focus(); - onFocusChangeRef.current?.(nextIndex); - } - }; - - // When an item receives focus (e.g. via click), sync the index - const handleFocusIn = (event: FocusEvent) => { - const items = getItems(); - const index = items.findIndex((el) => el === event.target); - if (index !== -1) { - setFocusedIndexState(index); - onFocusChangeRef.current?.(index); - } - }; - - const container = containerRef.current; - container.addEventListener('keydown', handleKeyDown); - container.addEventListener('focusin', handleFocusIn); - - return () => { - container.removeEventListener('keydown', handleKeyDown); - container.removeEventListener('focusin', handleFocusIn); - }; - }, [enabled, orientation, loop, getItems]); - - return { containerRef, focusedIndex, setFocusedIndex }; -} diff --git a/packages/react/src/hooks/useFocusTrap.ts b/packages/react/src/hooks/useFocusTrap.ts deleted file mode 100644 index 56ee1a1168..0000000000 --- a/packages/react/src/hooks/useFocusTrap.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { useEffect, useRef, useCallback } from 'react'; - -export interface FocusTrapOptions { - /** Whether the focus trap is currently active */ - enabled?: boolean; - /** Whether to auto-focus the first focusable element on mount */ - autoFocus?: boolean; - /** Whether to restore focus to the previously focused element on unmount */ - restoreFocus?: boolean; - /** Whether to release the trap on Escape key */ - escapeDeactivates?: boolean; - /** Callback when Escape is pressed (if escapeDeactivates is true) */ - onEscape?: () => void; - /** Initial element to focus (CSS selector) */ - initialFocus?: string; -} - -const FOCUSABLE_SELECTOR = [ - 'a[href]', - 'button:not([disabled])', - 'input:not([disabled])', - 'select:not([disabled])', - 'textarea:not([disabled])', - '[tabindex]:not([tabindex="-1"])', -].join(', '); - -/** - * Hook that traps focus within a container element. - * Implements FocusTrapConfigSchema from @objectstack/spec v2.0.7. - * - * @example - * ```tsx - * function Modal({ onClose }: { onClose: () => void }) { - * const trapRef = useFocusTrap({ - * enabled: true, - * autoFocus: true, - * restoreFocus: true, - * escapeDeactivates: true, - * onEscape: onClose, - * }); - * - * return
...
; - * } - * ``` - */ -export function useFocusTrap( - options: FocusTrapOptions = {} -): React.RefObject { - const { - enabled = true, - autoFocus = true, - restoreFocus = true, - escapeDeactivates = false, - onEscape, - initialFocus, - } = options; - - const containerRef = useRef(null); - const previousFocusRef = useRef(null); - - const getFocusableElements = useCallback(() => { - if (!containerRef.current) return []; - return Array.from( - containerRef.current.querySelectorAll(FOCUSABLE_SELECTOR) - ).filter((el) => !el.hasAttribute('disabled') && el.tabIndex >= 0); - }, []); - - useEffect(() => { - if (!enabled || !containerRef.current) return; - - // Save previously focused element - previousFocusRef.current = document.activeElement as HTMLElement; - - // Auto-focus - if (autoFocus) { - const focusableElements = getFocusableElements(); - if (initialFocus) { - const target = containerRef.current.querySelector(initialFocus); - if (target) { - target.focus(); - } else if (focusableElements.length > 0) { - focusableElements[0].focus(); - } - } else if (focusableElements.length > 0) { - focusableElements[0].focus(); - } - } - - const handleKeyDown = (event: KeyboardEvent) => { - if (!containerRef.current) return; - - if (event.key === 'Escape' && escapeDeactivates) { - onEscape?.(); - return; - } - - if (event.key !== 'Tab') return; - - const focusableElements = getFocusableElements(); - if (focusableElements.length === 0) return; - - const firstElement = focusableElements[0]; - const lastElement = focusableElements[focusableElements.length - 1]; - - if (event.shiftKey) { - if (document.activeElement === firstElement) { - event.preventDefault(); - lastElement.focus(); - } - } else { - if (document.activeElement === lastElement) { - event.preventDefault(); - firstElement.focus(); - } - } - }; - - const container = containerRef.current; - container.addEventListener('keydown', handleKeyDown); - - return () => { - container.removeEventListener('keydown', handleKeyDown); - if (restoreFocus && previousFocusRef.current) { - previousFocusRef.current.focus(); - } - }; - }, [enabled, autoFocus, restoreFocus, escapeDeactivates, onEscape, initialFocus, getFocusableElements]); - - return containerRef; -} diff --git a/packages/react/src/hooks/usePageTransition.ts b/packages/react/src/hooks/usePageTransition.ts deleted file mode 100644 index 610b909214..0000000000 --- a/packages/react/src/hooks/usePageTransition.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { useMemo } from 'react'; -import { useReducedMotion } from './useReducedMotion.js'; - -// --------------------------------------------------------------------------- -// Types aligned with @objectstack/spec v2.0.7 PageTransitionSchema -// --------------------------------------------------------------------------- - -/** Page transition type. */ -export type PageTransitionType = - | 'none' - | 'fade' - | 'slide_up' - | 'slide_down' - | 'slide_left' - | 'slide_right' - | 'scale' - | 'rotate' - | 'flip'; - -/** Easing function for page transitions. */ -export type PageTransitionEasing = - | 'linear' - | 'ease' - | 'ease_in' - | 'ease_out' - | 'ease_in_out' - | 'spring'; - -/** Page transition configuration aligned with PageTransitionSchema. */ -export interface PageTransitionConfig { - /** Transition type. */ - type?: PageTransitionType; - /** Duration in milliseconds. */ - duration?: number; - /** Easing function. */ - easing?: PageTransitionEasing; - /** Whether entering and exiting pages cross-fade. */ - crossFade?: boolean; -} - -/** Resolved CSS classes and styles for a page transition. */ -export interface PageTransitionResult { - /** Tailwind CSS classes for the enter transition. */ - enterClassName: string; - /** Tailwind CSS classes for the exit transition. */ - exitClassName: string; - /** Inline styles for the enter transition. */ - enterStyle: React.CSSProperties; - /** Inline styles for the exit transition. */ - exitStyle: React.CSSProperties; - /** Whether the transition is active (not 'none' and motion not reduced). */ - isActive: boolean; - /** The resolved transition type. */ - type: PageTransitionType; - /** The resolved duration in ms. */ - duration: number; -} - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const EASING_CSS: Record = { - linear: 'linear', - ease: 'ease', - ease_in: 'ease-in', - ease_out: 'ease-out', - ease_in_out: 'ease-in-out', - spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)', -}; - -const ENTER_CLASSES: Record = { - none: '', - fade: 'animate-in fade-in', - slide_up: 'animate-in slide-in-from-bottom-4', - slide_down: 'animate-in slide-in-from-top-4', - slide_left: 'animate-in slide-in-from-right-4', - slide_right: 'animate-in slide-in-from-left-4', - scale: 'animate-in zoom-in-95', - rotate: 'animate-in spin-in-90', - flip: 'animate-in fade-in zoom-in-95', -}; - -const EXIT_CLASSES: Record = { - none: '', - fade: 'animate-out fade-out', - slide_up: 'animate-out slide-out-to-top-4', - slide_down: 'animate-out slide-out-to-bottom-4', - slide_left: 'animate-out slide-out-to-left-4', - slide_right: 'animate-out slide-out-to-right-4', - scale: 'animate-out zoom-out-95', - rotate: 'animate-out spin-out-90', - flip: 'animate-out fade-out zoom-out-95', -}; - -const DEFAULT_DURATION = 300; -const DEFAULT_EASING: PageTransitionEasing = 'ease_in_out'; - -// --------------------------------------------------------------------------- -// usePageTransition hook -// --------------------------------------------------------------------------- - -/** - * Hook for page-level transition animations aligned with - * PageTransitionSchema from @objectstack/spec v2.0.7. - * - * Generates Tailwind CSS animate-in / animate-out classes and inline styles - * for enter/exit page transitions. Respects `prefers-reduced-motion`. - * - * @example - * ```tsx - * function PageWrapper({ children }: { children: React.ReactNode }) { - * const transition = usePageTransition({ type: 'fade', duration: 200 }); - * - * return ( - *
- * {children} - *
- * ); - * } - * ``` - */ -export function usePageTransition(config: PageTransitionConfig = {}): PageTransitionResult { - const { - type = 'none', - duration = DEFAULT_DURATION, - easing = DEFAULT_EASING, - crossFade = false, - } = config; - - const reducedMotion = useReducedMotion(); - - return useMemo(() => { - const isActive = type !== 'none' && !reducedMotion; - - if (!isActive) { - return { - enterClassName: '', - exitClassName: '', - enterStyle: {}, - exitStyle: {}, - isActive: false, - type, - duration, - }; - } - - const enterClassName = ENTER_CLASSES[type] || ''; - const exitClassName = EXIT_CLASSES[type] || ''; - - const baseStyle: React.CSSProperties = { - animationDuration: `${duration}ms`, - animationTimingFunction: EASING_CSS[easing] || EASING_CSS.ease_in_out, - animationFillMode: 'both', - }; - - const enterStyle: React.CSSProperties = { ...baseStyle }; - const exitStyle: React.CSSProperties = { ...baseStyle }; - - // When crossFade is enabled, both the entering and exiting pages - // should overlap and transition opacity simultaneously. - if (crossFade) { - enterStyle.position = 'absolute'; - enterStyle.inset = '0'; - exitStyle.position = 'absolute'; - exitStyle.inset = '0'; - } - - return { - enterClassName, - exitClassName, - enterStyle, - exitStyle, - isActive: true, - type, - duration, - }; - }, [type, duration, easing, crossFade, reducedMotion]); -} diff --git a/packages/react/src/hooks/useSchemaPersistence.ts b/packages/react/src/hooks/useSchemaPersistence.ts deleted file mode 100644 index 7ec02606bb..0000000000 --- a/packages/react/src/hooks/useSchemaPersistence.ts +++ /dev/null @@ -1,373 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { useState, useCallback, useRef, useMemo, useInsertionEffect } from 'react'; - -/** - * Persistence adapter interface for schema save/load operations. - * Implement this to connect to any backend (REST API, GraphQL, localStorage, etc.). - */ -export interface SchemaPersistenceAdapter { - /** Save a schema to the backend. Returns the saved schema ID. */ - save: (id: string, schema: Record) => Promise; - /** Load a schema from the backend by ID. */ - load: (id: string) => Promise | null>; - /** List all available schemas. */ - list?: () => Promise>; - /** Delete a schema from the backend. */ - delete?: (id: string) => Promise; -} - -export interface SchemaPersistenceState { - /** Whether a save/load operation is in progress */ - loading: boolean; - /** The last error from a save/load operation */ - error: Error | null; - /** Whether the current schema has unsaved changes */ - isDirty: boolean; - /** The last saved timestamp */ - lastSavedAt: Date | null; -} - -export interface SchemaPersistenceResult extends SchemaPersistenceState { - /** Save the current schema */ - save: (id: string, schema: Record) => Promise; - /** Load a schema by ID */ - load: (id: string) => Promise | null>; - /** List available schemas */ - list: () => Promise>; - /** Delete a schema */ - remove: (id: string) => Promise; - /** Mark the current schema as dirty (has unsaved changes) */ - markDirty: () => void; - /** Clear the error state */ - clearError: () => void; -} - -/** - * Default localStorage adapter for schema persistence. - * Useful for development and demos. - */ -export function createLocalStorageAdapter(prefix = 'objectui-schema'): SchemaPersistenceAdapter { - return { - async save(id: string, schema: Record): Promise { - const key = `${prefix}:${id}`; - const entry = { schema, updatedAt: new Date().toISOString() }; - localStorage.setItem(key, JSON.stringify(entry)); - // Update the index - const indexKey = `${prefix}:__index__`; - const index: string[] = JSON.parse(localStorage.getItem(indexKey) || '[]'); - if (!index.includes(id)) { - index.push(id); - localStorage.setItem(indexKey, JSON.stringify(index)); - } - return id; - }, - async load(id: string): Promise | null> { - const key = `${prefix}:${id}`; - const raw = localStorage.getItem(key); - if (!raw) return null; - const entry = JSON.parse(raw); - return entry.schema ?? null; - }, - async list(): Promise> { - const indexKey = `${prefix}:__index__`; - const index: string[] = JSON.parse(localStorage.getItem(indexKey) || '[]'); - return index.map((id) => { - const raw = localStorage.getItem(`${prefix}:${id}`); - const entry = raw ? JSON.parse(raw) : {}; - return { id, name: id, updatedAt: entry.updatedAt }; - }); - }, - async delete(id: string): Promise { - const key = `${prefix}:${id}`; - localStorage.removeItem(key); - const indexKey = `${prefix}:__index__`; - const index: string[] = JSON.parse(localStorage.getItem(indexKey) || '[]'); - localStorage.setItem(indexKey, JSON.stringify(index.filter((i) => i !== id))); - }, - }; -} - -/** - * Cap on how many offending paths the refusal message spells out in full. A - * schema that puts callables on every row would otherwise produce an error - * string nobody can read; the count is always reported exactly. - */ -const MAX_REPORTED_CALLABLE_PATHS = 20; - -/** - * Collect the path of every function-valued property reachable in `value`. - * - * Walks own enumerable properties β€” the same set `JSON.stringify` serializes β€” - * so what this finds is exactly what `JSON.stringify` would fail to preserve. - * It fails in TWO different ways, and neither says a word. Both are visible in - * a single document: - * - * JSON.stringify({ columns: [{ name: 'c', cell: fn }, fn] }) - * // => {"columns":[{"name":"c"},null]} - * // the object key `cell` is DROPPED; the array element is COERCED to null - * - * The array case is arguably the worse of the two. A dropped key at least - * disappears, so a reader of the stored document can see something is missing; - * a coerced `null` survives as a plausible-looking value and reads as real data - * on reload. That is why arrays are walked by index (`columns[2].cell`) rather - * than skipped β€” and it is the realistic shape besides, since handlers live on - * column/field entries, not only at the top level. - * - * A `seen` set keeps a cyclic schema from recursing forever (`JSON.stringify` - * throws on those; this guard runs first and must not hang before it can). It - * also means a subtree reachable by two paths is reported at the first one - * only β€” the refusal is still correct, the path list is just not exhaustive for - * shared references. - */ -function collectCallablePaths( - value: unknown, - path: string, - out: string[], - seen: Set, -): void { - if (typeof value === 'function') { - out.push(path || '(root)'); - return; - } - if (value === null || typeof value !== 'object') return; - if (seen.has(value)) return; - seen.add(value); - - if (Array.isArray(value)) { - value.forEach((item, index) => collectCallablePaths(item, `${path}[${index}]`, out, seen)); - return; - } - for (const [key, child] of Object.entries(value as Record)) { - collectCallablePaths(child, path ? `${path}.${key}` : key, out, seen); - } -} - -/** - * The paths of the function-valued keys anywhere in a schema, in walk order. - * Empty means the schema is fully declarative and serializes without loss. - * - * Two corners worth knowing, both measured, neither a reason to loosen this: - * - * - `Object.entries` INVOKES getters. A schema with a throwing getter therefore - * throws inside this guard rather than inside the adapter's `JSON.stringify`. - * Different call site, same outcome: `save()` refuses and surfaces the - * getter's own error. - * - A schema carrying its own `toJSON` method is refused, even though - * `JSON.stringify` would have serialized it THROUGH that method. That is a - * deliberate false positive: the method itself cannot survive the round trip, - * so what `load()` hands back would not be the object that was saved. - */ -function findCallablePaths(schema: Record): string[] { - const out: string[] = []; - collectCallablePaths(schema, '', out, new Set()); - return out; -} - -/** - * The refusal an author sees instead of a save that quietly lost their handler. - * - * It names the exact offending paths and both escapes, because an error that - * only says "no" leaves the author guessing at a defect whose symptom would - * otherwise have surfaced at render or click time, in another component, with - * no link back to the save that caused it. - */ -function callableRefusalMessage(id: string, paths: string[]): string { - const shown = paths.slice(0, MAX_REPORTED_CALLABLE_PATHS); - const listed = shown.join(', '); - const elided = - paths.length > shown.length ? ` (and ${paths.length - shown.length} more)` : ''; - const plural = paths.length === 1 ? 'key is' : 'keys are'; - return ( - `useSchemaPersistence: refusing to save schema "${id}" β€” ` + - `${paths.length} function-valued ${plural} not serializable: JSON.stringify ` + - `silently drops an object key, or coerces an array element to null. ` + - `Offending paths: ${listed}${elided}. ` + - 'Two ways forward: strip the callables before saving (persist only the serializable ' + - 'data and re-attach the handlers after load), or express the behaviour in the ' + - 'declarative form of these keys so it survives the round trip.' - ); -} - -/** - * Hook for persisting designer schemas (save/load/list/delete). - * Implements schema persistence for @object-ui/plugin-designer. - * - * Accepts a pluggable adapter for connecting to any backend. - * Falls back to a localStorage adapter for development use. - * - * `save()` refuses a schema carrying function-valued keys anywhere in it β€” - * every adapter ends in a `JSON.stringify`, which drops callables silently, so - * the save would report success and the reload would hand back a schema that - * lost them. The refusal sets `error` (naming the offending key paths and both - * escapes), returns `null`, leaves `lastSavedAt` untouched, and never reaches - * the adapter. Fully declarative schemas are unaffected. - * - * @example - * ```tsx - * // With default localStorage adapter - * const persistence = useSchemaPersistence(); - * - * // Save current design - * await persistence.save('my-page', pageSchema); - * - * // Load a design - * const schema = await persistence.load('my-page'); - * - * // With custom API adapter - * const apiAdapter: SchemaPersistenceAdapter = { - * save: (id, schema) => fetch(`/api/schemas/${id}`, { method: 'PUT', body: JSON.stringify(schema) }).then(r => r.json()), - * load: (id) => fetch(`/api/schemas/${id}`).then(r => r.json()), - * list: () => fetch('/api/schemas').then(r => r.json()), - * delete: (id) => fetch(`/api/schemas/${id}`, { method: 'DELETE' }).then(() => {}), - * }; - * const persistence = useSchemaPersistence(apiAdapter); - * ``` - */ -export function useSchemaPersistence( - adapter?: SchemaPersistenceAdapter, -): SchemaPersistenceResult { - // Built once per hook instance. `useRef(createLocalStorageAdapter())` ran the - // factory on EVERY render and discarded all but the first result; `useMemo` - // runs it once and β€” unlike a ref β€” needs no `.current` read during render. - // Re-creating it would be harmless either way: the adapter is a stateless - // facade over `localStorage`, so a fresh one behaves identically to the one - // it replaces, and nothing outside this hook ever sees its identity. - const defaultAdapter = useMemo(() => createLocalStorageAdapter(), []); - const resolvedAdapter = adapter ?? defaultAdapter; - - // The four callbacks below are created once (`[]` deps) and read the adapter - // at CALL time, so the latest adapter must reach them without changing their - // identity. That is the whole job of this ref. - // - // The write lives in `useInsertionEffect` β€” not in the render body, where it - // used to be, and not in `useEffect`/`useLayoutEffect`: - // - // - Render body: a render React discards or replays (StrictMode, a - // Suspense retry, a concurrent interruption) still performed the write, - // so a save could be routed through an adapter from a render that never - // committed. That is what `react-hooks/refs` flags. - // - `useEffect`: runs after paint, so a call made from any layout effect in - // the same commit would still reach the PREVIOUS adapter. - // - `useLayoutEffect`: a child's layout effects run BEFORE its parent's, so - // a child calling `save()` from its own layout effect would still see the - // previous adapter. - // - // Insertion effects run in the mutation phase β€” before every layout effect in - // the tree, before paint, and before any event handler can fire β€” so every - // call site that may legally invoke `save`/`load`/`list`/`remove` observes - // exactly what the old render-body write gave it. The single window that did - // change is a read during the render phase itself, which no legal consumer - // has: these calls are side effects and are never allowed during render. - // - // `useSchemaPersistence.adapterTracking.test.tsx` pins both halves β€” that a - // changed `adapter` prop is tracked, and that it is already in place by the - // time a child's layout effect runs in that same commit. - const adapterRef = useRef(resolvedAdapter); - useInsertionEffect(() => { - adapterRef.current = resolvedAdapter; - }, [resolvedAdapter]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [isDirty, setIsDirty] = useState(false); - const [lastSavedAt, setLastSavedAt] = useState(null); - - const save = useCallback( - async (id: string, schema: Record): Promise => { - setLoading(true); - setError(null); - try { - // Refuse BEFORE the adapter runs, so the guard covers a host-injected or - // REST adapter too β€” every one of them ends in a `JSON.stringify` that - // drops callables without a word (objectui#6658). - const callablePaths = findCallablePaths(schema); - if (callablePaths.length > 0) { - throw new Error(callableRefusalMessage(id, callablePaths)); - } - const result = await adapterRef.current.save(id, schema); - setIsDirty(false); - setLastSavedAt(new Date()); - return result; - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))); - return null; - } finally { - setLoading(false); - } - }, - [], - ); - - const load = useCallback(async (id: string): Promise | null> => { - setLoading(true); - setError(null); - try { - const result = await adapterRef.current.load(id); - setIsDirty(false); - return result; - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))); - return null; - } finally { - setLoading(false); - } - }, []); - - const list = useCallback(async (): Promise> => { - setLoading(true); - setError(null); - try { - if (adapterRef.current.list) { - return await adapterRef.current.list(); - } - return []; - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))); - return []; - } finally { - setLoading(false); - } - }, []); - - const remove = useCallback(async (id: string): Promise => { - setLoading(true); - setError(null); - try { - if (adapterRef.current.delete) { - await adapterRef.current.delete(id); - return true; - } - return false; - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))); - return false; - } finally { - setLoading(false); - } - }, []); - - const markDirty = useCallback(() => setIsDirty(true), []); - const clearError = useCallback(() => setError(null), []); - - return useMemo( - () => ({ - loading, - error, - isDirty, - lastSavedAt, - save, - load, - list, - remove, - markDirty, - clearError, - }), - [loading, error, isDirty, lastSavedAt, save, load, list, remove, markDirty, clearError], - ); -} diff --git a/packages/types/src/__tests__/crud-retirement-5373.test.ts b/packages/types/src/__tests__/crud-retirement-5373.test.ts index 93ece704b0..8b70d82c33 100644 --- a/packages/types/src/__tests__/crud-retirement-5373.test.ts +++ b/packages/types/src/__tests__/crud-retirement-5373.test.ts @@ -29,8 +29,8 @@ * The TS interface face cannot be pinned at runtime (types are erased) and is * covered by `tsc`: `packages/types` type-checks, and no `CRUDSchema` import * survives anywhere in the workspace. The validator face is pinned in - * `@object-ui/core`'s `schema-validator.test.ts`, and the builder face in its - * `schema-builder.test.ts`. + * `@object-ui/core`'s `schema-validator.test.ts`. (The builder face went with + * core's `schema-builder`, retired in objectui#13453.) */ import { describe, it, expect } from 'vitest'; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 356b10b65b..b833a9408e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1335,10 +1335,9 @@ export type { // parsed before the retirement parses byte-for-byte the same after it, and the // re-exports below were type-only surface with no runtime half to lose. // -// The `DndConfig` / `KeyboardShortcut` names still live in this repo as LOCAL -// declarations in `@object-ui/core`'s `DndProtocol.ts` / `KeyboardProtocol.ts`. -// Those never imported the spec β€” the collision is a naming coincidence, not a -// coupling, and they are deliberately untouched (objectui#3363). +// The `DndConfig` / `KeyboardShortcut` names lived on for a while as LOCAL +// declarations in `@object-ui/core`'s `DndProtocol.ts` / `KeyboardProtocol.ts`; +// those bridges had no importer and retired in objectui#13453. // ============================================================================ // v2.0.7 Spec UI Types β€” Notifications diff --git a/scripts/check-doc-example-types.mjs b/scripts/check-doc-example-types.mjs index eee09be20d..cf9dd4bcb4 100644 --- a/scripts/check-doc-example-types.mjs +++ b/scripts/check-doc-example-types.mjs @@ -978,12 +978,6 @@ export const UNGATED_EXAMPLES = { reason: 'usage fragment: references `Dashboard`, `analytics`, which the example never declares', }, - 'packages/react/src/hooks/useSchemaPersistence.ts useSchemaPersistence #1': { - card: null, - codes: [2304, 2451, 7006], - reason: - 'usage fragment: references `SchemaPersistenceAdapter`, `pageSchema`, which the example never declares, so what depends on them is judged unbound', - }, 'packages/react/src/hooks/useSettledSchema.ts useSettledSchema #1': { card: null, codes: [2304], From 3ac776583e8baff8eea28fb20b7a2e5c0be0dcb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:53:12 +0000 Subject: [PATCH 2/7] chore(runner): make @object-ui/runner private and take it out of the release group runner is a Vite application with no importable entry. It is now `private: true`, leaves the fixed group for `ignore` (check-changeset-fixed wants every workspace package classified), and the two pending changesets that bumped it drop their runner line, because changesets refuses a changeset that names an ignored and a non-ignored package together. The tooling-exclude gate's runner carve-out goes with it (stale-carve-out). Claude-Session: https://claude.ai/code/session_011yfYeuX2ptMSCcYZiq61tS Co-authored-by: Claude --- .changeset/13464-declare-side-effects.md | 1 - .changeset/25685-one-react-line.md | 1 - .changeset/config.json | 2 +- README.md | 2 +- content/docs/utilities/runner.mdx | 4 ++-- packages/runner/README.md | 1 - packages/runner/package.json | 2 +- ...published-tsconfig-tooling-exclude.test.ts | 5 ++++- ...eck-published-tsconfig-tooling-exclude.mjs | 20 ++++--------------- 9 files changed, 13 insertions(+), 25 deletions(-) diff --git a/.changeset/13464-declare-side-effects.md b/.changeset/13464-declare-side-effects.md index e49f4c95d6..c36d502c4c 100644 --- a/.changeset/13464-declare-side-effects.md +++ b/.changeset/13464-declare-side-effects.md @@ -27,7 +27,6 @@ "@object-ui/plugin-view": patch "@object-ui/providers": patch "@object-ui/react": patch -"@object-ui/runner": patch --- Clause-β‘‘: no diff --git a/.changeset/25685-one-react-line.md b/.changeset/25685-one-react-line.md index 0bf22290f3..b452ceae2e 100644 --- a/.changeset/25685-one-react-line.md +++ b/.changeset/25685-one-react-line.md @@ -28,7 +28,6 @@ "@object-ui/plugin-tree": patch "@object-ui/providers": patch "@object-ui/react": patch -"@object-ui/runner": patch --- Clause-β‘‘: no diff --git a/.changeset/config.json b/.changeset/config.json index b99edbdc68..8d113e1b84 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -14,7 +14,6 @@ "@object-ui/data-objectstack", "@object-ui/cli", "@object-ui/create-plugin", - "@object-ui/runner", "@object-ui/auth", "@object-ui/permissions", "@object-ui/mobile", @@ -51,6 +50,7 @@ "ignore": [ "@object-ui/example-*", "@object-ui/site", + "@object-ui/runner", "@object-ui/test-support" ], "privatePackages": { diff --git a/README.md b/README.md index 067ce5d910..14b46c6202 100644 --- a/README.md +++ b/README.md @@ -424,7 +424,7 @@ Grouped by dependency weight β€” atoms and fields stay light, heavy widgets live |---------|-------------| | **[@object-ui/cli](./packages/cli)** | Scaffold, develop, build, and validate schema-driven apps | | **[@object-ui/create-plugin](./packages/create-plugin)** | Scaffold a new Object UI plugin | -| **[@object-ui/runner](./packages/runner)** | Universal application runner for testing schemas | +| **[@object-ui/runner](./packages/runner)** | Universal application runner for testing schemas (private workspace app, not published) | | **[@object-ui/sdui-parser](./packages/sdui-parser)** | Constrained JSX source β†’ schema tree compiler (parse, never execute) | | **[@object-ui/react-runtime](./packages/react-runtime)** | Trusted runtime execution for `kind: 'react'` pages | | **[vscode-extension](./packages/vscode-extension)** | IntelliSense and live preview for schema files | diff --git a/content/docs/utilities/runner.mdx b/content/docs/utilities/runner.mdx index bcf4bc36aa..6cde9a5bb7 100644 --- a/content/docs/utilities/runner.mdx +++ b/content/docs/utilities/runner.mdx @@ -441,8 +441,8 @@ pnpm test ## Package Information -**Package Name:** `@object-ui/runner` β€” published on npm, see the -[npm page](https://www.npmjs.com/package/@object-ui/runner) for the current version +**Package Name:** `@object-ui/runner` β€” a private workspace package; it is not +published to npm and does not take part in the release group **Type:** Application, not a library. `package.json` declares no `main`, `module`, `exports` or `types`, so there is nothing to `import` from it β€” you run it from a checkout of this repository, as shown above. diff --git a/packages/runner/README.md b/packages/runner/README.md index bdae36d626..8342cbcd89 100644 --- a/packages/runner/README.md +++ b/packages/runner/README.md @@ -173,7 +173,6 @@ For detailed documentation, visit the [Object UI Documentation](https://www.obje ## Links - πŸ“š [Documentation](https://www.objectui.org/docs/utilities/runner) -- πŸ“¦ [npm package](https://www.npmjs.com/package/@object-ui/runner) - πŸ“ [Changelog](./CHANGELOG.md) - πŸ› [Report an issue](https://github.com/objectstack-ai/objectui/issues) - 🀝 [Contributing Guide](https://github.com/objectstack-ai/objectui/blob/main/CONTRIBUTING.md) diff --git a/packages/runner/package.json b/packages/runner/package.json index 883287a01a..79e20b123c 100644 --- a/packages/runner/package.json +++ b/packages/runner/package.json @@ -1,6 +1,6 @@ { "name": "@object-ui/runner", - "private": false, + "private": true, "version": "18.1.0", "description": "Universal Object UI Application Runner", "type": "module", diff --git a/scripts/__tests__/check-published-tsconfig-tooling-exclude.test.ts b/scripts/__tests__/check-published-tsconfig-tooling-exclude.test.ts index 6cf6f07063..2c42dda873 100644 --- a/scripts/__tests__/check-published-tsconfig-tooling-exclude.test.ts +++ b/scripts/__tests__/check-published-tsconfig-tooling-exclude.test.ts @@ -317,12 +317,15 @@ describe('the shipped carve-outs, against the emitters they claim', () => { // predicate fired `carve-out-no-longer-holds` and named the remedy. The // package is now ENFORCED (the gate's population reads 34 enforced / 5 // carve-outs, up from 33 / 6), so this is protection gained, not waived. + // + // FOUR since objectui#13453 made `@object-ui/runner` private: an + // unpublished package has no published `dist` to protect, and the gate's + // `stale-carve-out` finding named the remedy. expect(Object.keys(EMITTER_CARVE_OUTS).sort()).toEqual([ '@object-ui/cli', '@object-ui/console', '@object-ui/create-plugin', '@object-ui/data-objectstack', - '@object-ui/runner', ]); }); diff --git a/scripts/check-published-tsconfig-tooling-exclude.mjs b/scripts/check-published-tsconfig-tooling-exclude.mjs index e7f69e386d..ed7f0ff2d5 100644 --- a/scripts/check-published-tsconfig-tooling-exclude.mjs +++ b/scripts/check-published-tsconfig-tooling-exclude.mjs @@ -79,10 +79,9 @@ * - `@object-ui/create-plugin` is a THIRD `tsup` package, alongside the `cli` * and `data-objectstack` the ruling names. Same `tsup.config.ts` shape, * same `dts: true`, same entry-graph emit. - * - `@object-ui/runner` is a SECOND Vite APPLICATION, alongside - * `@object-ui/console`. Its `vite.config.ts` loads no `dts()` plugin at all - * and its tsconfig sets `noEmit: true`, so it writes no declarations; its - * published `dist` is a bundle built from the Rollup entry graph. + * - `@object-ui/runner` was a SECOND Vite APPLICATION, alongside + * `@object-ui/console`, until objectui#13453 made it `private: true`; it + * is no longer published, so its carve-out entry was deleted. * * Requiring the directory form in either would be asserting something about a * config the emitter does not consult -- a phantom check, green about nothing, @@ -91,7 +90,7 @@ * So every entry below states the FACTS that make it a carve-out, and `requires` * re-checks them on every run (the technique `HOST_PROVIDED` in * `check-phantom-dependencies.mjs` uses). The day `create-plugin` stops using - * `tsup`, or `runner` gains a `dts()` plugin, the carve-out stops holding and + * `tsup`, or `console` gains a `dts()` plugin, the carve-out stops holding and * this gate goes red -- rather than silently exempting a package that now emits * declarations from a config nobody is checking. */ @@ -293,17 +292,6 @@ export const EMITTER_CARVE_OUTS = { return null; }, }, - '@object-ui/runner': { - reason: - 'the SECOND Vite application, found at authoring time rather than named in the ruling ' + - '(objectui#7212): same shape as `console` -- `noEmit: true`, no `dts()` plugin, published ' + - '`dist` is a bundle from the Rollup entry graph', - requires: (facts) => { - if (facts.hasDtsPlugin) return 'its vite config now loads a `dts()` plugin, so it emits declarations'; - if (facts.tsconfigNoEmit !== true) return 'its tsconfig no longer sets `noEmit: true`'; - return null; - }, - }, }; // -- the scan ----------------------------------------------------------------- From 8e30373f52245cda5b5f27ebe180df6fce2cc190 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:54:42 +0000 Subject: [PATCH 3/7] refactor!: take test-only reset helpers off the public barrels core (resetColumnIdentityWarnings, resetRetiredSortSpellingReports, resetDashboardFilterWarnings, resetReferenceKeyWarnings), app-shell (resetRuntimeConfigForTesting), types (resetInflightGetsForTesting) and plugin-detail (__clearRecordEditableCache). Each module keeps its export; the barrel stops re-exporting it, and the cross-package tests that used it import the module's src path directly, the pattern __resetVisibilityPredicateWarnings and useRecordEditable already follow. resetActionKeyWarnings and clearSharedDiscoveryCache stay: their files are held by another open card. Claude-Session: https://claude.ai/code/session_011yfYeuX2ptMSCcYZiq61tS Co-authored-by: Claude --- .../__tests__/faviconAfterNavigation.test.tsx | 4 +- .../__tests__/localizationBootDedup.test.tsx | 2 +- .../__tests__/runtimeConfigBootDedup.test.ts | 4 +- packages/app-shell/src/index.ts | 3 +- .../deriveRelatedLists.inheritSort.test.ts | 2 +- ...tailView.afterActionExplain-13341.test.tsx | 2 +- ...ew.hiddenAffordanceVerdicts-13045.test.tsx | 2 +- ...tailView.recordOpenRequests-11699.test.tsx | 2 +- ...tailView.recordOpenRequests-12701.test.tsx | 2 +- ...dDetailView.recordOpenWaves-13010.test.tsx | 2 +- ...iew.relatedListInheritedSort-5795.test.tsx | 2 +- packages/core/src/index.ts | 44 +++++++++++++++++-- ...t-calendar-renderer.propsContract.test.tsx | 2 +- packages/plugin-detail/src/index.tsx | 4 +- .../gridArrayArmOrderby-8973.test.tsx | 2 +- .../gridRetiredStringSort-8767.test.tsx | 2 +- .../src/__tests__/serverSorting.test.tsx | 2 +- .../__tests__/ObjectView.sortSink.test.tsx | 3 +- packages/types/src/index.ts | 3 +- 19 files changed, 65 insertions(+), 24 deletions(-) diff --git a/apps/console/src/__tests__/faviconAfterNavigation.test.tsx b/apps/console/src/__tests__/faviconAfterNavigation.test.tsx index a02f90f09c..f31b6b324e 100644 --- a/apps/console/src/__tests__/faviconAfterNavigation.test.tsx +++ b/apps/console/src/__tests__/faviconAfterNavigation.test.tsx @@ -41,9 +41,9 @@ import { AppShell } from '@object-ui/layout'; import { getFaviconUrl, initRuntimeConfig, - resetRuntimeConfigForTesting, } from '@object-ui/app-shell'; -import { resetInflightGetsForTesting } from '@object-ui/types'; +import { resetRuntimeConfigForTesting } from '../../../../packages/app-shell/src/runtime-config'; +import { resetInflightGetsForTesting } from '../../../../packages/types/src/http-inflight'; import { FaviconSync } from '../components/FaviconSync'; /** What an operator configures as `branding.faviconUrl` on the runtime. */ diff --git a/apps/console/src/__tests__/localizationBootDedup.test.tsx b/apps/console/src/__tests__/localizationBootDedup.test.tsx index d296ae993b..87aecaf302 100644 --- a/apps/console/src/__tests__/localizationBootDedup.test.tsx +++ b/apps/console/src/__tests__/localizationBootDedup.test.tsx @@ -16,7 +16,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import { useLocalization, readCachedLanguageSeed } from '@object-ui/i18n'; -import { resetInflightGetsForTesting } from '@object-ui/types'; +import { resetInflightGetsForTesting } from '../../../../packages/types/src/http-inflight'; import { seedTenantLanguage } from '../languageSeed'; import { LocalizationFetchProvider } from '../LocalizationFetchProvider'; diff --git a/apps/console/src/__tests__/runtimeConfigBootDedup.test.ts b/apps/console/src/__tests__/runtimeConfigBootDedup.test.ts index 5883d3b7ff..c2700be640 100644 --- a/apps/console/src/__tests__/runtimeConfigBootDedup.test.ts +++ b/apps/console/src/__tests__/runtimeConfigBootDedup.test.ts @@ -26,9 +26,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { initRuntimeConfig, getRuntimeConfig, - resetRuntimeConfigForTesting, } from '@object-ui/app-shell'; -import { resetInflightGetsForTesting } from '@object-ui/types'; +import { resetRuntimeConfigForTesting } from '../../../../packages/app-shell/src/runtime-config'; +import { resetInflightGetsForTesting } from '../../../../packages/types/src/http-inflight'; /** The `/api/v1/runtime/config` answer, as the server sends it. */ const CONFIG_BODY = { diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index 2e514d89fb..c0e26384e2 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -209,7 +209,8 @@ export { // objectui#5522. (Replaces `isClientErrorReportingAllowed`, removed with the // permission boolean it read β€” objectstack#12681.) getClientErrorReporting, - resetRuntimeConfigForTesting, + // `resetRuntimeConfigForTesting` is test-only and stays off the barrel + // (objectui#13453); tests import it from `runtime-config` directly. } from './runtime-config.js'; export type { AppShellRuntimeConfig, diff --git a/packages/app-shell/src/utils/__tests__/deriveRelatedLists.inheritSort.test.ts b/packages/app-shell/src/utils/__tests__/deriveRelatedLists.inheritSort.test.ts index 00f1855798..ffb320e4f0 100644 --- a/packages/app-shell/src/utils/__tests__/deriveRelatedLists.inheritSort.test.ts +++ b/packages/app-shell/src/utils/__tests__/deriveRelatedLists.inheritSort.test.ts @@ -61,7 +61,7 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { resetRetiredSortSpellingReports } from '@object-ui/core'; +import { resetRetiredSortSpellingReports } from '../../../../core/src/utils/sort-query'; import { deriveRelatedLists } from '../deriveRelatedLists'; const PARENT = { name: 'task_version', label: 'Task Version', fields: {} }; diff --git a/packages/app-shell/src/views/RecordDetailView.afterActionExplain-13341.test.tsx b/packages/app-shell/src/views/RecordDetailView.afterActionExplain-13341.test.tsx index d3f0ed664c..d08af1d2f4 100644 --- a/packages/app-shell/src/views/RecordDetailView.afterActionExplain-13341.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.afterActionExplain-13341.test.tsx @@ -57,7 +57,7 @@ vi.mock('sonner', () => ({ import { MetadataCtx, useMutationInvalidationBridge, notifyDataChanged } from '@object-ui/react'; import { ObjectStackAdapter } from '@object-ui/data-objectstack'; -import { __clearRecordEditableCache } from '@object-ui/plugin-detail'; +import { __clearRecordEditableCache } from '../../../plugin-detail/src/useRecordEditable'; import { RecordDetailView } from './RecordDetailView'; const BASE = 'http://test.local'; diff --git a/packages/app-shell/src/views/RecordDetailView.hiddenAffordanceVerdicts-13045.test.tsx b/packages/app-shell/src/views/RecordDetailView.hiddenAffordanceVerdicts-13045.test.tsx index 479078838e..c045c02a1f 100644 --- a/packages/app-shell/src/views/RecordDetailView.hiddenAffordanceVerdicts-13045.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.hiddenAffordanceVerdicts-13045.test.tsx @@ -67,7 +67,7 @@ vi.mock('./MetadataInspector', () => ({ })); import { MetadataCtx } from '@object-ui/react'; -import { __clearRecordEditableCache } from '@object-ui/plugin-detail'; +import { __clearRecordEditableCache } from '../../../plugin-detail/src/useRecordEditable'; import { RecordDetailView } from './RecordDetailView'; const OBJECT_NAME = 'os_13045_env'; diff --git a/packages/app-shell/src/views/RecordDetailView.recordOpenRequests-11699.test.tsx b/packages/app-shell/src/views/RecordDetailView.recordOpenRequests-11699.test.tsx index 4216bac099..4e7c6e70e7 100644 --- a/packages/app-shell/src/views/RecordDetailView.recordOpenRequests-11699.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.recordOpenRequests-11699.test.tsx @@ -124,7 +124,7 @@ vi.mock('sonner', () => ({ import { MetadataCtx } from '@object-ui/react'; import { ObjectStackAdapter } from '@object-ui/data-objectstack'; -import { __clearRecordEditableCache } from '@object-ui/plugin-detail'; +import { __clearRecordEditableCache } from '../../../plugin-detail/src/useRecordEditable'; import { RecordDetailView } from './RecordDetailView'; const BASE = 'http://test.local'; diff --git a/packages/app-shell/src/views/RecordDetailView.recordOpenRequests-12701.test.tsx b/packages/app-shell/src/views/RecordDetailView.recordOpenRequests-12701.test.tsx index 61a2a31916..30f421de09 100644 --- a/packages/app-shell/src/views/RecordDetailView.recordOpenRequests-12701.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.recordOpenRequests-12701.test.tsx @@ -52,7 +52,7 @@ vi.mock('sonner', () => ({ import { MetadataCtx } from '@object-ui/react'; import { ObjectStackAdapter } from '@object-ui/data-objectstack'; -import { __clearRecordEditableCache } from '@object-ui/plugin-detail'; +import { __clearRecordEditableCache } from '../../../plugin-detail/src/useRecordEditable'; import { __resetRecordApprovalsStore } from '../hooks/useRecordApprovals'; import { RecordDetailView } from './RecordDetailView'; diff --git a/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx b/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx index 788bf882a6..07434dc099 100644 --- a/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx @@ -47,7 +47,7 @@ vi.mock('sonner', () => ({ import { MetadataCtx } from '@object-ui/react'; import { RelatedCountStore } from '@object-ui/components'; import { ObjectStackAdapter } from '@object-ui/data-objectstack'; -import { __clearRecordEditableCache } from '@object-ui/plugin-detail'; +import { __clearRecordEditableCache } from '../../../plugin-detail/src/useRecordEditable'; import { __resetRecordApprovalsStore } from '../hooks/useRecordApprovals'; import { RecordDetailView } from './RecordDetailView'; // Registers `record:attachments`, which the package entry registers in the console. diff --git a/packages/app-shell/src/views/RecordDetailView.relatedListInheritedSort-5795.test.tsx b/packages/app-shell/src/views/RecordDetailView.relatedListInheritedSort-5795.test.tsx index 7f98178da1..852662caf3 100644 --- a/packages/app-shell/src/views/RecordDetailView.relatedListInheritedSort-5795.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.relatedListInheritedSort-5795.test.tsx @@ -64,7 +64,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, waitFor, cleanup } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { MetadataCtx } from '@object-ui/react'; -import { resetRetiredSortSpellingReports } from '@object-ui/core'; +import { resetRetiredSortSpellingReports } from '../../../core/src/utils/sort-query'; vi.mock('@object-ui/auth', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c0819204e5..4d6c1b5986 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -53,9 +53,29 @@ export * from './utils/unmaterialized-fields.js'; // because a consumer that reaches for one must be able to see the other and // know which one the platform actually served. export * from './utils/column-sortability.js'; -export * from './utils/column-identity.js'; +// Named, not `export *`: the module's `reset*` helper is test-only and stays off +// the public barrel (objectui#13453); in-repo tests import it from the module. +export { + CANONICAL_COLUMN_IDENTITY_KEY, + LEGACY_COLUMN_IDENTITY_KEYS, + TABLE_ADAPTER_COLUMN_KEY, + columnIdentity, + hasConflictingColumnIdentity, + normalizeColumnIdentity, + normalizeColumnIdentities, + CANONICAL_COLUMN_LABEL_KEY, + TABLE_ADAPTER_HEADER_KEY, + columnHeader, +} from './utils/column-identity.js'; export * from './utils/sort-values.js'; -export * from './utils/sort-query.js'; +// Named, not `export *`: the module's `reset*` helper is test-only and stays off +// the public barrel (objectui#13453); in-repo tests import it from the module. +export { + type QuerySortEntry, + type NormalizedSortEntry, + normalizeSortEntries, + convertSortToQueryParams, +} from './utils/sort-query.js'; export * from './utils/resolve-view-id.js'; // The list view `gantt` block's flat spelling, one copy below both lowerings // (`plugin-list`'s `ListView`, `plugin-view`'s `ObjectView`), objectui#13458. @@ -93,7 +113,18 @@ export * from './utils/date-macros.js'; // plus `resolveFilterPlaceholders`, the single entry point every surface // should call so no vocabulary is silently skipped (objectstack-ai/objectstack#3574). export * from './utils/filter-tokens.js'; -export * from './utils/dashboard-filters.js'; +// Named, not `export *`: the module's `reset*` helper is test-only and stays off +// the public barrel (objectui#13453); in-repo tests import it from the module. +export { + DATE_RANGE_FILTER_NAME, + type DashboardFilterDef, + type DateRangeValue, + DATE_RANGE_PRESETS, + resolveDashboardFilterDefs, + dashboardFilterVariableDefs, + buildFilterCondition, + buildWidgetScopedFilter, +} from './utils/dashboard-filters.js'; export * from './utils/merge-filters.js'; export * from './utils/compare-to.js'; export * from './utils/chart-series.js'; @@ -142,7 +173,12 @@ export * from './utils/record-title.js'; // instead of each guessing at the raw stored value. export * from './utils/record-color.js'; export * from './utils/export-filename.js'; -export * from './utils/reference-keys.js'; +// Named, not `export *`: the module's `reset*` helper is test-only and stays off +// the public barrel (objectui#13453); in-repo tests import it from the module. +export { + normalizeFieldReferenceKeys, + normalizeSchemaReferenceKeys, +} from './utils/reference-keys.js'; // Binds a fetched record into an expression scope the way the SERVER binds it // (a relation is its foreign key, never the expanded record) β€” see // `toPredicateRecord` for why an unnormalized one gives the same predicate diff --git a/packages/plugin-calendar/src/object-calendar-renderer.propsContract.test.tsx b/packages/plugin-calendar/src/object-calendar-renderer.propsContract.test.tsx index ebadd6608c..96f0ff293c 100644 --- a/packages/plugin-calendar/src/object-calendar-renderer.propsContract.test.tsx +++ b/packages/plugin-calendar/src/object-calendar-renderer.propsContract.test.tsx @@ -84,7 +84,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import React from 'react'; import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; -import { __clearRecordEditableCache } from '@object-ui/plugin-detail'; +import { __clearRecordEditableCache } from '../../plugin-detail/src/useRecordEditable'; // Module scope: the registration side effect this file renders through // (AGENTS.md ζ΅‹θ―•ηΊͺεΎ‹ β€” never inside a hook). import './index'; diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 00c430659a..2445fb436e 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -125,7 +125,9 @@ export type { ConcurrentUpdateConflict, ConcurrentUpdateDialogProps, } from './ConcurrentUpdateDialog'; -export { useRecordEditable, __clearRecordEditableCache } from './useRecordEditable'; +// `__clearRecordEditableCache` is test-only and stays off the barrel +// (objectui#13453); tests import it from `useRecordEditable` directly. +export { useRecordEditable } from './useRecordEditable'; export type { RecordOperation } from './useRecordEditable'; export { SectionGroup } from './SectionGroup'; export { HeaderHighlight } from './HeaderHighlight'; diff --git a/packages/plugin-grid/src/__tests__/gridArrayArmOrderby-8973.test.tsx b/packages/plugin-grid/src/__tests__/gridArrayArmOrderby-8973.test.tsx index 1c3ab5d173..4854a0ff5d 100644 --- a/packages/plugin-grid/src/__tests__/gridArrayArmOrderby-8973.test.tsx +++ b/packages/plugin-grid/src/__tests__/gridArrayArmOrderby-8973.test.tsx @@ -86,7 +86,7 @@ import { join } from 'node:path'; import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react'; import React from 'react'; import { ActionProvider, SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; -import { resetRetiredSortSpellingReports } from '@object-ui/core'; +import { resetRetiredSortSpellingReports } from '../../../core/src/utils/sort-query'; import { registerAllFields } from '@object-ui/fields'; import { ObjectGrid, parseSchemaSort } from '../ObjectGrid'; // Registers `object-grid` and its `view:grid` alias. diff --git a/packages/plugin-grid/src/__tests__/gridRetiredStringSort-8767.test.tsx b/packages/plugin-grid/src/__tests__/gridRetiredStringSort-8767.test.tsx index 833cf22fef..ca67c238b6 100644 --- a/packages/plugin-grid/src/__tests__/gridRetiredStringSort-8767.test.tsx +++ b/packages/plugin-grid/src/__tests__/gridRetiredStringSort-8767.test.tsx @@ -43,7 +43,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, waitFor } from '@testing-library/react'; import React from 'react'; import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; -import { resetRetiredSortSpellingReports } from '@object-ui/core'; +import { resetRetiredSortSpellingReports } from '../../../core/src/utils/sort-query'; // Registers `object-grid` and its `view:grid` alias. import '../index'; diff --git a/packages/plugin-grid/src/__tests__/serverSorting.test.tsx b/packages/plugin-grid/src/__tests__/serverSorting.test.tsx index 48d8f7962a..c98d550c21 100644 --- a/packages/plugin-grid/src/__tests__/serverSorting.test.tsx +++ b/packages/plugin-grid/src/__tests__/serverSorting.test.tsx @@ -22,7 +22,7 @@ import React from 'react'; import { ObjectGrid, parseSchemaSort } from '../ObjectGrid'; import { registerAllFields } from '@object-ui/fields'; -import { resetRetiredSortSpellingReports } from '@object-ui/core'; +import { resetRetiredSortSpellingReports } from '../../../core/src/utils/sort-query'; import { ActionProvider } from '@object-ui/react'; registerAllFields(); diff --git a/packages/plugin-view/src/__tests__/ObjectView.sortSink.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.sortSink.test.tsx index 92f174dfb4..592e74f927 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.sortSink.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.sortSink.test.tsx @@ -70,7 +70,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, waitFor } from '@testing-library/react'; -import { convertSortToQueryParams, resetRetiredSortSpellingReports } from '@object-ui/core'; +import { convertSortToQueryParams } from '@object-ui/core'; +import { resetRetiredSortSpellingReports } from '../../../core/src/utils/sort-query'; import { ObjectView } from '../ObjectView'; import type { ObjectViewSchema } from '@object-ui/types'; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b833a9408e..ba49cb923c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1590,5 +1590,6 @@ export { INFLIGHT_GET_REGISTRY_KEY, inflightGetKey, sharedGetJson, - resetInflightGetsForTesting, + // `resetInflightGetsForTesting` is test-only and stays off the barrel + // (objectui#13453); tests import it from `http-inflight` directly. } from './http-inflight.js'; From 44c01fef4dbe5879832f20a7a6d306756b76a107 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 05:06:46 +0000 Subject: [PATCH 4/7] test(types): read resetInflightGetsForTesting from its module, not the barrel Claude-Session: https://claude.ai/code/session_011yfYeuX2ptMSCcYZiq61tS Co-authored-by: Claude --- packages/types/src/__tests__/http-inflight.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/types/src/__tests__/http-inflight.test.ts b/packages/types/src/__tests__/http-inflight.test.ts index ee7c7ad29d..9bfa770466 100644 --- a/packages/types/src/__tests__/http-inflight.test.ts +++ b/packages/types/src/__tests__/http-inflight.test.ts @@ -21,8 +21,9 @@ import { HttpFetchError, inflightGetKey, sharedGetJson, - resetInflightGetsForTesting, } from '../index.js'; +// Test-only, so off the package barrel (objectui#13453): read from the module. +import { resetInflightGetsForTesting } from '../http-inflight.js'; /** A promise plus its settlers, so a test controls exactly when a fetch answers. */ function deferred() { From 45580a058e59d058d9bae5eac81e6f23f37ed791 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 05:22:57 +0000 Subject: [PATCH 5/7] test(changeset-presence): runner's index.html is no longer a guarded file Claude-Session: https://claude.ai/code/session_011yfYeuX2ptMSCcYZiq61tS Co-authored-by: Claude --- scripts/__tests__/check-changeset-presence.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/__tests__/check-changeset-presence.test.ts b/scripts/__tests__/check-changeset-presence.test.ts index 74846ca6d1..2010053dc6 100644 --- a/scripts/__tests__/check-changeset-presence.test.ts +++ b/scripts/__tests__/check-changeset-presence.test.ts @@ -442,19 +442,22 @@ describe("the population is a package's PUBLISHED, EXECUTABLE source β€” not `sr expect(isPublishedSource('tsconfig.json', consoleLike)).toBe(false); }); - it('answers THIS repository the same way β€” the three files the widening adds, and no fourth', () => { + it('answers THIS repository the same way β€” the two files the widening adds, and no third', () => { // Read against the real tree rather than a fixture, because the fixture // cannot show that the rule stayed narrow across 40 released packages. The // assertion is per-file rather than a count, so it stays decidable as the - // tree grows. + // tree grows. `packages/runner/index.html` was the third until + // objectui#13453 made `@object-ui/runner` private and took it out of the + // release: an unreleased package's entry is not guarded, so it moved to the + // second list. const packages = discoverPackages(repoRoot, readReleaseConfig(repoRoot)); const guardedFiles = (paths: string[]): string[] => classifyChangedPaths(paths, packages).guarded.map((entry: { file: string }) => entry.file); expect( - guardedFiles(['apps/console/index.html', 'apps/console/plugin.ts', 'packages/runner/index.html']), + guardedFiles(['apps/console/index.html', 'apps/console/plugin.ts']), 'the shipped, executable files outside src/ that objectui#5733 measured as unguarded', - ).toEqual(['apps/console/index.html', 'apps/console/plugin.ts', 'packages/runner/index.html']); + ).toEqual(['apps/console/index.html', 'apps/console/plugin.ts']); expect( guardedFiles([ @@ -465,6 +468,7 @@ describe("the population is a package's PUBLISHED, EXECUTABLE source β€” not `sr 'apps/console/vite.config.ts', 'packages/plugin-grid/demo/index.html', 'packages/runner/tsconfig.json', + 'packages/runner/index.html', ]), 'the widening is not a blanket over the package directory', ).toEqual([]); From a5dc7bb68b5d8359572902a0fe92e11230f78c41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 05:28:59 +0000 Subject: [PATCH 6/7] chore(changeset): declare the objectui#13453 retirements, one minor per package Claude-Session: https://claude.ai/code/session_011yfYeuX2ptMSCcYZiq61tS Co-authored-by: Claude --- .../13453-collaboration-retire-live-cursors.md | 8 ++++++++ .changeset/13453-core-retire-dead-modules.md | 12 ++++++++++++ .changeset/13453-react-retire-dead-hooks.md | 8 ++++++++ .changeset/13453-test-helpers-off-barrels.md | 10 ++++++++++ 4 files changed, 38 insertions(+) create mode 100644 .changeset/13453-collaboration-retire-live-cursors.md create mode 100644 .changeset/13453-core-retire-dead-modules.md create mode 100644 .changeset/13453-react-retire-dead-hooks.md create mode 100644 .changeset/13453-test-helpers-off-barrels.md diff --git a/.changeset/13453-collaboration-retire-live-cursors.md b/.changeset/13453-collaboration-retire-live-cursors.md new file mode 100644 index 0000000000..698d18bb7c --- /dev/null +++ b/.changeset/13453-collaboration-retire-live-cursors.md @@ -0,0 +1,8 @@ +--- +"@object-ui/collaboration": minor +--- + +Clause-β‘‘: no +Metadata-protocol: none + +**Breaking:** removed `LiveCursors` and `LiveCursorsProps`, which nothing rendered (objectui#13453). Declared `minor` per the version-alignment policy (a changeset never declares `major`). `usePresence` still reports each user's cursor, so a host can draw them itself. diff --git a/.changeset/13453-core-retire-dead-modules.md b/.changeset/13453-core-retire-dead-modules.md new file mode 100644 index 0000000000..856950dc95 --- /dev/null +++ b/.changeset/13453-core-retire-dead-modules.md @@ -0,0 +1,12 @@ +--- +"@object-ui/core": minor +--- + +Clause-β‘‘: no +Metadata-protocol: none + +**Breaking:** removed exports with no reader (objectui#13453). Declared `minor` per the version-alignment policy (a changeset never declares `major`). + +- The fluent schema builders from `builder/schema-builder`: `form`, `button`, `input`, `card`, `grid`, `flex` and the `FormBuilder`, `ButtonBuilder`, `InputBuilder`, `CardBuilder`, `GridBuilder`, `FlexBuilder` classes. Write the schema object literally instead. +- The three protocol bridges, `DndProtocol`, `KeyboardProtocol` and `SharingProtocol`: `resolveDndConfig`, `createDragItemProps`, `createDropZoneProps`, `resolveDragConstraints`, `resolveKeyboardConfig`, `parseShortcutKey`, `matchesShortcut`, `createFocusTrapConfig`, `resolveSharingConfig`, `resolveEmbedConfig`, `generateEmbedCode`, `validateSharingConfig` and their types (`DndConfig`, `DragItem`, `DropZone`, `KeyboardShortcut`, `FocusTrapConfig`, `KeyboardNavigationConfig` and the rest). The spec vocabulary they bridged was already deleted upstream; no replacement. +- The test-only helpers `resetColumnIdentityWarnings`, `resetRetiredSortSpellingReports`, `resetDashboardFilterWarnings` and `resetReferenceKeyWarnings` are no longer on the package root. They reset module state for tests and had no other reader. diff --git a/.changeset/13453-react-retire-dead-hooks.md b/.changeset/13453-react-retire-dead-hooks.md new file mode 100644 index 0000000000..90d7cba659 --- /dev/null +++ b/.changeset/13453-react-retire-dead-hooks.md @@ -0,0 +1,8 @@ +--- +"@object-ui/react": minor +--- + +Clause-β‘‘: no +Metadata-protocol: none + +**Breaking:** removed four hooks with no reader (objectui#13453): `useFocusTrap`, `useFocusReturn` and `useRovingFocus` (from `useFocusManagement`), `usePageTransition`, and `useSchemaPersistence` with `createLocalStorageAdapter`, plus their option and result types. Declared `minor` per the version-alignment policy (a changeset never declares `major`). No replacement inside ObjectUI; a host that used one should keep its own copy. diff --git a/.changeset/13453-test-helpers-off-barrels.md b/.changeset/13453-test-helpers-off-barrels.md new file mode 100644 index 0000000000..5628fc031f --- /dev/null +++ b/.changeset/13453-test-helpers-off-barrels.md @@ -0,0 +1,10 @@ +--- +"@object-ui/app-shell": minor +"@object-ui/types": minor +"@object-ui/plugin-detail": minor +--- + +Clause-β‘‘: no +Metadata-protocol: none + +**Breaking:** test-only reset helpers are no longer on the package roots (objectui#13453): `resetRuntimeConfigForTesting` (`@object-ui/app-shell`), `resetInflightGetsForTesting` (`@object-ui/types`) and `__clearRecordEditableCache` (`@object-ui/plugin-detail`). They reset module state between tests and had no other reader. Declared `minor` per the version-alignment policy (a changeset never declares `major`). From 94cf2f0261a865a2b6978bfeb31dea78c5f38947 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 05:42:35 +0000 Subject: [PATCH 7/7] docs(quick-reference): count runner among the private packages (#13453) Making @object-ui/runner private drops the published count under packages/ from 38 to 37; quick-reference-commands-4149 derives that number and wants every private package named in the row. Claude-Session: https://claude.ai/code/session_011yfYeuX2ptMSCcYZiq61tS Co-authored-by: Claude --- QUICK_REFERENCE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md index f58f06776c..5177c3b5af 100644 --- a/QUICK_REFERENCE.md +++ b/QUICK_REFERENCE.md @@ -81,7 +81,7 @@ pnpm changeset publish # Publish to npm (CI only) | Path | Purpose | | --- | --- | -| `packages/*` | 38 published packages (`@object-ui/*`), plus the private `vscode-extension` and `test-support` (test-only, never released) | +| `packages/*` | 37 published packages (`@object-ui/*`), plus the private `vscode-extension`, `test-support` (test-only, never released) and `runner` (workspace demo app, never released) | | `apps/console` | Full ObjectUI console app (Vite + React) | | `apps/site` | Public docs site at (fumadocs) | | `examples/*` | Runnable examples and the schema catalog β€” see [`examples/README.md`](./examples/README.md) |