diff --git a/.changeset/page-hard-remove-dead-schemas.md b/.changeset/page-hard-remove-dead-schemas.md new file mode 100644 index 0000000000..b81812ea5e --- /dev/null +++ b/.changeset/page-hard-remove-dead-schemas.md @@ -0,0 +1,22 @@ +--- +"@objectstack/spec": minor +--- + +chore(spec): hard-remove the dead `blank`/`record_review` page config (enforce-or-remove) + +Completes the enforce-or-remove started in framework#2265. The `blank` and +`record_review` page types were already removed from `PageTypeSchema` (no +renderer), their fields marked `@deprecated`, and objectui dropped all +references (objectui#1949). This deletes the now-unreachable surface: + +- `BlankPageLayoutSchema`, `BlankPageLayoutItemSchema`, `RecordReviewConfigSchema` + (and their inferred types `BlankPageLayout`, `BlankPageLayoutItem`, + `RecordReviewConfig`). +- The `blankLayout` and `recordReview` fields on `PageSchema`. +- `page-builder.zod.ts` (the `blank`-type drag-drop canvas config: + `PageBuilderConfigSchema` / `CanvasSnapSettingsSchema` / `CanvasZoomSettingsSchema` + / `ElementPaletteItemSchema` / `InterfaceBuilderConfigSchema` and their types) + and its `@objectstack/spec/studio` re-exports — nothing consumed them. + +The `page` liveness ledger drops to 15 properties (the 2 `dead` entries are gone). +No consumers in framework or objectui (objectui#1949 already merged). diff --git a/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index 121d9b296b..284211be12 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -1,6 +1,6 @@ { "type": "page", - "_note": "PageSchema (UI). Renderers live in objectui, so consumers are cited as prose in `note` (not `evidence`, which resolves framework file:line). Seeded from the Studio page-design dogfood (framework#2254/#2261/#2265). Containers (variables/regions/interfaceConfig/slots/aria) classified at top level — one drill level, no divergent sub-statuses. `recordReview`/`blankLayout` are `dead`: their page types (record_review/blank) were removed from PageTypeSchema (framework#2265) and objectui dropped all references (objectui#1949); the fields remain @deprecated pending hard-removal.", + "_note": "PageSchema (UI). Renderers live in objectui, so consumers are cited as prose in `note` (not `evidence`, which resolves framework file:line). Seeded from the Studio page-design dogfood (framework#2254/#2261/#2265). Containers (variables/regions/interfaceConfig/slots/aria) classified at top level — one drill level, no divergent sub-statuses. The removed roadmap page types (record_review/blank) and their config fields (recordReview/blankLayout) were hard-removed — no longer authorable.", "props": { "name": { "status": "live", @@ -30,14 +30,6 @@ "status": "live", "note": "record-page binding + interfaceConfig source object — objectui PageView / InterfaceListPage." }, - "recordReview": { - "status": "dead", - "note": "Config for the removed `record_review` page type (no renderer; dropped from PageTypeSchema, framework#2265; objectui refs removed in objectui#1949). Field kept @deprecated — enforce-or-remove: slated for hard-removal." - }, - "blankLayout": { - "status": "dead", - "note": "Config for the removed `blank` page type (no renderer; dropped from PageTypeSchema, framework#2265; objectui refs removed in objectui#1949). Field kept @deprecated — enforce-or-remove: slated for hard-removal." - }, "template": { "status": "live", "note": "layout template (e.g. header-sidebar-main) selects the region arrangement — objectui components/renderers/layout/page.tsx + containers.tsx." diff --git a/packages/spec/src/studio/index.ts b/packages/spec/src/studio/index.ts index dfb0bb3e54..34e4a1f382 100644 --- a/packages/spec/src/studio/index.ts +++ b/packages/spec/src/studio/index.ts @@ -82,23 +82,8 @@ export { defineObjectDesignerConfig, } from './object-designer.zod'; -export { - // Page Builder Schemas - CanvasSnapSettingsSchema, - CanvasZoomSettingsSchema, - ElementPaletteItemSchema, - PageBuilderConfigSchema, - /** @deprecated Use PageBuilderConfigSchema instead */ - InterfaceBuilderConfigSchema, - - // Page Builder Types - type CanvasSnapSettings, - type CanvasZoomSettings, - type ElementPaletteItem, - type PageBuilderConfig, - /** @deprecated Use PageBuilderConfig instead */ - type InterfaceBuilderConfig, -} from './page-builder.zod'; +// Page Builder schemas removed — they configured the `blank` page-type drag-drop +// canvas, which has no renderer and was dropped from PageTypeSchema (framework#2265). export { // Flow Builder Schemas diff --git a/packages/spec/src/studio/page-builder.test.ts b/packages/spec/src/studio/page-builder.test.ts deleted file mode 100644 index 47ab45c3be..0000000000 --- a/packages/spec/src/studio/page-builder.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - CanvasSnapSettingsSchema, - CanvasZoomSettingsSchema, - ElementPaletteItemSchema, - PageBuilderConfigSchema, - type PageBuilderConfig, -} from './page-builder.zod'; - -// --------------------------------------------------------------------------- -// CanvasSnapSettingsSchema -// --------------------------------------------------------------------------- -describe('CanvasSnapSettingsSchema', () => { - it('should accept empty config with defaults', () => { - const settings = CanvasSnapSettingsSchema.parse({}); - expect(settings.enabled).toBe(true); - expect(settings.gridSize).toBe(8); - expect(settings.showGrid).toBe(true); - expect(settings.showGuides).toBe(true); - }); - - it('should accept custom snap settings', () => { - const settings = CanvasSnapSettingsSchema.parse({ - enabled: false, - gridSize: 16, - showGrid: false, - showGuides: false, - }); - expect(settings.enabled).toBe(false); - expect(settings.gridSize).toBe(16); - }); -}); - -// --------------------------------------------------------------------------- -// CanvasZoomSettingsSchema -// --------------------------------------------------------------------------- -describe('CanvasZoomSettingsSchema', () => { - it('should accept empty config with defaults', () => { - const settings = CanvasZoomSettingsSchema.parse({}); - expect(settings.min).toBe(0.25); - expect(settings.max).toBe(3); - expect(settings.default).toBe(1); - expect(settings.step).toBe(0.1); - }); - - it('should accept custom zoom settings', () => { - const settings = CanvasZoomSettingsSchema.parse({ - min: 0.5, - max: 5, - default: 1.5, - step: 0.25, - }); - expect(settings.min).toBe(0.5); - expect(settings.max).toBe(5); - }); -}); - -// --------------------------------------------------------------------------- -// ElementPaletteItemSchema -// --------------------------------------------------------------------------- -describe('ElementPaletteItemSchema', () => { - it('should accept valid palette item', () => { - const item = ElementPaletteItemSchema.parse({ - type: 'element:button', - label: 'Button', - category: 'interactive', - }); - expect(item.type).toBe('element:button'); - expect(item.defaultWidth).toBe(4); - expect(item.defaultHeight).toBe(2); - }); - - it('should accept all category values', () => { - const categories = ['content', 'interactive', 'data', 'layout'] as const; - categories.forEach(category => { - expect(() => ElementPaletteItemSchema.parse({ - type: 'element:text', - label: 'Text', - category, - })).not.toThrow(); - }); - }); - - it('should reject without required fields', () => { - expect(() => ElementPaletteItemSchema.parse({})).toThrow(); - expect(() => ElementPaletteItemSchema.parse({ type: 'element:text' })).toThrow(); - }); -}); - -// --------------------------------------------------------------------------- -// PageBuilderConfigSchema -// --------------------------------------------------------------------------- -describe('PageBuilderConfigSchema', () => { - it('should accept empty config with defaults', () => { - const config: PageBuilderConfig = PageBuilderConfigSchema.parse({}); - expect(config.showLayerPanel).toBe(true); - expect(config.showPropertyPanel).toBe(true); - expect(config.undoLimit).toBe(50); - }); - - it('should accept full builder config', () => { - const config = PageBuilderConfigSchema.parse({ - snap: { enabled: true, gridSize: 4 }, - zoom: { min: 0.5, max: 2 }, - palette: [ - { type: 'element:button', label: 'Button', category: 'interactive' }, - { type: 'element:text', label: 'Text', category: 'content' }, - ], - showLayerPanel: false, - showPropertyPanel: true, - undoLimit: 100, - }); - - expect(config.snap?.gridSize).toBe(4); - expect(config.palette).toHaveLength(2); - expect(config.undoLimit).toBe(100); - }); -}); diff --git a/packages/spec/src/studio/page-builder.zod.ts b/packages/spec/src/studio/page-builder.zod.ts deleted file mode 100644 index a1bb5dee78..0000000000 --- a/packages/spec/src/studio/page-builder.zod.ts +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * @module studio/page-builder - * - * Studio Page Builder Protocol - * - * Defines the specification for the drag-and-drop Page Builder UI. - * The builder allows visual composition of blank pages by placing - * elements on a grid canvas with snapping, alignment, and layer ordering. - */ - -import { z } from 'zod'; - -/** - * Canvas Snap Settings Schema - * Controls grid snapping behavior during element placement. - */ -import { lazySchema } from '../shared/lazy-schema'; -export const CanvasSnapSettingsSchema = lazySchema(() => z.object({ - enabled: z.boolean().default(true).describe('Enable snap-to-grid'), - gridSize: z.number().int().min(1).default(8).describe('Snap grid size in pixels'), - showGrid: z.boolean().default(true).describe('Show grid overlay on canvas'), - showGuides: z.boolean().default(true).describe('Show alignment guides when dragging'), -})); - -/** - * Canvas Zoom Settings Schema - * Controls zoom behavior for the builder canvas. - */ -export const CanvasZoomSettingsSchema = lazySchema(() => z.object({ - min: z.number().min(0.1).default(0.25).describe('Minimum zoom level'), - max: z.number().max(10).default(3).describe('Maximum zoom level'), - default: z.number().default(1).describe('Default zoom level'), - step: z.number().default(0.1).describe('Zoom step increment'), -})); - -/** - * Element Palette Item Schema - * An element available in the builder palette for drag-and-drop placement. - */ -export const ElementPaletteItemSchema = lazySchema(() => z.object({ - type: z.string().describe('Component type (e.g. "element:button", "element:text")'), - label: z.string().describe('Display label in palette'), - icon: z.string().optional().describe('Icon name for palette display'), - category: z.enum(['content', 'interactive', 'data', 'layout']) - .describe('Palette category grouping'), - defaultWidth: z.number().int().min(1).default(4).describe('Default width in grid columns'), - defaultHeight: z.number().int().min(1).default(2).describe('Default height in grid rows'), -})); - -/** - * Page Builder Config Schema - * Configuration for the Studio Page Builder. - */ -export const PageBuilderConfigSchema = lazySchema(() => z.object({ - snap: CanvasSnapSettingsSchema.optional().describe('Canvas snap settings'), - zoom: CanvasZoomSettingsSchema.optional().describe('Canvas zoom settings'), - palette: z.array(ElementPaletteItemSchema).optional() - .describe('Custom element palette (defaults to all registered elements)'), - showLayerPanel: z.boolean().default(true).describe('Show layer ordering panel'), - showPropertyPanel: z.boolean().default(true).describe('Show property inspector panel'), - undoLimit: z.number().int().min(1).default(50).describe('Maximum undo history steps'), -})); - -// Backward compatibility alias -/** @deprecated Use PageBuilderConfigSchema instead */ -export const InterfaceBuilderConfigSchema = lazySchema(() => PageBuilderConfigSchema); - -// Type Exports -export type CanvasSnapSettings = z.infer; -export type CanvasZoomSettings = z.infer; -export type ElementPaletteItem = z.infer; -/** @deprecated Use PageBuilderConfig instead */ -export type InterfaceBuilderConfig = z.infer; -export type PageBuilderConfig = z.infer; diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 59882aae27..fcb0ea81c9 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -5,16 +5,13 @@ import { PageComponentSchema, PageRegionSchema, PageTypeSchema, - RecordReviewConfigSchema, ElementDataSourceSchema, - BlankPageLayoutSchema, PageVariableSchema, InterfacePageConfigSchema, type Page, type PageComponent, type PageRegion, type ElementDataSource, - type RecordReviewConfig, type InterfacePageConfig, } from './page.zod'; @@ -492,83 +489,6 @@ describe('PageTypeSchema', () => { }); }); -// --------------------------------------------------------------------------- -// RecordReviewConfigSchema -// --------------------------------------------------------------------------- -describe('RecordReviewConfigSchema', () => { - it('should accept minimal review config', () => { - const config: RecordReviewConfig = RecordReviewConfigSchema.parse({ - object: 'order', - actions: [ - { label: 'Approve', type: 'approve' }, - ], - }); - - expect(config.object).toBe('order'); - expect(config.actions).toHaveLength(1); - expect(config.navigation).toBe('sequential'); - expect(config.showProgress).toBe(true); - }); - - it('should accept full review config', () => { - const config = RecordReviewConfigSchema.parse({ - object: 'invoice', - filter: { status: 'pending' }, - sort: [{ field: 'created_at', order: 'desc' }], - displayFields: ['amount', 'vendor', 'description'], - actions: [ - { label: 'Approve', type: 'approve', field: 'status', value: 'approved', nextRecord: true }, - { label: 'Reject', type: 'reject', field: 'status', value: 'rejected' }, - { label: 'Skip', type: 'skip', nextRecord: true }, - { label: 'Flag', type: 'custom', field: 'flagged', value: true }, - ], - navigation: 'filtered', - showProgress: false, - }); - - expect(config.actions).toHaveLength(4); - expect(config.navigation).toBe('filtered'); - expect(config.showProgress).toBe(false); - expect(config.displayFields).toEqual(['amount', 'vendor', 'description']); - }); - - it('should reject review config without object', () => { - expect(() => RecordReviewConfigSchema.parse({ - actions: [{ label: 'Approve', type: 'approve' }], - })).toThrow(); - }); - - it('should reject review config without actions', () => { - expect(() => RecordReviewConfigSchema.parse({ - object: 'order', - })).toThrow(); - }); - - it('should accept all action types', () => { - const types = ['approve', 'reject', 'skip', 'custom'] as const; - - types.forEach(type => { - expect(() => RecordReviewConfigSchema.parse({ - object: 'order', - actions: [{ label: 'Action', type }], - })).not.toThrow(); - }); - }); - - it('should accept all navigation modes', () => { - const modes = ['sequential', 'random', 'filtered'] as const; - - modes.forEach(navigation => { - const config = RecordReviewConfigSchema.parse({ - object: 'order', - actions: [{ label: 'Ok', type: 'approve' }], - navigation, - }); - expect(config.navigation).toBe(navigation); - }); - }); -}); - // --------------------------------------------------------------------------- // PageSchema with page types // --------------------------------------------------------------------------- @@ -722,75 +642,6 @@ describe('PageComponent dataSource integration', () => { }); }); -// --------------------------------------------------------------------------- -// BlankPageLayoutSchema — free-form canvas composition -// --------------------------------------------------------------------------- -describe('BlankPageLayoutSchema', () => { - it('should accept minimal layout with defaults', () => { - const layout = BlankPageLayoutSchema.parse({ - items: [], - }); - expect(layout.columns).toBe(12); - expect(layout.rowHeight).toBe(40); - expect(layout.gap).toBe(8); - expect(layout.items).toHaveLength(0); - }); - - it('should accept full layout config', () => { - const layout = BlankPageLayoutSchema.parse({ - columns: 24, - rowHeight: 20, - gap: 4, - items: [ - { componentId: 'text_1', x: 0, y: 0, width: 12, height: 2 }, - { componentId: 'btn_1', x: 0, y: 2, width: 6, height: 1 }, - { componentId: 'picker_1', x: 6, y: 2, width: 6, height: 3 }, - ], - }); - expect(layout.columns).toBe(24); - expect(layout.items).toHaveLength(3); - expect(layout.items[0].x).toBe(0); - expect(layout.items[2].width).toBe(6); - }); - - it('should reject item with invalid dimensions', () => { - expect(() => BlankPageLayoutSchema.parse({ - items: [{ componentId: 'a', x: 0, y: 0, width: 0, height: 1 }], - })).toThrow(); - - expect(() => BlankPageLayoutSchema.parse({ - items: [{ componentId: 'a', x: -1, y: 0, width: 1, height: 1 }], - })).toThrow(); - }); - - it('should accept page with blankLayout', () => { - const page = PageSchema.parse({ - name: 'blank_canvas', - label: 'Canvas', - type: 'home', - regions: [ - { - name: 'main', - components: [ - { id: 'text_1', type: 'element:text', properties: { content: 'Hello' } }, - { id: 'btn_1', type: 'element:button', properties: { label: 'Click' } }, - ], - }, - ], - blankLayout: { - columns: 12, - items: [ - { componentId: 'text_1', x: 0, y: 0, width: 12, height: 2 }, - { componentId: 'btn_1', x: 0, y: 2, width: 4, height: 1 }, - ], - }, - }); - - expect(page.blankLayout?.items).toHaveLength(2); - expect(page.blankLayout?.columns).toBe(12); - }); -}); - // --------------------------------------------------------------------------- // PageVariableSchema — record_picker variable binding // --------------------------------------------------------------------------- @@ -817,7 +668,6 @@ describe('PageVariableSchema record_id type', () => { name: 'blank_picker', label: 'Picker Page', type: 'home', - blankLayout: { items: [] }, variables: [ { name: 'selected_id', type: 'record_id', source: 'account_picker' }, { name: 'show_details', type: 'boolean', defaultValue: false }, @@ -891,31 +741,6 @@ describe('Page end-to-end', () => { expect(page.regions[0].components).toHaveLength(5); }); - it('should accept a record_review page with full config', () => { - const page = PageSchema.parse({ - name: 'page_review', - label: 'Review Queue', - type: 'home', - object: 'order', - recordReview: { - object: 'order', - filter: { status: 'pending_review' }, - sort: [{ field: 'priority', order: 'desc' }], - displayFields: ['customer_name', 'total', 'items_count'], - actions: [ - { label: 'Approve', type: 'approve', field: 'status', value: 'approved' }, - { label: 'Reject', type: 'reject', field: 'status', value: 'rejected' }, - { label: 'Skip', type: 'skip' }, - ], - navigation: 'sequential', - showProgress: true, - }, - regions: [], - }); - - expect(page.recordReview?.actions).toHaveLength(3); - }); - it('should accept a list page bound to an object', () => { // A grid is a *visualization* of a list page, not a page type — bind the // object on a `list` page and pick the grid visualization via interfaceConfig. @@ -1145,27 +970,6 @@ describe('PageComponentSchema - Negative Validation', () => { }); }); -describe('RecordReviewConfigSchema - Negative Validation', () => { - it('should reject review config without object', () => { - expect(() => RecordReviewConfigSchema.parse({ - actions: [{ label: 'Approve', type: 'approve' }], - })).toThrow(); - }); - - it('should reject review config without actions', () => { - expect(() => RecordReviewConfigSchema.parse({ - object: 'lead', - })).toThrow(); - }); - - it('should reject review action with invalid type enum', () => { - expect(() => RecordReviewConfigSchema.parse({ - object: 'lead', - actions: [{ label: 'Bad', type: 'invalid_action' }], - })).toThrow(); - }); -}); - // ============================================================================ // PageSchema cross-field requirements — none by design (enforce-or-remove) // ============================================================================ diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 25339cd844..984f3b77f8 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -131,29 +131,9 @@ export const PageVariableSchema = lazySchema(() => z.object({ source: z.string().optional().describe('Component ID that writes to this variable'), })); -/** - * Blank Page Layout Item Schema - * Positions a component on a free-form grid canvas. - */ -export const BlankPageLayoutItemSchema = lazySchema(() => z.object({ - componentId: z.string().describe('Reference to a PageComponent.id in the page'), - x: z.number().int().min(0).describe('Grid column position (0-based)'), - y: z.number().int().min(0).describe('Grid row position (0-based)'), - width: z.number().int().min(1).describe('Width in grid columns'), - height: z.number().int().min(1).describe('Height in grid rows'), -})); - -/** - * Blank Page Layout Schema - * Free-form canvas composition with grid-based positioning. - * Used when page type is 'blank' to enable drag-and-drop element placement. - */ -export const BlankPageLayoutSchema = lazySchema(() => z.object({ - columns: z.number().int().min(1).default(12).describe('Number of grid columns'), - rowHeight: z.number().int().min(1).default(40).describe('Height of each grid row in pixels'), - gap: z.number().int().min(0).default(8).describe('Gap between grid items in pixels'), - items: z.array(BlankPageLayoutItemSchema).describe('Positioned components on the canvas'), -})); +// BlankPageLayoutItemSchema / BlankPageLayoutSchema removed — the `blank` page +// type has no renderer and was dropped from PageTypeSchema (framework#2265, +// enforce-or-remove); objectui dropped all references in objectui#1949. /** * Page Type Schema @@ -218,35 +198,9 @@ export const PAGE_TYPE_ROADMAP = [ 'blank', // Free-form canvas (config: BlankPageLayoutSchema) ] as const; -/** - * Record Review Config Schema - * Configuration for a sequential record review/approval page. - * Users navigate through records one-by-one, taking actions (approve/reject/skip). - * Only applicable when page type is 'record_review'. - */ -export const RecordReviewConfigSchema = lazySchema(() => z.object({ - object: z.string().describe('Target object for review'), - filter: FilterConditionSchema.optional().describe('Filter criteria for review queue'), - sort: z.array(SortItemSchema).optional().describe('Sort order for review queue'), - displayFields: z.array(z.string()).optional() - .describe('Fields to display on the review page'), - actions: z.array(z.object({ - label: z.string().describe('Action button label'), - type: z.enum(['approve', 'reject', 'skip', 'custom']) - .describe('Action type'), - field: z.string().optional() - .describe('Field to update on action'), - value: z.union([z.string(), z.number(), z.boolean()]).optional() - .describe('Value to set on action'), - nextRecord: z.boolean().optional().default(true) - .describe('Auto-advance to next record after action'), - })).describe('Review actions'), - navigation: z.enum(['sequential', 'random', 'filtered']) - .optional().default('sequential') - .describe('Record navigation mode'), - showProgress: z.boolean().optional().default(true) - .describe('Show review progress indicator'), -})); +// RecordReviewConfigSchema removed — the `record_review` page type has no +// renderer and was dropped from PageTypeSchema (framework#2265, enforce-or-remove); +// objectui dropped all references in objectui#1949. /** * Interface Page Configuration Schema (Airtable Interface parity) @@ -353,19 +307,10 @@ export const PageSchema = lazySchema(() => z.object({ /** Context */ object: z.string().optional().describe('Bound object (for Record pages)'), - /** @deprecated Config for the `record_review` page type, which is not yet - * rendered and has been removed from {@link PageTypeSchema} (see - * {@link PAGE_TYPE_ROADMAP}). Retained as an optional field for back-compat; - * slated for removal once the type ships a renderer or consumers drop it. */ - recordReview: RecordReviewConfigSchema.optional() - .describe('@deprecated Record review configuration (record_review type is roadmap, not authorizable)'), - - /** @deprecated Config for the `blank` page type, which is not yet rendered and - * has been removed from {@link PageTypeSchema} (see {@link PAGE_TYPE_ROADMAP}). - * Retained as an optional field for back-compat; slated for removal. */ - blankLayout: BlankPageLayoutSchema.optional() - .describe('@deprecated Free-form grid layout (blank type is roadmap, not authorizable)'), - + // recordReview / blankLayout fields removed — the record_review/blank page + // types have no renderer and were dropped from PageTypeSchema (framework#2265); + // objectui dropped all references in objectui#1949. + /** Layout Template */ template: z.string().default('default').describe('Layout template name (e.g. "header-sidebar-main")'), @@ -455,7 +400,4 @@ export type PageComponent = z.infer; export type PageRegion = z.infer; export type PageVariable = z.infer; export type ElementDataSource = z.infer; -export type RecordReviewConfig = z.infer; -export type BlankPageLayoutItem = z.infer; -export type BlankPageLayout = z.infer; export type InterfacePageConfig = z.infer;