From 872709427adc06a3062b67d2abc22bc25f219dcb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:25:03 +0000 Subject: [PATCH 01/10] =?UTF-8?q?wip(ui-merge):=20K4=20=E2=80=94=20delete?= =?UTF-8?q?=20ui/apps/site=20and=20ui/content/docs,=20drop=20deploy-ui-sit?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01L9qibyMpJZ67jrDrno4jY1 Co-authored-by: Claude --- .github/workflows/docs-deploy.yml | 154 +- ui/apps/site/.gitignore | 33 - ui/apps/site/CHANGELOG.md | 24 - ui/apps/site/README.md | 45 - ui/apps/site/app/(home)/layout.tsx | 6 - ui/apps/site/app/(home)/page.tsx | 283 ---- ui/apps/site/app/api/search/route.ts | 7 - ui/apps/site/app/components/ComponentDemo.tsx | 16 - .../site/app/components/InteractiveDemo.tsx | 167 -- ui/apps/site/app/components/LiveSplitDemo.tsx | 308 ---- ui/apps/site/app/components/Logo.tsx | 16 - .../site/app/components/ObjectUIProvider.tsx | 23 - ui/apps/site/app/components/PluginLoader.tsx | 112 -- .../site/app/components/ReactVsObjectUI.tsx | 209 --- .../app/components/SchemaCatalogIndex.tsx | 445 ----- ui/apps/site/app/components/SchemaExample.tsx | 38 - .../site/app/components/SchemaThumbnail.tsx | 167 -- .../site/app/components/galleryDataSource.ts | 219 --- .../app/components/registerCatalogBlocks.ts | 189 --- .../app/components/registerLayoutBlocks.ts | 42 - ui/apps/site/app/docs/[[...slug]]/page.tsx | 61 - ui/apps/site/app/docs/layout.tsx | 11 - ui/apps/site/app/global.css | 44 - ui/apps/site/app/layout.tsx | 26 - ui/apps/site/app/llms-full.txt/route.ts | 10 - .../app/llms.mdx/docs/[[...slug]]/route.ts | 20 - ui/apps/site/app/llms.txt/route.ts | 13 - ui/apps/site/app/og/docs/[...slug]/route.tsx | 27 - ui/apps/site/app/playground/page.tsx | 1422 ---------------- ui/apps/site/components/ai/page-actions.tsx | 230 --- ui/apps/site/lib/cn.ts | 1 - ui/apps/site/lib/layout.shared.tsx | 27 - ui/apps/site/lib/source.ts | 27 - ui/apps/site/mdx-components.tsx | 14 - ui/apps/site/next.config.mjs | 61 - ui/apps/site/package.json | 56 - ui/apps/site/postcss.config.mjs | 5 - .../filter-bindings-inspector.png | Bin 18801 -> 0 bytes .../dashboard-filters/revenue-pulse-emea.png | Bin 105018 -> 0 bytes .../guide/dashboard-filters/revenue-pulse.png | Bin 117146 -> 0 bytes ui/apps/site/public/logo.svg | 25 - ui/apps/site/source.config.ts | 43 - ui/apps/site/tsconfig.json | 37 - ui/apps/site/vercel.json | 8 - ui/content/docs/api/index.md | 23 - ui/content/docs/api/meta.json | 6 - ui/content/docs/api/schema-reference.md | 1443 ----------------- ui/content/docs/blocks/authentication.mdx | 86 - ui/content/docs/blocks/dashboard.mdx | 79 - ui/content/docs/blocks/ecommerce.mdx | 85 - ui/content/docs/blocks/forms.mdx | 126 -- ui/content/docs/blocks/index.mdx | 122 -- ui/content/docs/blocks/marketing.mdx | 63 - ui/content/docs/blocks/meta.json | 10 - .../docs/components/basic/button-group.mdx | 49 - ui/content/docs/components/basic/div.mdx | 107 -- ui/content/docs/components/basic/icon.mdx | 69 - ui/content/docs/components/basic/image.mdx | 24 - ui/content/docs/components/basic/meta.json | 15 - .../docs/components/basic/navigation-menu.mdx | 32 - .../docs/components/basic/pagination.mdx | 35 - .../docs/components/basic/separator.mdx | 20 - ui/content/docs/components/basic/sidebar.mdx | 70 - ui/content/docs/components/basic/span.mdx | 79 - ui/content/docs/components/basic/text.mdx | 76 - .../docs/components/complex/carousel.mdx | 60 - .../docs/components/complex/data-table.mdx | 309 ---- .../components/complex/filter-builder.mdx | 116 -- .../docs/components/complex/filter-ui.mdx | 57 - ui/content/docs/components/complex/meta.json | 14 - .../docs/components/complex/resizable.mdx | 65 - .../docs/components/complex/scroll-area.mdx | 77 - .../docs/components/complex/sort-ui.mdx | 44 - ui/content/docs/components/complex/table.mdx | 18 - .../docs/components/complex/view-switcher.mdx | 61 - .../docs/components/data-display/alert.mdx | 23 - .../docs/components/data-display/avatar.mdx | 23 - .../docs/components/data-display/badge.mdx | 19 - .../components/data-display/breadcrumb.mdx | 63 - .../docs/components/data-display/kbd.mdx | 36 - .../docs/components/data-display/list.mdx | 50 - .../docs/components/data-display/meta.json | 13 - .../components/data-display/statistic.mdx | 63 - .../components/data-display/tree-view.mdx | 81 - .../docs/components/disclosure/accordion.mdx | 37 - .../components/disclosure/collapsible.mdx | 27 - .../docs/components/disclosure/meta.json | 8 - .../components/disclosure/toggle-group.mdx | 51 - ui/content/docs/components/feedback/empty.mdx | 43 - .../docs/components/feedback/loading.mdx | 27 - ui/content/docs/components/feedback/meta.json | 13 - .../docs/components/feedback/progress.mdx | 20 - .../docs/components/feedback/skeleton.mdx | 19 - .../docs/components/feedback/sonner.mdx | 54 - .../docs/components/feedback/spinner.mdx | 40 - ui/content/docs/components/feedback/toast.mdx | 53 - .../docs/components/feedback/toaster.mdx | 68 - ui/content/docs/components/form/button.mdx | 81 - ui/content/docs/components/form/calendar.mdx | 89 - ui/content/docs/components/form/checkbox.mdx | 28 - ui/content/docs/components/form/combobox.mdx | 84 - ui/content/docs/components/form/command.mdx | 51 - .../docs/components/form/date-picker.mdx | 67 - .../docs/components/form/file-upload.mdx | 74 - ui/content/docs/components/form/form.mdx | 66 - ui/content/docs/components/form/input-otp.mdx | 58 - ui/content/docs/components/form/input.mdx | 28 - ui/content/docs/components/form/label.mdx | 25 - ui/content/docs/components/form/meta.json | 22 - .../docs/components/form/radio-group.mdx | 65 - ui/content/docs/components/form/select.mdx | 35 - ui/content/docs/components/form/slider.mdx | 22 - ui/content/docs/components/form/switch.mdx | 33 - ui/content/docs/components/form/textarea.mdx | 35 - ui/content/docs/components/form/toggle.mdx | 49 - ui/content/docs/components/index.md | 186 --- .../docs/components/layout/aspect-ratio.mdx | 45 - ui/content/docs/components/layout/box.mdx | 73 - ui/content/docs/components/layout/card.mdx | 26 - .../docs/components/layout/container.mdx | 34 - ui/content/docs/components/layout/flex.mdx | 59 - ui/content/docs/components/layout/grid.mdx | 42 - ui/content/docs/components/layout/meta.json | 15 - ui/content/docs/components/layout/page.mdx | 90 - .../docs/components/layout/semantic.mdx | 77 - ui/content/docs/components/layout/stack.mdx | 27 - ui/content/docs/components/layout/tabs.mdx | 23 - ui/content/docs/components/meta.json | 14 - .../docs/components/navigation/header-bar.mdx | 164 -- .../docs/components/navigation/meta.json | 6 - .../docs/components/overlay/alert-dialog.mdx | 57 - .../docs/components/overlay/context-menu.mdx | 64 - ui/content/docs/components/overlay/dialog.mdx | 23 - ui/content/docs/components/overlay/drawer.mdx | 22 - .../docs/components/overlay/dropdown-menu.mdx | 66 - .../docs/components/overlay/hover-card.mdx | 27 - .../docs/components/overlay/menubar.mdx | 64 - ui/content/docs/components/overlay/meta.json | 15 - .../docs/components/overlay/popover.mdx | 21 - ui/content/docs/components/overlay/sheet.mdx | 35 - .../docs/components/overlay/tooltip.mdx | 35 - ui/content/docs/core/app-schema.mdx | 317 ---- ui/content/docs/core/enhanced-actions.mdx | 530 ------ ui/content/docs/core/meta.json | 10 - ui/content/docs/core/report-schema.mdx | 494 ------ ui/content/docs/core/schema-renderer.mdx | 188 --- ui/content/docs/core/theme-schema.mdx | 440 ----- ui/content/docs/fields/auto-number.mdx | 158 -- ui/content/docs/fields/boolean.mdx | 87 - ui/content/docs/fields/currency.mdx | 94 -- ui/content/docs/fields/date.mdx | 116 -- ui/content/docs/fields/datetime.mdx | 128 -- ui/content/docs/fields/email.mdx | 71 - ui/content/docs/fields/file.mdx | 121 -- ui/content/docs/fields/formula.mdx | 117 -- ui/content/docs/fields/grid.mdx | 370 ----- ui/content/docs/fields/image.mdx | 98 -- ui/content/docs/fields/location.mdx | 152 -- ui/content/docs/fields/lookup.mdx | 245 --- ui/content/docs/fields/meta.json | 32 - ui/content/docs/fields/number.mdx | 97 -- ui/content/docs/fields/object.mdx | 220 --- ui/content/docs/fields/password.mdx | 73 - ui/content/docs/fields/percent.mdx | 115 -- ui/content/docs/fields/phone.mdx | 61 - ui/content/docs/fields/rich-text.mdx | 158 -- ui/content/docs/fields/select.mdx | 202 --- ui/content/docs/fields/summary.mdx | 180 -- ui/content/docs/fields/text.mdx | 88 - ui/content/docs/fields/textarea.mdx | 61 - ui/content/docs/fields/time.mdx | 59 - ui/content/docs/fields/url.mdx | 70 - ui/content/docs/fields/user.mdx | 169 -- ui/content/docs/fields/vector.mdx | 225 --- ui/content/docs/fields/widget-props.mdx | 119 -- ui/content/docs/guide/agent-skills.md | 106 -- .../docs/guide/architecture-overview.md | 342 ---- ui/content/docs/guide/architecture.md | 476 ------ ui/content/docs/guide/building-crud-app.md | 436 ----- ui/content/docs/guide/component-registry.md | 465 ------ ui/content/docs/guide/console.md | 148 -- ui/content/docs/guide/dashboard-filters.md | 359 ---- ui/content/docs/guide/data-source.md | 623 ------- ui/content/docs/guide/deployment.md | 295 ---- .../docs/guide/designing-app-navigation.md | 140 -- ui/content/docs/guide/expressions.md | 760 --------- ui/content/docs/guide/fields.md | 237 --- ui/content/docs/guide/flow-designer.md | 177 -- ui/content/docs/guide/index.md | 8 - ui/content/docs/guide/layout.md | 926 ----------- ui/content/docs/guide/meta.json | 39 - ui/content/docs/guide/metadata-diagnostics.md | 322 ---- ui/content/docs/guide/notifications.md | 237 --- .../docs/guide/objectos-integration.mdx | 690 -------- ui/content/docs/guide/plugin-development.md | 561 ------- ui/content/docs/guide/plugins.md | 553 ------- ui/content/docs/guide/print-pages.md | 105 -- ui/content/docs/guide/public-forms.md | 289 ---- ui/content/docs/guide/quick-start.md | 333 ---- ui/content/docs/guide/react-pages.md | 413 ----- ui/content/docs/guide/record-edit-modes.md | 162 -- ui/content/docs/guide/release-notes.md | 26 - ui/content/docs/guide/schema-catalog.mdx | 48 - ui/content/docs/guide/schema-overview.md | 317 ---- ui/content/docs/guide/schema-playground.md | 292 ---- ui/content/docs/guide/schema-rendering.md | 648 -------- ui/content/docs/guide/slotted-pages.md | 382 ----- ui/content/docs/guide/theming.md | 365 ----- ui/content/docs/guide/troubleshooting.md | 429 ----- .../docs/guide/user-state-persistence.md | 346 ---- ui/content/docs/index.md | 87 - ui/content/docs/layout/app-shell.mdx | 306 ---- ui/content/docs/layout/meta.json | 8 - ui/content/docs/layout/page-header.mdx | 319 ---- ui/content/docs/layout/sidebar-nav.mdx | 339 ---- ui/content/docs/meta.json | 13 - ui/content/docs/plugins/index.md | 422 ----- ui/content/docs/plugins/meta.json | 24 - ui/content/docs/plugins/plugin-ai.mdx | 26 - .../docs/plugins/plugin-calendar-view.mdx | 48 - ui/content/docs/plugins/plugin-calendar.mdx | 728 --------- ui/content/docs/plugins/plugin-charts.mdx | 428 ----- ui/content/docs/plugins/plugin-chatbot.mdx | 771 --------- ui/content/docs/plugins/plugin-dashboard.mdx | 634 -------- ui/content/docs/plugins/plugin-designer.mdx | 26 - ui/content/docs/plugins/plugin-detail.mdx | 611 ------- ui/content/docs/plugins/plugin-editor.mdx | 168 -- ui/content/docs/plugins/plugin-form.mdx | 701 -------- ui/content/docs/plugins/plugin-gantt.mdx | 564 ------- ui/content/docs/plugins/plugin-grid.mdx | 948 ----------- ui/content/docs/plugins/plugin-kanban.mdx | 586 ------- ui/content/docs/plugins/plugin-list.mdx | 68 - ui/content/docs/plugins/plugin-map.mdx | 652 -------- ui/content/docs/plugins/plugin-markdown.mdx | 394 ----- ui/content/docs/plugins/plugin-report.mdx | 354 ---- ui/content/docs/plugins/plugin-timeline.mdx | 469 ------ ui/content/docs/plugins/plugin-view.mdx | 745 --------- ui/content/docs/utilities/cli.mdx | 424 ----- ui/content/docs/utilities/create-plugin.mdx | 190 --- .../docs/utilities/data-objectstack.mdx | 559 ------- ui/content/docs/utilities/index.md | 224 --- ui/content/docs/utilities/meta.json | 9 - ui/content/docs/utilities/runner.mdx | 622 ------- .../docs/utilities/vscode-extension.mdx | 502 ------ .../design}/console-architecture.md | 0 .../docs/rfcs/0001-clipboard-paste.md | 0 246 files changed, 6 insertions(+), 41847 deletions(-) delete mode 100644 ui/apps/site/.gitignore delete mode 100644 ui/apps/site/CHANGELOG.md delete mode 100644 ui/apps/site/README.md delete mode 100644 ui/apps/site/app/(home)/layout.tsx delete mode 100644 ui/apps/site/app/(home)/page.tsx delete mode 100644 ui/apps/site/app/api/search/route.ts delete mode 100644 ui/apps/site/app/components/ComponentDemo.tsx delete mode 100644 ui/apps/site/app/components/InteractiveDemo.tsx delete mode 100644 ui/apps/site/app/components/LiveSplitDemo.tsx delete mode 100644 ui/apps/site/app/components/Logo.tsx delete mode 100644 ui/apps/site/app/components/ObjectUIProvider.tsx delete mode 100644 ui/apps/site/app/components/PluginLoader.tsx delete mode 100644 ui/apps/site/app/components/ReactVsObjectUI.tsx delete mode 100644 ui/apps/site/app/components/SchemaCatalogIndex.tsx delete mode 100644 ui/apps/site/app/components/SchemaExample.tsx delete mode 100644 ui/apps/site/app/components/SchemaThumbnail.tsx delete mode 100644 ui/apps/site/app/components/galleryDataSource.ts delete mode 100644 ui/apps/site/app/components/registerCatalogBlocks.ts delete mode 100644 ui/apps/site/app/components/registerLayoutBlocks.ts delete mode 100644 ui/apps/site/app/docs/[[...slug]]/page.tsx delete mode 100644 ui/apps/site/app/docs/layout.tsx delete mode 100644 ui/apps/site/app/global.css delete mode 100644 ui/apps/site/app/layout.tsx delete mode 100644 ui/apps/site/app/llms-full.txt/route.ts delete mode 100644 ui/apps/site/app/llms.mdx/docs/[[...slug]]/route.ts delete mode 100644 ui/apps/site/app/llms.txt/route.ts delete mode 100644 ui/apps/site/app/og/docs/[...slug]/route.tsx delete mode 100644 ui/apps/site/app/playground/page.tsx delete mode 100644 ui/apps/site/components/ai/page-actions.tsx delete mode 100644 ui/apps/site/lib/cn.ts delete mode 100644 ui/apps/site/lib/layout.shared.tsx delete mode 100644 ui/apps/site/lib/source.ts delete mode 100644 ui/apps/site/mdx-components.tsx delete mode 100644 ui/apps/site/next.config.mjs delete mode 100644 ui/apps/site/package.json delete mode 100644 ui/apps/site/postcss.config.mjs delete mode 100644 ui/apps/site/public/img/guide/dashboard-filters/filter-bindings-inspector.png delete mode 100644 ui/apps/site/public/img/guide/dashboard-filters/revenue-pulse-emea.png delete mode 100644 ui/apps/site/public/img/guide/dashboard-filters/revenue-pulse.png delete mode 100644 ui/apps/site/public/logo.svg delete mode 100644 ui/apps/site/source.config.ts delete mode 100644 ui/apps/site/tsconfig.json delete mode 100644 ui/apps/site/vercel.json delete mode 100644 ui/content/docs/api/index.md delete mode 100644 ui/content/docs/api/meta.json delete mode 100644 ui/content/docs/api/schema-reference.md delete mode 100644 ui/content/docs/blocks/authentication.mdx delete mode 100644 ui/content/docs/blocks/dashboard.mdx delete mode 100644 ui/content/docs/blocks/ecommerce.mdx delete mode 100644 ui/content/docs/blocks/forms.mdx delete mode 100644 ui/content/docs/blocks/index.mdx delete mode 100644 ui/content/docs/blocks/marketing.mdx delete mode 100644 ui/content/docs/blocks/meta.json delete mode 100644 ui/content/docs/components/basic/button-group.mdx delete mode 100644 ui/content/docs/components/basic/div.mdx delete mode 100644 ui/content/docs/components/basic/icon.mdx delete mode 100644 ui/content/docs/components/basic/image.mdx delete mode 100644 ui/content/docs/components/basic/meta.json delete mode 100644 ui/content/docs/components/basic/navigation-menu.mdx delete mode 100644 ui/content/docs/components/basic/pagination.mdx delete mode 100644 ui/content/docs/components/basic/separator.mdx delete mode 100644 ui/content/docs/components/basic/sidebar.mdx delete mode 100644 ui/content/docs/components/basic/span.mdx delete mode 100644 ui/content/docs/components/basic/text.mdx delete mode 100644 ui/content/docs/components/complex/carousel.mdx delete mode 100644 ui/content/docs/components/complex/data-table.mdx delete mode 100644 ui/content/docs/components/complex/filter-builder.mdx delete mode 100644 ui/content/docs/components/complex/filter-ui.mdx delete mode 100644 ui/content/docs/components/complex/meta.json delete mode 100644 ui/content/docs/components/complex/resizable.mdx delete mode 100644 ui/content/docs/components/complex/scroll-area.mdx delete mode 100644 ui/content/docs/components/complex/sort-ui.mdx delete mode 100644 ui/content/docs/components/complex/table.mdx delete mode 100644 ui/content/docs/components/complex/view-switcher.mdx delete mode 100644 ui/content/docs/components/data-display/alert.mdx delete mode 100644 ui/content/docs/components/data-display/avatar.mdx delete mode 100644 ui/content/docs/components/data-display/badge.mdx delete mode 100644 ui/content/docs/components/data-display/breadcrumb.mdx delete mode 100644 ui/content/docs/components/data-display/kbd.mdx delete mode 100644 ui/content/docs/components/data-display/list.mdx delete mode 100644 ui/content/docs/components/data-display/meta.json delete mode 100644 ui/content/docs/components/data-display/statistic.mdx delete mode 100644 ui/content/docs/components/data-display/tree-view.mdx delete mode 100644 ui/content/docs/components/disclosure/accordion.mdx delete mode 100644 ui/content/docs/components/disclosure/collapsible.mdx delete mode 100644 ui/content/docs/components/disclosure/meta.json delete mode 100644 ui/content/docs/components/disclosure/toggle-group.mdx delete mode 100644 ui/content/docs/components/feedback/empty.mdx delete mode 100644 ui/content/docs/components/feedback/loading.mdx delete mode 100644 ui/content/docs/components/feedback/meta.json delete mode 100644 ui/content/docs/components/feedback/progress.mdx delete mode 100644 ui/content/docs/components/feedback/skeleton.mdx delete mode 100644 ui/content/docs/components/feedback/sonner.mdx delete mode 100644 ui/content/docs/components/feedback/spinner.mdx delete mode 100644 ui/content/docs/components/feedback/toast.mdx delete mode 100644 ui/content/docs/components/feedback/toaster.mdx delete mode 100644 ui/content/docs/components/form/button.mdx delete mode 100644 ui/content/docs/components/form/calendar.mdx delete mode 100644 ui/content/docs/components/form/checkbox.mdx delete mode 100644 ui/content/docs/components/form/combobox.mdx delete mode 100644 ui/content/docs/components/form/command.mdx delete mode 100644 ui/content/docs/components/form/date-picker.mdx delete mode 100644 ui/content/docs/components/form/file-upload.mdx delete mode 100644 ui/content/docs/components/form/form.mdx delete mode 100644 ui/content/docs/components/form/input-otp.mdx delete mode 100644 ui/content/docs/components/form/input.mdx delete mode 100644 ui/content/docs/components/form/label.mdx delete mode 100644 ui/content/docs/components/form/meta.json delete mode 100644 ui/content/docs/components/form/radio-group.mdx delete mode 100644 ui/content/docs/components/form/select.mdx delete mode 100644 ui/content/docs/components/form/slider.mdx delete mode 100644 ui/content/docs/components/form/switch.mdx delete mode 100644 ui/content/docs/components/form/textarea.mdx delete mode 100644 ui/content/docs/components/form/toggle.mdx delete mode 100644 ui/content/docs/components/index.md delete mode 100644 ui/content/docs/components/layout/aspect-ratio.mdx delete mode 100644 ui/content/docs/components/layout/box.mdx delete mode 100644 ui/content/docs/components/layout/card.mdx delete mode 100644 ui/content/docs/components/layout/container.mdx delete mode 100644 ui/content/docs/components/layout/flex.mdx delete mode 100644 ui/content/docs/components/layout/grid.mdx delete mode 100644 ui/content/docs/components/layout/meta.json delete mode 100644 ui/content/docs/components/layout/page.mdx delete mode 100644 ui/content/docs/components/layout/semantic.mdx delete mode 100644 ui/content/docs/components/layout/stack.mdx delete mode 100644 ui/content/docs/components/layout/tabs.mdx delete mode 100644 ui/content/docs/components/meta.json delete mode 100644 ui/content/docs/components/navigation/header-bar.mdx delete mode 100644 ui/content/docs/components/navigation/meta.json delete mode 100644 ui/content/docs/components/overlay/alert-dialog.mdx delete mode 100644 ui/content/docs/components/overlay/context-menu.mdx delete mode 100644 ui/content/docs/components/overlay/dialog.mdx delete mode 100644 ui/content/docs/components/overlay/drawer.mdx delete mode 100644 ui/content/docs/components/overlay/dropdown-menu.mdx delete mode 100644 ui/content/docs/components/overlay/hover-card.mdx delete mode 100644 ui/content/docs/components/overlay/menubar.mdx delete mode 100644 ui/content/docs/components/overlay/meta.json delete mode 100644 ui/content/docs/components/overlay/popover.mdx delete mode 100644 ui/content/docs/components/overlay/sheet.mdx delete mode 100644 ui/content/docs/components/overlay/tooltip.mdx delete mode 100644 ui/content/docs/core/app-schema.mdx delete mode 100644 ui/content/docs/core/enhanced-actions.mdx delete mode 100644 ui/content/docs/core/meta.json delete mode 100644 ui/content/docs/core/report-schema.mdx delete mode 100644 ui/content/docs/core/schema-renderer.mdx delete mode 100644 ui/content/docs/core/theme-schema.mdx delete mode 100644 ui/content/docs/fields/auto-number.mdx delete mode 100644 ui/content/docs/fields/boolean.mdx delete mode 100644 ui/content/docs/fields/currency.mdx delete mode 100644 ui/content/docs/fields/date.mdx delete mode 100644 ui/content/docs/fields/datetime.mdx delete mode 100644 ui/content/docs/fields/email.mdx delete mode 100644 ui/content/docs/fields/file.mdx delete mode 100644 ui/content/docs/fields/formula.mdx delete mode 100644 ui/content/docs/fields/grid.mdx delete mode 100644 ui/content/docs/fields/image.mdx delete mode 100644 ui/content/docs/fields/location.mdx delete mode 100644 ui/content/docs/fields/lookup.mdx delete mode 100644 ui/content/docs/fields/meta.json delete mode 100644 ui/content/docs/fields/number.mdx delete mode 100644 ui/content/docs/fields/object.mdx delete mode 100644 ui/content/docs/fields/password.mdx delete mode 100644 ui/content/docs/fields/percent.mdx delete mode 100644 ui/content/docs/fields/phone.mdx delete mode 100644 ui/content/docs/fields/rich-text.mdx delete mode 100644 ui/content/docs/fields/select.mdx delete mode 100644 ui/content/docs/fields/summary.mdx delete mode 100644 ui/content/docs/fields/text.mdx delete mode 100644 ui/content/docs/fields/textarea.mdx delete mode 100644 ui/content/docs/fields/time.mdx delete mode 100644 ui/content/docs/fields/url.mdx delete mode 100644 ui/content/docs/fields/user.mdx delete mode 100644 ui/content/docs/fields/vector.mdx delete mode 100644 ui/content/docs/fields/widget-props.mdx delete mode 100644 ui/content/docs/guide/agent-skills.md delete mode 100644 ui/content/docs/guide/architecture-overview.md delete mode 100644 ui/content/docs/guide/architecture.md delete mode 100644 ui/content/docs/guide/building-crud-app.md delete mode 100644 ui/content/docs/guide/component-registry.md delete mode 100644 ui/content/docs/guide/console.md delete mode 100644 ui/content/docs/guide/dashboard-filters.md delete mode 100644 ui/content/docs/guide/data-source.md delete mode 100644 ui/content/docs/guide/deployment.md delete mode 100644 ui/content/docs/guide/designing-app-navigation.md delete mode 100644 ui/content/docs/guide/expressions.md delete mode 100644 ui/content/docs/guide/fields.md delete mode 100644 ui/content/docs/guide/flow-designer.md delete mode 100644 ui/content/docs/guide/index.md delete mode 100644 ui/content/docs/guide/layout.md delete mode 100644 ui/content/docs/guide/meta.json delete mode 100644 ui/content/docs/guide/metadata-diagnostics.md delete mode 100644 ui/content/docs/guide/notifications.md delete mode 100644 ui/content/docs/guide/objectos-integration.mdx delete mode 100644 ui/content/docs/guide/plugin-development.md delete mode 100644 ui/content/docs/guide/plugins.md delete mode 100644 ui/content/docs/guide/print-pages.md delete mode 100644 ui/content/docs/guide/public-forms.md delete mode 100644 ui/content/docs/guide/quick-start.md delete mode 100644 ui/content/docs/guide/react-pages.md delete mode 100644 ui/content/docs/guide/record-edit-modes.md delete mode 100644 ui/content/docs/guide/release-notes.md delete mode 100644 ui/content/docs/guide/schema-catalog.mdx delete mode 100644 ui/content/docs/guide/schema-overview.md delete mode 100644 ui/content/docs/guide/schema-playground.md delete mode 100644 ui/content/docs/guide/schema-rendering.md delete mode 100644 ui/content/docs/guide/slotted-pages.md delete mode 100644 ui/content/docs/guide/theming.md delete mode 100644 ui/content/docs/guide/troubleshooting.md delete mode 100644 ui/content/docs/guide/user-state-persistence.md delete mode 100644 ui/content/docs/index.md delete mode 100644 ui/content/docs/layout/app-shell.mdx delete mode 100644 ui/content/docs/layout/meta.json delete mode 100644 ui/content/docs/layout/page-header.mdx delete mode 100644 ui/content/docs/layout/sidebar-nav.mdx delete mode 100644 ui/content/docs/meta.json delete mode 100644 ui/content/docs/plugins/index.md delete mode 100644 ui/content/docs/plugins/meta.json delete mode 100644 ui/content/docs/plugins/plugin-ai.mdx delete mode 100644 ui/content/docs/plugins/plugin-calendar-view.mdx delete mode 100644 ui/content/docs/plugins/plugin-calendar.mdx delete mode 100644 ui/content/docs/plugins/plugin-charts.mdx delete mode 100644 ui/content/docs/plugins/plugin-chatbot.mdx delete mode 100644 ui/content/docs/plugins/plugin-dashboard.mdx delete mode 100644 ui/content/docs/plugins/plugin-designer.mdx delete mode 100644 ui/content/docs/plugins/plugin-detail.mdx delete mode 100644 ui/content/docs/plugins/plugin-editor.mdx delete mode 100644 ui/content/docs/plugins/plugin-form.mdx delete mode 100644 ui/content/docs/plugins/plugin-gantt.mdx delete mode 100644 ui/content/docs/plugins/plugin-grid.mdx delete mode 100644 ui/content/docs/plugins/plugin-kanban.mdx delete mode 100644 ui/content/docs/plugins/plugin-list.mdx delete mode 100644 ui/content/docs/plugins/plugin-map.mdx delete mode 100644 ui/content/docs/plugins/plugin-markdown.mdx delete mode 100644 ui/content/docs/plugins/plugin-report.mdx delete mode 100644 ui/content/docs/plugins/plugin-timeline.mdx delete mode 100644 ui/content/docs/plugins/plugin-view.mdx delete mode 100644 ui/content/docs/utilities/cli.mdx delete mode 100644 ui/content/docs/utilities/create-plugin.mdx delete mode 100644 ui/content/docs/utilities/data-objectstack.mdx delete mode 100644 ui/content/docs/utilities/index.md delete mode 100644 ui/content/docs/utilities/meta.json delete mode 100644 ui/content/docs/utilities/runner.mdx delete mode 100644 ui/content/docs/utilities/vscode-extension.mdx rename ui/{content/docs/guide => docs/design}/console-architecture.md (100%) rename ui/{content => }/docs/rfcs/0001-clipboard-paste.md (100%) diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 9ea0c9d1ea1..e56cb1993b6 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -1,18 +1,12 @@ name: Docs Deploy -# The ONE path by which this repository's two docs sites reach production, -# one job each: +# The ONE path by which this repository's docs site reaches production: the +# `deploy` job, Vercel project `objectstack`, Root Directory `apps/docs`. # -# - `deploy`: the ObjectStack docs, Vercel project `objectstack`, Root -# Directory `apps/docs`; -# - `deploy-ui-site`: the Object UI docs at www.objectui.org, Vercel project -# `objectui`, Root Directory `ui/apps/site`. That site came here with the -# objectui tree; objectui's own copy of this workflow was deleted with the -# rest of `ui/.github/workflows/`, and nothing else deploys it. Its header -# below says what differs from `deploy`. -# -# Everything this header says applies to both jobs. The two are independent: -# one going red does not stop or cancel the other. +# The Object UI docs site (www.objectui.org) was retired when its pages folded +# into this one. Its old URLs answer host-scoped permanent +# redirects from `apps/docs/redirects.mjs` (`objectuiRedirects`) once the +# objectui.org domains point at this project; nothing deploys it any more. # # ## Why the build runs here and not on Vercel # @@ -217,139 +211,3 @@ jobs: echo echo "Deployment: ${{ steps.deploy.outputs.url || 'none (the run stopped before the deploy step finished)' }}" } >> "$GITHUB_STEP_SUMMARY" - - # ── The Object UI docs site (www.objectui.org) ───────────────────────────── - # - # The same build-here, upload-prebuilt path as `deploy` above, for the Vercel - # project `objectui`. `ui/apps/site/vercel.json` turns that project's Git - # deployments off (`git.deploymentEnabled: false`), so this job is the only - # thing that publishes the site, and every failure is red, as above. - # - # What differs from `deploy`, and why: - # - # - The project's Root Directory must read `ui/apps/site`. It read - # `apps/site` while the site lived in the objectui repository, and it is - # a dashboard setting that no file here can change. A step right after - # `vercel pull` reads the setting the pull wrote and goes red, naming the - # setting and its value, when it is anything else; without that step - # the old value fails later inside `vercel build`, as an error that does - # not say a dashboard setting is the cause. - # - `vercel build` gets no token. It runs `vercel.json`'s install and build - # commands, which run every workspace package's lifecycle scripts, and - # it does not call the Vercel API. Only the `pull` and `deploy` steps, - # which do, see VERCEL_TOKEN. objectui's own workflow made this choice - # for the same reason. - # - objectui's pinned-@objectstack restore and build steps are not ported. - # In this repository every @objectstack package the site imports is a - # workspace package, and `vercel.json`'s build command builds it from - # this tree through turbo's `^build`. - # - # The site's own install and build commands live in `ui/apps/site/vercel.json` - # and are unchanged from objectui: `pnpm i` installs this whole workspace from - # any member directory, and its build command's `cd ../..` lands in `ui/`, - # from which turbo resolves this repository's root and builds - # `@object-ui/site` with its full closure (65 tasks, all built, on the - # local run measured below). - deploy-ui-site: - name: Deploy objectui docs to Vercel (production) - runs-on: ubuntu-latest - # The build behind `vercel.json`'s command (the site, its @object-ui - # packages and their @objectstack closure: 65 turbo tasks, none cached) - # measured 596 s at `--concurrency=2` on a shared dev container; the runner - # adds install and upload. - timeout-minutes: 30 - env: - # Identifiers, not credentials, as in `deploy`: the same team, and the - # project that serves www.objectui.org. - VERCEL_ORG_ID: team_06nvlGKZCkwhzdXmRdxeCNnB - VERCEL_PROJECT_ID: prj_v1rXIczaI7YGmXmseQCLFUFOFxTB - # The same pin as `deploy`'s VERCEL_CLI_VERSION; move the two together. - VERCEL_CLI_VERSION: '60.1.3' - # The value the project's Root Directory must have; see the header. - VERCEL_ROOT_DIRECTORY: ui/apps/site - steps: - - name: Refuse any ref but main - if: github.ref != 'refs/heads/main' - run: | - echo "::error::Docs Deploy publishes to PRODUCTION and runs only from main; this run is on ${GITHUB_REF}. Re-run it with 'main' selected in the branch picker." - exit 1 - - - name: Require the Vercel token - env: - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - run: | - if [ -z "${VERCEL_TOKEN:-}" ]; then - echo "::error::The secret VERCEL_TOKEN is not visible to this run, so www.objectui.org cannot be deployed and production is NOT being updated. It is provided as an objectstack-ai ORGANIZATION secret: check that it still exists and that this repository is in its repository access list (org Settings -> Secrets and variables -> Actions). Then re-run this workflow." - exit 1 - fi - echo "VERCEL_TOKEN is set." - - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Setup workspace - uses: ./.github/actions/setup-workspace - - # From the repository root, for the reason `deploy`'s comment on this - # step gives: the CLI applies the Root Directory relative to its cwd. - - name: Pull the production project settings - env: - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - run: npx --yes "vercel@${VERCEL_CLI_VERSION}" pull --yes --environment=production --token="$VERCEL_TOKEN" - - - name: Require the project's Root Directory to be ui/apps/site - run: | - set -euo pipefail - actual="$(node -e " - const fs = require('node:fs'); - const file = '.vercel/project.json'; - if (!fs.existsSync(file)) { console.log('(vercel pull wrote no ' + file + ')'); process.exit(0); } - const settings = JSON.parse(fs.readFileSync(file, 'utf8')).settings || {}; - console.log(settings.rootDirectory ?? '(not set)'); - ")" - if [ "$actual" != "$VERCEL_ROOT_DIRECTORY" ]; then - echo "::error::The Vercel project ${VERCEL_PROJECT_ID} has Root Directory '${actual}', and this repository builds the site from '${VERCEL_ROOT_DIRECTORY}'. Nothing is deployed. A maintainer sets it in the Vercel dashboard: project objectui -> Settings -> Build and Deployment -> Root Directory = ${VERCEL_ROOT_DIRECTORY}. Then re-run this workflow." - exit 1 - fi - echo "Root Directory: ${actual}" - - # No VERCEL_TOKEN here; see the header. TURBO_FORCE for the reason - # `deploy`'s build step gives. - - name: Build the site (vercel build --prod) - env: - TURBO_FORCE: 'true' - run: npx --yes "vercel@${VERCEL_CLI_VERSION}" build --prod - - - name: Assert the build produced a deployable output - run: | - set -euo pipefail - if [ ! -f ui/apps/site/.next/BUILD_ID ]; then - echo "::error file=ui/apps/site/vercel.json::vercel build exited 0 but produced no ui/apps/site/.next/BUILD_ID - the site was not built, so nothing is deployed." - exit 1 - fi - if [ ! -f .vercel/output/config.json ]; then - echo "::error::vercel build exited 0 but wrote no .vercel/output/config.json at the repository root - there is no prebuilt output to deploy." - exit 1 - fi - echo "Built the objectui site: BUILD_ID=$(cat ui/apps/site/.next/BUILD_ID)" - - - name: Deploy the prebuilt output to production - id: deploy - env: - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - run: | - set -euo pipefail - url=$(npx --yes "vercel@${VERCEL_CLI_VERSION}" deploy --prebuilt --prod --token="$VERCEL_TOKEN") - echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "Deployed: ${url}" - - - name: Publish the run to the summary - if: always() - run: | - { - echo "### Docs Deploy (www.objectui.org) — ${{ job.status }}" - echo - echo "Commit: \`${{ github.sha }}\` (${{ github.event_name }})" - echo - echo "Deployment: ${{ steps.deploy.outputs.url || 'none (the run stopped before the deploy step finished)' }}" - } >> "$GITHUB_STEP_SUMMARY" diff --git a/ui/apps/site/.gitignore b/ui/apps/site/.gitignore deleted file mode 100644 index c692d4a40d1..00000000000 --- a/ui/apps/site/.gitignore +++ /dev/null @@ -1,33 +0,0 @@ -# deps -/node_modules - -# generated content -.source - -# test & build -/coverage -/.next/ -/out/ -/build -*.tsbuildinfo - -# misc -.DS_Store -*.pem -/.pnp -.pnp.js -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# others -.env*.local -.vercel -next-env.d.ts - -# generated by `next dev` when it detects an AI coding agent (objectui#4160) — -# turned off at the source by `agentRules: false` in next.config.mjs, ignored -# here and in the repo root .gitignore (which carries the reasoning) because -# that flag is upstream-owned. Anchored: only this directory's own files. -/AGENTS.md -/CLAUDE.md diff --git a/ui/apps/site/CHANGELOG.md b/ui/apps/site/CHANGELOG.md deleted file mode 100644 index 4df8fb56b1c..00000000000 --- a/ui/apps/site/CHANGELOG.md +++ /dev/null @@ -1,24 +0,0 @@ -# @objectui/site - -## 0.0.1 - -### Patch Changes - -- Updated dependencies - - @object-ui/types@0.3.1 - - @object-ui/core@0.3.1 - - @object-ui/react@0.3.1 - - @object-ui/components@0.3.1 - - @object-ui/plugin-view@0.3.1 - - @object-ui/plugin-grid@0.3.1 - - @object-ui/plugin-form@0.3.1 - - @object-ui/plugin-kanban@0.3.1 - - @object-ui/plugin-charts@0.3.1 - - @object-ui/plugin-calendar@0.3.1 - - @object-ui/plugin-editor@0.3.1 - - @object-ui/plugin-gantt@0.3.1 - - @object-ui/plugin-map@0.3.1 - - @object-ui/plugin-markdown@0.3.1 - - @object-ui/plugin-timeline@0.3.1 - - @object-ui/plugin-aggrid@0.4.1 - - @object-ui/plugin-chatbot@0.3.1 diff --git a/ui/apps/site/README.md b/ui/apps/site/README.md deleted file mode 100644 index 86c46a0a26d..00000000000 --- a/ui/apps/site/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# my-app - -This is a Next.js application generated with -[Create Fumadocs](https://github.com/fuma-nama/fumadocs). - -Run development server: - -```bash -npm run dev -# or -pnpm dev -# or -yarn dev -``` - -Open http://localhost:3000 with your browser to see the result. - -## Explore - -In the project, you can see: - -- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content. -- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep. - -| Route | Description | -| ------------------------- | ------------------------------------------------------ | -| `app/(home)` | The route group for your landing page and other pages. | -| `app/docs` | The documentation layout and pages. | -| `app/api/search/route.ts` | The Route Handler for search. | - -### Fumadocs MDX - -A `source.config.ts` config file has been included, you can customise different options like frontmatter schema. - -Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details. - -## Learn More - -To learn more about Next.js and Fumadocs, take a look at the following -resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js - features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. -- [Fumadocs](https://fumadocs.dev) - learn about Fumadocs diff --git a/ui/apps/site/app/(home)/layout.tsx b/ui/apps/site/app/(home)/layout.tsx deleted file mode 100644 index 77379fac3f0..00000000000 --- a/ui/apps/site/app/(home)/layout.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { HomeLayout } from 'fumadocs-ui/layouts/home'; -import { baseOptions } from '@/lib/layout.shared'; - -export default function Layout({ children }: LayoutProps<'/'>) { - return {children}; -} diff --git a/ui/apps/site/app/(home)/page.tsx b/ui/apps/site/app/(home)/page.tsx deleted file mode 100644 index 142df83b4a8..00000000000 --- a/ui/apps/site/app/(home)/page.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import type { Metadata } from 'next'; -import Link from 'next/link'; -import { LiveSplitDemo } from '@/app/components/LiveSplitDemo'; -import { ReactVsObjectUI } from '@/app/components/ReactVsObjectUI'; - -const TITLE = 'Object UI — schema-driven UI engine for React'; -const DESCRIPTION = - 'AI writes the schema; Object UI renders it — production React from JSON, Tailwind and Shadcn native, any backend.'; - -// The route had no metadata at all, so the homepage shipped with no , -// no description and no link-preview fields. Open Graph and Twitter carry no -// image: the site ships none, and naming one it does not serve would render a -// broken preview. Nothing here resolves against an origin, so this object needs -// no `metadataBase` of its own; `app/layout.tsx` sets one for the docs pages, -// whose OG images are site-relative. -export const metadata: Metadata = { - title: TITLE, - description: DESCRIPTION, - openGraph: { type: 'website', title: TITLE, description: DESCRIPTION }, - twitter: { card: 'summary', title: TITLE, description: DESCRIPTION }, -}; - -export default function HomePage() { - return ( - <> - <section className="relative overflow-hidden bg-gradient-to-br from-fd-background via-fd-background to-fd-muted/20 px-6 py-24 sm:py-32 lg:px-8"> - <div className="relative mx-auto max-w-7xl"> - <div className="mx-auto max-w-3xl text-center"> - <div className="mb-8 inline-flex items-center rounded-full border border-fd-primary/20 bg-fd-primary/10 px-4 py-1.5 text-sm font-medium text-fd-primary"> - ✨ The Universal Schema-Driven UI Engine - </div> - <h1 className="mb-6 text-4xl font-bold tracking-tight text-fd-foreground sm:text-5xl md:text-6xl lg:text-7xl"> - From JSON to - <span className="bg-gradient-to-r from-blue-600 to-violet-600 bg-clip-text text-transparent dark:from-blue-400 dark:to-violet-400"> World-Class UI </span> - in Minutes - </h1> - <p className="mb-10 text-lg leading-8 text-fd-muted-foreground"> - Build beautiful, production-ready interfaces without writing React code. - Just define your UI in JSON and let ObjectUI handle the rest with Tailwind CSS and Shadcn UI. - </p> - <div className="flex flex-col items-center justify-center gap-4 sm:flex-row"> - <Link - href="/docs" - className="inline-flex items-center justify-center rounded-lg bg-fd-primary px-8 py-3.5 text-base font-semibold text-fd-primary-foreground shadow-lg transition-all hover:bg-fd-primary/90 hover:shadow-xl" - > - Get Started - <svg className="ml-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" /> - </svg> - </Link> - <a - href="https://github.com/objectstack-ai/objectui" - target="_blank" - rel="noopener noreferrer" - className="inline-flex items-center justify-center rounded-lg border border-fd-border bg-fd-card px-8 py-3.5 text-base font-semibold text-fd-foreground shadow-sm transition-all hover:bg-fd-accent hover:shadow-md" - > - <svg className="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"> - <path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.205 11.387.6.111.82-.261.82-.577 0-.286-.011-1.244-.017-2.257-3.338.726-4.042-1.416-4.042-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.085 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.418-1.305.762-1.605-2.665-.305-5.467-1.334-5.467-5.93 0-1.31.469-2.382 1.236-3.222-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.398 3.003-.403 1.02.005 2.047.137 3.006.403 2.29-1.553 3.297-1.23 3.297-1.23.653 1.652.242 2.873.118 3.176.77.84 1.235 1.911 1.235 3.222 0 4.61-2.807 5.62-5.479 5.92.43.371.815 1.103.815 2.222 0 1.606-.014 2.898-.014 3.293 0 .319.218.694.825.576C20.565 21.797 24 17.298 24 12c0-6.627-5.373-12-12-12z" /> - </svg> - Star on GitHub - </a> - </div> - - {/* Trust strip — version, downloads, license, etc. via shields.io */} - <ul - aria-label="Project stats" - className="mt-8 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 text-sm" - > - {[ - { - href: 'https://www.npmjs.com/package/@object-ui/react', - src: 'https://img.shields.io/npm/v/@object-ui/react?label=npm&color=cb3837', - alt: 'Latest npm version', - }, - { - href: 'https://www.npmjs.com/package/@object-ui/react', - src: 'https://img.shields.io/npm/dm/@object-ui/react?label=downloads&color=4c1', - alt: 'Monthly npm downloads', - }, - { - href: 'https://github.com/objectstack-ai/objectui/stargazers', - src: 'https://img.shields.io/github/stars/objectstack-ai/objectui?style=flat&color=fbca04', - alt: 'GitHub stars', - }, - { - href: 'https://github.com/objectstack-ai/objectui/graphs/contributors', - src: 'https://img.shields.io/github/contributors/objectstack-ai/objectui?color=informational', - alt: 'Contributors', - }, - { - href: 'https://github.com/objectstack-ai/objectui/actions/workflows/ci.yml?query=branch%3Amain', - src: 'https://img.shields.io/github/actions/workflow/status/objectstack-ai/objectui/ci.yml?branch=main&label=CI', - alt: 'CI status', - }, - { - href: 'https://github.com/objectstack-ai/objectui/blob/main/LICENSE', - src: 'https://img.shields.io/github/license/objectstack-ai/objectui?color=informational', - alt: 'MIT licensed', - }, - ].map((b) => ( - <li key={b.alt}> - <a - href={b.href} - target="_blank" - rel="noopener noreferrer" - className="inline-block transition hover:opacity-80" - > - {/* Plain <img>, not next/image: these are third-party badge - endpoints that render their own SVG and need no optimisation. */} - <img - src={b.src} - alt={b.alt} - loading="lazy" - decoding="async" - height={20} - className="h-5" - /> - </a> - </li> - ))} - </ul> - </div> - - {/* Live Split View — JSON in, UI out */} - <div className="mx-auto mt-16 max-w-6xl"> - <LiveSplitDemo /> - </div> - </div> - </section> - - {/* React vs ObjectUI — the same dashboard, written both ways */} - <ReactVsObjectUI /> - - {/* Features Section */} - <section className="py-24 sm:py-32 bg-fd-muted/30"> - <div className="mx-auto max-w-7xl px-6 lg:px-8"> - <div className="mx-auto max-w-2xl text-center mb-16"> - <h2 className="text-3xl font-bold tracking-tight text-fd-foreground sm:text-4xl"> - Why Choose ObjectUI? - </h2> - <p className="mt-4 text-lg text-fd-muted-foreground"> - Stop writing repetitive UI code. Build faster with better results. - </p> - </div> - - <div className="mx-auto grid max-w-6xl grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3"> - {/* Feature 1 */} - <div className="relative rounded-2xl border border-fd-border bg-fd-card p-8 shadow-sm transition-all hover:shadow-lg"> - <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-fd-primary/10 text-fd-primary"> - <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /> - </svg> - </div> - <h3 className="mb-2 text-xl font-semibold text-fd-foreground"> - Blazing Fast - </h3> - <p className="text-fd-muted-foreground"> - Lazy-loaded plugins and tree-shakable packages: a page pays for the components it renders, nothing else. - </p> - </div> - - {/* Feature 2 */} - <div className="relative rounded-2xl border border-fd-border bg-fd-card p-8 shadow-sm transition-all hover:shadow-lg"> - <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-fd-accent/10 text-fd-accent"> - <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" /> - </svg> - </div> - <h3 className="mb-2 text-xl font-semibold text-fd-foreground"> - Beautiful by Default - </h3> - <p className="text-fd-muted-foreground"> - Professional designs using Tailwind CSS and Shadcn UI. Light/dark theme support, fully customizable, and WCAG 2.1 AA accessible. - </p> - </div> - - {/* Feature 3 */} - <div className="relative rounded-2xl border border-fd-border bg-fd-card p-8 shadow-sm transition-all hover:shadow-lg"> - <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-fd-primary/10 text-fd-primary"> - <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /> - </svg> - </div> - <h3 className="mb-2 text-xl font-semibold text-fd-foreground"> - Developer Friendly - </h3> - <p className="text-fd-muted-foreground"> - TypeScript-first with complete type definitions. Zero learning curve if you know React. Works with existing tools and workflows. - </p> - </div> - - {/* Feature 4 */} - <div className="relative rounded-2xl border border-fd-border bg-fd-card p-8 shadow-sm transition-all hover:shadow-lg"> - <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-fd-accent/10 text-fd-accent"> - <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> - </svg> - </div> - <h3 className="mb-2 text-xl font-semibold text-fd-foreground"> - Production Ready - </h3> - <p className="text-fd-muted-foreground"> - TypeScript end to end, a CI suite on every change, and documentation that is gated against the code. - </p> - </div> - - {/* Feature 5 */} - <div className="relative rounded-2xl border border-fd-border bg-fd-card p-8 shadow-sm transition-all hover:shadow-lg"> - <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-fd-primary/10 text-fd-primary"> - <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" /> - </svg> - </div> - <h3 className="mb-2 text-xl font-semibold text-fd-foreground"> - Modular Architecture - </h3> - <p className="text-fd-muted-foreground"> - Tree-shakable packages and lazy-loaded plugins. Only load what you need. - </p> - </div> - - {/* Feature 6 */} - <div className="relative rounded-2xl border border-fd-border bg-fd-card p-8 shadow-sm transition-all hover:shadow-lg"> - <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-fd-accent/10 text-fd-accent"> - <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" /> - </svg> - </div> - <h3 className="mb-2 text-xl font-semibold text-fd-foreground"> - Backend Agnostic - </h3> - <p className="text-fd-muted-foreground"> - Works with any backend through universal DataSource interface. REST, GraphQL, Firebase, or custom adapters. - </p> - </div> - </div> - </div> - </section> - - {/* CTA Section */} - <section className="py-24 sm:py-32"> - <div className="mx-auto max-w-7xl px-6 lg:px-8"> - <div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-blue-600 to-violet-600 px-8 py-16 text-center shadow-2xl sm:px-16 dark:from-blue-700 dark:to-violet-700"> - <div className="relative z-10"> - <h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl"> - Ready to Build Something Amazing? - </h2> - <p className="mx-auto mt-6 max-w-2xl text-lg text-white/90"> - Join developers who are building faster with ObjectUI. Get started in minutes with our comprehensive documentation. - </p> - <div className="mt-10 flex flex-col items-center justify-center gap-4 sm:flex-row"> - <Link - href="/docs" - className="rounded-lg bg-fd-background px-8 py-3.5 text-base font-semibold text-fd-foreground shadow-lg transition-all hover:bg-fd-background/90" - > - Get Started Now - </Link> - <Link - href="/docs/guide/schema-catalog" - className="rounded-lg border border-white/40 px-8 py-3.5 text-base font-semibold text-white transition-all hover:bg-white/10" - > - Browse the schema catalog - </Link> - </div> - </div> - </div> - </div> - </section> - - {/* Footer */} - <footer className="border-t border-fd-border"> - <div className="mx-auto max-w-7xl px-6 py-12 lg:px-8"> - <p className="text-center text-sm text-fd-muted-foreground"> - Built with ❤️ by the{' '} - <a href="https://github.com/objectstack-ai" className="font-medium hover:text-fd-foreground"> - ObjectStack Team - </a> - </p> - </div> - </footer> - </> - ) -} diff --git a/ui/apps/site/app/api/search/route.ts b/ui/apps/site/app/api/search/route.ts deleted file mode 100644 index 7ba7e8231a6..00000000000 --- a/ui/apps/site/app/api/search/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { source } from '@/lib/source'; -import { createFromSource } from 'fumadocs-core/search/server'; - -export const { GET } = createFromSource(source, { - // https://docs.orama.com/docs/orama-js/supported-languages - language: 'english', -}); diff --git a/ui/apps/site/app/components/ComponentDemo.tsx b/ui/apps/site/app/components/ComponentDemo.tsx deleted file mode 100644 index fbdc25b0d3d..00000000000 --- a/ui/apps/site/app/components/ComponentDemo.tsx +++ /dev/null @@ -1,16 +0,0 @@ -// Re-export the new InteractiveDemo as the main demo component -export { InteractiveDemo as ComponentDemo, DemoGrid } from './InteractiveDemo'; - -// Legacy exports for backward compatibility -export { InteractiveDemo, InteractiveDemo as CodeDemo } from './InteractiveDemo'; - -// Catalog-driven example: pulls schema + meta from -// @object-ui/example-schema-catalog. Prefer this over inline <InteractiveDemo> -// in new MDX content so every example is smoke-tested. -export { SchemaExample } from './SchemaExample'; - -// Export PluginLoader for use in MDX files -export { PluginLoader } from './PluginLoader'; - -// Export types for use in MDX files -export type { SchemaNode } from './InteractiveDemo'; diff --git a/ui/apps/site/app/components/InteractiveDemo.tsx b/ui/apps/site/app/components/InteractiveDemo.tsx deleted file mode 100644 index b62df6293e6..00000000000 --- a/ui/apps/site/app/components/InteractiveDemo.tsx +++ /dev/null @@ -1,167 +0,0 @@ -'use client'; - -import React, { useMemo } from 'react'; -import { SchemaRenderer, SchemaRendererContext, toRenderableSchema } from '@object-ui/react'; -import { SidebarProvider } from '@object-ui/components'; -// Registers `@object-ui/layout`'s blocks — see the module header (objectui#3787). -import './registerLayoutBlocks'; -import { galleryDataSource } from './galleryDataSource'; -import type { SchemaNode } from '@object-ui/core'; -import { Tabs, Tab } from 'fumadocs-ui/components/tabs'; -import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock'; - -// Re-export SchemaNode type for use in MDX files -export type { SchemaNode } from '@object-ui/core'; - -/** - * The provider the demos render under. `dataSource` is the docs gallery's - * stand-in fixture — the SAME module `SchemaThumbnail` supplies to the catalog - * gallery, rather than a second one — because a demo whose schema is - * object-bound has no other way to reach data: `dataSource` is not a schema - * key, it is what the registered renderer pulls off this context - * (`packages/plugin-view/src/index.tsx`). - * - * It was `{}` until objectui#5113, which is why the three `plugin-view` - * examples on `content/docs/plugins/plugin-view.mdx` could only be hand-drawn - * pictures of a view rather than the view itself. - * - * Importing the fixture pulls in NO plugin package: this host keeps its plugin - * registration lazy through `PluginLoader`, per page — the gallery's eager - * block-registration module stays out of here, and `galleryDataSource` imports - * nothing at all (objectui#4600/#4616). - */ -const defaultCtx = { dataSource: galleryDataSource }; -function DemoProvider({ children }: { children: React.ReactNode }) { - const value = useMemo(() => defaultCtx, []); - return ( - <SchemaRendererContext.Provider value={value}> - {children} - </SchemaRendererContext.Provider> - ); -} - -interface InteractiveDemoProps { - schema: SchemaNode; - title?: string; - description?: string; - /** - * Show multiple examples with their own schemas - */ - examples?: Array<{ - schema: SchemaNode; - label: string; - description?: string; - }>; -} - -export function InteractiveDemo({ - schema, - title, - description, - examples -}: InteractiveDemoProps) { - - // If examples are provided, show a multi-example view - if (examples && examples.length > 0) { - return ( - <div className="not-prose my-6"> - {(title || description) && ( - <div className="mb-3"> - {title && <h4 className="text-sm font-semibold mb-1">{title}</h4>} - {description && <p className="text-sm text-muted-foreground">{description}</p>} - </div> - )} - <Tabs items={['Preview', 'Code']} defaultIndex={0}> - <Tab value="Preview"> - <div className="space-y-6"> - {examples.map((example, index) => ( - <div key={index} className="border rounded-lg overflow-hidden"> - {example.label && ( - <div className="border-b bg-muted px-4 py-2"> - <p className="text-sm font-medium">{example.label}</p> - {example.description && ( - <p className="text-xs text-muted-foreground mt-0.5">{example.description}</p> - )} - </div> - )} - <div className="p-6 bg-background"> - <DemoProvider> - <SidebarProvider className="min-h-0 w-full" defaultOpen={false}> - <div className="w-full"> - <SchemaRenderer - schema={toRenderableSchema(example.schema)} - dataSource={galleryDataSource} - /> - </div> - </SidebarProvider> - </DemoProvider> - </div> - </div> - ))} - </div> - </Tab> - <Tab value="Code"> - <div className="space-y-4"> - {examples.map((example, index) => ( - <div key={index}> - {example.label && ( - <p className="text-sm font-medium mb-2">{example.label}</p> - )} - <CodeBlock> - <Pre> - <code>{JSON.stringify(example.schema, null, 2)}</code> - </Pre> - </CodeBlock> - </div> - ))} - </div> - </Tab> - </Tabs> - </div> - ); - } - - // Single example view with Preview/Code tabs - return ( - <div className="not-prose my-6"> - {(title || description) && ( - <div className="mb-3"> - {title && <h4 className="text-sm font-semibold mb-1">{title}</h4>} - {description && <p className="text-sm text-muted-foreground">{description}</p>} - </div> - )} - <Tabs items={['Preview', 'Code']} defaultIndex={0}> - <Tab value="Preview"> - <div className="border rounded-lg p-6 bg-background"> - <DemoProvider> - <SidebarProvider className="min-h-0 w-full" defaultOpen={false}> - <div className="w-full"> - <SchemaRenderer schema={toRenderableSchema(schema)} dataSource={galleryDataSource} /> - </div> - </SidebarProvider> - </DemoProvider> - </div> - </Tab> - <Tab value="Code"> - <CodeBlock> - <Pre> - <code>{JSON.stringify(schema, null, 2)}</code> - </Pre> - </CodeBlock> - </Tab> - </Tabs> - </div> - ); -} - -interface DemoGridProps { - children: React.ReactNode; -} - -export function DemoGrid({ children }: DemoGridProps) { - return ( - <div className="not-prose grid gap-4 md:grid-cols-2 my-6"> - {children} - </div> - ); -} diff --git a/ui/apps/site/app/components/LiveSplitDemo.tsx b/ui/apps/site/app/components/LiveSplitDemo.tsx deleted file mode 100644 index d3fe57f8858..00000000000 --- a/ui/apps/site/app/components/LiveSplitDemo.tsx +++ /dev/null @@ -1,308 +0,0 @@ -'use client'; - -/** - * 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, { - Component, - type ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { SchemaRenderer, SchemaRendererContext, toRenderableSchema } from '@object-ui/react'; -import { SidebarProvider } from '@object-ui/components'; -import type { SchemaNode } from '@object-ui/core'; -import { - getExample, - type Example, -} from '@object-ui/example-schema-catalog'; -// Registers `@object-ui/layout`'s blocks — see the module header (objectui#3787). -import './registerLayoutBlocks'; - -const PRESET_IDS = [ - 'auth/login-simple', - 'dashboard/stats-cards-grid', - 'components-form-form/contact-form', - 'blocks-gallery/block-gallery-stats-card', - 'components-complex-table/basic-table', -] as const; - -const PRESET_LABELS: Record<(typeof PRESET_IDS)[number], string> = { - 'auth/login-simple': 'Login', - 'dashboard/stats-cards-grid': 'Dashboard', - 'components-form-form/contact-form': 'Form', - 'blocks-gallery/block-gallery-stats-card': 'Stats Card', - 'components-complex-table/basic-table': 'Table', -}; - -/** - * The provider value these demos render under. Every preset in `PRESET_IDS` is - * a STATIC schema — the object-bound examples live in `InteractiveDemo` / - * `SchemaThumbnail`, which inject `galleryDataSource` — so this surface has no - * adapter to hand over and says so with `undefined` (objectui#7912). - * - * It used to say `{}`. An empty object is TRUTHY, so it walked past the - * `if (!dataSource)` guard every reader writes for exactly this state; the seam - * now declares `DataSource | null | undefined`, so the absence is stated rather - * than smuggled. Still a module constant, for the same reason as before: the - * memo below keys on its identity, and `undefined` is render-stable. - */ -const defaultCtx = { dataSource: undefined }; - -class PreviewErrorBoundary extends Component< - { children: ReactNode; signal: unknown }, - { hasError: boolean; message: string } -> { - state = { hasError: false, message: '' }; - - static getDerivedStateFromError(err: unknown) { - return { - hasError: true, - message: err instanceof Error ? err.message : String(err), - }; - } - - componentDidUpdate(prev: { signal: unknown }) { - if (prev.signal !== this.props.signal && this.state.hasError) { - this.setState({ hasError: false, message: '' }); - } - } - - render() { - if (this.state.hasError) { - return ( - <div className="flex h-full w-full flex-col items-center justify-center gap-2 p-6 text-center"> - <div className="text-sm font-medium text-fd-foreground"> - Could not render this schema - </div> - <code className="max-w-sm break-words text-xs text-fd-muted-foreground"> - {this.state.message} - </code> - </div> - ); - } - return this.props.children; - } -} - -interface LiveSplitDemoProps { - /** Preset ids to expose as tabs. Defaults to a curated selection. */ - presetIds?: ReadonlyArray<string>; -} - -/** - * Hero-style live split view: editable JSON on the left, rendered ObjectUI - * on the right. Used on the marketing homepage to communicate the - * "JSON-to-UI" pitch in the first three seconds. - * - * - Tab strip of preset ids drawn from the schema catalog. - * - Debounced JSON parse: bad JSON shows an inline error and keeps the - * last good preview visible (no flicker). - * - Reset button restores the active preset to its catalog source. - * - "Open in Playground" deep-links to /playground (TODO: param wiring - * for shareable URLs is part of P4). - */ -export function LiveSplitDemo({ - presetIds = PRESET_IDS, -}: LiveSplitDemoProps) { - const presets = useMemo(() => { - const out: Example[] = []; - for (const id of presetIds) { - try { - out.push(getExample(id)); - } catch { - // Preset disappeared from the catalog — skip silently. - } - } - return out; - }, [presetIds]); - - const [activeId, setActiveId] = useState<string>( - presets[0]?.id ?? presetIds[0] ?? '', - ); - - const active = useMemo( - () => presets.find((p) => p.id === activeId) ?? presets[0], - [presets, activeId], - ); - - const initialText = useMemo( - () => (active ? JSON.stringify(active.schema, null, 2) : '{}'), - [active], - ); - - const [text, setText] = useState(initialText); - const [debouncedText, setDebouncedText] = useState(initialText); - const [lastValidSchema, setLastValidSchema] = useState<SchemaNode>( - (active?.schema as SchemaNode) ?? null, - ); - const [parseError, setParseError] = useState<string | null>(null); - - useEffect(() => { - setText(initialText); - setDebouncedText(initialText); - setLastValidSchema(active?.schema as SchemaNode); - setParseError(null); - }, [active, initialText]); - - // Debounce textarea -> live preview to keep typing responsive. - useEffect(() => { - const t = window.setTimeout(() => setDebouncedText(text), 200); - return () => window.clearTimeout(t); - }, [text]); - - useEffect(() => { - try { - const parsed = JSON.parse(debouncedText); - setLastValidSchema(parsed as SchemaNode); - setParseError(null); - } catch (err) { - setParseError(err instanceof Error ? err.message : String(err)); - } - }, [debouncedText]); - - const textareaRef = useRef<HTMLTextAreaElement | null>(null); - const ctxValue = useMemo(() => defaultCtx, []); - - const handleReset = useCallback(() => { - if (!active) return; - const fresh = JSON.stringify(active.schema, null, 2); - setText(fresh); - setDebouncedText(fresh); - setLastValidSchema(active.schema as SchemaNode); - setParseError(null); - textareaRef.current?.focus(); - }, [active]); - - const handleCopy = useCallback(async () => { - try { - await navigator.clipboard.writeText(text); - } catch { - // best-effort - } - }, [text]); - - if (!active) { - return null; - } - - return ( - <div className="not-prose flex flex-col gap-3"> - <div className="flex flex-wrap items-center justify-between gap-2"> - <div - role="tablist" - aria-label="Preset schemas" - className="flex flex-wrap items-center gap-1 rounded-lg border border-fd-border bg-fd-card p-1" - > - {presets.map((p) => { - const isActive = p.id === active.id; - return ( - <button - key={p.id} - role="tab" - aria-selected={isActive} - type="button" - onClick={() => setActiveId(p.id)} - className={ - 'rounded-md px-3 py-1.5 text-xs font-medium transition ' + - (isActive - ? 'bg-fd-primary text-fd-primary-foreground shadow-sm' - : 'text-fd-muted-foreground hover:bg-fd-accent/60 hover:text-fd-foreground') - } - > - {PRESET_LABELS[p.id as (typeof PRESET_IDS)[number]] ?? - p.meta.title} - </button> - ); - })} - </div> - <div className="flex items-center gap-2"> - <button - type="button" - onClick={handleCopy} - className="rounded-md border border-fd-border bg-fd-background px-3 py-1.5 text-xs font-medium text-fd-muted-foreground transition hover:bg-fd-accent hover:text-fd-accent-foreground" - title="Copy JSON" - > - Copy JSON - </button> - <button - type="button" - onClick={handleReset} - className="rounded-md border border-fd-border bg-fd-background px-3 py-1.5 text-xs font-medium text-fd-muted-foreground transition hover:bg-fd-accent hover:text-fd-accent-foreground" - title="Restore the original preset" - > - Reset - </button> - </div> - </div> - - <div className="grid gap-3 overflow-hidden rounded-2xl border border-fd-border bg-fd-card shadow-2xl ring-1 ring-fd-border md:grid-cols-2 md:gap-0"> - {/* Editor */} - <div className="flex flex-col border-fd-border md:border-r"> - <div className="flex items-center justify-between gap-2 border-b border-fd-border bg-fd-muted/40 px-4 py-2"> - <div className="flex items-center gap-1.5"> - <span className="h-2.5 w-2.5 rounded-full bg-red-500" /> - <span className="h-2.5 w-2.5 rounded-full bg-yellow-500" /> - <span className="h-2.5 w-2.5 rounded-full bg-green-500" /> - </div> - <code className="truncate text-[11px] text-fd-muted-foreground"> - {active.id} - </code> - </div> - <div className="relative"> - <textarea - ref={textareaRef} - spellCheck={false} - value={text} - onChange={(e) => setText(e.target.value)} - aria-label="Edit ObjectUI JSON schema" - className="block h-[420px] w-full resize-none bg-fd-background p-4 font-mono text-[12px] leading-relaxed text-fd-foreground outline-none focus:ring-2 focus:ring-inset focus:ring-fd-ring/40" - /> - {parseError && ( - <div - role="status" - className="absolute bottom-2 left-2 right-2 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-[11px] text-red-600 shadow-sm dark:text-red-300" - > - <span className="font-medium">JSON error:</span> {parseError} - </div> - )} - </div> - </div> - - {/* Preview */} - <div className="flex flex-col"> - <div className="flex items-center justify-between gap-2 border-b border-fd-border bg-fd-muted/40 px-4 py-2"> - <span className="text-[11px] font-medium uppercase tracking-wide text-fd-muted-foreground"> - Live preview - </span> - <span className="text-[11px] text-fd-muted-foreground"> - Try editing the JSON → - </span> - </div> - <div className="h-[420px] overflow-auto bg-fd-background p-4"> - <PreviewErrorBoundary signal={lastValidSchema}> - <SchemaRendererContext.Provider value={ctxValue}> - <SidebarProvider - className="min-h-0 w-full" - defaultOpen={false} - > - <div className="w-full"> - <SchemaRenderer schema={toRenderableSchema(lastValidSchema)} /> - </div> - </SidebarProvider> - </SchemaRendererContext.Provider> - </PreviewErrorBoundary> - </div> - </div> - </div> - </div> - ); -} diff --git a/ui/apps/site/app/components/Logo.tsx b/ui/apps/site/app/components/Logo.tsx deleted file mode 100644 index 1be983ed524..00000000000 --- a/ui/apps/site/app/components/Logo.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import Image from 'next/image'; - -export function Logo({ className }: { className?: string }) { - return ( - <div className={`flex items-center gap-2 font-bold ${className}`}> - <Image - src="/logo.svg" - alt="Object UI Logo" - width={32} - height={32} - className="h-8 w-8" - /> - <span>Object UI</span> - </div> - ); -} diff --git a/ui/apps/site/app/components/ObjectUIProvider.tsx b/ui/apps/site/app/components/ObjectUIProvider.tsx deleted file mode 100644 index 348f65e714d..00000000000 --- a/ui/apps/site/app/components/ObjectUIProvider.tsx +++ /dev/null @@ -1,23 +0,0 @@ -'use client'; - -// Import components to trigger registration -import { initializeComponents } from '@object-ui/components'; -// Side-effect import: the module runs `registerAllFields()` at load, which is the -// ONE registration path (objectui#3308). The docs site used to also call -// `registerFields()` here — the demo adapter that re-registered every -// `field:<type>` key wrapped in synthesized label/state chrome, so the docs -// rendered bare field nodes in a way no real application does. Ruling B of -// objectui#3798 removed it: the catalog's field examples are form-hosted -// (`{ type: 'form', fields: [...] }`) and the real form renderer owns the label -// and the value state (objectui#3910). -import '@object-ui/fields'; -import { useEffect } from 'react'; - -export function ObjectUIProvider({ children }: { children: React.ReactNode }) { - // Explicitly call init to ensure components are registered - useEffect(() => { - initializeComponents(); - }, []); - - return <>{children}</>; -} diff --git a/ui/apps/site/app/components/PluginLoader.tsx b/ui/apps/site/app/components/PluginLoader.tsx deleted file mode 100644 index 95debc220af..00000000000 --- a/ui/apps/site/app/components/PluginLoader.tsx +++ /dev/null @@ -1,112 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; - -type PluginName = - | 'editor' - | 'charts' - | 'dashboard' - | 'kanban' - | 'markdown' - | 'timeline' - | 'calendar' - | 'gantt' - | 'map' - | 'chatbot' - | 'form' - | 'grid' - | 'view'; - -interface PluginLoaderProps { - plugins: PluginName[]; - children: React.ReactNode; -} - -/** - * PluginLoader component - Loads specific plugins on-demand - * - * Usage in MDX files: - * ```mdx - * import { PluginLoader } from '@/app/components/PluginLoader'; - * - * <PluginLoader plugins={['grid']}> - * <InteractiveDemo schema={{...}} /> - * </PluginLoader> - * ``` - */ -export function PluginLoader({ plugins, children }: PluginLoaderProps) { - const [loaded, setLoaded] = useState(false); - - useEffect(() => { - let cancelled = false; - - const loadPlugins = async () => { - // On server side, skip actual imports but mark as loaded to avoid hydration mismatch - if (typeof window === 'undefined') { - if (!cancelled) { - setLoaded(true); - } - return; - } - - try { - // Dynamically import plugins based on the list - const imports = plugins.map(async (plugin) => { - switch (plugin) { - case 'editor': - return import('@object-ui/plugin-editor'); - case 'charts': - return import('@object-ui/plugin-charts'); - case 'dashboard': - return import('@object-ui/plugin-dashboard'); - case 'kanban': - return import('@object-ui/plugin-kanban'); - case 'markdown': - return import('@object-ui/plugin-markdown'); - case 'timeline': - return import('@object-ui/plugin-timeline'); - case 'calendar': - return import('@object-ui/plugin-calendar'); - case 'gantt': - return import('@object-ui/plugin-gantt'); - case 'map': - return import('@object-ui/plugin-map'); - case 'chatbot': - return import('@object-ui/plugin-chatbot'); - case 'form': - return import('@object-ui/plugin-form'); - case 'grid': - return import('@object-ui/plugin-grid'); - case 'view': - return import('@object-ui/plugin-view'); - default: - console.warn(`Unknown plugin: ${plugin}`); - return Promise.resolve(); - } - }); - - await Promise.all(imports); - if (!cancelled) { - setLoaded(true); - } - } catch (error) { - console.error('Failed to load plugins:', error); - if (!cancelled) { - setLoaded(true); // Still render children even if plugin loading fails - } - } - }; - - loadPlugins(); - - return () => { - cancelled = true; - }; - }, [plugins]); - - if (!loaded) { - return <div className="p-6 text-center text-muted-foreground">Loading plugins...</div>; - } - - return <>{children}</>; -} diff --git a/ui/apps/site/app/components/ReactVsObjectUI.tsx b/ui/apps/site/app/components/ReactVsObjectUI.tsx deleted file mode 100644 index a497aea1a1c..00000000000 --- a/ui/apps/site/app/components/ReactVsObjectUI.tsx +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Side-by-side comparison: the same stats-card dashboard written in - * traditional React + Shadcn versus the ObjectUI JSON equivalent. - * - * The section contrasts two authoring models side by side — keep both - * snippets honest (no cheating by omitting imports, no padding the - * React side with formatting). - * - * The JSON side must stay COPY-RUNNABLE, because "an AI agent can author this" - * is the claim the panel makes (objectui#4786). Two ways it stopped being true - * before, both measured through a real SchemaRenderer: - * - * - `"type": "stats-card"` is registered by nothing in the repo, so every one - * of the four children painted the registry's OBJUI-001 "Unknown component - * type" panel. It is now `statistic`, which `@object-ui/components` - * registers and which reads `label` / `value` / `description` / `icon`. - * - the keys were wrapped in a `props` envelope. `SchemaRenderer` spreads - * `schema.props` as React props instead of merging it into the node, and the - * renderers read `schema.*` — so the envelope rendered an empty card and - * leaked into the DOM as `props="[object Object]"` (same defect class as the - * `cols` fix in objectui#4011 / PR #4785). - * - * Copy-runnability is the whole constraint on this snippet, and now the only - * one: the panel renders no line counts and no percentage, so a correctness - * fix here is free to change its length (objectui#8948 — the homepage stopped - * leaning on counts, so there is no number left for an edit to move). - */ - -const REACT_CODE = `import { - Card, CardContent, CardHeader, CardTitle, -} from "@/components/ui/card"; -import { - DollarSign, Users, CreditCard, Activity, -} from "lucide-react"; - -const stats = [ - { title: "Total Revenue", value: "$45,231.89", - change: "+20.1% from last month", Icon: DollarSign }, - { title: "Subscriptions", value: "+2,350", - change: "+180.1% from last month", Icon: Users }, - { title: "Sales", value: "+12,234", - change: "+19% from last month", Icon: CreditCard }, - { title: "Active Now", value: "+573", - change: "+201 since last hour", Icon: Activity }, -]; - -export function StatsCards() { - return ( - <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> - {stats.map(({ title, value, change, Icon }) => ( - <Card key={title}> - <CardHeader - className="flex flex-row items-center - justify-between space-y-0 pb-2" - > - <CardTitle className="text-sm font-medium"> - {title} - </CardTitle> - <Icon className="h-4 w-4 text-muted-foreground" /> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold">{value}</div> - <p className="text-xs text-muted-foreground"> - {change} - </p> - </CardContent> - </Card> - ))} - </div> - ); -}`; - -const OBJECTUI_CODE = `{ - "type": "grid", - "columns": { "xs": 1, "md": 2, "lg": 4 }, "gap": 4, - "children": [ - { "type": "statistic", "label": "Total Revenue", - "value": "$45,231.89", "description": "+20.1% from last month", - "icon": "dollar-sign" }, - { "type": "statistic", "label": "Subscriptions", - "value": "+2,350", "description": "+180.1% from last month", - "icon": "users" }, - { "type": "statistic", "label": "Sales", - "value": "+12,234", "description": "+19% from last month", - "icon": "credit-card" }, - { "type": "statistic", "label": "Active Now", - "value": "+573", "description": "+201 since last hour", - "icon": "activity" } - ] -}`; - -function CodePanel({ - label, - badge, - badgeTone, - code, -}: { - label: string; - badge: string; - badgeTone: 'muted' | 'primary'; - code: string; -}) { - const lineNumbers = code.split('\n').map((_, i) => i + 1); - return ( - <div className="flex min-w-0 flex-col overflow-hidden rounded-xl border border-fd-border bg-fd-card shadow-sm"> - <div className="flex items-center border-b border-fd-border bg-fd-muted/40 px-4 py-2.5"> - <div className="flex items-center gap-2"> - <span className="text-sm font-semibold text-fd-foreground">{label}</span> - <span - className={ - badgeTone === 'primary' - ? 'rounded-full bg-fd-primary/15 px-2 py-0.5 text-[11px] font-medium text-fd-primary' - : 'rounded-full bg-fd-muted px-2 py-0.5 text-[11px] font-medium text-fd-muted-foreground' - } - > - {badge} - </span> - </div> - </div> - <div className="flex max-h-[460px] overflow-auto text-[12.5px] leading-[1.55]"> - <pre - aria-hidden="true" - className="select-none border-r border-fd-border bg-fd-muted/20 px-3 py-4 text-right font-mono text-fd-muted-foreground/70" - > - {lineNumbers.join('\n')} - </pre> - <pre className="flex-1 overflow-x-auto px-4 py-4 font-mono text-fd-foreground"> - <code>{code}</code> - </pre> - </div> - </div> - ); -} - -export function ReactVsObjectUI() { - return ( - <section className="border-y border-fd-border bg-fd-background py-20 sm:py-28"> - <div className="mx-auto max-w-7xl px-6 lg:px-8"> - <div className="mx-auto max-w-3xl text-center"> - <div className="mb-4 inline-flex items-center rounded-full border border-fd-border bg-fd-muted/40 px-3 py-1 text-xs font-medium text-fd-muted-foreground"> - Side by side - </div> - <h2 className="text-3xl font-bold tracking-tight text-fd-foreground sm:text-4xl"> - Same UI.{' '} - <span className="bg-gradient-to-r from-blue-600 to-violet-600 bg-clip-text text-transparent dark:from-blue-400 dark:to-violet-400"> - Written as JSON. - </span> - </h2> - <p className="mt-4 text-lg text-fd-muted-foreground"> - Here's a stats-card dashboard written two ways. Both render the - same pixels — one is a React component, the other is plain JSON - an AI agent can author, edit, or generate in a single tool call. - </p> - </div> - - <div className="mx-auto mt-12 grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2"> - <CodePanel - label="React + Shadcn" - badge="StatsCards.tsx" - badgeTone="muted" - code={REACT_CODE} - /> - <CodePanel - label="ObjectUI" - badge="stats-cards.schema.json" - badgeTone="primary" - code={OBJECTUI_CODE} - /> - </div> - - <div className="mx-auto mt-10 grid max-w-5xl grid-cols-1 gap-4 sm:grid-cols-3"> - <div className="rounded-xl border border-fd-border bg-fd-card p-5"> - <div className="text-xs font-medium uppercase tracking-wide text-fd-muted-foreground"> - What you maintain - </div> - <div className="mt-1 text-2xl font-bold text-fd-foreground"> - One JSON file - </div> - <p className="mt-1 text-sm text-fd-muted-foreground"> - No component code, no build step. - </p> - </div> - <div className="rounded-xl border border-fd-border bg-fd-card p-5"> - <div className="text-xs font-medium uppercase tracking-wide text-fd-muted-foreground"> - Editable by AI agents - </div> - <div className="mt-1 text-2xl font-bold text-fd-foreground"> - One tool call - </div> - <p className="mt-1 text-sm text-fd-muted-foreground"> - JSON patches, no AST surgery or codegen pipelines. - </p> - </div> - <div className="rounded-xl border border-fd-border bg-fd-card p-5"> - <div className="text-xs font-medium uppercase tracking-wide text-fd-muted-foreground"> - Pixel parity - </div> - <div className="mt-1 text-2xl font-bold text-fd-foreground"> - Same Shadcn - </div> - <p className="mt-1 text-sm text-fd-muted-foreground"> - Identical components — just authored declaratively. - </p> - </div> - </div> - </div> - </section> - ); -} diff --git a/ui/apps/site/app/components/SchemaCatalogIndex.tsx b/ui/apps/site/app/components/SchemaCatalogIndex.tsx deleted file mode 100644 index bedda33afeb..00000000000 --- a/ui/apps/site/app/components/SchemaCatalogIndex.tsx +++ /dev/null @@ -1,445 +0,0 @@ -'use client'; - -/** - * 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, { - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; -import { - allExamples, - type Example, -} from '@object-ui/example-schema-catalog'; -import type { SchemaNode } from '@object-ui/core'; -import { SchemaThumbnail } from './SchemaThumbnail'; -import { InteractiveDemo } from './InteractiveDemo'; - -/** Top-level taxonomy bucket derived from the category slug. */ -interface Group { - /** Slug, e.g. `components`, `auth`. */ - key: string; - /** Display label. */ - label: string; - entries: Example[]; -} - -const GROUP_LABELS: Record<string, string> = { - actions: 'Actions', - app: 'App Shell', - auth: 'Auth', - block: 'Block Schema', - blocks: 'Blocks Gallery', - components: 'Components', - core: 'Core', - dashboard: 'Dashboard', - ecommerce: 'E-commerce', - fields: 'Fields', - layout: 'Layout', - plugins: 'Plugins', - utilities: 'Utilities', -}; - -/** Best-effort label for an unknown group key. */ -function fallbackLabel(key: string): string { - return key - .split('-') - .map((s) => (s.length === 0 ? s : s[0].toUpperCase() + s.slice(1))) - .join(' '); -} - -/** "components-basic-button-group" → "components". */ -function groupKey(category: string): string { - return category.split('-')[0] || category; -} - -/** "components-basic-button-group" → "basic / button-group". */ -function subLabel(category: string): string { - const parts = category.split('-'); - if (parts.length <= 1) return ''; - return parts.slice(1).join(' / '); -} - -function buildGroups(entries: Example[]): Group[] { - const map = new Map<string, Example[]>(); - for (const e of entries) { - const k = groupKey(e.meta.category); - const list = map.get(k) ?? []; - list.push(e); - map.set(k, list); - } - return Array.from(map.entries()) - .map(([key, list]) => ({ - key, - label: GROUP_LABELS[key] ?? fallbackLabel(key), - entries: list, - })) - .sort((a, b) => a.label.localeCompare(b.label)); -} - -function matches(e: Example, q: string): boolean { - if (!q) return true; - const haystack = ( - e.id + - ' ' + - e.meta.title + - ' ' + - (e.meta.description ?? '') + - ' ' + - (e.meta.tags?.join(' ') ?? '') - ).toLowerCase(); - return haystack.includes(q); -} - -interface CardProps { - entry: Example; - onOpen: (e: Example) => void; -} - -/** - * One gallery card. - * - * The shell is deliberately NON-interactive and the click target is an overlay - * sibling, never an ancestor of the thumbnail (objectui#3903). `SchemaThumbnail` - * renders the example itself through a real `SchemaRenderer`, and 85 of the 423 - * catalog examples contain `"type": "button"` nodes — with a `button` shell those - * cards shipped `button` inside `button`, which React classifies as a hydration - * error (the HTML parser hoists the inner button out of the outer one, so the - * server HTML and the client tree disagree) on top of being an accessibility - * defect in its own right. A `div role="button"` shell would keep both problems; - * an absolutely positioned overlay removes them without giving up a real, - * natively focusable control. - */ -const GalleryCard = React.memo(function GalleryCard({ - entry, - onOpen, -}: CardProps) { - return ( - <div className="group relative flex flex-col gap-2 rounded-lg border border-fd-border bg-fd-card p-3 text-left transition hover:border-fd-primary hover:shadow-sm"> - <SchemaThumbnail schema={entry.schema as SchemaNode} /> - <div className="flex flex-col gap-0.5"> - <div className="line-clamp-1 text-sm font-medium text-fd-foreground"> - {entry.meta.title} - </div> - <code className="line-clamp-1 break-all text-[11px] text-fd-muted-foreground"> - {entry.id} - </code> - </div> - {/* - The card's only focusable node: it covers the whole card (`inset-0` - resolves against the shell's padding box), so the focus ring still reads - as "the card is focused", and its accessible name carries both labels the - card shows — title and id — which keeps WCAG 2.5.3 (label in name) - satisfied. The `button` role itself announces what activation does. - */} - <button - type="button" - onClick={() => onOpen(entry)} - aria-label={`Open ${entry.meta.title} (${entry.id})`} - className="absolute inset-0 cursor-pointer rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring" - /> - </div> - ); -}); - -interface DetailDialogProps { - entry: Example; - onClose: () => void; -} - -function DetailDialog({ entry, onClose }: DetailDialogProps) { - const [copied, setCopied] = useState<'id' | 'snippet' | null>(null); - - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', onKey); - const prevOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - return () => { - window.removeEventListener('keydown', onKey); - document.body.style.overflow = prevOverflow; - }; - }, [onClose]); - - const snippet = `<SchemaExample id="${entry.id}" />`; - - const copy = async (text: string, kind: 'id' | 'snippet') => { - try { - await navigator.clipboard.writeText(text); - setCopied(kind); - setTimeout(() => setCopied((c) => (c === kind ? null : c)), 1200); - } catch { - // best-effort - } - }; - - return ( - <div - role="dialog" - aria-modal="true" - aria-label={entry.meta.title} - className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-8" - > - <div - className="absolute inset-0 bg-black/50 backdrop-blur-sm" - onClick={onClose} - /> - <div className="not-prose relative z-10 flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-xl border border-fd-border bg-fd-background shadow-xl"> - <header className="flex items-start justify-between gap-4 border-b border-fd-border px-5 py-3"> - <div className="min-w-0 flex-1"> - <h3 className="truncate text-base font-semibold text-fd-foreground"> - {entry.meta.title} - </h3> - <code className="block truncate text-xs text-fd-muted-foreground"> - {entry.id} - </code> - </div> - <div className="flex shrink-0 items-center gap-2"> - <button - type="button" - onClick={() => copy(entry.id, 'id')} - className="rounded border border-fd-border px-2 py-1 font-mono text-xs text-fd-muted-foreground hover:bg-fd-accent hover:text-fd-accent-foreground" - title="Copy catalog id" - > - {copied === 'id' ? '✓ id' : 'copy id'} - </button> - <button - type="button" - onClick={() => copy(snippet, 'snippet')} - className="rounded border border-fd-border px-2 py-1 font-mono text-xs text-fd-muted-foreground hover:bg-fd-accent hover:text-fd-accent-foreground" - title="Copy MDX snippet" - > - {copied === 'snippet' ? '✓ snippet' : 'copy <SchemaExample>'} - </button> - <button - type="button" - onClick={onClose} - className="rounded border border-fd-border px-2 py-1 text-xs text-fd-muted-foreground hover:bg-fd-accent hover:text-fd-accent-foreground" - aria-label="Close" - > - ✕ - </button> - </div> - </header> - <div className="min-h-0 flex-1 overflow-auto p-5"> - <InteractiveDemo - schema={entry.schema as SchemaNode} - description={entry.meta.description} - /> - </div> - </div> - </div> - ); -} - -interface SidebarProps { - groups: Group[]; - activeKey: string | null; - onSelect: (key: string | null) => void; - totalAll: number; - totalFiltered: number; - isFiltered: boolean; -} - -function CategorySidebar({ - groups, - activeKey, - onSelect, - totalAll, - totalFiltered, - isFiltered, -}: SidebarProps) { - return ( - <nav - aria-label="Schema categories" - className="flex flex-col gap-1 text-sm" - > - <button - type="button" - onClick={() => onSelect(null)} - className={ - 'flex items-center justify-between rounded-md px-2 py-1.5 text-left transition ' + - (activeKey === null - ? 'bg-fd-accent text-fd-accent-foreground' - : 'hover:bg-fd-accent/60') - } - > - <span className="font-medium">All</span> - <span className="text-xs text-fd-muted-foreground"> - {isFiltered ? `${totalFiltered}/${totalAll}` : totalAll} - </span> - </button> - {groups.map((g) => ( - <button - key={g.key} - type="button" - onClick={() => onSelect(g.key)} - className={ - 'flex items-center justify-between rounded-md px-2 py-1.5 text-left transition ' + - (activeKey === g.key - ? 'bg-fd-accent text-fd-accent-foreground' - : 'hover:bg-fd-accent/60') - } - > - <span>{g.label}</span> - <span className="text-xs text-fd-muted-foreground"> - {g.entries.length} - </span> - </button> - ))} - </nav> - ); -} - -/** - * Visual gallery of every schema in `@object-ui/example-schema-catalog`. - * - * - Sticky category sidebar (top-level taxonomy). - * - Searchable across id / title / description / tags. - * - Lazy-mounted scaled thumbnails so 400+ previews stay performant. - * - Click any card → modal with full-size interactive preview, JSON tab, - * and a one-click copy of the `<SchemaExample id="…">` snippet. - */ -export function SchemaCatalogIndex() { - const all = useMemo(() => allExamples(), []); - const [query, setQuery] = useState(''); - const [activeGroup, setActiveGroup] = useState<string | null>(null); - const [openEntry, setOpenEntry] = useState<Example | null>(null); - - const q = query.trim().toLowerCase(); - - const filtered = useMemo( - () => (q ? all.filter((e) => matches(e, q)) : all), - [all, q], - ); - - const allGroups = useMemo(() => buildGroups(all), [all]); - const filteredGroups = useMemo(() => buildGroups(filtered), [filtered]); - - const visibleGroups = useMemo(() => { - if (activeGroup === null) return filteredGroups; - return filteredGroups.filter((g) => g.key === activeGroup); - }, [filteredGroups, activeGroup]); - - const handleOpen = useCallback((e: Example) => setOpenEntry(e), []); - const handleClose = useCallback(() => setOpenEntry(null), []); - - const totalVisible = visibleGroups.reduce( - (acc, g) => acc + g.entries.length, - 0, - ); - - return ( - <div className="not-prose flex flex-col gap-4"> - <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> - <input - type="search" - value={query} - onChange={(e) => setQuery(e.target.value)} - placeholder="Filter by id, title, description, tag…" - className="w-full rounded-md border border-fd-border bg-fd-background px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-fd-ring sm:max-w-md" - aria-label="Filter schema catalog" - /> - <span className="text-xs text-fd-muted-foreground"> - {q - ? `${totalVisible} match${totalVisible === 1 ? '' : 'es'}` - : `${all.length} examples · ${allGroups.length} groups`} - </span> - </div> - - <div className="grid gap-4 md:grid-cols-[200px_1fr]"> - <aside className="md:sticky md:top-20 md:self-start"> - <CategorySidebar - groups={allGroups} - activeKey={activeGroup} - onSelect={setActiveGroup} - totalAll={all.length} - totalFiltered={filtered.length} - isFiltered={q.length > 0} - /> - </aside> - - <div className="flex flex-col gap-6"> - {visibleGroups.length === 0 && ( - <div className="rounded-md border border-dashed border-fd-border p-6 text-center text-sm text-fd-muted-foreground"> - No examples match <code className="font-mono">{query}</code>. - </div> - )} - {visibleGroups.map((g) => ( - <section - key={g.key} - id={`group-${g.key}`} - className="flex flex-col gap-3" - > - <header className="flex items-baseline justify-between border-b border-fd-border pb-1"> - <h3 className="text-sm font-semibold text-fd-foreground"> - {g.label} - </h3> - <span className="text-xs text-fd-muted-foreground"> - {g.entries.length} - </span> - </header> - <GroupGrid entries={g.entries} onOpen={handleOpen} /> - </section> - ))} - </div> - </div> - - {openEntry && ( - <DetailDialog entry={openEntry} onClose={handleClose} /> - )} - </div> - ); -} - -interface GroupGridProps { - entries: Example[]; - onOpen: (e: Example) => void; -} - -/** - * Sub-grouping: cards inside a top-level group are visually sub-grouped by - * their full category slug so neighbours stay together. - */ -function GroupGrid({ entries, onOpen }: GroupGridProps) { - const byCategory = useMemo(() => { - const map = new Map<string, Example[]>(); - for (const e of entries) { - const list = map.get(e.meta.category) ?? []; - list.push(e); - map.set(e.meta.category, list); - } - return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b)); - }, [entries]); - - return ( - <div className="flex flex-col gap-5"> - {byCategory.map(([category, items]) => { - const sub = subLabel(category); - return ( - <div key={category} className="flex flex-col gap-2"> - {sub && ( - <div className="text-xs font-medium uppercase tracking-wide text-fd-muted-foreground"> - {sub} - </div> - )} - <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> - {items.map((e) => ( - <GalleryCard key={e.id} entry={e} onOpen={onOpen} /> - ))} - </div> - </div> - ); - })} - </div> - ); -} diff --git a/ui/apps/site/app/components/SchemaExample.tsx b/ui/apps/site/app/components/SchemaExample.tsx deleted file mode 100644 index e3dcfa18fab..00000000000 --- a/ui/apps/site/app/components/SchemaExample.tsx +++ /dev/null @@ -1,38 +0,0 @@ -'use client'; - -import React from 'react'; -import { getExample } from '@object-ui/example-schema-catalog'; -import type { SchemaNode } from '@object-ui/core'; -import { InteractiveDemo } from './InteractiveDemo'; - -interface SchemaExampleProps { - /** - * Catalog id, e.g. `"auth/login-simple"`. Looked up in - * `@object-ui/example-schema-catalog`. - */ - id: string; - /** Override the catalog title. */ - title?: string; - /** Override the catalog description. */ - description?: string; -} - -/** - * MDX-friendly thin wrapper around <InteractiveDemo /> that pulls the schema - * and metadata from the shared schema catalog instead of inlining a JSX - * object literal. This keeps MDX files readable and makes every example - * automatically smoke-tested. - * - * Usage: - * <SchemaExample id="auth/login-simple" /> - */ -export function SchemaExample({ id, title, description }: SchemaExampleProps) { - const entry = getExample(id); - return ( - <InteractiveDemo - title={title ?? entry.meta.title} - description={description ?? entry.meta.description} - schema={entry.schema as SchemaNode} - /> - ); -} diff --git a/ui/apps/site/app/components/SchemaThumbnail.tsx b/ui/apps/site/app/components/SchemaThumbnail.tsx deleted file mode 100644 index 3835a809bb9..00000000000 --- a/ui/apps/site/app/components/SchemaThumbnail.tsx +++ /dev/null @@ -1,167 +0,0 @@ -'use client'; - -/** - * 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, { - Component, - type ReactNode, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { SchemaRenderer, SchemaRendererContext, toRenderableSchema } from '@object-ui/react'; -import { SidebarProvider } from '@object-ui/components'; -import type { SchemaNode } from '@object-ui/core'; -// Registers `@object-ui/layout`'s blocks — see the module header (objectui#3787). -// Named directly, not reached through the module below: `scripts/__tests__/ -// site-playground-layout-registration-3904.test.ts` discovers every -// `SchemaRenderer` host and requires it to import THIS module, and a host that -// pulled it in transitively would read to that guard as a host that registers -// nothing (measured — it went red on exactly that). -import './registerLayoutBlocks'; -// Registers the dashboard + chart blocks the gallery draws (objectui#4600). -import './registerCatalogBlocks'; -import { galleryDataSource } from './galleryDataSource'; - -// The gallery's data source. It is handed to `SchemaRenderer` BOTH ways on -// purpose: the context is what nested blocks read, while `DashboardRenderer` -// takes `dataSource` as a React prop — a context-only value never reaches it, -// which is why its dataset-bound widgets rendered "This data source does not -// support dataset queries." while the context already held one (objectui#4600). -const defaultCtx = { dataSource: galleryDataSource }; - -/** - * Tiny class-based error boundary so a single bad schema doesn't take down - * the whole gallery. Errors fall back to a quiet placeholder. - */ -class ThumbnailErrorBoundary extends Component< - { children: ReactNode; fallback: ReactNode }, - { hasError: boolean } -> { - state = { hasError: false }; - - static getDerivedStateFromError() { - return { hasError: true }; - } - - componentDidCatch() { - // Intentionally swallow — gallery context only. - } - - render() { - return this.state.hasError ? this.props.fallback : this.props.children; - } -} - -interface SchemaThumbnailProps { - schema: SchemaNode; - /** Scale factor applied to the rendered schema (e.g. 0.4 = 40%). */ - scale?: number; - /** Logical viewport width fed to the scaled child. */ - viewportWidth?: number; - /** CSS aspect-ratio for the thumbnail frame. */ - aspect?: string; - className?: string; -} - -/** - * A scaled, non-interactive preview of a SchemaRenderer. Mount is deferred - * until the thumbnail scrolls near the viewport (IntersectionObserver) so a - * page with hundreds of thumbnails stays responsive. - */ -export function SchemaThumbnail({ - schema, - scale = 0.4, - viewportWidth = 900, - aspect = '4 / 3', - className, -}: SchemaThumbnailProps) { - const ref = useRef<HTMLDivElement | null>(null); - const [visible, setVisible] = useState(false); - const ctx = useMemo(() => defaultCtx, []); - - useEffect(() => { - if (visible || !ref.current) return; - const el = ref.current; - const io = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - if (entry.isIntersecting) { - setVisible(true); - io.disconnect(); - break; - } - } - }, - { rootMargin: '200px' }, - ); - io.observe(el); - return () => io.disconnect(); - }, [visible]); - - const inv = 1 / scale; - - return ( - <div - ref={ref} - className={ - 'relative w-full overflow-hidden rounded-md border border-fd-border bg-fd-background ' + - (className ?? '') - } - style={{ aspectRatio: aspect }} - aria-hidden - > - {visible ? ( - <ThumbnailErrorBoundary - fallback={ - <div className="flex h-full w-full items-center justify-center p-4 text-center text-xs text-fd-muted-foreground"> - Preview unavailable - </div> - } - > - <div - // `inert`, not just `pointer-events: none`: the preview renders the - // example's own controls, and this frame is `aria-hidden`. A - // focusable node inside an `aria-hidden` subtree is itself a - // violation (axe `aria-hidden-focus`), and because the preview - // precedes the card's open button in DOM order, a keyboard user - // otherwise tabs through every example's internal buttons before - // reaching the card (measured: Tab from card 1 stopped on Submit, - // Save Draft, Delete, Cancel — objectui#3903). `inert` removes the - // subtree from focus order and hit-testing, which is what makes the - // "non-interactive preview" in this component's contract true. - inert - className="pointer-events-none absolute left-0 top-0 origin-top-left select-none" - style={{ - width: `${viewportWidth}px`, - height: `${inv * 100}%`, - transform: `scale(${scale})`, - }} - > - <SchemaRendererContext.Provider value={ctx}> - <SidebarProvider className="min-h-0 w-full" defaultOpen={false}> - <div className="w-full p-4"> - <SchemaRenderer - schema={toRenderableSchema(schema)} - dataSource={galleryDataSource} - /> - </div> - </SidebarProvider> - </SchemaRendererContext.Provider> - </div> - </ThumbnailErrorBoundary> - ) : ( - <div className="flex h-full w-full items-center justify-center bg-fd-muted/40"> - <div className="h-2 w-16 animate-pulse rounded bg-fd-muted-foreground/30" /> - </div> - )} - </div> - ); -} diff --git a/ui/apps/site/app/components/galleryDataSource.ts b/ui/apps/site/app/components/galleryDataSource.ts deleted file mode 100644 index 685bb04a91a..00000000000 --- a/ui/apps/site/app/components/galleryDataSource.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - */ - -/** - * The docs gallery's stand-in data source (objectui#4600, extended by #5113). - * - * The catalog is a **presentation corpus**: it ships JSON, not a backend, and - * the gallery renders it with no application behind it. Most entries need - * nothing — their data is inline. Two kinds of entry are the exception, and - * both route through the host's `dataSource`: - * - * - dataset-bound dashboard widgets — `DatasetWidget` calls - * `dataSource.queryDataset`, and with no such function it renders "This data - * source does not support dataset queries." (objectui#4600); - * - object-bound entries — `object-view` / `object-grid` / `object-form` call - * `getObjectSchema` and `find`, and `dataSource` is not a schema key: it is - * a prop the registered renderer pulls off `SchemaRendererContext` - * (`packages/plugin-view/src/index.tsx`). With nothing behind it, a - * `plugin-view` example can only be a hand-drawn picture of one — which is - * what the three `plugin-view` catalog entries were until objectui#5113. - * - * So this is the smallest thing that lets both draw. It is a DEMO fixture, not - * a data-source implementation — real ones live in `@object-ui/data-*`. - * - * ## What it honours, and what it does not - * - * Stated because a fixture that silently ignores a query parameter turns any - * example authoring that parameter into a lie on the page — the exact defect - * objectui#5113 exists to remove. - * - * - `queryDataset` — canned rows shaped from the query's own `dimensions` / - * `measures`. Does NOT filter, aggregate or honour `runtimeFilter`. - * - `find` — honours `$search` (case-insensitive substring over the object's - * string fields), `$orderby` (all of the shapes `QueryParams` declares) and - * the `$skip` / `$top` window. Does NOT honour `$filter` or `$expand`, - * which is why no catalog entry authors a `filter` on a view it renders - * through this fixture. - * - writes (`create` / `update` / `delete`) — applied to this module's own - * in-memory rows, so a record a reader creates in a demo drawer shows up in - * that page's list. They live as long as the tab does and reach nothing. - * - * It is gallery-only on purpose: `apps/site` is `private`, so nothing here is - * a published package surface. - */ - -/** The subset of a dataset query this fixture reads. */ -interface GalleryDatasetQuery { - dimensions?: string[]; - measures?: string[]; -} - -/** One canned row: dimension values plus measure values. */ -type GalleryRow = Record<string, unknown>; - -/** A record in the demo object below. `id` is what row clicks resolve. */ -type GalleryRecord = Record<string, unknown> & { id: string }; - -/** - * The one object the gallery serves, in the shape `getObjectSchema` returns - * everywhere else in the repo — `{ label, fields: { <name>: { label, type } } }` - * — because that is what `ObjectGrid` / `ObjectForm` read to pick a cell - * renderer and a field widget. - * - * `users` and these field names are the ones `content/docs/plugins/ - * plugin-view.mdx` teaches in its own prose examples, so a reader comparing the - * live example against the snippet above it sees one object, not two. - */ -const USERS_SCHEMA = { - name: 'users', - label: 'Users', - fields: { - name: { label: 'Name', type: 'text' }, - email: { label: 'Email', type: 'email' }, - role: { - label: 'Role', - type: 'select', - options: [ - { label: 'Admin', value: 'admin' }, - { label: 'Member', value: 'member' }, - { label: 'Viewer', value: 'viewer' }, - ], - }, - department: { label: 'Department', type: 'text' }, - status: { - label: 'Status', - type: 'select', - options: [ - { label: 'Active', value: 'active' }, - { label: 'Invited', value: 'invited' }, - { label: 'Suspended', value: 'suspended' }, - ], - }, - created_at: { label: 'Created', type: 'date' }, - }, -} as const; - -const USERS_ROWS: GalleryRecord[] = [ - { id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'admin', department: 'Engineering', status: 'active', created_at: '2024-01-14' }, - { id: '2', name: 'Bob Chen', email: 'bob@example.com', role: 'member', department: 'Design', status: 'active', created_at: '2024-02-03' }, - { id: '3', name: 'Carla Gómez', email: 'carla@example.com', role: 'member', department: 'Sales', status: 'invited', created_at: '2024-03-21' }, - { id: '4', name: 'Dan Whitfield', email: 'dan@example.com', role: 'viewer', department: 'Support', status: 'suspended', created_at: '2024-04-09' }, - { id: '5', name: 'Emily Novak', email: 'emily@example.com', role: 'member', department: 'Engineering', status: 'active', created_at: '2024-05-30' }, -]; - -/** Rows per object name. Unknown names resolve to an empty collection. */ -const OBJECTS: Record<string, { schema: typeof USERS_SCHEMA; rows: GalleryRecord[] }> = { - users: { schema: USERS_SCHEMA, rows: USERS_ROWS }, -}; - -/** `$orderby` in every shape `QueryParams` declares, as [field, direction]. */ -function orderPairs(orderby: unknown): Array<[string, 'asc' | 'desc']> { - if (!orderby) return []; - if (typeof orderby === 'string') { - return orderby - .split(',') - .map((clause) => clause.trim()) - .filter(Boolean) - .map((clause) => { - const [field, direction] = clause.split(/\s+/); - return [field, direction?.toLowerCase() === 'desc' ? 'desc' : 'asc'] as [string, 'asc' | 'desc']; - }); - } - if (Array.isArray(orderby)) { - return orderby.flatMap((entry) => - typeof entry === 'string' - ? orderPairs(entry) - : entry && typeof entry === 'object' && 'field' in entry - ? [[(entry as { field: string }).field, (entry as { order?: string }).order === 'desc' ? 'desc' : 'asc'] as [string, 'asc' | 'desc']] - : [], - ); - } - if (typeof orderby === 'object') { - return Object.entries(orderby as Record<string, string>).map( - ([field, direction]) => [field, direction === 'desc' ? 'desc' : 'asc'] as [string, 'asc' | 'desc'], - ); - } - return []; -} - -function compare(a: unknown, b: unknown): number { - if (a == null && b == null) return 0; - if (a == null) return -1; - if (b == null) return 1; - if (typeof a === 'number' && typeof b === 'number') return a - b; - return String(a).localeCompare(String(b)); -} - -export const galleryDataSource = { - async queryDataset(dataset: string, query: GalleryDatasetQuery) { - const dimensions = query?.dimensions ?? []; - const measures = query?.measures ?? []; - const measure = measures[0] ?? 'value'; - const rows: GalleryRow[] = dimensions.length - ? [ - { [dimensions[0]]: 'Alpha', [measure]: 42 }, - { [dimensions[0]]: 'Beta', [measure]: 27 }, - ] - : [{ [measure]: 69 }]; - // `object` is what makes a chart drillable; a demo fixture has nothing to - // drill INTO, so it is deliberately omitted along with `dimensionFields`. - return { rows, fields: [] }; - }, - - async getObjectSchema(objectName: string) { - return OBJECTS[objectName]?.schema ?? { name: objectName, label: objectName, fields: {} }; - }, - - async find(objectName: string, params?: Record<string, unknown>) { - const rows = OBJECTS[objectName]?.rows ?? []; - const fields = OBJECTS[objectName]?.schema.fields ?? {}; - let result = [...rows]; - - const search = typeof params?.$search === 'string' ? params.$search.trim().toLowerCase() : ''; - if (search) { - const searchable = Array.isArray(params?.$searchFields) - ? (params.$searchFields as string[]) - : Object.keys(fields); - result = result.filter((row) => - searchable.some((field) => String(row[field] ?? '').toLowerCase().includes(search)), - ); - } - - for (const [field, direction] of orderPairs(params?.$orderby).reverse()) { - result.sort((a, b) => (direction === 'desc' ? -1 : 1) * compare(a[field], b[field])); - } - - const total = result.length; - const skip = typeof params?.$skip === 'number' ? params.$skip : 0; - const top = typeof params?.$top === 'number' ? params.$top : undefined; - return { data: result.slice(skip, top === undefined ? undefined : skip + top), total }; - }, - - async findOne(objectName: string, id: string | number) { - return (OBJECTS[objectName]?.rows ?? []).find((row) => String(row.id) === String(id)) ?? null; - }, - - async create(objectName: string, data: Record<string, unknown>) { - const record: GalleryRecord = { ...data, id: `demo-${Date.now()}` }; - OBJECTS[objectName]?.rows.unshift(record); - return record; - }, - - async update(objectName: string, id: string | number, data: Record<string, unknown>) { - const rows = OBJECTS[objectName]?.rows ?? []; - const index = rows.findIndex((row) => String(row.id) === String(id)); - if (index === -1) return { ...data, id } as GalleryRecord; - rows[index] = { ...rows[index], ...data }; - return rows[index]; - }, - - async delete(objectName: string, id: string | number) { - const rows = OBJECTS[objectName]?.rows ?? []; - const index = rows.findIndex((row) => String(row.id) === String(id)); - if (index !== -1) rows.splice(index, 1); - return true; - }, -}; diff --git a/ui/apps/site/app/components/registerCatalogBlocks.ts b/ui/apps/site/app/components/registerCatalogBlocks.ts deleted file mode 100644 index 4e610fd22fc..00000000000 --- a/ui/apps/site/app/components/registerCatalogBlocks.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - */ - -/** - * Registers the plugin blocks the **catalog gallery** renders. A companion to - * `./registerLayoutBlocks`, which every example host imports directly and which - * this module deliberately does NOT re-export: `scripts/__tests__/site- - * playground-layout-registration-3904.test.ts` discovers each `SchemaRenderer` - * host and reads its imports, so a host reaching the layout registrar through - * here would read as a host that registers nothing. Two imports, two - * responsibilities, one visible in each host. - * - * Why this exists (objectui#4600): `ComponentRegistry` only knows a type once - * the package owning it has been loaded, and `/docs/guide/schema-catalog` - * loads no plugin at all — `SchemaCatalogIndex` -> `SchemaThumbnail` imported - * `@object-ui/react`, `@object-ui/components` and the layout blocks, and - * nothing else. `PluginLoader` covers the individual docs pages that embed a - * demo, but the gallery embeds EVERY example and never wraps them in one. - * - * ## Why the list is the WHOLE bucket census, not two packages (objectui#4616) - * - * #4600 registered only the two packages its own category needed. Measured on - * `origin/main` @ `e028dfcd8` — every one of the 423 catalog entries rendered - * the way `SchemaThumbnail` renders it — that left **33 entries** still - * painting the registry's red "Unknown component type … (OBJUI-001)" panel, in - * 14 categories: - * - * components-form-calendar 6 (calendar) plugin-chatbot 3 (chatbot) - * plugin-editor 3 (code-editor) plugin-gantt 3 (object-gantt) - * plugin-map 3 (object-map) plugin-markdown 3 (markdown) - * plugin-timeline 3 (timeline) plugin-calendar 2 (calendar-view) - * plugin-kanban 2 (kanban) components-complex-filter-ui 1 - * components-complex-sort-ui 1 components-complex-view-switcher 1 - * components-disclosure-toggle-group 1 core-schema-renderer 1 - * - * The gallery page's PURPOSE is to render the whole catalog, so its baseline is - * that every type an entry names is registered before the render. Nine of the - * imports below are exactly the packages that census resolved to — not a - * precautionary sweep of the workspace. - * - * ## The list is no longer ONLY that census (objectui#6167) - * - * `@object-ui/plugin-form` is here for a different reason, and the header has - * to say so rather than let the sentence above keep describing a property this - * file no longer has. The two `plugin-form` catalog entries are real - * `object-form` nodes as of objectui#6167, and `object-form` is registered by - * `@object-ui/plugin-form` — so the gallery depends on that package by name. - * - * What it did NOT do is change what resolves. Measured before adding it, with - * exactly the eleven imports this file used to carry: `object-form`, - * `plugin-form:object-form`, `embeddable-form`, `form-analytics`, - * `object-master-detail-form`, `record:line_items` and `view:form` all resolved - * already, because `@object-ui/plugin-view` — import #11 below — does - * `import { ObjectForm } from '@object-ui/plugin-form'` - * (`packages/plugin-view/src/ObjectView.tsx:38`), and importing that entry runs - * its `ComponentRegistry.register` calls. The package's graph was therefore - * ALREADY in this route's eager closure; the import below adds a declaration, - * not a payload. Registration is idempotent for the same reason: an ES module - * executes once, so naming it here does not re-run anything. - * - * `@object-ui/plugin-grid` joins on the same argument and with the same - * measured non-effect (objectui#6025). The `plugin-grid` entries have been real - * `object-grid` nodes since objectui#5856, and `object-grid` reached this - * registry only because `ObjectView.tsx:37` — the line above the one quoted - * above — imports `ObjectGrid`. That is a COMPONENT import, not a plugin - * dependency: a refactor that stopped `object-view` from drawing a grid itself - * would be entirely reasonable, would say nothing about the gallery, and would - * turn two catalog tiles into OBJUI-001 panels several files from the cause. - * - * So the list below is: the census (nine), plus objectui#4600's original two, - * plus two packages the gallery's own entries depend on BY NAME. What it is not - * is a sweep — nothing is here that the catalog does not render. - * - * ## The cost, measured before deciding (objectui#4616 ruling 1) - * - * Eager registration pulls each package's graph into the `/docs/[[...slug]]` - * route — and that route is shared by all 181 docs pages, not just the gallery - * (measured: `/docs/guide/schema-catalog` and `/docs/guide/architecture` load - * an identical chunk set, and `SchemaThumbnail`'s own code sits in one of - * them). So the cost had to be measured, not assumed. Next 16.3 + Turbopack - * prints no "Size / First Load JS" columns, so the figure was reconstructed - * from the build on disk — the `<script src>` set of the prerendered route: - * - * before 7738.7 kB across 29 chunks - * after 9542.6 kB across 40 chunks - * delta +1803.9 kB (+23.3%) — the ruling's stop condition was +50% - * - * The prerender is unaffected: the build still emits 556/556 static pages, and - * the gallery's own HTML still carries all 440 thumbnail frames (it grew - * 572.1 -> 580.7 kB). `/playground`, which imports the layout registrar but not - * this module, is byte-identical at 6872.8 kB — the widening is confined to the - * docs route. Whole-app `static/chunks` did not grow at all (27582.2 -> - * 27517.2 kB): the plugin code was already being emitted for the docs pages - * that load it through `PluginLoader`, and what changed is which chunks the - * docs route pulls in up front. - * - * Worth knowing when re-reading this: the thumbnails themselves are - * NOT in that HTML — `SchemaThumbnail` gates its mount behind an - * `IntersectionObserver`, so the OBJUI-001 panels appeared on the client after - * hydration. The eagerness here is still what makes registration precede the - * first client render of a tile; it is not what puts a tile in the HTML. - * - * ## What this does NOT fix - * - * 31 of those 33 tiles go from the panel to a drawn component. The rest are - * not registration defects, so they are filed and named rather than fixed here: - * - * objectui#4624 `components-disclosure-toggle-group/with-labels` names root - * type `single`, which no package registers. - * objectui#4625 the six `components-form-calendar/*` author the bare - * `calendar` keyword, which `@object-ui/components` - * deliberately registers as `ui:calendar` only (`skipFallback`, - * "collides with the plugin-calendar full CRUD calendar VIEW") - * — so they reach `ObjectCalendar` and swap the OBJUI-001 - * panel for its missing-data-source error. - * objectui#4627 the two `plugin-calendar/*` entries author 2024 event dates, - * outside the window `calendar-view` paints. - * - * (`core-schema-renderer/unknown-component-type` also keeps the panel, on - * purpose — the panel IS that example.) Two further entries were found blank - * rather than red by the same sweep and filed as objectui#4626. - * - * EAGER, at module scope, for the same reason `./registerLayoutBlocks` is - * eager: registration has then already happened before the first tile renders, - * instead of a tile painting the red panel and repainting once a lazy chunk - * lands. Not every package declares `sideEffects: false`, so a bare side-effect - * import survives bundling (checked); the layout package is the one that needs - * an explicit call, which its own module does. - * - * Deliberately NOT imported by `InteractiveDemo` / `LiveSplitDemo`: those host - * demos on ordinary docs pages, which opt into their plugins through - * `PluginLoader`, and making them eager would pull every graph below into any - * page carrying a demo. The gallery page's own modal preview is unaffected — it - * renders after `SchemaThumbnail` has already registered these on that page. - * - * IMPORT ORDER IS CONTRACT. The last registration of a bare keyword wins, so - * where two of the packages below claim one, the order decides what draws. - * Re-derived with `deriveRegistryKeys` (`scripts/check-doc-component-types.mjs`) - * on `origin/main` @ `e929c562a`, none of them do today (objectui#6442): bare - * `chart` is `plugin-charts`' alone — neither `plugin-dashboard` nor - * `plugin-report` registers it, and the two `apps/console` sites that also hold - * the key are `registerLazy` stubs whose loader is `plugin-charts` itself — and - * bare `calendar` is `plugin-calendar`'s alone, because `@object-ui/components` - * passes `skipFallback` on `ui:calendar` precisely to stay out of that keyword. - * The order stays fixed against the day that changes: the first two lines keep - * the order objectui#4600 established; the rest are appended, which is the - * order the census above was measured in. - * - * Guarded by two pins in `examples/schema-catalog/test/`: - * `plugin-dashboard-gallery-render.test.tsx` (objectui#4600, the dashboard - * category) and `catalog-gallery-render.test.tsx` (objectui#4616, every - * category), both of which mirror this list and fail if it stops loading a - * package. - * - * ## What weighs an ADDITION to this list (objectui#6316) - * - * Not `check:eager-closure`, whatever the cards that added to this list said: - * that gate reads `apps/console/dist/eager-closure.json` and weighs the CONSOLE. - * The figures above were taken by hand, once, and nothing re-takes them. - * - * `pnpm check:docs-route-closure` - * (`scripts/check-docs-route-eager-closure.mjs`) is what governs a new line - * here. It walks the `/docs/[[...slug]]` route's STATIC module graph — the - * entries, plus every compiled `content/docs/**\/*.mdx` module — and requires - * each package named below to be either already reachable without this file - * naming it (a declaration and no payload: measurably the case for the last two - * imports, through `@object-ui/plugin-view`) or recorded in that script's - * `MEASURED_PAYLOAD` with what it is for. A package that is neither is a - * genuinely new graph on a route all 181 docs pages share, and it fails there - * so that someone argues for it in review. - * - * It is structural, not byte-level, and deliberately so: it costs no docs - * build, and it does not re-take the measurement above or claim to. - */ -import '@object-ui/plugin-dashboard'; -import '@object-ui/plugin-charts'; -import '@object-ui/plugin-calendar'; -import '@object-ui/plugin-chatbot'; -import '@object-ui/plugin-editor'; -import '@object-ui/plugin-gantt'; -import '@object-ui/plugin-kanban'; -import '@object-ui/plugin-map'; -import '@object-ui/plugin-markdown'; -import '@object-ui/plugin-timeline'; -import '@object-ui/plugin-view'; -import '@object-ui/plugin-form'; -import '@object-ui/plugin-grid'; diff --git a/ui/apps/site/app/components/registerLayoutBlocks.ts b/ui/apps/site/app/components/registerLayoutBlocks.ts deleted file mode 100644 index f16a1002aa5..00000000000 --- a/ui/apps/site/app/components/registerLayoutBlocks.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - */ - -/** - * Registers `@object-ui/layout`'s blocks (`app-schema-renderer`, - * `layout:page:card`) for the docs site's schema renderers. (The `page-header` - * alias this module was first added for was retired by objectui#10859 batch 8; - * authors write `page:header`, which `@object-ui/components` registers. - * `responsive-grid` and `navigation-renderer` retired under objectui#11441: - * authors write `grid` with a breakpoint `columns` object, and navigation is - * application metadata.) - * - * Why this exists (objectui#3787): the site renders catalog examples through - * `SchemaRenderer`, and `ComponentRegistry` only knows a type once the package - * owning it has been loaded. `@object-ui/components` registers its own blocks - * as an import side-effect and every renderer host already imports it, so - * `div`/`text`/`button` resolved — but nothing pulled in `@object-ui/layout`, - * so `page-header` resolved to nothing and rendered the red - * "Unknown component type" panel (OBJUI-001). It was not noticed because no - * example used the component: the one demo on the PageHeader docs page - * hand-rolled the header out of `div`s instead, which is the defect #3787 is - * about. - * - * Imported for effect by EVERY host that renders a catalog example - * (`InteractiveDemo`, `SchemaThumbnail`, `LiveSplitDemo`) rather than wired - * into one docs page: `SchemaThumbnail` renders the whole catalog on - * `/docs/guide/schema-catalog`, so a page-local loader would fix the component - * page and leave that index showing the error panel for the same example. - * - * `registerLayout()` is called EXPLICITLY even though the package body also - * calls it on load: `@object-ui/layout` declares `"sideEffects": false` - * (`packages/layout/package.json`), which permits a bundler to drop a module - * imported only for its side-effects. A named import that is actually invoked - * cannot be dropped. Registration is idempotent, so the double call is safe, - * and doing it at module scope (not in an effect) means it has already happened - * for the server render — the demos stay in the prerendered HTML. - */ -import { registerLayout } from '@object-ui/layout'; - -registerLayout(); diff --git a/ui/apps/site/app/docs/[[...slug]]/page.tsx b/ui/apps/site/app/docs/[[...slug]]/page.tsx deleted file mode 100644 index 8e76ac46bb9..00000000000 --- a/ui/apps/site/app/docs/[[...slug]]/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { getPageImage, source } from '@/lib/source'; -import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page'; -import { notFound } from 'next/navigation'; -import { getMDXComponents } from '@/mdx-components'; -import type { Metadata } from 'next'; -import { createRelativeLink } from 'fumadocs-ui/mdx'; -import { LLMCopyButton, ViewOptions } from '@/components/ai/page-actions'; - -export default async function Page(props: PageProps<'/docs/[[...slug]]'>) { - const params = await props.params; - const page = source.getPage(params.slug); - if (!page) notFound(); - - const MDX = page.data.body; - const gitConfig = { - user: 'username', - repo: 'repo', - branch: 'main', - }; - - return ( - <DocsPage toc={page.data.toc} full={page.data.full}> - <DocsTitle>{page.data.title}</DocsTitle> - <DocsDescription className="mb-0">{page.data.description}</DocsDescription> - <div className="flex flex-row gap-2 items-center border-b pb-6"> - <LLMCopyButton markdownUrl={`${page.url}.mdx`} /> - <ViewOptions - markdownUrl={`${page.url}.mdx`} - // update it to match your repo - githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/docs/content/docs/${page.path}`} - /> - </div> - <DocsBody> - <MDX - components={getMDXComponents({ - // this allows you to link to other pages with relative file paths - a: createRelativeLink(source, page), - })} - /> - </DocsBody> - </DocsPage> - ); -} - -export async function generateStaticParams() { - return source.generateParams(); -} - -export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): Promise<Metadata> { - const params = await props.params; - const page = source.getPage(params.slug); - if (!page) notFound(); - - return { - title: page.data.title, - description: page.data.description, - openGraph: { - images: getPageImage(page).url, - }, - }; -} diff --git a/ui/apps/site/app/docs/layout.tsx b/ui/apps/site/app/docs/layout.tsx deleted file mode 100644 index a373143bf42..00000000000 --- a/ui/apps/site/app/docs/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { source } from '@/lib/source'; -import { DocsLayout } from 'fumadocs-ui/layouts/docs'; -import { baseOptions } from '@/lib/layout.shared'; - -export default function Layout({ children }: LayoutProps<'/docs'>) { - return ( - <DocsLayout tree={source.getPageTree()} {...baseOptions()}> - {children} - </DocsLayout> - ); -} diff --git a/ui/apps/site/app/global.css b/ui/apps/site/app/global.css deleted file mode 100644 index b8bbb772531..00000000000 --- a/ui/apps/site/app/global.css +++ /dev/null @@ -1,44 +0,0 @@ -@import 'tailwindcss'; -@import 'fumadocs-ui/css/neutral.css'; -@import 'fumadocs-ui/css/preset.css'; - - -/* Scan sources for Tailwind clcd asses */ -@source '../node_modules/fumadocs-ui/dist/**/*.js'; -@source '../../../packages/components/src/**/*.{ts,tsx}'; -@source '../../../packages/react/src/**/*.{ts,tsx}'; -@source '../../../packages/plugin-*/src/**/*.{ts,tsx}'; - -/* - * Shadcn color-token aliases. - * - * @object-ui/components ships Shadcn primitives that reference utility - * classes like `bg-primary`, `bg-card`, `text-muted-foreground`, etc. - * Fumadocs preset only defines `--color-fd-*` tokens, so without these - * aliases those classes resolve to empty values (e.g. the Sign In / - * Submit buttons render with no background). Map them onto the fumadocs - * theme so primitives inherit the site palette automatically in both - * light and dark mode. - */ -@theme { - --color-background: var(--color-fd-background); - --color-foreground: var(--color-fd-foreground); - --color-card: var(--color-fd-card); - --color-card-foreground: var(--color-fd-card-foreground); - --color-popover: var(--color-fd-popover); - --color-popover-foreground: var(--color-fd-popover-foreground); - --color-primary: var(--color-fd-primary); - --color-primary-foreground: var(--color-fd-primary-foreground); - --color-secondary: var(--color-fd-secondary); - --color-secondary-foreground: var(--color-fd-secondary-foreground); - --color-muted: var(--color-fd-muted); - --color-muted-foreground: var(--color-fd-muted-foreground); - --color-accent: var(--color-fd-accent); - --color-accent-foreground: var(--color-fd-accent-foreground); - --color-destructive: oklch(0.577 0.245 27.325); - --color-destructive-foreground: oklch(0.985 0 0); - --color-border: var(--color-fd-border); - --color-input: var(--color-fd-border); - --color-ring: var(--color-fd-ring); -} - diff --git a/ui/apps/site/app/layout.tsx b/ui/apps/site/app/layout.tsx deleted file mode 100644 index a7a91b48d8f..00000000000 --- a/ui/apps/site/app/layout.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { Metadata } from 'next'; -import { RootProvider } from 'fumadocs-ui/provider/next'; -import './global.css'; -import { ObjectUIProvider } from '@/app/components/ObjectUIProvider'; - -// `app/docs/[[...slug]]/page.tsx` sets `openGraph.images` to the site-relative -// `/og/docs/...` route. Without a `metadataBase` Next resolves that against -// `http://localhost:3000` and says so at build time — so every docs page has -// been shipping an Open Graph image URL pointing at localhost. The origin is -// the one the published packages already name in their `homepage` fields. -export const metadata: Metadata = { - metadataBase: new URL('https://www.objectui.org'), -}; - - -export default function Layout({ children }: LayoutProps<'/'>) { - return ( - <html lang="en" suppressHydrationWarning> - <body className="flex flex-col min-h-screen"> - <RootProvider> - <ObjectUIProvider>{children}</ObjectUIProvider> - </RootProvider> - </body> - </html> - ); -} diff --git a/ui/apps/site/app/llms-full.txt/route.ts b/ui/apps/site/app/llms-full.txt/route.ts deleted file mode 100644 index d494d2cbb68..00000000000 --- a/ui/apps/site/app/llms-full.txt/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { getLLMText, source } from '@/lib/source'; - -export const revalidate = false; - -export async function GET() { - const scan = source.getPages().map(getLLMText); - const scanned = await Promise.all(scan); - - return new Response(scanned.join('\n\n')); -} diff --git a/ui/apps/site/app/llms.mdx/docs/[[...slug]]/route.ts b/ui/apps/site/app/llms.mdx/docs/[[...slug]]/route.ts deleted file mode 100644 index fde26d99f9c..00000000000 --- a/ui/apps/site/app/llms.mdx/docs/[[...slug]]/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { getLLMText, source } from '@/lib/source'; -import { notFound } from 'next/navigation'; - -export const revalidate = false; - -export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) { - const { slug } = await params; - const page = source.getPage(slug); - if (!page) notFound(); - - return new Response(await getLLMText(page), { - headers: { - 'Content-Type': 'text/markdown', - }, - }); -} - -export function generateStaticParams() { - return source.generateParams(); -} diff --git a/ui/apps/site/app/llms.txt/route.ts b/ui/apps/site/app/llms.txt/route.ts deleted file mode 100644 index 6639c252a1b..00000000000 --- a/ui/apps/site/app/llms.txt/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { source } from '@/lib/source'; - -export const revalidate = false; - -export async function GET() { - const lines: string[] = []; - lines.push('# Documentation'); - lines.push(''); - for (const page of source.getPages()) { - lines.push(`- [${page.data.title}](${page.url}): ${page.data.description}`); - } - return new Response(lines.join('\n')); -} diff --git a/ui/apps/site/app/og/docs/[...slug]/route.tsx b/ui/apps/site/app/og/docs/[...slug]/route.tsx deleted file mode 100644 index 2d741be3c15..00000000000 --- a/ui/apps/site/app/og/docs/[...slug]/route.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { getPageImage, source } from '@/lib/source'; -import { notFound } from 'next/navigation'; -import { ImageResponse } from 'next/og'; -import { generate as DefaultImage } from 'fumadocs-ui/og'; - -export const revalidate = false; - -export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) { - const { slug } = await params; - const page = source.getPage(slug.slice(0, -1)); - if (!page) notFound(); - - return new ImageResponse( - <DefaultImage title={page.data.title} description={page.data.description} site="My App" />, - { - width: 1200, - height: 630, - }, - ); -} - -export function generateStaticParams() { - return source.getPages().map((page) => ({ - lang: page.locale, - slug: getPageImage(page).segments, - })); -} diff --git a/ui/apps/site/app/playground/page.tsx b/ui/apps/site/app/playground/page.tsx deleted file mode 100644 index 569de7826b0..00000000000 --- a/ui/apps/site/app/playground/page.tsx +++ /dev/null @@ -1,1422 +0,0 @@ -'use client'; - -import React, { useState, useEffect } from 'react'; -import { SchemaRenderer, toRenderableSchema } from '@object-ui/react'; -import type { SchemaNode } from '@object-ui/core'; -import dynamic from 'next/dynamic'; -import { ObjectUIProvider } from '@/app/components/ObjectUIProvider'; -// Registers `@object-ui/layout`'s blocks — see the module header (objectui#3787). -// The Playground renders hand-typed schemas, so it needs the same registration -// as the three catalog hosts: without it, typing `page-header` here produced the -// red OBJUI-001 "Unknown component type" panel for a component the rest of the -// docs site rendered fine (objectui#3904). -import '@/app/components/registerLayoutBlocks'; -import { ChevronLeft, ChevronRight, Monitor, Smartphone, Tablet } from 'lucide-react'; - -// Dynamically import Monaco Editor to avoid SSR issues -const Editor = dynamic(() => import('@monaco-editor/react'), { ssr: false }); - -// Default example schema -const DEFAULT_SCHEMA: SchemaNode = { - type: "div", - className: "space-y-4", - children: [ - { - type: "text", - content: "Welcome to ObjectUI Playground", - className: "text-2xl font-bold" - }, - { - type: "text", - content: "Edit the JSON schema on the left to see live updates here." - }, - { - type: "card", - className: "p-6", - children: [ - { - type: "text", - content: "Quick Example", - className: "text-xl font-semibold mb-2" - }, - { - type: "text", - content: "Try changing the text or adding new components!", - className: "mb-4" - }, - { - type: "button", - variant: "default", - label: "Click me" - } - ] - } - ] -}; - -// Example schemas for users to try -const EXAMPLE_SCHEMAS = { - basic: DEFAULT_SCHEMA, - form: { - type: "card", - className: "max-w-2xl mx-auto", - children: [ - { - type: "div", - className: "p-6 space-y-6", - children: [ - { - type: "div", - className: "space-y-2", - children: [ - { - type: "text", - content: "Contact Us", - className: "text-3xl font-bold" - }, - { - type: "text", - content: "Fill out the form below and we'll get back to you soon.", - className: "text-muted-foreground" - } - ] - }, - { - type: "separator" - }, - { - type: "div", - className: "grid grid-cols-2 gap-4", - children: [ - { - type: "div", - className: "space-y-2", - children: [ - { - type: "label", - htmlFor: "firstName", - label: "First Name" - }, - { - type: "input", - id: "firstName", - name: "firstName", - placeholder: "John" - } - ] - }, - { - type: "div", - className: "space-y-2", - children: [ - { - type: "label", - htmlFor: "lastName", - label: "Last Name" - }, - { - type: "input", - id: "lastName", - name: "lastName", - placeholder: "Doe" - } - ] - } - ] - }, - { - type: "div", - className: "space-y-2", - children: [ - { - type: "label", - htmlFor: "email", - label: "Email Address" - }, - { - type: "input", - id: "email", - name: "email", - inputType: "email", - placeholder: "john.doe@example.com" - } - ] - }, - { - type: "div", - className: "space-y-2", - children: [ - { - type: "label", - htmlFor: "subject", - label: "Subject" - }, - { - type: "select", - id: "subject", - name: "subject", - placeholder: "Select a subject", - options: [ - { label: "General Inquiry", value: "general" }, - { label: "Technical Support", value: "support" }, - { label: "Sales", value: "sales" }, - { label: "Partnership", value: "partnership" } - ] - } - ] - }, - { - type: "div", - className: "space-y-2", - children: [ - { - type: "label", - htmlFor: "message", - label: "Message" - }, - { - type: "textarea", - id: "message", - name: "message", - placeholder: "Tell us more about your inquiry...", - rows: 5 - } - ] - }, - { - type: "div", - className: "flex items-center gap-2", - children: [ - { - type: "checkbox", - id: "newsletter", - name: "newsletter" - }, - { - type: "label", - htmlFor: "newsletter", - label: "Subscribe to our newsletter" - } - ] - }, - { - type: "div", - className: "flex gap-3", - children: [ - { - type: "button", - variant: "default", - label: "Send Message", - className: "flex-1" - }, - { - type: "button", - variant: "outline", - label: "Reset" - } - ] - } - ] - } - ] - }, - dashboard: { - type: "div", - className: "space-y-6", - children: [ - { - type: "div", - className: "flex items-center justify-between", - children: [ - { - type: "div", - children: [ - { - type: "text", - content: "Analytics Dashboard", - className: "text-3xl font-bold" - }, - { - type: "text", - content: "Overview of your business metrics", - className: "text-muted-foreground mt-1" - } - ] - }, - { - type: "button", - variant: "outline", - label: "Download Report", - icon: "Download" - } - ] - }, - { - type: "grid", - columns: 4, - gap: 4, - children: [ - { - type: "card", - className: "p-6", - children: [ - { - type: "div", - className: "flex items-center justify-between mb-2", - children: [ - { - type: "text", - content: "Total Revenue", - className: "text-sm font-medium text-muted-foreground" - }, - { - type: "icon", - icon: "DollarSign", - className: "h-4 w-4 text-muted-foreground" - } - ] - }, - { - type: "text", - content: "$45,231.89", - className: "text-2xl font-bold" - }, - { - type: "div", - className: "flex items-center gap-1 mt-2", - children: [ - { - type: "text", - content: "+20.1%", - className: "text-sm font-medium text-green-600" - }, - { - type: "text", - content: "from last month", - className: "text-sm text-muted-foreground" - } - ] - } - ] - }, - { - type: "card", - className: "p-6", - children: [ - { - type: "div", - className: "flex items-center justify-between mb-2", - children: [ - { - type: "text", - content: "Active Users", - className: "text-sm font-medium text-muted-foreground" - }, - { - type: "icon", - icon: "Users", - className: "h-4 w-4 text-muted-foreground" - } - ] - }, - { - type: "text", - content: "2,350", - className: "text-2xl font-bold" - }, - { - type: "div", - className: "flex items-center gap-1 mt-2", - children: [ - { - type: "text", - content: "+180.1%", - className: "text-sm font-medium text-green-600" - }, - { - type: "text", - content: "from last month", - className: "text-sm text-muted-foreground" - } - ] - } - ] - }, - { - type: "card", - className: "p-6", - children: [ - { - type: "div", - className: "flex items-center justify-between mb-2", - children: [ - { - type: "text", - content: "Sales", - className: "text-sm font-medium text-muted-foreground" - }, - { - type: "icon", - icon: "CreditCard", - className: "h-4 w-4 text-muted-foreground" - } - ] - }, - { - type: "text", - content: "+12,234", - className: "text-2xl font-bold" - }, - { - type: "div", - className: "flex items-center gap-1 mt-2", - children: [ - { - type: "text", - content: "+19%", - className: "text-sm font-medium text-green-600" - }, - { - type: "text", - content: "from last month", - className: "text-sm text-muted-foreground" - } - ] - } - ] - }, - { - type: "card", - className: "p-6", - children: [ - { - type: "div", - className: "flex items-center justify-between mb-2", - children: [ - { - type: "text", - content: "Active Now", - className: "text-sm font-medium text-muted-foreground" - }, - { - type: "icon", - icon: "Activity", - className: "h-4 w-4 text-muted-foreground" - } - ] - }, - { - type: "text", - content: "+573", - className: "text-2xl font-bold" - }, - { - type: "div", - className: "flex items-center gap-1 mt-2", - children: [ - { - type: "text", - content: "+201", - className: "text-sm font-medium text-green-600" - }, - { - type: "text", - content: "since last hour", - className: "text-sm text-muted-foreground" - } - ] - } - ] - } - ] - }, - { - type: "grid", - columns: 2, - gap: 4, - children: [ - { - type: "card", - children: [ - { - type: "div", - className: "p-6", - children: [ - { - type: "text", - content: "Recent Activity", - className: "text-xl font-semibold mb-4" - }, - { - type: "div", - className: "space-y-4", - children: [ - { - type: "div", - className: "flex items-start gap-4", - children: [ - { - type: "avatar", - fallback: "JD", - className: "h-10 w-10" - }, - { - type: "div", - className: "flex-1 space-y-1", - children: [ - { - type: "text", - content: "John Doe made a purchase", - className: "text-sm font-medium" - }, - { - type: "text", - content: "2 minutes ago", - className: "text-xs text-muted-foreground" - } - ] - }, - { - type: "badge", - label: "New", - variant: "default" - } - ] - }, - { - type: "div", - className: "flex items-start gap-4", - children: [ - { - type: "avatar", - fallback: "SM", - className: "h-10 w-10" - }, - { - type: "div", - className: "flex-1 space-y-1", - children: [ - { - type: "text", - content: "Sarah Miller subscribed", - className: "text-sm font-medium" - }, - { - type: "text", - content: "10 minutes ago", - className: "text-xs text-muted-foreground" - } - ] - }, - { - type: "badge", - label: "Pro", - variant: "secondary" - } - ] - }, - { - type: "div", - className: "flex items-start gap-4", - children: [ - { - type: "avatar", - fallback: "RJ", - className: "h-10 w-10" - }, - { - type: "div", - className: "flex-1 space-y-1", - children: [ - { - type: "text", - content: "Robert Johnson commented", - className: "text-sm font-medium" - }, - { - type: "text", - content: "1 hour ago", - className: "text-xs text-muted-foreground" - } - ] - }, - { - type: "badge", - label: "Comment", - variant: "outline" - } - ] - } - ] - } - ] - } - ] - }, - { - type: "card", - children: [ - { - type: "div", - className: "p-6", - children: [ - { - type: "text", - content: "Top Products", - className: "text-xl font-semibold mb-4" - }, - { - type: "div", - className: "space-y-4", - children: [ - { - type: "div", - className: "flex items-center justify-between", - children: [ - { - type: "div", - className: "flex items-center gap-3", - children: [ - { - type: "div", - className: "h-10 w-10 rounded bg-blue-100 dark:bg-blue-900 flex items-center justify-center", - children: [ - { - type: "icon", - icon: "Smartphone", - className: "h-5 w-5 text-blue-600 dark:text-blue-400" - } - ] - }, - { - type: "div", - children: [ - { - type: "text", - content: "iPhone 15 Pro", - className: "text-sm font-medium" - }, - { - type: "text", - content: "1,234 sales", - className: "text-xs text-muted-foreground" - } - ] - } - ] - }, - { - type: "text", - content: "$999", - className: "font-semibold" - } - ] - }, - { - type: "div", - className: "flex items-center justify-between", - children: [ - { - type: "div", - className: "flex items-center gap-3", - children: [ - { - type: "div", - className: "h-10 w-10 rounded bg-purple-100 dark:bg-purple-900 flex items-center justify-center", - children: [ - { - type: "icon", - icon: "Laptop", - className: "h-5 w-5 text-purple-600 dark:text-purple-400" - } - ] - }, - { - type: "div", - children: [ - { - type: "text", - content: "MacBook Pro", - className: "text-sm font-medium" - }, - { - type: "text", - content: "856 sales", - className: "text-xs text-muted-foreground" - } - ] - } - ] - }, - { - type: "text", - content: "$1,999", - className: "font-semibold" - } - ] - }, - { - type: "div", - className: "flex items-center justify-between", - children: [ - { - type: "div", - className: "flex items-center gap-3", - children: [ - { - type: "div", - className: "h-10 w-10 rounded bg-green-100 dark:bg-green-900 flex items-center justify-center", - children: [ - { - type: "icon", - icon: "Headphones", - className: "h-5 w-5 text-green-600 dark:text-green-400" - } - ] - }, - { - type: "div", - children: [ - { - type: "text", - content: "AirPods Pro", - className: "text-sm font-medium" - }, - { - type: "text", - content: "542 sales", - className: "text-xs text-muted-foreground" - } - ] - } - ] - }, - { - type: "text", - content: "$249", - className: "font-semibold" - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - }, - dataTable: { - type: "div", - className: "space-y-4", - children: [ - { - type: "div", - className: "flex items-center justify-between", - children: [ - { - type: "div", - children: [ - { - type: "text", - content: "User Management", - className: "text-3xl font-bold" - }, - { - type: "text", - content: "Manage your team members and their roles", - className: "text-muted-foreground mt-1" - } - ] - }, - { - type: "button", - variant: "default", - label: "Add User", - icon: "Plus" - } - ] - }, - { - type: "card", - children: [ - { - type: "div", - className: "p-6", - children: [ - { - type: "table", - columns: [ - { - header: "User", - accessorKey: "user" - }, - { - header: "Email", - accessorKey: "email" - }, - { - header: "Role", - accessorKey: "role" - }, - { - header: "Status", - accessorKey: "status" - }, - { - header: "Actions", - accessorKey: "actions" - } - ], - data: [ - { - user: "John Doe", - email: "john@example.com", - role: "Admin", - status: "Active", - actions: "•••" - }, - { - user: "Jane Smith", - email: "jane@example.com", - role: "Editor", - status: "Active", - actions: "•••" - }, - { - user: "Bob Johnson", - email: "bob@example.com", - role: "Viewer", - status: "Inactive", - actions: "•••" - }, - { - user: "Alice Williams", - email: "alice@example.com", - role: "Editor", - status: "Active", - actions: "•••" - }, - { - user: "Charlie Brown", - email: "charlie@example.com", - role: "Viewer", - status: "Active", - actions: "•••" - } - ] - } - ] - } - ] - }, - { - type: "div", - className: "flex items-center justify-between", - children: [ - { - type: "text", - content: "Showing 1 to 5 of 50 results", - className: "text-sm text-muted-foreground" - }, - { - type: "pagination", - currentPage: 1, - totalPages: 10 - } - ] - } - ] - }, - ecommerce: { - type: "div", - className: "space-y-6", - children: [ - { - type: "div", - className: "flex items-center justify-between", - children: [ - { - type: "text", - content: "Featured Products", - className: "text-3xl font-bold" - }, - { - type: "div", - className: "flex gap-2", - children: [ - { - type: "button", - variant: "outline", - label: "Filter", - icon: "Funnel" - }, - { - type: "button", - variant: "outline", - label: "Sort", - icon: "ArrowUpDown" - } - ] - } - ] - }, - { - type: "grid", - columns: 3, - gap: 6, - children: [ - { - type: "card", - className: "overflow-hidden", - children: [ - { - type: "div", - className: "aspect-square bg-gradient-to-br from-blue-100 to-blue-200 dark:from-blue-900 dark:to-blue-800 flex items-center justify-center", - children: [ - { - type: "icon", - icon: "Smartphone", - className: "h-20 w-20 text-blue-600 dark:text-blue-400" - } - ] - }, - { - type: "div", - className: "p-4 space-y-3", - children: [ - { - type: "div", - className: "flex items-start justify-between", - children: [ - { - type: "div", - children: [ - { - type: "text", - content: "iPhone 15 Pro", - className: "font-semibold text-lg" - }, - { - type: "text", - content: "Latest flagship phone", - className: "text-sm text-muted-foreground" - } - ] - }, - { - type: "badge", - label: "New", - variant: "default" - } - ] - }, - { - type: "div", - className: "flex items-center gap-2", - children: [ - { - type: "text", - content: "$999", - className: "text-2xl font-bold" - }, - { - type: "text", - content: "$1,099", - className: "text-sm text-muted-foreground line-through" - } - ] - }, - { - type: "div", - className: "flex items-center gap-1", - children: [ - { - type: "icon", - icon: "Star", - className: "h-4 w-4 fill-yellow-400 text-yellow-400" - }, - { - type: "icon", - icon: "Star", - className: "h-4 w-4 fill-yellow-400 text-yellow-400" - }, - { - type: "icon", - icon: "Star", - className: "h-4 w-4 fill-yellow-400 text-yellow-400" - }, - { - type: "icon", - icon: "Star", - className: "h-4 w-4 fill-yellow-400 text-yellow-400" - }, - { - type: "icon", - icon: "Star", - className: "h-4 w-4 text-gray-300" - }, - { - type: "text", - content: "(128 reviews)", - className: "text-sm text-muted-foreground ml-1" - } - ] - }, - { - type: "button", - variant: "default", - label: "Add to Cart", - className: "w-full" - } - ] - } - ] - }, - { - type: "card", - className: "overflow-hidden", - children: [ - { - type: "div", - className: "aspect-square bg-gradient-to-br from-purple-100 to-purple-200 dark:from-purple-900 dark:to-purple-800 flex items-center justify-center", - children: [ - { - type: "icon", - icon: "Laptop", - className: "h-20 w-20 text-purple-600 dark:text-purple-400" - } - ] - }, - { - type: "div", - className: "p-4 space-y-3", - children: [ - { - type: "div", - className: "flex items-start justify-between", - children: [ - { - type: "div", - children: [ - { - type: "text", - content: "MacBook Pro 16\"", - className: "font-semibold text-lg" - }, - { - type: "text", - content: "M3 Max, 36GB RAM", - className: "text-sm text-muted-foreground" - } - ] - }, - { - type: "badge", - label: "Hot", - variant: "destructive" - } - ] - }, - { - type: "div", - className: "flex items-center gap-2", - children: [ - { - type: "text", - content: "$2,499", - className: "text-2xl font-bold" - } - ] - }, - { - type: "div", - className: "flex items-center gap-1", - children: [ - { - type: "icon", - icon: "Star", - className: "h-4 w-4 fill-yellow-400 text-yellow-400" - }, - { - type: "icon", - icon: "Star", - className: "h-4 w-4 fill-yellow-400 text-yellow-400" - }, - { - type: "icon", - icon: "Star", - className: "h-4 w-4 fill-yellow-400 text-yellow-400" - }, - { - type: "icon", - icon: "Star", - className: "h-4 w-4 fill-yellow-400 text-yellow-400" - }, - { - type: "icon", - icon: "Star", - className: "h-4 w-4 fill-yellow-400 text-yellow-400" - }, - { - type: "text", - content: "(256 reviews)", - className: "text-sm text-muted-foreground ml-1" - } - ] - }, - { - type: "button", - variant: "default", - label: "Add to Cart", - className: "w-full" - } - ] - } - ] - }, - { - type: "card", - className: "overflow-hidden", - children: [ - { - type: "div", - className: "aspect-square bg-gradient-to-br from-green-100 to-green-200 dark:from-green-900 dark:to-green-800 flex items-center justify-center", - children: [ - { - type: "icon", - icon: "Headphones", - className: "h-20 w-20 text-green-600 dark:text-green-400" - } - ] - }, - { - type: "div", - className: "p-4 space-y-3", - children: [ - { - type: "div", - className: "flex items-start justify-between", - children: [ - { - type: "div", - children: [ - { - type: "text", - content: "AirPods Pro", - className: "font-semibold text-lg" - }, - { - type: "text", - content: "Active noise cancellation", - className: "text-sm text-muted-foreground" - } - ] - } - ] - }, - { - type: "div", - className: "flex items-center gap-2", - children: [ - { - type: "text", - content: "$249", - className: "text-2xl font-bold" - }, - { - type: "text", - content: "$279", - className: "text-sm text-muted-foreground line-through" - } - ] - }, - { - type: "div", - className: "flex items-center gap-1", - children: [ - { - type: "icon", - icon: "Star", - className: "h-4 w-4 fill-yellow-400 text-yellow-400" - }, - { - type: "icon", - icon: "Star", - className: "h-4 w-4 fill-yellow-400 text-yellow-400" - }, - { - type: "icon", - icon: "Star", - className: "h-4 w-4 fill-yellow-400 text-yellow-400" - }, - { - type: "icon", - icon: "Star", - className: "h-4 w-4 fill-yellow-400 text-yellow-400" - }, - { - type: "icon", - icon: "Star", - className: "h-4 w-4 fill-yellow-400 text-yellow-400" - }, - { - type: "text", - content: "(89 reviews)", - className: "text-sm text-muted-foreground ml-1" - } - ] - }, - { - type: "button", - variant: "default", - label: "Add to Cart", - className: "w-full" - } - ] - } - ] - } - ] - } - ] - } -} satisfies Record<string, SchemaNode>; - -type ViewMode = 'desktop' | 'tablet' | 'mobile'; - -const VIEW_MODES = { - desktop: { width: '100%', label: 'Desktop', icon: Monitor }, - tablet: { width: '768px', label: 'Tablet', icon: Tablet }, - mobile: { width: '375px', label: 'Mobile', icon: Smartphone }, -} as const; - -export default function PlaygroundPage() { - const [schema, setSchema] = useState<SchemaNode>(DEFAULT_SCHEMA); - const [editorValue, setEditorValue] = useState(JSON.stringify(DEFAULT_SCHEMA, null, 2)); - const [error, setError] = useState<string | null>(null); - const [selectedExample, setSelectedExample] = useState<keyof typeof EXAMPLE_SCHEMAS>('basic'); - const [editorTheme, setEditorTheme] = useState<'vs-dark' | 'light'>('vs-dark'); - const [isEditorCollapsed, setIsEditorCollapsed] = useState(false); - const [viewMode, setViewMode] = useState<ViewMode>('desktop'); - - // Load collapsed state from localStorage - useEffect(() => { - if (typeof window !== 'undefined') { - const savedState = localStorage.getItem('playground-editor-collapsed'); - if (savedState) { - setIsEditorCollapsed(savedState === 'true'); - } - } - }, []); - - // Save collapsed state to localStorage - useEffect(() => { - if (typeof window !== 'undefined') { - localStorage.setItem('playground-editor-collapsed', String(isEditorCollapsed)); - } - }, [isEditorCollapsed]); - - // Detect system theme - React.useEffect(() => { - const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)'); - setEditorTheme(darkModeQuery.matches ? 'vs-dark' : 'light'); - - const handleChange = (e: MediaQueryListEvent) => { - setEditorTheme(e.matches ? 'vs-dark' : 'light'); - }; - - darkModeQuery.addEventListener('change', handleChange); - return () => darkModeQuery.removeEventListener('change', handleChange); - }, []); - - const handleEditorChange = (value: string | undefined) => { - if (!value) return; - - setEditorValue(value); - - try { - const parsed = JSON.parse(value); - setSchema(parsed); - setError(null); - } catch (e) { - setError(e instanceof Error ? e.message : 'Invalid JSON'); - } - }; - - const loadExample = (exampleKey: keyof typeof EXAMPLE_SCHEMAS) => { - const exampleSchema = EXAMPLE_SCHEMAS[exampleKey]; - setSelectedExample(exampleKey); - setSchema(exampleSchema); - setEditorValue(JSON.stringify(exampleSchema, null, 2)); - setError(null); - }; - - return ( - <ObjectUIProvider> - <div className="flex flex-col h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950"> - {/* Header */} - <div className="border-b border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm shadow-sm"> - <div className="px-6 py-4"> - <div className="flex items-center justify-between"> - <div className="flex items-center gap-4"> - <div> - <h1 className="text-2xl font-bold bg-gradient-to-r from-slate-900 to-slate-700 dark:from-white dark:to-slate-300 bg-clip-text text-transparent"> - ObjectUI Playground - </h1> - <p className="text-sm text-slate-600 dark:text-slate-400 mt-0.5"> - Edit JSON schema and see live preview - </p> - </div> - </div> - <div className="flex items-center gap-3"> - <span className="text-sm font-medium text-slate-700 dark:text-slate-300">Examples:</span> - <div className="flex gap-2"> - {(Object.keys(EXAMPLE_SCHEMAS) as Array<keyof typeof EXAMPLE_SCHEMAS>).map((key) => { - const labels: Record<keyof typeof EXAMPLE_SCHEMAS, string> = { - basic: 'Basic', - form: 'Form', - dashboard: 'Dashboard', - dataTable: 'Data Table', - ecommerce: 'E-commerce' - }; - return ( - <button type="button" - key={key} - onClick={() => loadExample(key)} - className={`px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 ${ - selectedExample === key - ? 'bg-gradient-to-r from-blue-600 to-blue-500 text-white shadow-md shadow-blue-500/30 scale-105' - : 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 hover:shadow' - }`} - > - {labels[key]} - </button> - ); - })} - </div> - </div> - </div> - </div> - </div> - - {/* Main Content */} - <div className="flex-1 flex overflow-hidden"> - {/* Editor Panel */} - <div - className={`border-r border-slate-200 dark:border-slate-800 flex flex-col bg-white dark:bg-slate-900 transition-all duration-300 ease-in-out ${ - isEditorCollapsed ? 'w-0' : 'w-1/2' - }`} - style={{ overflow: isEditorCollapsed ? 'hidden' : 'visible' }} - > - <div className="bg-slate-50 dark:bg-slate-800/50 px-4 py-3 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between"> - <h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">JSON Schema</h2> - {error && ( - <div className="flex-1 mx-4 text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 px-3 py-1.5 rounded-lg border border-red-200 dark:border-red-800 max-w-md truncate"> - {error} - </div> - )} - </div> - <div className="flex-1 overflow-hidden"> - <Editor - height="100%" - defaultLanguage="json" - value={editorValue} - onChange={handleEditorChange} - theme={editorTheme} - options={{ - minimap: { enabled: false }, - fontSize: 14, - lineNumbers: 'on', - scrollBeyondLastLine: false, - automaticLayout: true, - tabSize: 2, - fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace", - padding: { top: 16, bottom: 16 }, - smoothScrolling: true, - cursorBlinking: 'smooth', - cursorSmoothCaretAnimation: 'on', - }} - /> - </div> - </div> - - {/* Collapse/Expand Button */} - <button type="button" - onClick={() => setIsEditorCollapsed(!isEditorCollapsed)} - className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-r-lg p-2 shadow-lg hover:shadow-xl hover:bg-slate-50 dark:hover:bg-slate-700 group transition-all duration-300" - style={{ - left: isEditorCollapsed ? '0' : 'calc(50% - 16px)', - }} - aria-label={isEditorCollapsed ? 'Expand editor' : 'Collapse editor'} - > - {isEditorCollapsed ? ( - <ChevronRight className="w-4 h-4 text-slate-600 dark:text-slate-400 group-hover:text-slate-900 dark:group-hover:text-slate-100" /> - ) : ( - <ChevronLeft className="w-4 h-4 text-slate-600 dark:text-slate-400 group-hover:text-slate-900 dark:group-hover:text-slate-100" /> - )} - </button> - - {/* Preview Panel */} - <div className={`flex flex-col bg-slate-50 dark:bg-slate-900 transition-all duration-300 ${ - isEditorCollapsed ? 'w-full' : 'w-1/2' - }`}> - <div className="bg-slate-50 dark:bg-slate-800/50 px-4 py-3 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between"> - <h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Live Preview</h2> - <div className="flex items-center gap-2 bg-white dark:bg-slate-800 rounded-lg p-1 border border-slate-200 dark:border-slate-700 shadow-sm"> - {(Object.keys(VIEW_MODES) as ViewMode[]).map((mode) => { - const ModeIcon = VIEW_MODES[mode].icon; - return ( - <button type="button" - key={mode} - onClick={() => setViewMode(mode)} - className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-200 ${ - viewMode === mode - ? 'bg-gradient-to-r from-blue-600 to-blue-500 text-white shadow-md' - : 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-700' - }`} - aria-label={`Switch to ${VIEW_MODES[mode].label} view`} - > - <ModeIcon className="w-3.5 h-3.5" /> - <span className="hidden sm:inline">{VIEW_MODES[mode].label}</span> - </button> - ); - })} - </div> - </div> - <div className="flex-1 overflow-auto p-6 flex justify-center"> - {error ? ( - <div className="flex items-center justify-center h-full"> - <div className="text-center space-y-3 bg-white dark:bg-slate-800 p-8 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-700"> - <div className="text-5xl">⚠️</div> - <div className="text-slate-600 dark:text-slate-400 font-medium"> - Fix the JSON syntax to see preview - </div> - </div> - </div> - ) : ( - <div - className="transition-all duration-300 ease-in-out" - style={{ - width: VIEW_MODES[viewMode].width, - maxWidth: '100%', - }} - > - <div className={`bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden ${ - viewMode === 'mobile' ? 'min-h-[667px]' : viewMode === 'tablet' ? 'min-h-[600px]' : '' - }`}> - <div className="p-6"> - <SchemaRenderer schema={toRenderableSchema(schema)} /> - </div> - </div> - </div> - )} - </div> - </div> - </div> - </div> - </ObjectUIProvider> - ); -} diff --git a/ui/apps/site/components/ai/page-actions.tsx b/ui/apps/site/components/ai/page-actions.tsx deleted file mode 100644 index 410de18d43e..00000000000 --- a/ui/apps/site/components/ai/page-actions.tsx +++ /dev/null @@ -1,230 +0,0 @@ -'use client'; -import { useMemo, useState } from 'react'; -import { Check, ChevronDown, Copy, ExternalLinkIcon, MessageCircleIcon } from 'lucide-react'; -import { cn } from '@/lib/cn'; -import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button'; -import { buttonVariants } from 'fumadocs-ui/components/ui/button'; -import { Popover, PopoverContent, PopoverTrigger } from 'fumadocs-ui/components/ui/popover'; - -const cache = new Map<string, string>(); - -export function LLMCopyButton({ - /** - * A URL to fetch the raw Markdown/MDX content of page - */ - markdownUrl, -}: { - markdownUrl: string; -}) { - const [isLoading, setLoading] = useState(false); - const [checked, onClick] = useCopyButton(async () => { - const cached = cache.get(markdownUrl); - if (cached) return navigator.clipboard.writeText(cached); - - setLoading(true); - - try { - await navigator.clipboard.write([ - new ClipboardItem({ - 'text/plain': fetch(markdownUrl).then(async (res) => { - const content = await res.text(); - cache.set(markdownUrl, content); - - return content; - }), - }), - ]); - } finally { - setLoading(false); - } - }); - - return ( - <button type="button" - disabled={isLoading} - className={cn( - buttonVariants({ - color: 'secondary', - size: 'sm', - className: 'gap-2 [&_svg]:size-3.5 [&_svg]:text-fd-muted-foreground', - }), - )} - onClick={onClick} - > - {checked ? <Check /> : <Copy />} - Copy Markdown - </button> - ); -} - -export function ViewOptions({ - markdownUrl, - githubUrl, -}: { - /** - * A URL to the raw Markdown/MDX content of page - */ - markdownUrl: string; - - /** - * Source file URL on GitHub - */ - githubUrl: string; -}) { - const items = useMemo(() => { - const fullMarkdownUrl = - typeof window !== 'undefined' ? new URL(markdownUrl, window.location.origin) : 'loading'; - const q = `Read ${fullMarkdownUrl}, I want to ask questions about it.`; - - return [ - { - title: 'Open in GitHub', - href: githubUrl, - icon: ( - <svg fill="currentColor" role="img" viewBox="0 0 24 24"> - <title>GitHub - - - ), - }, - { - title: 'Open in Scira AI', - href: `https://scira.ai/?${new URLSearchParams({ - q, - })}`, - icon: ( - - Scira AI - - - - - - - - - ), - }, - { - title: 'Open in ChatGPT', - href: `https://chatgpt.com/?${new URLSearchParams({ - hints: 'search', - q, - })}`, - icon: ( - - OpenAI - - - ), - }, - { - title: 'Open in Claude', - href: `https://claude.ai/new?${new URLSearchParams({ - q, - })}`, - icon: ( - - Anthropic - - - ), - }, - { - title: 'Open in T3 Chat', - href: `https://t3.chat/new?${new URLSearchParams({ - q, - })}`, - icon: , - }, - ]; - }, [githubUrl, markdownUrl]); - - return ( - - - Open - - - - {items.map((item) => ( - - {item.icon} - {item.title} - - - ))} - - - ); -} diff --git a/ui/apps/site/lib/cn.ts b/ui/apps/site/lib/cn.ts deleted file mode 100644 index ba66fd250be..00000000000 --- a/ui/apps/site/lib/cn.ts +++ /dev/null @@ -1 +0,0 @@ -export { twMerge as cn } from 'tailwind-merge'; diff --git a/ui/apps/site/lib/layout.shared.tsx b/ui/apps/site/lib/layout.shared.tsx deleted file mode 100644 index 59139c9e8ad..00000000000 --- a/ui/apps/site/lib/layout.shared.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared'; -import Image from 'next/image'; - -export function baseOptions(): BaseLayoutProps { - return { - nav: { - title: ( -
- ObjectUI - ObjectUI -
- ), - }, - githubUrl: 'https://github.com/objectstack-ai/objectui', - links: [ - { - text: 'Documentation', - url: '/docs', - }, - ], - }; -} diff --git a/ui/apps/site/lib/source.ts b/ui/apps/site/lib/source.ts deleted file mode 100644 index 2b2d52f8528..00000000000 --- a/ui/apps/site/lib/source.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { docs } from 'fumadocs-mdx:collections/server'; -import { type InferPageType, loader } from 'fumadocs-core/source'; -import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons'; - -// See https://fumadocs.dev/docs/headless/source-api for more info -export const source = loader({ - baseUrl: '/docs', - source: docs.toFumadocsSource(), - plugins: [lucideIconsPlugin()], -}); - -export function getPageImage(page: InferPageType) { - const segments = [...page.slugs, 'image.png']; - - return { - segments, - url: `/og/docs/${segments.join('/')}`, - }; -} - -export async function getLLMText(page: InferPageType) { - const processed = await page.data.getText('processed'); - - return `# ${page.data.title} - -${processed}`; -} diff --git a/ui/apps/site/mdx-components.tsx b/ui/apps/site/mdx-components.tsx deleted file mode 100644 index 4f176b36fae..00000000000 --- a/ui/apps/site/mdx-components.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import defaultMdxComponents from 'fumadocs-ui/mdx'; -import type { MDXComponents } from 'mdx/types'; -import { InteractiveDemo, DemoGrid } from './app/components/InteractiveDemo'; -import { SchemaExample } from './app/components/SchemaExample'; - -export function getMDXComponents(components?: MDXComponents): MDXComponents { - return { - ...defaultMdxComponents, - InteractiveDemo, - DemoGrid, - SchemaExample, - ...components, - }; -} diff --git a/ui/apps/site/next.config.mjs b/ui/apps/site/next.config.mjs deleted file mode 100644 index 9684b411f38..00000000000 --- a/ui/apps/site/next.config.mjs +++ /dev/null @@ -1,61 +0,0 @@ -import { createMDX } from 'fumadocs-mdx/next'; - -const withMDX = createMDX(); - -/** @type {import('next').NextConfig} */ -const config = { - reactStrictMode: true, - // Do not let `next dev` mint AGENTS.md / CLAUDE.md in this directory - // (objectui#4160). From next 16.3 the dev server detects an AI coding agent - // from the environment and writes both files here - // (node_modules/next/dist/server/lib/generate-agent-files.js), carrying a - // managed block whose own text asks the reader to commit it. This repo's - // AGENTS.md / CLAUDE.md are hand-written and binding, and this app's - // CLAUDE.md would be a bare `@AGENTS.md` import splicing framework prose into - // that authority chain — so we opt out here and additionally gitignore both - // paths, since `agentRules` is upstream-owned and a later next release can - // rename or drop it. Both halves are pinned by - // `scripts/__tests__/site-next-agent-files-4160.test.ts`. - agentRules: false, - // Every entry MUST be a declared dependency of this app (pinned by - // `scripts/__tests__/site-playground-layout-registration-3904.test.ts`). - // Next resolves each name as `/package.json` FROM THIS APP'S DIRECTORY to - // decide what to bundle instead of externalize; under pnpm's strict linker an - // undeclared package is simply unresolvable here, so the entry silently does - // nothing. `@object-ui/i18n` and `@object-ui/plugin-aggrid` sat here in exactly - // that state (objectui#3904) — and that is how `@object-ui/layout` looked - // supported while `page-header` rendered a red OBJUI-001 panel until #3787 - // added the real dependency. A dead entry here is worse than a missing one: it - // reads as "already wired up". - transpilePackages: [ - '@object-ui/core', - '@object-ui/components', - '@object-ui/fields', - '@object-ui/layout', - '@object-ui/react', - '@object-ui/types', - '@object-ui/plugin-calendar', - '@object-ui/plugin-charts', - '@object-ui/plugin-chatbot', - '@object-ui/plugin-dashboard', - '@object-ui/plugin-editor', - '@object-ui/plugin-form', - '@object-ui/plugin-gantt', - '@object-ui/plugin-grid', - '@object-ui/plugin-kanban', - '@object-ui/plugin-map', - '@object-ui/plugin-markdown', - '@object-ui/plugin-timeline', - '@object-ui/plugin-view', - ], - async rewrites() { - return [ - { - source: '/docs/:path*.mdx', - destination: '/llms.mdx/docs/:path*', - }, - ]; - }, -}; - -export default withMDX(config); diff --git a/ui/apps/site/package.json b/ui/apps/site/package.json deleted file mode 100644 index f064a80ec20..00000000000 --- a/ui/apps/site/package.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "name": "@object-ui/site", - "version": "3.1.0", - "private": true, - "scripts": { - "build": "next build", - "lint": "eslint .", - "dev": "next dev", - "start": "next start", - "type-check": "fumadocs-mdx && next typegen && tsc --noEmit", - "postinstall": "fumadocs-mdx" - }, - "dependencies": { - "@monaco-editor/react": "^4.7.0", - "@object-ui/components": "workspace:*", - "@object-ui/core": "workspace:*", - "@object-ui/example-schema-catalog": "workspace:*", - "@object-ui/fields": "workspace:*", - "@object-ui/layout": "workspace:*", - "@object-ui/plugin-calendar": "workspace:*", - "@object-ui/plugin-charts": "workspace:*", - "@object-ui/plugin-chatbot": "workspace:*", - "@object-ui/plugin-dashboard": "workspace:*", - "@object-ui/plugin-editor": "workspace:*", - "@object-ui/plugin-form": "workspace:*", - "@object-ui/plugin-gantt": "workspace:*", - "@object-ui/plugin-grid": "workspace:*", - "@object-ui/plugin-kanban": "workspace:*", - "@object-ui/plugin-map": "workspace:*", - "@object-ui/plugin-markdown": "workspace:*", - "@object-ui/plugin-timeline": "workspace:*", - "@object-ui/plugin-view": "workspace:*", - "@object-ui/react": "workspace:*", - "@object-ui/types": "workspace:*", - "@objectstack/spec": "workspace:^", - "fumadocs-core": "16.15.4", - "fumadocs-mdx": "15.2.3", - "fumadocs-ui": "16.15.4", - "lucide-react": "^1.43.0", - "next": "16.4.1", - "react": "19.3.0", - "react-dom": "19.3.0", - "tailwind-merge": "^3.6.0" - }, - "devDependencies": { - "@tailwindcss/postcss": "^4.3.3", - "@types/mdx": "^2.0.14", - "@types/node": "^26.6.5", - "@types/react": "19.3.0", - "@types/react-dom": "19.3.0", - "postcss": "^8.5.26", - "tailwindcss": "^4.3.3", - "tailwindcss-animate": "^1.0.7", - "typescript": "^6.0.3" - } -} diff --git a/ui/apps/site/postcss.config.mjs b/ui/apps/site/postcss.config.mjs deleted file mode 100644 index a34a3d560dc..00000000000 --- a/ui/apps/site/postcss.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -export default { - plugins: { - '@tailwindcss/postcss': {}, - }, -}; diff --git a/ui/apps/site/public/img/guide/dashboard-filters/filter-bindings-inspector.png b/ui/apps/site/public/img/guide/dashboard-filters/filter-bindings-inspector.png deleted file mode 100644 index f5243357286b6a9a7c81c8c20a60536287801be6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18801 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 bK~w_(etAQ+2K}_#00000NkvXXu0mjfz!~#N diff --git a/ui/apps/site/public/img/guide/dashboard-filters/revenue-pulse-emea.png b/ui/apps/site/public/img/guide/dashboard-filters/revenue-pulse-emea.png deleted file mode 100644 index 49e00b8afef599f660d788151ff317025562d9ca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 105018 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

R64;O?6W3R)xwri~s<;m6}XUt(;J;BZe~^SL?L z(evhdw3lh~TCat9c;?R!Sm=SeZ4D+1=|PgwxVWanDctw~)5O}^tEI<47K7KWC#=7_ zy6SEAHH;+C4JmkFZf>#Om4=C#jBI>j;_FDs?!lsoX8B^~*icmgEf!X6#Nk5Ndu}D= zuI>JFJOwTrD(}Hv@Z)DWYbIp={IHlw_RGtQfr*j`Nj3^iJhLOu!7cfxI^)gwVlt*= z023|kD*;cTwei@5@-@2S8qh+KvM7ifzq{c(i-9}IBRK$&@QE@boET_r-4zsy3EDnF zFuW#JdDAGJbLa4o4Sz+kyPL%DtWS6;&;x~5dlI%$hwoRdTMR`T`EoLNdJ3{&UIX>` zEX;$9P((Tsj)h&sR4*?a2)GJJ#~O(Fi`yr+Nd!DLHa}la#J>qaVKHc=6(Bzwi;jIA zEIq@;xKMMt*@}9&77HE~^t?(92`en@9~1H1n{qNy`x*1o0;pYkuUb;VNWlH3_vNm+ z`>x%qmN_^{wW)LOSzx~0r)?FOe$(%|&5)4pU{}9xo#B2u@qR;C#YqAccD-)>3ZaC^ z)9V)Z=TM8PpN#r9%Z>7*IYw@foa_H%0sB)y*Jcq{g>5-5(?dksw6AFI z^#T7clTOS0`qJa0mOurh^x$xJY2U#tzx$f2bM@9I8}0iDA}nH$-6F8$BC{aU!~J!} z5wHfr>4_YX%Iakd#yF%Db|Is^Sy{!x?4+oytX$lidb;fe6)>`z&>_NNG!w_eD^GhY zQAj}XFrnK^y#*5DgfAeY!j1Ld{=%fs4Udhr<4Sm%=pF>_-(rD_p**guZrW~6Op+;C zEqr^smZ(2|cY>J(!@?9s2Z|2Wsx-|E8q+W6$y~bw9>ZHKds}5DCa3Yo%g;~kH9^av za`K~7pXy_2lK__F6vuaNNiY zRpaYe@H#1p_Dye0<~z4-a()(JqLGB09s$eLGQQFi>S+MjIR)G78-VzLn_KI9&vhSvwf*UkSD`=`}n=!ft@c*0krUaz$0)<)1b>_Yv>;R?6FETle84 z*EsxR&#J^nfjHx`vI(&6X^osmeBU5lI8Dv;XMIv zMb7UGzglD8-dH%*J~XA!D$Y7NCIRjlknfX)S+oi8Xn;_Wihx~?RyGbUNb7AIZ&j;+ z*V#pku*SGc5zh9;h9FvG?kd0YF+L$-;8M%rNTB7xlKxsoYD(SFQ0k-SxoLPENWW?a ze2k8cK8F!*fhd7SSZ&rDu&YYF^cp>fHtH18O~>TPj?mU-JMg_0~h)tZ1k-5-rP|MrY93GJG(PsD{hsR zG>w?=ACNIJR({r-JsS=P7|5sHEMyHQtryVb5nq5#dC^LMm7^9H%}Psl*ffmCt73hAnS1Bd7NTSTU{M zab1|JgwgxyGE(J!A5!nydy9%EoWbh0TqaKufc|v9e2HCTB-5+OX;wH6fk1>GE_AjP z4xMat+U)lm%k=oFvS_DeVKL`NUV(^^nyT6;{@@7Fkh$Z+!f^$%4XM$jX^0EBZURZ&}1ueaB)efv{5e=(6(~c?u7J#Xv`PKUCRM z0`nD*$DZ9>r7=zPVT z(&T)6y*tgsN6pO3>mNeo$nXEk!y?R+{0hv;VhRz8K3 zh{N)g=CXo}LXqBrKeMN5ca7(QhJH8TU`+1rVozaTg5l%o_llgrLtz)Wqq@l{r407Q z(Uu_azB6&o7*dP5&(eg{CAUFF#ADiatmQ)w`QiMYHDg8FO_mo0Of3lrt@a{RuK*l56z7fk_4W1t7!R{RRb&_ z*^s0vDl;jO4$Y~ln^KQt*r_LVlU<&ILaoQzg~rF(2AY(zn_C?3kEF4BxoswbxZ}zd ztB&*#Fo=1x9ztJ+=(Gx0P2^@l3socsFD{0%vwtHkrC)^apkD30{kY!~mxm3z^5aCm z+BIhRrbDHX?iz#X@-BGF@(S73tKjo{BqT?4)9>57pBLS3SRPzLt%`=VTB?0TI6mMS?c`_z40m6$PS=&Ymj`xDeU1%=k^=h zpneb1KiehM<|Q~wu$9lEPtC4`DBjys4sqI8UEdLYT9EhF1o1j-KN$h((|#udZxjYuV5dG6_ z@}la9_xHSrLWy<_uCltM7I$~Pj4kd?#2j`(A3w}h?6*H6uZ9!GbH2O2y2#{PFI~tQ zYKprlxo8ThjPXa>q8f)Qze>hr##Pe0T7rCU^h(}iPcXpB{*mXzMhEz&{Msh4+QD`B z&T^r#5E(SuF{Q*i=1!g!&9M9A;G38+ftLv@;1j_eF=~2kfv-9nX?BkU+_1F>g8Wf zIPnr8Jo-&e>%AG?<(jjP*UwB}78;wT>^54{9MOxX)J}g!1P9x!9Ci!t7r3h03NrC+ zArcz0 z9fVN>wddPO96VChMnROBi;w$uvC2l^uy}=iXHbS8uU^$ zlHR#1y+yHfad-6+GNCSQtEo{BHI+9*$seozg)ylNX2%O%#oCM2#p+_KG zys>?P;!o0yt1nDj0mgP44FZ9SO?Q(Gr?VXS@%Pac2cB@S?2m*5NJ8P^YtPc7!|wL9iwa@;_lN_a~~X9;y%KKQP`t$VosD1GGkDn8xJy>y|MVLR~yO=tXNFeCQw z8<0xC=$AAR)Ju+c{+icYdcsj{?!NWxnWdvvTyjmW1~#IoPo1Iuk~GF$!c3hlNZ9g9 z)if6ljyjxHl>Du=R!kw1KNc~C?GC1b$Q}vWj%ZhRf>p0WkLfb$X$gPrK(n|Ic`%3t zrK>DU>+eM&m?{Ow6Vv;n*ycXWPSN+=>F;puU@p*9g{rw^R6iR{CNEka zK5fCE&Gx?d^RiNXmWU_I)}d60RM$GWeoWn+EQY)L^g@M|x5GtfYATU4K-10r`p6Rt z%SbV@QI{PO4-V=t;T0Zwbeo^*&-ncL^N&S$9+SISSh8h)eyEnYKPlM$uWDbxG{cDhLU zT}`{j;7#+XB_sl96w}aK?tSDrEPn4SIRBAU2=$0PyQSxb_{V3uim;k`@fr&6_T z`anh&9PvvO`EqxmnBg!DJevKadkQ+usp7Q2vuY*o%9^k5B3df>IO~HEfx$)h>yq{i z($Pud=7ed!JQJxb;w56J;S^EjnRKtbefKNVj?cJD%!~$^Wz?;jc)rf&vGcmhWb(e+ zdW3b5S-)|<+P0HHvz+De5Inmq<@FTI)QTWW?_G(WoLgJl#U>`;wcgmKZqfc5$-KIT zi;hUFgI{7vAs4Q}S~qFt;ovrOG^00_$JJ?Cc;D;Z5#A=S@+T}c?89DBYUuT>tcGl` zYrp>ecXqF`t^8h7u>eheK;GwK>hya>q{&~f2c;s`C@%W)p4jt5xcjI96CHK3 zBo*pE2#_B34R@fV1n=(eHVfv3m3FP>OwE8-Vepu>B-5b>fwDtR&T@)-wRf7%nbsR{ z^31Zry;Rd4cz$5Cjal#d*zRZ`o09KT=ccm`i`pB~-G-pfloWDn-)$KJe0W%|!)DG} zg<%K}4fSN7|BnT1_8wC!5>5VcU6_8h(Yjms9lS2dwmJRFxBkv`8>qD%0c!k_B51!i ziYjyGW3<@70IV`tUWj!~U`)MXIZ9BObUvS!3dYGu0_^3>o6W30d#M_n@8#0rto(A= zD7Fg?u}%g=2CH8%;?P+V*trc$DZT-wq*#3Iye2N01cm{@!Ho&do672;p9sYfhlVL` z6Pz~kdtQfI^%d?}?b7L{hY(8+CJPu|#9ILp9&4P==Qj@$9|L6wvM~vvk>RRJEyV%x zo5eRjA`WquGRUv}^wS+7Y}R6(gT>52%I>nD%h?LZ3to0X)+y;uCO?LL|trRAJlkqKYkMJPnX#o zyE3z|P`rX({RNS(Pe2YB zEK54tM{6M#o0|HG1(HMM$;GWNWGHanT@BGJ3mEelIAwy}Eo!sPhI%3VsTi%}OW?P7 z(JZvI4Mnh#e_=&S=?&w>d>*o$*<7~rVtZy38$#2>^nLveB~~}_9t`(!yWq^X zkv+vZNUZNL*i7IEZHx%AHzp>-~n-yEYm7bVB7CeOdKfoE!_g zx$e9q(bQ^zHnP${DY0b3o8BfE2H&qr*z+8Q1M#(@B1mJLKadmU+Bd=1p$1`Xv! z6Fpzmt?LOnOIdC>+qdN=oMGNMTQfAgsLJ;iD!y5zfIZ&2)5NgjYGQ+^ydTud(^K;N zc|Ig$82oZS7UAGfJxaoe!HW$I?0#-^+^sg6ZJoB_Mmt!(7q^Bc*AO<{IDfql&Lmf! zZ5at}=UDHJ$gLx}gr-ZX#w5T1UL!MhSaio?=C!`^MG~j>;2_l=kNIkMdNJ>(y^4z6 z#yVInyzRY*$^A$R^waa*eOs-K$9`jziJ6(O%X>p3rq%=DuXC9!`NP_EhN0eI3^m&p z&+Q`^Se+A^gCwJt`zUUk@s_~kkmEbIrh)>qC(D(*$EUT^STJ3H`mzpDMr}pmTWk-1 z?(em=Gccsw@S3F3IkT&~1*Pfv*XP)PYH3(RiS-T(B1O+H+Mgub+T!&1)31ftrk$|v zlZBOBnm{_8m-!&%yaNWV!on`~uPSF*IW#E%E8{PjAStV-h7>vpw-Cr>FI0Y`CD5Wv&}wX z7@jb0oBGvNjWb=y-RpD$r)7;n3cH(-*JI_U4S!hJg*G=x!f+TBuN)hD!x?=i*W>YX zwak(}Nnj+>pQjtSA0gmaO>;JC9eVp3;ZF$Gxu2;C(f%QLm+Z;dd4e0j>v9XuSx=+nQSUy)0CfP|H|@jH{%AMOv1GH8@$wJi^)^tTGy9c&bG;AuSE zGMq>7a;dest27e-h{kyaf0@jFacU&3FZlL>pKZO{k31otz>WIB>i)dmIC>UE^{an) zqlLlT&6aBg#uAFoH(C^3DPiiL(H83+t?>k(JH?qOR!d)XpO0nR@7sGK?cJbFyylH^ zwzWEn*K@u23Rd};d-#6Q)U(=Od?@MJF7J;QA?68t@$i+j^YPPWj~623baS6FJ05gi zwf&tmAh69N{)=Ac#KPeDU?En6KGSA)$V_EkC@mcCw;e4kEQ%|(3h30`(&bUWq7f;& zsAl?Z(}I%?w3&j2CNLjkXEwCVYxMSR(7;PioAQmhm4&yE z;n4StC(X0G7*()bhAfwa1oZ2G`$QL;+-W%L;LoQ<*L~(9$b`BZ45)=ZEJsgWl(1h~ z|IW&6uZ(}TG&dik=Hg~QSSaRqMqjQosj9B7UVgd>4Incdrdx-@+)&l+8lyy57Ceun? zRIN?sR=iT`j&WbT)K1nGY;x3Zvj~Jjl2cMVpI|_Jpke^DUeu(v*t_>KrM4=ToxLid zRJYS9T^C#{I|_skLxCl;KSOwHx#;H=U;3}oZk;VR4inBk)>z(xtY$%3ot|!PNQi@S z^78df)wHR`%)p8>^Q)3VsM%n&H!U@n;X{Fc=ua`p-kWOcr8ZEc5sl0pIlhoXW<|xR zuOH2m))gkx;J`$s&a6V%zz)D-de1Sf>1uLfB3bD2TvjueqI6zc8vybz`^*3=8Qd;* zMBkaqaZzo1{`1P3{&;HfOR5`p*x~GavmPF=Y$-TYF(g8km3!(MG#U5(EjIO>FpwabuJ!2LQ+ST zy<;PqiY$_P>FAH|=Pc=6#Gv3s%Sc|cHV8mgjZ?>aj$p|U{<4%8wO_a6;QUl)Fq$YN zU5Sa2U#XnA(bdlZOP}kVUsqn)bk9!Lk0DWbY47;=Pi{G!9r%0ad zQl*^PO08nrsOCyA6UI<;1&~u$X2cU_Mm1vjC#2}K)7E>6A8m_X;iPNF(=@R%3F?PF z&0kDRt&Rg;&!-Nqb9i++Bwx)8NMq?O#XCEK^u20RBa|M&7F>H}Ct302(X=cqZBrO( z!W!gydNvIJq9v42P5>990Wwymn3o8dUek%4b6KArPP>ehj3>ck(Psp}bcXRCs%1PC z6O*hgG))|GXBF$Xx%F6oV_b`=3^$#ZZ~9s5JzG3I8{1-6Zrn_VLpYOg+t^(Fg+v!V zK5sw(j^MUIlVP!0OQV`?)Aa|Bp*}J>leucg?QMf04+j?G-lNB3=Ee>g1%+FWfuWpU zMRbxmuuj4VQwm^sbhORud}K9;#_5oEtg7j`XtRx>jl2v+o%`}BnwKYZo8uEWFuQML#j=cDALT($W{bc(heY zR23CP2*3M7g3N@zd_ksnB;kV~gq@`%M)q1>{xW2lzI(}0MM+8QwqXirQdxBlL#xyZ zGc+}P5M=V!@|lOsG|&bHh8ew)vG|ah>G&_WLP+tl3d+$TQT`i=0C1QblhqQst&= z3Pe(+hROYU|KdW z&**7>kIFy%K)8=JWN%)ns<2;PRC98E^L9+m!k>s@<~O z#FXL1unRS}CAZ@g(OxmHOO7Y;-&yfr`Db5TFbZXqe_ehFr!EBq3T=L)pb*qgD$0EI ztyvNWGUfVNPA+k!|1!Ia7T#aLJgfm>c_P@LIH^%jkE@jZ`0AaV`^1;`uJ92mc$os> zXF6a13=Ee^1hYjH)b7Ll>1n!|EDdOjph8X7Ue>pOj~~1oUfu`6uDtLw@uN2@E+Bea z)nXE6S}XJK=-&z$_Dn=;IWVR*aTr29{*MJD$QsN<{$)G+=(@Sht1-ctj@a1PIM~>@ zxF}*fsiZ|88jZ`dMBego5fd{JC~XG+QbPY_2v4QxqQ1F4v=EK5OCsUgt*ETL313xN zr6HtFPt{55VgBbm_~%nYuq%GhmHQa);}Zgp++d5=y5auwAjf1gMpqf)FP*Q9-Tl$w z@BA>y=9T`E&HH+=;)X=%E3756e~e0X!J4pdj08yxxb9hh{)%g^g8Xl2C7ZayWEMij zFY9Pw1L!Qd$?hk4^C5kYiBm2|6!gq7(OSHatG}B8Yp;afS9->9ET^`&UI_>-aN$5j zFR(vW#{|$?*sHXHhyL0>jE+l_JvAos837w~w-VLpEYAI%98xdp>W9@}!v`aZ{hPR* zM)ogr|77Emm!7LLMn^4fNUu^bSZXt{zK=S}+K}A6`2U3qY6z9;UgCOzMgQsnj*-;# zq=!B^G23NOGu*!w7X(U`5-HYrr!MSODsf4&6WzVwKUea#_0>*)=WxmT;5SXi;g6UU1m=`VQ1D zRmd!mNuWA~71s4p8e_Hc^JjZR{@B@L7tr=9ppcT>`M=5KPa1Xt)^MM6AC(?7$-||n z@vk*5?z9`F)+hvm+sF$q&^)v^OyMBb)Jf5C8-Rf5*x}rm_*}Y(w7!Px|8~Eq8cZK-q#iU8CbLh4!q_YWCNM`0B(OA?)eYG zIKQkC7DnV$pEWPsfd8$i^aMvS22CO^X6vxXR2ns6R?t~0Kp0z7+7+a)#8g-ty1WrP z%PUDa`CZfwBYVQMJJa*c->&W8kB^r=YE)67x-dKRYd6YZ8*FaAo&r6MRCwGlq3%6KFgG5R>w%*gb4*4&78l(}OC_La<>^n-4(63D zgIJW8V@uDZB>`&9SIlPf`$7~ZIfUiMB0h?*>L#fuh;^4}50|h^4t;Wek^?klf5`sr z2$z|>!ccb5FTn8=O#3d2v4HH72d$s?Lm9l?xu0>O=w<}Bj1VnL%Kj{!C4eMr?$W6c`tK$fDe6*2RUf0nNF_$@6g#5i50 zqt9vo#_2GchD&{5UxDwvkDXGC_tFSRpfIw}!IdFCEO6|f!GFB$OxVF!ySPg#w=41n z35#hoPD|Wozjv`4peg>|q+c7W-CvOU9ZKu=_~mDvTj!q#F-!}4tMaH3Ymy35nh)4| z^a<8uWaYMjoc*_b=%F9xln?tCyU#x1YC z>h0#yD$-y3NjM*MaHQ;dkW-Cp_K_=hKtVIzTMfubiC<+z3JN2U_$ONtS!-7sUGwa9 z)~`n{dROj{f~hM48ItrDnW+csYHH?_WGZo6jtaOfBXt77?&|+$%1l;D!0!~K-5g#9 zOv=FY;x70><*XS;TqHb1FDkG|CO$yGu9u--R(F(u{H-GQ=7Q^c`M)s+99$8HEh$kq z0NkhWPP(~kBX6}VoI@pHtU*U@K(o|e(KqCpS^9GN_a?yU#}t^7J<9>l!y)1V23H;%vga=u$B`1TA+enMuuEh6G>7avkGJ z6pE#i`~3q{7gAVG3MS`&{ZD5_2(8VvcQJq>+D<(VmP=}ENEs0f${{~j7 zIa&5%aBY(5Z=pHEzIOdUuV{v^>xUr*@aZ-QI5VM1da_+k9(WoJZ?~cQ&r+sS6eJ90 zam4947g;t(;#5G)N>6XQgo^T1oGUETfPJ??mWtx5IKpr6^`ib=QEG~y7ewzIg-zHn z)b|2SETA3h^L`&Msjq#@>A4G)sNCaza)_h(?fhr|HB^qFD2i+4iF-92XO6%RpB&Gz zG+JBA-4xv2uO?gx|I(0vS1mOUtN2$*=zlwAYOf83(KQH*E3**BJc{6AV_PrO`su^+aVm5f!jT&KBI3FT z?{VzDpAq#9i%Ad6u8()p{_XkE@rt^gtZp5xkWDxF;o+gh{ThRi+ityQ7?vm|;546W zbR<*>u~}?PTZ$lkzRePqeD8`kokv1?Pm*51ZZ^{V{G>zUta7~c==c4*`O5AjIE~+V zxK9ea`go72kjNVB>m0DK8x!fLKBX(g?%wB#f{Aw)H6Ueo&H?-v?7|iNoKjaDm}Vjs z@?iCTQrz9ehlhWnqmvdOS)Q4>I$6zezdFcy84wWA8Hm~HdVU34qT9s|^{p*LOF}|| z+MncTsU;yU4h9h(@9fM~7~MM^&a)o-KuuqekX(j^$%W9I)*EkaJ)$9y{je z-Oe^%%Y8;8nDntmd+c+t2O&0Hey0hYagz@Lrg{ZjN5eeR5#Zt>BlfQZ8c73#Ya1aOMeiIW{ zc|G>bl45AfLBplN|D0qcnKQTINPXD4;XQh9uusoAa4Wm}zL> zqN&MgXgHy}J3~$#pueiCe^s*<@9Uc_9k8Sk5x@Uy9yi9#ufH{yG1KqA_Y1d$#iEe8 zIVA@_;?C_*NlPoM&pJBi`mp)w!KZ$;C6pa zDqy>|u{T|e%c4*4&Mr-YM8GBJiCMe)+YuC`S*opHtii;6I-Vm5075H0?@hUC>OP5I zlrGeP+RR3mG|SgYOG?a#PS<^H7HV4^_qm&bnw!r&IxGWvvHF+1$fb`CW$i$E%A68= zIo32e#|^>ACR=98qq~leJ%`>i)(>Wz$B%axUsi}S&36YA%6D+wuAkC91MxhB&Gt4< z|COeC5Zr`N)9#IYtPPbc!!0T(mg0uL{+ieQj-R525m3 zzm!x}&tWDCT5Qbnes1-Ceq`yUt_Ko~r1Gw>DudR0o=u07z3wiOhEwV)V(skg0?-L7 zZ5HwiRcV%6+?^L2!=aK28A69UJMCVStgPP#f7(DP3ztai4G9tj?~C*0Q!Wl56s#w- zEd*SaV+G2cSTrvhY6NZ9IQ?Z|b)r;lFZhx5^cFQrJupaxWI%}Dv)kKMVP||}OShFp zYgoUViD#)fet^dAbfbo8tz}Wdk7m+#sF#9;1$0(jPAU)vQ%4Rt_ikY(2(}$8 zLbS3{I2K^g@9)6utCptf+PuCwnBc&>*xjvOq&?V*hK5F`wz{z)hHyAvb2!hnve7>Y zCD+x{L&6}+EC<1sxehZUVnMgmG*08b515#kA&i_rsLMv5p|3AU=&O;Dpt+Lw5rt#~ zQNqEt%2g{;;VCo(cFF}j@2r)T-=a%O_QE*C1+sYFkV`YiBWUm%!3$qjTH3j;uEd$} z6rJ1FFI?`ybw5zvqBo3l_P?yemwO#O@kN)aNOfT|g_mP%IDM(g-KV1BEkKB$-)z~p z6HP50Mx7ta&!63S>t|6VtB`!QY5mXn3!$$OGKl4IU2ezMsS8JfPu33v@hmM-Qlga1 zSLQ0PM$-ic2M57*HCD4*)YCIpA?QuGfD-yqMIWWzt1H7Sdc2(rO`(WFTIPUzp3V0r# zQnx!O!ZE*=!R3iCF%D3Py2#*o{RuXwMWHs<=>uM>Vmt*v8aNJ{e0EhMg($hPVesAxMwu3xDEvbp)f$}aI%>2`0yqvgpl;F|D%_x#f+#s zaTzEpD^{qD4vLS*5eMkEde8wLcPGubflvX^5|PzDL4JO|P8|>vqjiW@LrW_vE{+ql zbj@L=@Vpi(y|smlNg|NQYtJ37iH)bkF8!K|%hCIJ1gww-oG#jgRBcN{lF)pk5C1ot+xs`b_#Fg4eSn{3aF(+r|FkV+I%D+)HFk(pa=_L1NjS z$dR@n-ixb*bX3&?q=q~b)0SF*5kUuLV_EH4X7RK!HQ91!NZMMZjpqdZ%N$_=W4u%=jCPbSghxB$yDKCWk9y@ z7-uVnl!!cucBMs(C>GVbQ=w0GMt1i0uyT_f*>UQ;y6U#6Y1xS4&83^aT35mVDO`x> z35Ka!dVIDDR@p6#7>C^-Y5doeR8%q%EVO;#B5kcXK^7R#+~&b~p-JO_{BoieP3^<{4dKRTfF#Z5oQkqs>GLj_jp z_UcNMju}UnhAH&fdP4Ky#K)_p{?r|lDE=TPKAT3jG-dC+RcILB)`ETq0xB>@)Om-< zD3o$xa*|hU2Y#w}Ano5lVqjpnPq`HylrLY4jZ7B`-J7Z@H+B$x7fveh)dN?y1QdY0cnY$dw`*a?nb)1 zQ@UZOp*h>Qc6pLDxBq2evw;6z+uyRBNl`w)xGY_`PbemsZq z#@14AXokrtG;g9BpPa|Wn}qzrq_zRf(HlVz@-v;Y4vq;0H_?cjgdO>ZigEw-g?tL(Q!8$Cp`>bjBl1odBU(qtS=;)z2u z^j79V4h0e{)$1K#5@8?ZDe3fz%$EftAfbEHuD^iZ?D%JMX~Ts#*&gxWdw>R3CL=zT z>Cfzix3t=luw*ZI`vy&g`-hVL)iWy8G2)TBkJz^U`UsiyQp%gKrR2>2CRdtO)%Zd| z;i+Yzi($Wp9>v-dB*w5qqe%; zd#6c7LCsX>iN87XZdJSI8Hc;!zG~(ws&{#ffj-FNszMgz?c1i@8^1rUUjZUwi>R$5 zIpN{$ajJf4JcL@go{+(@#>QBIR30EbTGKI;I-T#0~XV@sGgD0JL@GwjoJ%SV`HdlN=B=eJGYbrpOR~P2#FB0Meo5M zp(<^VAGT~vVz|r!n`l^K$(+PtaYD;On_#lBF(b{twDhPeQgrIie7);%5Lrb<#Z;ZM z&nR58-bG(mSD{c-L%4LV&bfXT2`(NT%x=^@FVhjZ=vGU&UwB+x1x)?5m4%Kut8chj zjxAnry6s*d_Uae(eDZh9EY7sgC>_3Uq&5iNT#=B>0XfZNy6=Jl8SmMl(q2QrIKqvT ztW}#Umk$m%ETRO~G&EAzsy5YPU0t&WBZhLp!Av(~lNsIyyNqQOCf*L|1pNqI`eqh%j!cR*^~TA<}qiXYdav3Vysl zA>_~xc+e$eCRWM(u1vi0^p9Y0R2JE1P)$%^a@_kHB)OdjY6i;7mp8sf4<%;L7a-xa zxr)`!od2w>%-VRR)ATE%>W%RG4f~6od#8-P+(srTnsc#OmY@OvKD3zjtC7!?9+*xz zK;v^WGua(>jHbdoCQa9%dtgn}IxuD^!14H9+*Gf1>P!Q@O*7lMqTZYp5e+mdT=Mgd z3xis+NgR2olb zJf>_wh|~j?Z6!|uL6Y2IHi43|QaD%<1$UFwYs{Xty{&Q$`U+$#!7o_51_x7xYRCW2 ztK5=wRvIqV%}H0OAiCc7XnaS}KU-ZI>$ub6-i(r{LovjLD?apg`+#?Psz4pWKua5Y zyIi;vD-ly@K3`$lI_R}C!&9!2EDTj|(JDJ|?INdrwmDYc12`zUwOg|u8%`5UNl%_c zOp49bI*d;+JKsmB?Q)vu_xANA3(w{r3n<&anHIIu@Gn{6khHQ#wBe5AS}fH0<=`OP z)ZEy2ZC8!EYjVY%T&fDSO1b7M;=Cqk&T_vRKESGn?WjJEqzNr9E?%3zX|SW3h;E+Sm6CE=-@CR+ z$q5e+CvrgvprSv$sCSKKdl?wydfp-vR4s;aiCXj99)pbF8_CNb0zCJ^nf@?iINqV+ z@5Td^)t~zQ05du4O))3T&mlNC-S$3NjUE|IvEA`nJcK^VDuLQ7DgD`5;R1?&scq#X zo{BJ%E&=>AezSj<*{^hY&t-f2GCQ-mCo%MCHc&dPoMep!5^K)Lr}K8S63=$#T=9!r zfbquSML^Y4Vvg^flOu-A8Cijr@ts{=X;=q!`|i05Whcc?;Z%l`jg~SNvAahRbbC(e zfbl--#LqvM_GAE-#GyNX*f{<}^m7Sk4hDg3Z~@WkB`$HjEy^qQg>_6O0kZn3i{)lXquSXyv+5O%!W4Kv zfjAV`=ghZiE>|l=OrIlSVjMR*-!pk7A-Ez={_j^qcgsj`6Oci%{!sRzK!v9;cQ6V6 z&cL%G_vxA-b5O2f&iLeC$f-eC7wB@d2u zJNZyS-_TUwxLL8Hq#*b808DE~&`D9u8wm4CG4@^l1p%G}0$eCv^x(@9n8+^(F)oU#N;CnNiDqt*i{CSp44Ikh?kvj<$@4R1n5S{}B9t zR&T`F*y1!7Kq9w^g2umJ6ky7hEEPsDae!3JU&cZzt3TiuZOaYxb$3UY{{)^c^JyB} z`Vxe~^Nj))$5uJjfRLeb_!kU=xG7x2s5C3Ydw?v7^HL(Widt%Fm*olPo01w#Y9{CUk4<# z4`Xn78kqj;sWwV)LfOX=L=T<+%T-5ZuSow_A3#H!7re{$&kTY}X}6H)e??QYT)zKz z|EzF%I{V?!i)WPGK`N@ zM$0P7*f_Q4>n#Zj$Hs;#fIgJigZgaz)CYbY@5~A{If)hugDvrGanZ?CXip$r9}m>c zn!Z;`agrd zBGYxBHe5mwmQQ6ErErBQRH#C>8g9-h6e7D9?(7>?tfppXE93V}?1lu%I5=8=7odWk zAfTjr^K-?DT{4qf2!C?Hpc5-w9Yx?k#DSXdNZ_=a7#i2 zOOs`T&l1sHDQ5aLc58c+WnVIrVikC~;HT8ExqLu_D3FIazI*r8!PN0!`+vEB>xIoU z_IDfYeMLX3BmJW{6(TOLDSNMS&6LnQ4|5X#dK1|CJ?7@&VL?lIXUb4Ie}e!%V&Wz) zcIv;AVe6I)VOMTWGWT;|=K75}6im%mzB*!JKg^e6&Wgy;*Q@55mbZfqud7$T?F(rn zooHM#Rz#iubd<+g7&1erAL?+R-I(f%$&r^IVcjfD2*N0=sy9;lb{lq1PK^P*@!PyV|3j!&&$$mESz^nsv6}P%mZCC zO}~cg@s6;YPHuIMw!d;nf>UjAmxV8X%?lgOHGLi66jq+O+4_fHDi~){Y@KOAz=Mj7 zhPe{xOl~a@AvniVNaMITkE_Z{VfFNYaDl!+Q<}rKYpY&Qn~RZRHPfoe7XAFZ=SI&t z_Zu${xw9tvAr_!6>2GU#0No&bSC-J8OJ?eYfrk4XFJYkeDZ$b%2Eomv3*yS<9j648#tqS zh4I=CuhR-;Yh;&QH8l@MvcUG9j$o>*+Nw&u(Y9RKhGr|PJv;kArJ6!Yz1yYc*+^{F z-FP7k{E0m>HNYw)K_$AK#ikc5)xOza);LR_RQP%}@^t;vQl!F(T^Wh%*xqylRf^V- z>q>Otj9Sa@x17vjr}@=6kCi%~F|KN!mA1f{sj@QE;t~d#W~we*y@_Cw!8ikV7iwI# zZ#WhF72;oeEq3PgRPGCQb@vzNXwSoRB~)m5iEp{K+79h6sd09#H;ywiR_uU2yB40KdU1@DKO5`}Mvwf1Yu+wr#O9Z~NO z5B(y7yVDp#Vha3MSJt?v)M~k=>852N7^=4oE**eB3FE&UDUs6h5QsHZl?>vtx#(st z?Eu;Voe^2h%rG^zxb<~HJ|_a;$nnEL8z=5Vyeag-*VEzMJW&aH|e5Cu54QVzvt7cLmX^pqw!MEXTR`=yi z`ZC(CetdO$AA_(3he2gRI&*=JoD!R%b#m}ethz@}2va`WTGpaC{S`T-!67Z z;8TbiuCH*7mt|Rsi#`#5>xeyDee2ZI(6rE)(As~X?{=8pSW!Zh&?i`?&!k?@;Cwey zje|<%>z;vLYX{LEa5Cyu`lK_{LcKz8aj~$1#&`KdA^{QGs4_PqrAKrKDK%tWMY<3# zgc*(NDt%=+f0uZGT$k=h0SOoLm(~ajbY{1g)avpHjsSpJmq04Q_3Pyg>O?+4(Lo^* ztH%2Mn_w(~dqJ3Xrlxw?9Rv&MIvIt6*@nDXXKUf;bJMdmgkr*zlLv^D(#ywkieGZR zmu$E6u~IW3C#M+75Wv337z1y2F0CMsl2d6e|r6^>MAPPn!=UZSe>RGx=QAdIZoBrqe&e7)$)eVTJ{RFX#pCqz%GCM+}8( zB?_7{Nl>AQ4U!lNjat$kmkvnNxw?k*1sAbd_n0|_?#fpZd#@F{ECah6w$zk{vLZ7! z(7tfHp~8>K4$Bm1BONV9%51l(anRg!x``G$J3GIYtPC)y6o3S|L6hfAa`IA2U{R4j za9O(f1adH1fJR?*^RmQ;^NtJ~p9SB98^UzClPeK+*%W(>=9?r;&6ea%3VQsIZ=t8mOeVp!tW#Vkv=YE6oRdBxxRL)P4;vV ziSZv#{H5JP4iB-xmf|CDa&}sf-pOEXuFN@AgGXPV3?7(cN{CTcX;R%I`L-o~l@hu4mGwu`P_DhjwDxYY32 z`JpD-eSrU$Ej2zc6q_zv|E&4(V~$p2e*k6jXVf9ewxTj?din27H#P=tV)oMl+wDC{ zrl!X>m*^UiaPx@dIH|D;Whws4v&*4xEJ;5Qvqc``tsnW?l5z&mv68gID2DrnXZ0rj z*vce#IFDNE(a6meO4J%uu)j?f@NmAGEf?~Nak;D{o+`AJmQL1VJ@XGAFcv!5*bst; z1<)D$%V^9yUVUmTFptHm1tKDvMrufHMb=XRVTrWt@%UYT-U^f1EPbEY$-6v{qj>Z9 zMSU!0aVh2mo6@qLLBbn3_ELDAW1+%bkmY91-}g~QqUY;PT@oY?NmqaJqwAPD5R$i{y{sxtl5}xW7z95k{!@mg~4wL??Y&Rn95Wt(Xp$YK_SZzTEwq zHB`U_$YN1+i+$@u&2czgbEqv05ZMy+zjfqBe?*)pdV$tL&q)D zJtm>NZ{=v8$XCKSw)BaDud8Q3rNNo1tFK+Wp=wK5X(*Ad@TT@ecH)$=9d9o_Mazlx ztv3g?!mO3pO9Gtzned91Wy9jBYG+C7M-`)tFM|&8A=`8?*VUEhX1rE`{N7#BQc4_@ z)y^n6GQvyuxoKJG+V3%02VU9*y9lD#l5{-=h|;0$IW2bs9S>g2vYM@Wq8T=JQ1<+u zndQ^-w|)(4%h_KYhL^LsDplDtm1TwignBPNq`lh_2ZJu0{Ys3NR@yMuy_CH_>AbNs zoz&4l%nX+rKWA`samae98v>kqM!eNY&3mOn=n6h=kCZiBMh!t|*Y~-cq9tU|rCdq! z+%?opO^xM>By+>at3|o9s%rf&>`l)(kQp|8jKWX~SOp^q5U^aIFC@1uNM@M6uxZSh z^}Q)weWc;_g8yC9@6p)j7ZdG}QW{(X43P-#$bL-PK@r#1{^^L*-!S-lc~&slAGt*j zpFg8&JhAE8qg$Pk?Ru@)YiZNduyO^pRH8}<&0IlqT5@~f%h8UrW1?;HSvxd1pY%SO z0KZ$7I;j6Xd5qlfxV_Xr-TCJ3-at*Ab$obq6vt{fo-T~NKUZ6+*CqeVprlLzUI2<~ zyTh56KORPo7cRu!UWi^NsTO_Lj$z`rn5#jEFNubc4-p&a>$5;bflowFkAe^eS*oJP z@sT9_En8|~o{;^+T-L!4z!Z~_c&y}gu{e`Qd{K1r2*Gyi9Q7JC}*sNLoY|^G`Wjw?wB9uM_bD7w0gB+swWdyCrU~7R z-?in2&U+j;?qDZTja3E%{uwA2Q@E+tP?#e`apd~>=nZNNV1@?acMQvOI6n*xy;;;j+cageEOV6@Gq$P37b1(~Wmi=zr=3bhT-JWL7# z{e*@f9wAKAEBkhAZ@8X`U%+NKyAG1sy5FQ9PyLU=*j0nDcy zP3C@J_jOv(b|G>3w`U)?w&IPTT%KQ-7(+Oxla_X0C}UsSa8mMiO?L--Q{wWgU*0_%O7onCAi_eyE)CJWU}1BY50#3Q7}bK?k~J>oI4GaLKq&{6sVS&{DZ5Ep-EKD6(=kEF(S zla-}3$3n6+GmPytx5b=cm6ir|Xer#7pnKmwjiMVqw3Hcj((%RqPZ&&Bi(3Bkhgqqx zWa)(GC#KV1eP;s}pIC9TGEzo1d-U!4lVDof0(X1VYxY}u+(Z7n5zPG_Rvr=+~)3k*e1c+OC@GJ@=TbR*dw$(4=}171haF z&4)=*MN+AZ!23j}B{UR=GO%L-F(c};y1Kdu%&CMC!r?BO?GI5E~Ej@ZT;Tg>|Jt)S( z32jjPK6pZC9q_HN58z_e1!YC#v+td%rIKMJl@BvIRckrWB7T9wm(hC4wdum zV|W%qKSv@BTbv!S@CGJfSaKvLDF!E2sh_xR$wQu7FtHnlSv{;eJ@RYon;v1c%^(I^ zOR%r%k~pLE6s2uUM2_xTZoMLKsdcz~g;KT=5=$R3t=W?P<}D2?TnKo_&Jm@i`<}z) zNFVpuZZj&LZr;f6;=-?R=3K#jL{?=}RBEqXv5dOxl1Gfqra)~5X>s;6d;_u0SuZYW zUsM`Z1Nm^i2F-Xw!f>{97?itZD=jGwGbvTnp-)hRrS{RXYYsh^?f-1kcVbT!_C2t3 zF8FO1rqUosh6xUDzTSwK!FRgcmGwh88#e_zQl^f|=+FL;Q~C>-A#*#&_Tk32w_}2I zc?ZsJGyy_Xu;Z_}D=#vaniZnsieFV;Pc6Ldu2j!j{W4FamkMuD9yNw+xLQ}om#BXq z#C50&H{4C~@AngQx6s$#QqIeQc~jug&Dt3Al8|ia8wAD3k5f>Nwb5ige|T5xrbyl< z!x~A((>udG($aVSeKAv*p>ovTKGjmOFbP)eo5 zB@o{;q;7WHj5jJed{wd3FR7f$7mBPB@syEuzC#K)ZTJrk*3(G{lDQxkJJq&8X8Tkt zTv}w-kEV641DG>TA*R$c%1rC zs3U}Ad2{oqK$y>P02WtoXQ$a{ z_MZ%~r$9pi_0YqTTWdF;cJnC*5=%>FU(iA;K6nMFuv*wj*rWMPnhPKd zyaD2Ch%4+|h7eGGO7XZhHC1JD-WXp^8@2n#p)d|Z3JJ^RoGF19Agv(tO6v$5+M{;+Ek1pJvFhJzns z7{7r<%`Ui%<&@w)WBgQprJP(cK&4W$$rXs_Np4SSj3MB3%*Q0KIlDST@EaEN^q;Vi zbdi>}Gb}Xp{OTxT9&~{auwEim{DV3&1s9Cl#$b3fW5UoGAhq4Wkb&yAFJWY7pV7uf zMzWesK8l|K)YS@$xdw|lkwEsrGyNWA5fOo#P56051;w?`CF_UeHJy8PpykAKwj-Jpx^1gLU`k z$u2G|nOFvg>6SjoMcA%;yI-2qwYlUY5RnlPfX}zPJaSmOzcxgjuGHQ6C57L0e0!Cg zg2(RNJ!X*O)M{|cE1!>~82~#UwOuGULa0@;5_Yo9*%$j>;~eEn{|W^8LnE}4(+rLoSkSFeFPVltHxNm~Pqs2n z*I5b@jUUXc7(K?xtx<~&VD1&^pnBkYD8u#GfYHygQd&m_f`-->&p zZy6=CD{OD&qGsvUOYd9Q9TJ=@jU`=%qwmHSQz97qyoG*aVPWa>3P<+Lj{edxceplQ z)zzdPY-7r*B4pDbGcSOh83iD63ks+sxbo6ZJYJmFjoRAP6i6^ToXuv=lzpxoKJ|SA z;!KG`{MWFm${}lHNlN)U_H@>=^d**?7_5FZD6h&u6-;@9GVxq-G7?>OC`Uk?|?RqDBPyT*Ove%NI! zuO&P5JT((|TMh+Omha6>f6I0-j(V?gRBLN_YH2R=#-jYy0Rk4zZgQ2I%C z(=aGB68cFr_AZD9+i_0D7kq-_o`f${_uEivvOdl zKqM!H{XL7(I;Fe1N<-uRgy5}Eb&dO6WexjK>T6159%7?Xv+K1UD(di`t8BgUP)>h@ zP7rN+=4-LkuRYPvhb(2Mvq+;; z`#)PrAbSCS`k;cWDyiJJs^c(Gy+%(o#)uaEGJH}+?3jZ}YSxB}j*5|+eeQSuu|m^! z+8@U~!K{?sBb7biF`u}lD~S|R8y+srb`epEq8V7myMKSgS@8ObglL0gW4olXxXvlA z?7GQVn2}@7lsA@`#C(eI%eEMNnHMjG@#IIs6M*nz`|X+Lszg)LS=)Z`;xo_DZt6F+ zcK#|z0k&RDoPsHNKguka1mrYpkG|TO-ASSW>(M@IKi%l@v}(NKE}zW>R%S--=pN;| z?UDGCnen2|?tX1k_Og%tnl84E50xOB({EM6wuTFcTpJ&n>k*5sZ<{q9LnVgffhVy1 z+~vV$Pg%fk3elAMH*k!XJS`SOoktsS3Vwj42}Jre4_k1@Z%tp>thx9Z%Pf9 zZ*n5!APG(-+>vw*uYwxrV8lEL%x~$Z-4E=fNFqn8sc+d`Tqa)TH-neRr1ebh zJYIHePsFHoOby@0=4b<3v-UbwN@BBH6B!wC$hnW3F_y|GHgnW<^z|8MVT4)!&p=_N zuEc9K&U&KL4d9@^88;QhZM-f|lwn1#M9;A&AdUWwCw>mwqkUd4!wCDUTD+;T$z2Ux z{Xkci2B%#n*qxk`Z(9b6EO{*EJXm)-8tA*uS9uR|KY<)=mvvSCf+`l-D`Vc-y35YY zvos02PATcWR8Mb3Ow!=t|839}YN+Ywt&LdNrg$r3|qPN}YmCV>@jNs{}lLLs8D&zUql9A{HE5|Okn7!Hb=w{qE zuaBHnH(XS5@9XUNg9Ax$Mfn1<#)^JD#E{c_#ZJizujWLgMO1J;$?yVJX1lZ1ysuxg@YiR+uYpn|hPe&t zsxlFg&5<(TXiZoB?Yb@KD~~nKgN;|*hS=ELjj)a|%nIY>j``e)T&14X9u?UK?R|(| zjYptbRi}x|E+HY|z^|*jTvuN|XiZ8b|13y^m?X#}Z);TUjLil+h*N4IBqT&$ zp$#HWf8KB~l-~0okAplF$Kc<}U1bmj>h0usU%kQz>n&=+LFCHrSIK1CB^*J?2h1!zyB)X4IBA!5ckxBv#q8D8}^IwodZ_GSx^4)5nz#a;y_E;SRN zQ4f{E?BH?ftGQEhY*3M^Of9ndj&dMzO!>vD#v>fdmc9;>gB}{y(1KGt^94R`D%dFW zq0x-GmJAKo%HBMTa06$4@=FSR&FrX%X8C4ox+WP-fu%FQ#%`ei$p;$ATMK?k_=)oK zEW7E9Y)o|JTMKg|Y6i|7Ku$f%UT)QN)Cr^@W2sWPlya4RaQ8K%)JbxZ+h(wT(&zP# z$~|G}ghp>VcLJ&;Rw?4C?bhmFi`&{Jie`PmNjnVH)^`=$45%IH?6j}I05*5Fj@w8? z?%z*RnzbyX{3aYR_+P52v9p5cFvd7zf$}aT4A7;azBrQ?7OC5kL>y0 za(0B=ri+t-!`{tPLKJE3hxL)DE~?8#*-sJ@KBt=iq1E$Tvgm)DS_3}7wB*R1K!h8+ zyQRm1NaR48Qp7IfSw}mzF_uHV;x$E*ZQ`fsuSM>=jI^H$61=#Y@VmC_mrAQI z_NjfB3B`*BoA@9oPgxP#J2f}&J#NrY zWth4Xl_YX{B^^u3wh=OEU~#ars+F7gIv|;s z07z`OcDVV|atVB72+E(T*EwyL8V*oPlgbFv;NhL#zrWNT31~|x9s>Gny_@!1qwhH@ zoemb8d*ez`dvoEjv1|ZHlW^o0S1@t=-5j*s!N6E1V$A|l47<6Sz9^CN`1KV%d{QpO z+&pKGTWLUh452>R7eA$zJVfo!);bROViOM0 z;mn9(ru`gPY;Kt<_`2R3x4yLWd)}y}?i%Pg@k8}{ZobppyLayz*%5es=}2fvbiNRi z!?d9yDn=sjK!4ejQJ0f*cuxEHFR8^_$Kk{%(_d;vrP8lZ`~VlfbILcBP@m8=%qVOppTD`vNE;s>#Bnv z>V^ZFpkGu9>$<504n%KzoD9tDM^RA~At6eDL7E@5QM0g!^Keg$5{SA9(<`Q$o0~If z;;hY=pb&#!vecv8w9v9`?)X?#@l2_DP1TYAFBhN|?<*K8K;YC>nL~pC{o`U(6XAWRuktfoiH$r!5yqohr?y)L%ZTFj=J>OIn9As94Tc zu|qBtvvv}bl4u(ajyDwy4c8YJC2AdZ4LVFsx644$t;Meos2I%P3F;^S=*UmDU70oO z^nU+9f`9277$ot#_V*$1LShn+hF~3T5XhTlI)a!qw2o?9!aH|(Ch}BJXUIr^_nu=3 zCvcdrPScKOYiDL!}1F0>2+x;TUH-kTQ?s6Ab5 z#!#&=YaWwec;ECGe4d@ueJjlZ-lZT624wWcck@$IaXAgS*C^rtaj57eR_}7H zP=BZAZ9*Tzx$~clTlQP`NY}q=RbUJudJi4%-ycHDK791wZ=!k9Z~?Fi zvOHb={i7qSu>ueq5s@{ITLkL#^kfK=YI)6!zc&XC+()cDgggsn>~yB{B#-PfVNzYYG&Qexua5jJ?RLAcBScw!*K9-VKuSBin_g>lk_7@bK`+y8Ssn+E}U_Fg|Mws_O0S-J8QWJ~;vSe{0OP(G^90 z*(?%5Y&C=%{xjg`&y0=sjUh_fn&zKEG*>%2`RVDev#OGQ*3{fjC{fCbK336M@Dy;r zy}=+pJvablZbWA0?&jv7wKb7uY+~o@3r32RXf{8AY#?H=uplRPndtAY5Q}Ul7)?bI z*n(UBdukx8O62mC3@B-BuP<=_Tf3=WoynhWZ)`Yi4v+2Zm|^M3k~R+xeg{s@#~kn9 zH&0D*Gcol8jTsPcNSPTJB*(^f;wj8}B|Hr4`0-=b%Fz+VU8DQcT6v2Uk)1~X4K0ZF6 z+vvxS0>09donOSo>3KgOqrlm2w?FUunhq16T*uJJ$S5{G{&n>Oetw9hWuXAXg;h4> z?!9{{+_qVe%Tx6QhX2;_YPGO0Q}WF8w0iZErA66DvgM5ptJQ9HkmVe?p>Ah z5eF_jGgJ8UXF$&FP6~^T9tW>^Z?@X@{P39z2^m==m=Ep85)#>b}*Em z-+OTI6I@M2=4`YNKJ}!37YB_noq&$TB7|fD|6w(#aEP=JJQWicKRi8kyE^;!`7>9- zO96r8BrGKr*}=@E4G~PcrvFxEME8id4_&BHYilbP*JP=s>3F^~P#F_<5%|9WE6`!F zDGlUoiGdz#a|8@V)d7z$`K&NBw12V~Hc_BaD2>FyJpmO9u$uFew3 z2qpp$v}>*w1?2n*@$q)s<4`a9ZJ-n~>`$~|dP>-u{GGk;&Ml zBXP5K+H&X^tEp*i8!wRdAunuQ8(d9sK{w%l#{c2l!Qmm+Uy}5T@a(9&KZa>#zHPYy z$090AE25m)Be~ZC0%N~_|3s&anUx$`-Mv>CM(})vMRv37*y*G;yOK0NN4H7$pT(}| ziH7O+?t#^ZXH>L$7Nl?M$`CJ^6%vm~NwUXGaxIqICpeckiH_LqU(jL(KGlamER7Jh z#`g^q3QS&jJov>NBY9u8PlTUsl)@c6WD8VcyW`eYZ3PK$=?T~{qc94W4uk_ky?sh-z*}X6x9@%3lPZjs++53*-5WY z_`+@454vTyOiXH2_4jnRX{5@GU8KeHxoU>YqbvNBk)2oR-gnToaCvLe-=M-}Uk;DH zof7qR?)B2Lp)7nro5*&BufeZtrup~hegPbJ_C43s(`U<=sQRAb+}Omp-Xbgts`m0x zAdVgTIiHegkN0bX9+m21{CoCpW0!95zU3fCI~QfI8lJGVmJj+py>zKx{KFfZC<1;E zEO)v(rF9l0PY*8Gj~<*c|MMG|eGqi|$PcuVecdA?@}hRjx><#=oV?8Bh&UPoETyZ? z@;P6ExgxtYR4iqvMG>#6mI~QWh~p2A{08YRfppw?^JoWL!keJWJJXN ziEA=a;-QuX*|4;N_NI=I)9~svyj^Cci{4IYV7(bGGq#g#+vb?{U9^#6M?jozUbJkg z7Q%?fs4*q?D=E;=$)fq+L3zJU zEBf&ALnDb&ctfbc$F$Th)`p}*X?H$8US7*fGwg-wo%@3CAlZdoUA>+@bD^%|Y1s1q zgp}6)`yFWWerT8cYfxBp8BEFJ088;Tc}m9~ zrJ5r$^t$7JSA$t-p1=aN2hPk&z3l_&Iy?BEn&&+ zpX+~yL7<(>NE21H8lD+Y;rPcEO%@7Jw(P^~txQ;^iLtoMz?UzQ(?!Ova6EHUZr16yOjJ2)}B7#Ii8&!(6#6Szjd=251uPAwvhLn?CFh_zsZLdl%?uj&M!@}b#MP3 z_linfkr>%`g+Hb7Xl0T#czYM_OgVmeL!dGcN{$_ssLU+C{J&g4ncHe3pFp8j3OQ)n zks-kDyfrcOo*8d{p}efmkGc~5S9ezr+OYgIeIii(Pim+v#&UZwc@a`Ohf zW{HGD$lT83{rp5OD-btV*M?v;S;T1&cm{sxjw136f8^40Qu=+P{dRYmWD&8@I5zFx zC1F9nu+RdPg_w@zxmw6j^L%pBXSJ#d(GgIWDW$<@GavpXMaka6NOH3|2E*nhbwRn; z_Sb`cZFW0@cxxj~U{anU)g0haLdp(%%nTLTc{wM$OA}47inIo%5Xphwnio$TsL$3S z*Na)Hao)1GBd_htZSc~5JzV*l8hZ0FLf#A{vV0OKbtWd2OZB-{NBq$rm{$dYhHVb>OXMZ@Us)Y!rfKH6tO3JvNb#aH&qti@ zO{V7M@t^cKL+*#B%RVTqY79UZ>c~ZodH0iccXg3)T2E)>l#XB5dgq83Ed&s7K6peB zc$-UzV#vJ+rKa@9CFV*cM2)vk$CEe|6;|6{&I1XP-ooBw{lM>8K3-nc!({?oWcGS{ zy?voV0?B+01Xae|UN}?54@_4)%yc8l@C$6(~7s zJ_h3)*Oprj0cxh@t(hR`!Ipy7C~r8{nI{cch@hd!fbQ~N(keX9=lfs(tR_rVa!*#aw~Iu5X#1wGug|P;I^i3nLe3X#m9xsX8#Hinve_)n<8*cg z1do=pHA3M*_fP5_EWX*Ee)_8bOc%SzTKd%uY_i)!iM#vgSK1MilU4jP6Rs{lFVVl1 zFM+7%64=~k1$p@AO<1Vcxb1GWeE_mv<6-=TzP&NaGt@$_ja^ZUDoBiZKYx3*E4QNS zSUEk?5Ze^ec>7Z4x*f6Z!_-#qHdfRy@%!GwDC+c4z~_^MvgCK}qZto%C-p#AUpBMx zQ}IDDRsY-ThSvwjQ@m*?K=Hgh-D|Mk)v~GlFj?;(m1+&UkYBc(t%D&Dj6`H-F9D># zc2g4%(bE+Xx_>fL0zrWulBFOiHY23#e!d`|{24#1?52dmFtBv6pC7Y7g(o&Vyxh1a z6iCiG+<+q(MDdr_7b0f*wN=3cd{wph{%f7s6-5cw<@qGFT^>M$@{f)u&b;D^CnBjD zdqRjV!?}L>cmNQE4KPTjxSc@*pB4(y5zl&rev0QfyZKuqH%axS;!+h_;s|JxX=)*F znWKMAK`5KR+pEn0+gHf092cHx&|K5ed?Df!_rh&MLu!%HT)%GA2Q8WPn5ku=zo)D$ zIymw7l^h&Skx$`1%or>0#H`RFCXG5To zV%(D2n~Xc81L?^q5;m97gJwX&PH*atVQpNd&V<8VUB;7GoJmtU7D{iw47j%xXyD|_ zwb+~udXaEiuHPY5tK7F3FMBM^Nh@1&(Reb$akIVpjq?y$y4W*8fUwfmZthYrH0CWc z8;T-vJ{Rw-#%=~pcUUQkPtDbU1~{m}*xvzRIfn;1JpNWlj!v*ByIP8gM^?DHv$3-A z0hyl$qn0lc6B6<{{|ZsOGy)X+?x$sDJH`7>gWw$5W5NqI0R-2;Aeo8^Hr)k4N&XcO zezW0){yeLlb5G=|+!1gh<)Ez4{H8p2M_=lyK8V5Q~wREn73gzC=PzNkj zK}UT0k3tNJtZ}f=G=ah}w4IUu@2gn#>}b7Hr?Rz0JkFjEdZ2Vc#gcRDo#l^?> zCUg6K!e@I|!h3z0%1y@(pl_qjnpl5dP!9Sul6nGGWq6k8U>r63?j#^$2f-V$I{nk}&E$!ZaYx}JCUjz)Qt zfz;f*<_T!P0W&Hm;W)6EnU2ZrR@64=C}KAx7K@C<-(A;=*+`hSEhMWwwT%Sb){B}U zmAjKI1e^;mMjJsa=hPVV#sa!Itov)F0|cX!NP>r3GitMnaOcwYY1L`buoN8u9f~o5 z8ggRy(kekYDwFB20s(>g;!j`L>29HVHp`C}G=S}u5-T$?(G3^Rss2*u&5blW;^f3` zx-463q@8|6`mR)WXRkw1DGTd+&~MgaM+qM7!x+M6EznvR=NKe%f^%%2v};Z~Y<<24 z`3K2#V>5E0%`@b7j2G@t6*a`n=2Y&@LQd$t^XPbMXY1|6_+y`2}Tc2 zjOQzC_viWVfYj6#Z}~D$sbXtqrxmKg{`L|QSDaZ|Q*+_z&QCIm(xK31a2~lU>abFp z4N}gkR#Fc^+auvIR|hT{n)QKad0|OhklT-3!kR{sFu$dEM0NGIfj#sYhn>v8y?~}t zkV;h?fFqN5%Pt*mm=V+V+oNga5(w3~8aoy?HrcW@vs$#=ccqq7@uhmYUie>f@k4nE z3VOF6-M`L!Aq!h01AaV?JM)fuw+>Tb5+}_c6M}98!a^IIHn*n7K$|roOjIUclfA%& z^@Y>v&=Fywjuurh=n3@WRL1?|Ccj$|#7a+pU9;MGVWcnaH8C5D0A2Z)muH9m&_Q#e$#zEJB}-)Ed#a!&=ZgajnGiT`GcH+R7A}@YM6y3Agg|l;DVTVs4K+I$J2t&5be4*NMXi4z)a$N?rIT#;T4vNX=v)OsWyV>v=Bx>hT!`8+S%dp*qi8?nTnO84B7cIj|5sZrfhIMw6uT)etwq1vZ zhr`3e=H@GWS4tYYeaO!m8X+Xf{QDg$$cK5+x5^{^ervON3kTlbcN~U)YGZUr$5vQ1 z*O~L49`8*l$;ixZ!ZI_@q1YYxWc>9#?cJ%o2%V~I0`6{3; zM8N|BB#fr2$Q6hS@bSWUqy=w^i?c$6tzkIf1)<}Gwltkt`%_aBQ~wupZyl878vYNW zfG8k{l(Y&WA>G{|-Jqn1bP3Xp2q*|BA=2H_T`wX6(jeVPcX#fE=bZ2CZ#Mqfo!NE9 z8Sorl?&p5)xZ)EH{tusoroIF?<0$ko74l-4`7;!~^#OtX_i)d`oWBL`#>Y-CQ;r^% zBqSiRvp-~JN`+Gg(_|#koF@pJW`)eHuBNtiH2&CFA1SF2@ja@3Ldxm(dA@ z5??gzSJSuz#I9VR^#8ns8WXR^b^XS`rx6X3kCj|4T)|^dmKqro@H`6H-V~e$`7XTa zp20ypbXk{5YHdZm8UqUz71l8cQ4*mkaF-zgMV+zBr6yTTAd73&>d~~c_Fl|omyC~r z`Dx`J`k{Qq^BFd#?rQe9#6+)6k;*DP*zlw!0pL? z`--}1NDTT7PVe1Tqx}0K-&1#5;e)my|{3;jr z=lI>uP%w_q_}tO5Za6Fj6eb|q<&W1+rF=fS(>zadh#nM3gY_dzPFuFb2sczfim4-k z{b#wqi-NUhe~@N~qn$PSuKk+~!rP?(;HJ=D_ZKpI+&k8>_7>CC)yg+5!E#@0HWe^Spq&Xhxn&R4lP4W*IX93KYk&NB3u-&c zw@08Fdc2or^6Cbv{=#I;JypwIkNucnTH0(d5Wg~=dbstiCbn|@+&2IR))z58U%|=%Cj`bY^Ofl2kz+`)nyeRBOxw-N-!O2acUSF#aQK1>t!-cR_;#Qo>wyh z7lUh^XM`)MEVo40T5qy!>`&LhAY({*O=z6WjBZO&hJ&?UL&?`DW9pf7C$k~T<7y8G zuRMs|dcJP8^a8=narM4+xuNw-yi>ZOxNFW=BU(P-z4$Ju@RC z{JVhiV}Bxep&m@kFeU6BWJ*d%knuUP^6*4|87nctR#ml?poi#c3W2=6 z{h_N%0gAm^S~I`Eu$0?rgFg>j{R0X67L-A86GIi$a|@yR2Sb1YYs^ZMrzai9O;;Od z{_1MRnO&Fkj8{xA=^x%j$DjYLT_IMp=lsSZ(kt_zraUh=*kh#Hx3a!f@^+nxT%!Bh zyUKcL0fG2kLPiF#qR<_GpN!o65l;xG9OODC6ZY5Q1c;(reujPg_)Vm-Sv5QPStEqQ z-3dIAv868OQ;-%;rF(y}@%puDONkKb7q+X670k8Ze;v1q8}E%LzRn;qiBEfS9b$ z_hbj250}}i0$^jmEeOco9(Zzvw8*xuQZ*_4-WYm4+c zJt_aDe<_c4)E;#yVMxqr321%%$B)s_-JuVC3b0)^W4X+efsF|pYvp8b)b#DGEU6&D zHw#bV*KZTE3)?Li7#uR{RNlMOLrXQ+3|-h|t5PH1yYSU8-qnatR4B-A`geRfQhT*_HuH?9tA=1?Z-gmz6Onj5`8?18*8yNAw{U zO7?4DoXxpzy)c`U-^_IPrh4|_!Nx%3Tkq{#I~0*G2YwVID$sUY@7zR@y*SG?IrPT9 zH*vpo(-fO7Cda0&{ppKhRdGoTqv2kqs!ETD_=nagB7A3d1Ya9s_fMNw>p-d1IiDsuKqqgM@5bxW?KpL5je^M1ApEY16{>*6%`L^ z=B>THOhsH=+>w??zweDmp7N7-x3@zNGZp|AZ~*CegJo5FfB!qOh23Lg;{|Xc7ft^D z>(~3(Td_$=_l3B)OgdxAFxB`<3uslE-R1fRoR9rP)m47Cm0(Nz!8I zgS;!EJpWu)>`y~a4uKG(&Yeqf)7gAUKHdW1@1`tU3HSu)Wo184-#enH)~bFDv@Okj zP(iPBwP~`d=^lLF-Z9cKH_xf6k^o`B7t_(88w}#NOUugu^s(dgnd@Ayu$}-nB( zOg}#t1%)nvDd2Suwzr4>zy*VvS!s7YP0h=uAj0vn{(e04Z%ZpH8JU@?mG9)G8v$Yw z6QiXTel`=-Eid0bs7vZTSE;Qgn6NnSsLB2%)||Yth0y3v3>ll?HnB`qVPN`~C1uuB(WNxv@Lv+ME0I!MZ5} zLsPRe=dwOTHiw}@bCceNw_gdzmNK+94L8!`pgs8ngD+j(atat3pjnDD<8nrV6EPZi{nWy)I!!K!MPO6DQi3C&kC=zq{ZT zG%j^ArHCdn-964BM?RM{*WwL2^?s2%vKG?S+8E-gY1;UE!RYYw(_f?~987<*k3={T zjoX-i_2)lfS7d!hQym270WOM1bms*4()PCXNlL=28TrkG^;utRZ}~}F zH?Z}>0+Tf2u}rb(SJdcSa-HpRcdN^lRyh=<#I*Ut9PK?3-Ua?-y2@dvPibD1mdYF` z8wi$MW{$TEp-^+yer~sS2rhEDYC;MFm}S~Gd-58VXc>o(@K23l(|ga)k?%^y9G!*T zdq=Dotl!mav90({8`0L;jWh82&tY7qp@wRlNKq=i-?&EvDJAdL8%8TfUCPP#M!Xs2 zrVcl+rUxC$`4nzV7G}Tna z_7iqXX__0Zein%aIv4*o`0lbMKgMAC#`H@8ACQ(O=8WC$3&)Of+KtRTB&vf-4Vp0;m7@{YW!4P~Z)ZWqi_Ml0@3i zK}1H0NWd}N`0iwr_h(cz_-YQ3XfUoO5gLJ4-+-N@Y&1p0-&uffwu=7e-~T_?srug| zhyMSAVfw!qiJ+>)*>+3u@bEL@;fl={ueTm$s923x9s|0K7~=i$I=Aj_h-@8J9j!&+ zk{YdcKvR_tp%5Yh_4&VV1$KR#Y61Z%z2-&y*RR6J087fqpiW322Q=i&?f_o;REddx zFtG*#A(+#lLv>fT+J3$(zNfz*I4dNCgeF2lLaiv)*7+wVCvx*HpysQmhX@a+ye;H@ z?{h>1t6{UfmDM_cG@ddn!Of0?6Y7se)}baGM9?{yuRE2cEXKjXaii@a`yS(ZHXs%L z1031VFEY@9kwT9C6rX?q1Mf+bABG6vemze1jOoOl{@RCV;s%PnokL#ar%(5AaA+fw zKny`HJ*A1m@>wETS8HogA#>(ZbIr3m?Zd;vV0AEAcRD$#hZ!LOCEOyz7O2FZpSl07 zU%>jig}>t4_zScI`v4hbe7vJBD98X>`!xJG#KZ&?mpK5sSRbo+V`o?E$UyaNI9oLn zE)=YLJ&XO>YwtzN7jQiTGXXsDrOCRWyu5g@dwT^58(d3DnnR?5?uDtT zlw4df($3D#K)W?P`r#L3(w&fgO>2x%_xeji{bG!ls?9Ae0E>EeW1(9=DK(YPr0d(- z+8Si|Qj&tby}cQ9PN|Z?rYrx0E_-@;;qjx9@)e=HKL5iRoa>}U1C_S3adBs$2+oGt z-L05@L*#0Y!p8iN^-e)jUJi0;xtE5sG~bog)cW&cg-`{pCu{4#lmM{RTo!}uyeFtm zK{Cq95B!jU{4Va$&t}qULg+Jn?u^;}C!x5`c(H0|5l#pn(m_z9AeUu1#0Gibs~ zOMj5t#=i(AsrWzOwp3MA;GMF)`X#JY2ly(`c5jVN%g9hcd8exc01{vh&(7!_tPTax zhNL~sNDFn^oRr@oh=f%Gm26nP4=5>jzx((a8yj1my%i+~2pStRv-|dp-?)To8=sRX z5lziZrGFN8^8-=7f59WuYF(;on3#L4Y;2}UM(qS^_j2C*zZ(aLU13p?r-jQ3u}&f} zq|MLDRw%NcE9XTnT={=s5Wd~pzYRcC$p?3mioc2r9+L|%6B{j*nUk-SO>>OoZi&H$bNQFF301~WBb+^cns%9+hnQ-Gv_JA6vO)`GxUxS0q5OSLf z9v{2l@qyLGC(`J+xVvD4>2-14`eRv{I%F6C!E@f3TmJd;omQFo{>p%uqGG5l^dZ~x z&7VJdln&0qOwS5Gku**m|L6o~6pf7(+Un%g)J9%#fM`Hc6aeayyH?&g5LJ_HnhUAVP0?EoTersA?bSyrPzom zEXwwtp6@+9rp+Ih+rnFQ*{x^*S&11T`#P+je+m?)T;g%;cKU08^LA)br?-EU#Rn3Izew0`YQlat=x=v2K=lM)T_ zqUusIoNIPblci>}Nl8JLr$O)D9i5%=+t_qN)+-_YuH+WYH(|D9Q&E_Ty7c*#m6e=a z3z^QAkr7(f9hOueFbHT-Br6FwasYRpm6ac=F&>nzvC+{uBqa2adc7%${4Og?rrp9L z|3=%wX#WC}?`Ov`a35?62{Qro4$Qy_7-HZvyt@~*pFbbSo~^cVIOkQ3dE*~u=r9zW#1jN^pN?eZ>{;LJJ6Mhg|9TZ4k z0FH?5_KcBw402_4b%GE@?TZG{16n%p)8X+vn*&{T5f&^hAD~3brbpG7237$s4wIbU z+lTev$lI)b7KaY$KB3zpB^Y^yTi!nPNpcr)@Y_M%+NizUX=-n$SH#9EO=t^xa4JC) zyc3b$wRoQ7Eeq!?Ued(bfgBr z<^_Qt1%zrgo5x%q8NfyCS`JtFd+7@gCndzRs_)<5)VUCB#xSI$oHvOIpt5_x+b zLr{Q5A8E7elwSW^n@b=%($i!LdUW%fC=zN30|Nu4rIjx`F7Q9J^Ln;JqL0NCNM*+% zHljy!<;B=r7;~@Q&g*++xTw~hc%tHYSux~!jGPBe^RnaTghqDd?&>pU58DXcd6f`I zlktc82&yYgNoy8AA^xr5Nt3ct_Q3Y_*p$<1BlJ)a4C<_z;Y6wfwQt;PL1@DZBr7X3 z<+7-#e%F1Jxg>G?79>4))YYiwc8M-l2~_2plH40I;nz=3 zxLnT5qFvl(MN*68T;rl)@_2zkN z>yNJlqkf&aGcY*tR-{fbw{9Cf|FpCpua61y`>;(7u{2jW&LSScbcB-Zeyx^oGtZs& zEx?T=KIjR}2em!M@2?lYFKl*QR<+?D)Fy@pWgXN#$b|2*SN(zWXm8yGzQ;7t4JB)9{)40=@~6S+Oj^ z%Cjd9DbxeJMU|#o*GR^z)-_w3L1O;O~Rhx#K7D2RL-q$!*)Y4w zyJ8YQ6fL9VQmm)NGB-DGQWRV~iK45+yy^F220O+5T7(_~j88*%?k3XZ+<@iBaW$aK zo%X1^;1H;!Z0YUWt?8yKBXM_t6DKqvi{D*Kuc9jQ+QxM6=mU#| z6q-=Kw$zg4p=9M#xwRT6zF~}oR`|`&bXHtci@`7kZv6Hr@t`Tz4iY$CZ2ulFHBa<+ zl2Ktf7R5i6p0;K%q@%raOR1@_lbxz-Ws>d&tAu2)+#KhgFbq3>XXlYho*y1;5uK|a zR!EO1P`UcySnAqd751j{6^BN|C!F|SjFSk)$;o1 zk^lX%K-Y=>oFo0`8|mU%{#F8p0}c+`V;-A0-aO`gae=2Mt^(_ zw*AWJ&}gzX!=WT-q3c#IRlJw_oZhu7K1~#pnGyl8BnI8`KxKJo(n$ne$~}Fm#Nq*# zMmKJgbPJbpdNk{_z1T&Zem+U|?ub5{zQKFBcoCAYC6(?s1dKBIK)16H5-tIuiQSK7kofB{^kVWvd8LLIxN70&{QV9D$sz&G9#%ZY&ij(Yz28G-~@pdct^)7J6>dc>BZewRY3$U<@t#9ANwf%Ls*d$LW_+Pdq~Q-6D)wA(9AD2l9) zBdKqQE#sAm2^$gx7`pp_2MVP>7TCZKWwR}U{&=?4g&? zY{XX|)Va?SN6F>f63`9ZF~b#ZC1$re9r$Kbsj=G2!h#$SlFsvtuYZ42P|5B&v~gDk zEtrE3O{du$f8c2;-bl(hHf`5bP&lakph{-Jm4wZxb!H-B@OgYcRPiYt9bIv`XKvZe z(3ZX-Lj73RlQkj_)Yf7mJoo74@!K_zi;~8Vg&56yN^j>iYnC7wpvn08P5t?2f|$5C z(GSb&Lp6_Lj~4RAN)y)Vn0@_&-S+7ca468P*Bp?_Av2s^AKoh+2hAA2AkWp&H+LRo z$Ag55l#;oBs&jP|C zrsaa*va-3SWMsM=9NYV%*%_PQz=2{fgdID)W9L?HA4q&i%Ie@2`9*3fiA z99h5n+((y)&NOU3;+!DaX_G;x^qptYGMdwJvASq7^U?1CgH-C@Ipyp9Vmk)}#V-e9 z7V=~^luox7FO^?ft3-J#fL+DYc+T%=SJyN?GkDdZ-Cpw89BfJt^3tlXJx!#)NdNo~ zBxks65h?1UxsFd}_x|QsIPJw}i_NKkkdhKa%+6*`x}?A4HGvz_WEsz=x-O#^rixwq zIJ_=-JXYTIm+qM;&rD(Gcqj+^`I~gd9UKNuv2*nEIWCjn?!Ki(1aiFi_4Qc+eg_^-Fg*#*q`?Fc%jsGQ6>A z1BPwZ9VV)~J3C&-%VWKiH*ChLoJKWwP3Mk^Ak<%1)W1CX6ThcbZ;Tp@S#y|V7Tlj zo6|E*+Pd-#Jebh$dvY$f@5h$o>i$`=VX z?Z#8*g-d`KiaToRFqS9j^Zz~gn+ROH^z;~G?Yg*x+;`8r^W0W@8M<#zdTIUG8eHx5?I99qbE#Zpum zGmfm~e1w@_y|9rS51;umT&an@fugOY^?Qtb;`-9VT>uBB6L+iLUJsqW{H9m-YIyqE z+P!?+i8G4J%)C5@gE`LwbLP(EY{eEK&9jnM+oFM1d_25Qbhd6IEA4w&&aeKSw|J}= zFJSjVdu%W-a42WiBs@bXFrts1PM1rt(2Y&EKP6YO{-XSw5eURxAMN|VRHKoT6kUSK zlk^7du~f@;BIN51DY>!?eA?PT+Y*}kq9YTS+@HJZ_@dkRz-p8H8u@MQyk}*O2OYFv z0l}W(QxOoAiN-fDsuf^ixHgr29wBY0yTUn9c6euW!xO#Sa!OlGO+1pGcw?$yF(a?$ zU`>eCv0uqmR{efX{#2#i=t%45Nym*}a?$J|H_$OKjJvg&-G6!4YhHA9y^}%VOQM?1 zF1}h=CE?*}SV9zda)fc&{>^S%5C$*wcmx$`C3u%rl0Ngaot{V>)rZRfB^BmrLGCU zHCu_t&aAp%@H$zxL?(r-pVtzXO?qvaPEAgv8_Nb|w0B{;%jDP|Kh(_EO=PyFIvgCW zmXz?6S$Z7y7Kis%XsnPRD zd_&0Ct4MX8)c57s_Cn_*WKFZYz>LpjbV!xj!`Q+?%y6l+p}k}yIM(UjQBTj?omSfa z!37{Ep_>k-;Xtu$Ox~~0j=wkR(3Zdb8Xu3tIoz^Oe;)21wjb9; zHl|yzY1ZjC(dP3fs z;6T@|?~h-cb_tZ}>Vo^*(?+W^cS}oirLur{`vV=NS;H_o?E_L^Cv4O~v>h#WNO_Tt)a@EZZTwu)yT+hUHgiTeUSQ0`e5E1)Izr?tY`Zl93qOI!- z0*6aGM@hPqAg9h;2LYE@NpW#!hukA6L_lH~<3)U>0h$}%sJErPFHV#o-wBWBiCnGt ziHN4EDhZn%a}?}wFLX}n3sbGHu`PA(t=|I<46c)!nVAWlmp*O!38Ym_ERC|XYihER zk~hYg;~(P(;`bCPwi{y|aExf|;e?Xw*4w%X*C$vO4_8L12;BwyWBdCdA)q8n1Wg5V z^OU?i$};hj=+ zNwV$*)%rLx_vM@v7f_tUBI>rDOFv-%V7DIeLde_aV#>{GcZFSj1Xc z^OGC8+X=aRO}Lu89~eHu#)@JL;13E+2Io`hFJEjrTYjsIG+fJxUeQJD>^3>|EiKiW zVefl-%1{O6e98FY<3oPS-H!>)TbAl9G^{y-mUIyr|m*DCMEa$%Jc2 zF8-J+QStF5PM5uq1RiYm`PSeLkG03=AOCD_wxz`;7u;A^S6tEfb718m@v!%x!tIao zv6H#etcEE-_0ND)f}rX%H8(HY-o3Rn{$OvzqiX%`wVYCH6+{e|l{gOg)mp##z;r|Z zbB!x>0aT*eX}<0-^Z#g9btlkH^~Xmw8ZIyUHs-V;+xh3~&(0?Sp{Ob!n=6b|CC9m9 z1>HIWC1y#uaulaNlZf?x+7*c+b`Ij3=o!M*-)w7lE1{NIzewP5 z`mF3z6AM>=W>1fsiqwsAUy_*h&M_xDI~kd9NG~@nd=*8?nDn7??_gddE>-{Os15&| zHaQmq@2A0-znW4Fr=Wcb$?i%2WF{m{c!zCxTc6A0MB?`ikO%d~mm%o^A(UwTR5sL5J1hB43aR;7SCK*Xqhj zu;Tsbae6qFt6g!xXTS8+z~Il>F@|Xe$jdV6Rzt41vAQZ|1K4R@Sl%owpIKM`;bn(V zV1p1!SbThTrU|ph$*cS+-7E!BvCZGfg&=~WGFzaF0nNYdFO@<2xN}P{fScxip;-SL z^;K(bgT8!ht=r}w<=J{bNNTFx4JE^Nzjv|p%g0KEqF{A{)9Cl`^EV3^DAylLoth~3 zE;6BDuT90~%P^c7SicdvB=EdO8GwDTW8dA?C1AT#T3$XF)`YR>zAL8XDoJ>$0ESu1 z%h4p9R;%Mh0+F^il3b7P&$nk9m4F2`CWU~@c?`bV*hr-jgid_(`FfV)?c^T*`ztup zt9_!HngLyP7spPG=V*T{E(oSlL?0J42JGoy44K;$R0AmVfQv%b;AQ`De*ow~`&&xd|NQPgq?(vRCP)tsPS zk)GQi)Rjn&k4_Zxs+tmw>sy{y_QI`bMD{$s_TYzur%4)+MAq}wbVVI zYh`I(IKVHBhDD)YZyW2Ci3L{HX^H%F975+iBp#o8DFbBER zyzHLzKw`H&w?*=jzHh1YDewOM_ZWB;53}9}(rnaeuMV2$xee6io@-m^I&9V*PNvYj zPpQ1eDsg}%eZj=@{xi+aVW*4CJO}JoiY+6#-8660! zIV2~(U@0~UWi02^NH)O9?ABTPUDf!>$0NGwfP~$yv(4|uiR+t-XD18mZdjQg-BcPw zv&V;++B(aQOT3x5See2z(nQXy7n$_TmS)6BECOm9IAoGDd>cPz;{pRhCREOhv9-Vd zXdCwuqDh-03ki8Nw}%Ln2kcL32Zrj&ju>U)r=2C)urxFVz}`CU%a@GQ@L1i`qwU$A zWM4Cb#V&yYy}BIrT+Ytu=h~X9!@X>r2Yu0Oiyj_{u+ij-Nlg|(U1G}QE-Aak*H527 zZRCaVLCM}Dy1NYx4eW=1X8Wo%@?xGpA1Pe`2ljW=H>E<(vQ^S$KwXF%L07Ou+XfD_ zefrh0{aJ2+sHk$?H(%_UO#87qQLJ(rGWNF#RHm~V zCx_MejglXkDqmx~DFIvaiK^4-D3-`v257I0+KW%7yZ0}T}dB{i~)oC)rZX~T=nxacm0aQ@9#tL+Hg%;wN1-Lw4ZyKLomy+Kr zvoC9rj~l7ERJ7mpBd0l7TJcy+(5Yh>EqAPO+#WvjqbFq2#w8=uZtrav%hk20Aov~R zU^UzMUlr5d?ylSUi9A3& z09y+kTY&F(#F0({-WsIQ%}h=0q0>65)pI6+VBrP`a_ipoaLu7N(Dhaat;0B~+zux} zDadVasTtD(J4fQAM!CPF!zFvwKSQo^p8t9@S3!7Y=7MDdw&R zWD9F*7L#ksD0n*cf$A~jq>70reB9j3gueZ`nk`d8-qNq{^h)QA=aX7;22{z=J$SIa zGfz(;B-xX8!{eF!3h$$b)uS>Kt=W^ii#|X-^6~KjGSa7Q1k9|0g1&=^`FN$lt5T_r7FVNovWjYNFjq5%=qoXl+^7{Hh5B}uxA@>@m zI&JE)OAz0^Yd81n*2&IjnFTIQ=Fer!FWfC6pC+JJ;Z30du!tlBLjcl1;$y)#$nR!X zFN*J`-O?*o9Ew~pUNMBoVs2rToE zaO8NM?4g>NJVy4Ffy5BF4a>_!)YR&Lbdi($IIwJ#ExNoJ`+zvZraUjwd~&6%)F-1OF=*T&4u&p)rJqIw{L;&wZz z%`~YIU9=e+v;rw4_Oj&kbTf0UM-Lz7O!F%!>;qb9pvK8*;XzbxZ<=)DK(0i*dvgdG zLv)_8d<*CSc${KMN=aFmnPC(6U+yke!uLJmXddNeeHmCYESAac;X2s=NX;PKSB?>p z{6IQ8^|Tr1v&nsFFRx29Yu>mX5Rm~WX#akh#ZSfk@0Y^!|4#=0 zfAZ0_zp1zZFy=Aa+!+y}ww#oZFj?zTV)ke259PIMVA`&+KA0DjolSc!atCyiK+fwU z377wW;|jU^nENg+Zq$?K)&>U3>4C}(CVEww*}>3-p?*ss%T?M~@gDK2&M8SnP_wJl-`SBb#`u=;!Z$esMZSEl;dp zWw-ysPZmDv{=dIgMtKHB>cun$??jPdkkYeXCI2u|x4f^NFBNV4`fNCf@X)MBgnvwb z^f=RU;zVCq_=S#6Ttb31;ICkxfun|eTTnzq@tHTMA%5j`2+XyR-LYcSM~HfPpq7x} z#df|d3UoOJT_`}zX)`12&8#J_7&QRtsCaW!o zJu@Ys!WLMd;B&m5*u6Pf7aJTb59iRcXGZ{{pP0}t8X-+N@f^l>zBp@wYvj}Klf^`@ zpp}N+d|mB_C&Yz(4lA$!{E+zXbqn*fIe+Q}Y~aRd8O)B`=g&A$qR1o~SqN(BRv)_tiH9Gm>x9hYvj88_^S8CP=*nanuJ+sY8 z50JPl&@9*}s1GrjJOl<3T$GqMZze^*zj2!w*2O3Q>6#DM`L?$HGy?9QLdY`=q1))s z2$)LXJ*O#xsHo*|;W204^CxZS5HW@@lC{U#TV zc3-qmyjk9So1ylFQFm_bES*9gVyGx4U*qnCF8(+^C&3B9NILbGF#(t+r?B2p{2ATW3XGJHIb!ZUW0hgry&Emz$KFP|E zQ;t>{9or&I`dGzb|KQ+giOFSKqy-cqCnhFLiwzGUU8%56WDzU<-rvt2llGO_08^j~ zC?_KkO%D%v6uc8P2qr%86LdS-doC~E zIpuj8nSs1Az;<-J_=o)si+Mj&hjK-oM{sMn1f?q|=&r1u(mW2}vYl~00q-0FI9y!! zSIVdwSnTF*x^5|A@atje{+RMAD5)TG*xN8+*WC8xH5nnEVg~R8SZ*WuGEiKT0&oo-45tzi~d3q zcA7&!U~?U8FO!p=HTM=#d%)CD#zZ}s@w@{ZR!mHHX-K-kNPKE%d_K`CZ~v2^Lvxx-S%lSk+NDd_(hsIA z_jIAr_%F4>dxn;|L1g<_BIGF`rDU)#jN<;7Fb>i@qt-XMJ#0x3KzUC4?A^`ug$}az zaH0cIbJOl|K0g3bne`c0*VKTFviE|_(iwOV&Tl)rU_^OpKfH4@=oy-VGR3C_nWqM( z<{9erLp*Pc3AWcXv5hbXs?GZRfj~-l5|NQ1nQR-wBCVjnxJN5gpJ{GR3W#Ai8xy!~ zbF{e<-|qH&3;cNYzVzL@pKX!!IRD;>P4u>JQ(YaDoj$x-WYl{0RrW0~|Mr-5R8_UZ zbl!jbNTotXgPy{JfsGyNixyg0QxWzNO~lB=Bs=#FII_bfO?m4EYF&mZF_qCeL~=_@ z_n4Rz;eGv>s`p}|q-|to!*#gf0uDL5cX=;i9|Ej7%!YG>;*+&xj7XA^G!wqd>na`T zuyk{E*%(vjvmP%qHnz3pSZF5Z zX;<_%Y7vzzudINwr8+BtWU}Xp*)M2}LufgaD&^!Fk)M}Gbnl-1@yCsOR~%eGq~+wOIY-Yyq{3PoJ=X z=0NwnN$HdQ>rgl@k7gs0h&G1K`Y^_jn9uZta@qY>jVXz zMuayy+2tJWb~i31^9gY3l^D-w$ZPv!^2lRPYnNMI-M=5M_cZSNVJu#rZZ6}dGO}z)@*ESX!O^NSK79>wD3)p zN&GVI_5ryr5c4Jej(@$Y@yy`!sR(gG>&A=|pCOZ&g)QR1FOsv~D~} zuVAf;1zgmc^$9`1wK`}8T->g$KVly4yo7PPKR>8KC>YOe`>m$tbbIzOgi27+LbtHU z^}3u8^;(8lHSI&*TLJEwnVII0_(F>zM}Tw_|*PFDl$;?zvd3tMX*3x>Usne10>k?>G}9#n`V+c zMf}uaP9EXyKm}5@{?!xWeGEGOzp2HGbGpF_ELJ-5+VFycg2cm5-$NC(wHJrr)3otr?a6|fY=97cX`Ebo#IyycIQ*Q5UAG? zd%2*M@L2Ie-O=&i{3r?2Llqlqgze7WG*WbrFEWvPY}q<)w1@OUmd2+n0!qP>$=)yug#yjE3~KTP!MtoM<&te|Ew zbg?<*4F%RZPZAuW`4{?)MH%rm+U*9ur#MJ745jcOyX*V8Zz_BM`tpgNl*PTr|1$4B z65UNmAk}x4w)>#@c^66PBbUw8ArbT2jr%eWJr&QFyAjAO`MULWw&!$YWLDRa9t9jg z-iRpjv^us4wRc1oPXC+F-QU?ZeAPZUDJ|U!fyx#Th?a)i7qQ^P^WJ&L{=|K7ya^?# zR`@NlaAe`gnpOx_8R>d_{0fu^6k?Cju6eWWEb`xY@$J7_fc~XSF$n~aULEn~7OLl? z`(u}5g?_g{k#2KlewB=W><$vmgTK$juy3*UZn@(T12k7bwg^SI3C5Eo17c#bUSD@` zg}kmK8O_bM{K^|Ud`f;%XYB&$2h)aJUnzId9|;~(Uys7(Ej~k5jC%EHsw*dD^YsFn z`4efVQ8&5{I4rTq1si@oe?o9gN!}82CZ#{RwRU253n0Cn7aJNimu)RL8MXu6%7>Jx zx8PX=!*$|Pva_o^+Aq1|n9%%1lRqJ4QU6^tzgj|Z!ir^)9|IDlwNa9>>-QUfKdC#g zz3~s*muNehXm^b-H=dQ;xJE+=**K87A0!)u75g9%d#aIi%U%K|+>rDu8G*J;waY0Z z0|*&zRp?9MdhcBSTLOqFKcb;sU0szCN>5L3f)WXK;}4-Lt<*?-VgfmvI&ZDbw$V-L98#5}HP37`~FqALzvgOTtl^eec)?coZZB#Z8Rl&Po4rP>Di#hfG0WU>%PJGjz$*TjOL%s zqoZo|j^_se)qGUa8O;-oB;1+|<&C(z0|oxK;}$y;v|c~R?27YRp!gMG#yOSii^c#| zuRBPQ{~XDNMemiK78e&opnrbmG7Vl9W`)2U1Y#9juokAK%&EP7E?;?!m@?A{7wUckxv2EhRG6gvzkWqAG(=XR`@s(s zq?XyD5%cP5t>0V*pT6t`f#8M+cl(4hE09a`RL8);k_yh+<${5{gX>%#YuPp{)(kAMztHQM17Q?ulGzoe@lV6qUqV_6bZr zd;gJgg*6#e3dO77qT#d}J%KtHbR}Sikdax05Obu+MAC_GFLa$Zj*q*q4J&tuQKq(W zm|q1_N5&c&>YJ0?VI%IORl^k|o zUa=?f@?xy&RVzA{ROdjML{WQDSs9tYNmLxEo?J)f^z=0N&_F~NAAU%YtAvA@dRtmNiUP$-PHz@ogGTLrsWq< zwe)yFP-nR(2Mb|~PsyiN(OZlQ&q|WFM@<9wiEegHkN>j=S79P{! zFbB1QfI!Kmy8vjjKs-oML4g=@gK!o=x*%KI&o>u~63{Dh!ArMQ^cHr(<>q#i9?hcH zK~G1w6B)T`G2y^A#mOTgVj z2yh4)7_LvZ#o+JxA|jOV+3oST9V64yNH8e!EmL^h_Qwk*0dfLzE>9r^AKy{-cu9Sf9Da(igs4=^ zr~rT25?R1_3di^Ap=h{Dj>(N&XQTzmU{*7gHZ8jdJgzyNS76F_N`s5LA>=3Np2jRA z%4)i53#xN<*}o70e$egJMn3djyP%+e+isokx-)$Uccf@(Ys+P&Lq=j1c&&iJ<7Te` z)5cug3Bbb=-rrYPFT2Mpr-|K!Q)t#&Pq(CrHt!;&q@1k!>j1zZd>6ss^XH)=jraID zGzgTo>wR(BO|FC-rL&zmz4^+XBOgV|QpC$CQE>V)5c^JRZf30_&iChsLF5ofO5$@} zmD-47->U)e;iwbT`G_8tb|O_W3k4xvF2`U@s<%}DQaN+vQm&3x#Mbki4tU!-Jk*=r zySK-C0W+}LYEi^HE)EaKrB6@IYn%=SK;H0JLR$2q-2cK;h;AHYurpk790X8e5e&4?% zAVO)jeTayPyuGyB+uPrvgM5~WYKw#~F+wTZi_ntfWL_=1_@&FscU~SZ@N-=JdSjTJ zHit+S8$bz%H5|ppe)WgV^vCg=urP(b*7H3cEiDaGvz^OJ`)nHj3L~aSW0}p!0Yg1K zA(T@kXE!$>MEB{6OiFUtpX0pF2mQgYs!SqBwe4CDm29HngH8^Q)AlNc;YsZ=3QeCRGA~gWrk@dz>9xmuBSa91`%!d z9<&Sb{)fPJ%aN1Kj7!`L0Re$QgB{Ch&brp~URP99w87Ehq$yw*jV0X3Y%G@qX=Zko z&)FI(Y$gbx(SQdmz|?CVQxU2gQeZ|Uz>=DIuIbHmRu@e*A0v8==_LV{cX>|ijS z4Q$+fLqpaF3ou|BLuUJcyF^dxT#R8nv#n4nP+@4WS#dNro&rK&ki}eWzeNg0NB_Nj zQ%QIKpZve%fZesMY&2RhAt$MhqxV1rAVQD4vjyn;{c5)oz{%8$wdc9sEV(#3M&B}B z92yf~W4|UObYJN}mDmWtWXREN^#y$$h`a)K;9HC;iBm+j+GD2+74Z1TN=k-5F}Z5< z$Fi8I)ZL)K1jsv`%Q$4F5PN>|hk4jH zOv2kWh_|=J)jOkLG4-hW;!P7?q;^JKsMYE%$ zqx$qk;7A-8IGZS0jE#<7$XAx4{FJ^nL<#$nyUD4M_NCaJnE7$)GE>TKop^E7ImIX?=aLn{fU+ zmXa5sXciVxQYsKa90c{y#m=NghdBtf0rA5;EJS|K4;G;*>=xO8gPEplfQ7XJA|7iS z8d&6jeL|5A)N!`@H2`WrmPu9}CfLo;a$>P9N3+j>q*zNUfu%-=q1_h1zz^5* z*&fSem`|76jAj>si@LkzTo@WE1rs4)eh^BOW;c`yQX+{yRT`UseJe*6R^$QMz$}sg z(+J}Q-M(0V;ScO$i_H-5d<=eATN{4>tF^VYeq6Gbjg;W?xPAvDCjbw|&9)VPDl5#D zGJM7Q_zV7RDbyN(`v`A6{RPzZe)EOJ2tVBK_s%B{&efW2xxr1BnjJ%*-l2 z34c0upe;{#`!0qBM_t#oIjDqVN&fR_%WkCP(x z3*xdev%!1fwmFhG5G3O;4_-X^40=UmFb#dA2i{R~znh$ybzKTp_$UkWnS+DxsilsD z&P9nu=Qig|rN#8uIHMz-2JUK;nXc~T@!3)`Bdd-!3J~Ioy1!w=?{c!?Y+TEsHl-!NV}R&2F&V0gbus z`QWg+%dlu9P&w3{ftPf@{TkW{-*URK+Ri;twI_;xPV?#PZXQBQLt(MTce7RC>e4W2 zQ(EVIqPw*70?ltvvp`hN>U}X6z5z9e;dC)EvXH3oL%;RHpLEjqO2a7glhr{F_<@1^ zy@{)##!@?l=|y6_{XyA%!DZV^JFJX6y=zkw)0c%ZR=n^f zFdKORCm}5GQPLHUkQp)u*DC2MHaOpG4O4&?TM%$hHm(o=z#jZ_JbucV%IAohYyR;~ zqB5=!)@ZO(B`%(~b#94V#@BQ(Bzg4B8g{;d;5swfiT0MQ_~q>#8EP}CKZoP~#4n;` zR+!D%{-~#8aw2>6Vi6)dYv<9;rbmY;0`c#RX>v;ctptwoA`>AN zwDOG{q?W1LOK=^KXCX6=ONnv7%dZnGMq%Nn!sP`F4X^jmF5p~V9^?`VQ zgd*0LG0N<#^)@V_&M;l)MOnv*b>fUa;uuvD9R%3s#5qA*P6;io#AMW)AG z+!Ljwg1Y*BMvqdj!GNFKI`UC!csyI7kOr=Lu7MRct!1U=_Ozc^2RTK~@UeFelZm4@ zQ6>+Xvkc3%g*xld_{8@7bP_N2DdorV(8I2&O}Qh!i;L`O)a#*^YT6`i=uM?V0D^3l z`I&uHL$#Fz%Qbk02-&&W7$WLQBZv}~Kd?ly*l$>^JJ)v_TL1ZzB;k46bO&8IHDl8A z0HgC{P8rFv0Tfog~q#`i{&S&iLNIkq%@pW z5-@e;{({(qSG>;0;RwV$Zd(1n$8sPj>L_CzgamYU_>CvjpZPBw+440?KNXtdbex`+ zf2fjBZ#h?pmlrn<<|TO-*G{Tg?CM}sjlrJ~=i>2)sIz#~yYYVge;GFI31 ztL4$u^Zg*A8)!PG1AgWenx4>OL)n?wxL3;AGO79`NB%Uv2(IC1uLI?d{5`%|fd-$> z-(6PR(Q2RY_}xR#2M8}RJCoTcIWoBXE{g3#sh;l@iu?ob8ZX+SnmSAFuI)n74YR96 z6%l2Gn_NydC?Br8KaG&vlkQaSd6BeseHYF)Fq&-`%}DjLXeSH;U~K zq>~zq4vUNG?@bdk9%&IYivan!3J1!ikVsL25`phxHpuIhH zaJY7|vo+g}v|7$Ua7Z&M(rHGTcewl_(_qSYwhIV|`nrZ=^>HW574J>r3W_Q5!gv{Y zl$g=yB{I?DwD{w5hV&dSGeYk;kW?VMq!3S4axE?ZBX_1rl z>FE#av=D@TauxQWUZ>*NWV!4pMoLBnrh_*>Un(RRS5QD|!0<;}&FmMZBwxA(1x!L# zh7Yo}u&)=hamN)4mrFm!^wr(kADRDZS+kdMdvt@YhWNdGaW|nUn`7}6`>LLE<|cD5 z*a+RfrB*jp>ED^+P@b-yT$zQ$`t`jcEYe`Db>Ff1X#N4iiS5+$Di~+MM>N|=e`L%< znMIFLH7R=!iJ$WroL5IEPMed-`y)-y(CUtUEiQC~cQ8l?E%=EbKVhw-`e3)#tna~| z;IMX%r4H)VGnD6+iclPeoh{?eb}D9;m&#|xkKVB}ev6}`^0P89kvyTXHvtbm=2zRE zgDZj=)7z%Y$)H|J7CyGULQ6j6)N%4tJ^P{Aw_I{)Dfa^IR_oD0J4p@aUx_L(h8-eP zHaR!I{g;T61Y)Ynbxehln++t1JZ6=z6WrQpDRF+utsXE&O~SF_FUF}I0(J=bsAni| zhylh;&BsAlEj5o1HumhGtxn;6d;UM7zU1S)H*4Hcx5XoI?#Zn|#&YXt+o+ z&ElEPF1gvop%RE7#p8L`;A*W838NgaeCXASGQ*R!%F{sKGgHa+2TjDp2qE7M(`$?V zfxY<2$^udEqIfP?-PVxSB0uQk!7eISBg&1Iv}m&_$YkYY1-yD3i99!PGc$^Gfrl~? zo)lP2@z4wj{;e`MErLlN=WW-6fg-A)O~~Qt87g-$7RlE5^n6E8V@}PiKZ?^v$%1T! zU!N8cvPD;|FvK_|*ezHq8&yg9gf?5pseMpPDnd%5Sgp5a==dPxW>|l%zDVpxEx}5c zfY!6GwBUg1@{5Y=CZiCOLqqXt@8fUF@iZ7z+qF$4jM!)(xgw2naH43z@?%Ape7QU09v;= z2Bv}><|^A#rwBBqQTlf;rs;M$$M(NvA8qiTLHkj%j#F~@;-`!XG&yTa(3f4(Z;j2*Z11NNp%wlmL4-o>2=hObBc zk15<&+u|;%k8OTF6rr!kYO7JnxPGG}f^zWh3%rT*r6pXRJ5iPVL#14P+qo_UDjYV> zvV*608x?yA>x`l8U+^?4SnX}rZ|Mkpvt5HnY>it;7JV&mn?^_D?MJPRNoJOaZ!gh9 z^xGQ;%kpy!mF0L2)z60V3_4SxS*mfBgoi?! zcwXG_|J=`yPfRmclM~m5TTQmQzGBz=zkBqXR17Cm%B$big!P~|sx0y3zSrGBBT`J) zPMm_p(E+y#r-^N>>PLR4wx^I(u#8X@<$RrHR^ffX4ktZ{ZaKkNe-a)$<95^CkjhH< zlAAl$tRUBVAu)(N@QNmXoF(7yX6Ga;>9C(#Y}WR-a0maApQ`b#^Ei+?24sCkMP7e2 z11jPPG75$EpL^^dy`OzL!tfI7EWi9Pa&DxI{~}z?6F%f@nP%|9Sw%Lw^vv3z@cyJ= z+F=&rk`X!hJHKjttPP6ghePOxv&bo>|M_?N&dDE9+0kUp{{R7J14rJ)9*Lp@%p}a_ zAI8QNg-OCYpS4mVUXeNPB(#!qe1CL&Nq*2CRJbblyJvkjyNQ)BzJNj$rAAKtb)|ae z6jzPT*ZZu*1~(Qli_x{*u)$@oS-0|ZudCB4cGR(%+w`LAuHW_<`GnfOhp!FFGF>yW1x;nJm*U-ty+EV5wi4T!jv8;Qa%6Vf%DZ^v`>VDjTbQRGqPRc2wNEjbak?;~6h3a~A7QUhZm8mBA^(~ZMf$dC zr51hkZCQ6yTgSzd!TT>NhWSJ3+3HpGI*^$B_e+h9E&msBG;$>YlMqATcIVyzRXt0l zSCiq(vi$W926K2fB46^V{N4Vy-JzTh7isGDam3zFChvMzbIW$|?WFgnN-R$Ti|n|W z8k>{$h6Ixg-;ZGW_^oGgJ1y0trgfuCtSG60wTiA;+^SEZtvP7QBV925IcW2}SH;dq zP~nurOjkY3BA4%egb&YBh}D81pF;TPV86G%uHv#7kDgtMI@F23*H5fGBK>LsYNE_+ zAcnA!N#*ZV&gUcS{3eBx#y&_`qqmmJ?O3`_{Gcr-cYMx`Z;F#eUAlH;^t<1^mtSvqWw;mf0ZiAZO!P z$6Z|NNBe$MY%fIESPw4Teg0cH$t?Jv-GSdBin0a6J^kk1 z{p+jUMO*59$7I?^ljpKwAMA-KB}orm=v^!4ZqO}y)zeojT*qGH786;&n=LL=>5tx) zlQpWrCy!^KkQLV%2-LsYB^=D;Y7l|W-8~|L8W$7CVxC1?Ex%Fsl6h>Yg{~mlnoZI< zqsDWLt>fsm(h^pPkW}k1^Fu{jDrJR>-SJt}C2U&Z*+tEI%rd80?D|8*j*wrh# zD9NrSd{>Sn>?PgM*iH5~76Wr#Lth*iTff=Q*>QYs^K!>n}nr<5jw2-i5VX>~CCmJ@5 z`MVQpjmyzX$z9R2IxWRcmF~somVIh>NA@QqQi!;KAXDj~u#nS4eWxZ#5xd*I7^mIB z_xF&Ic=x;B0$M0`c5V7?5=ve-R+}D_n4}DUFf0i*uVn!Ve+G(dpr1nac{qL_m;p-X z${DPHt8KY`>W7oAy`edsND-VnOsr@d{*E3z3c(Bm&i3>Hct5@H8`X1uI^2EtlBI}N z=BY}Z{h;8rB3mugJNMX}JwZ7m&oyQ-pq7}nAXMzCp8wgUBFi%oh(xS;fVs4ns7#W9 zareXsQ=_WSS0?&H%eD?57VA3Mu8KrC-CHW7@_ep*hm;KEzMQU%EHP%q-X%BrO&mjO za$+s&)$LFlZe(I^mFpQioQ;CP*kp2bAq#J0=YR=Jn5i|2MlMHB<{2P!Q@`BC{R+4k z@YJ%?RCmpJh<+|IRV`-C>ViE~-;!-;m?Vr>TY1VpG|6gM8lbR?CAmBIFr6MYsL-8( z4PxSe{09n_eO!FP(%bwF4_96~Dh(x>eF26lx-#2xaand@I#el$olnVgqU zcxqU-Qe0B*}!(>2@XvgVD1U|z^b|FPi3B+d%t4P0)oXoN2pzmMG@R!HQ1 z;hoXBn5Sl{k; zB7d6y%Z!sa*@Latu%;Cbc)F(EV&!~yF8OH&|LgTrY@+|x z!~=$yCOPW8vfsQb)>(1<)I>4WMW^_U-`SEl4lGPqT!Qs@xVD}m!^X*SR9lk)a$bR= zHd$?8N>F-QP?|IgD(uIhs1EQ|RChm%I&IS?BpbAgJw{pW7*F<(zL`3}_j=T{W9qg6*3D^@)GpJD^ zwcROCW;?PBjgSeP?JP8rr@I8dE&aYOEVLhHQd;~zZxW6pquG5$!(rN%mn3?q<~2(@1YApfVID?@sBF=IXQ|ecDR3Ya9tf3IrykPZL@D;Af0okQb9^?q_K|dxcKmvO_9aP zQV}!iANsVt5-?3X+!02R8jStOY;Zr#o#n#RpYJe5k0Vv{lUk4;zFg7gUPheYIHy?F z;P^2zXs(Y99S2=zR^2id72`Qa-qC*;$Ht2IY;MjkDH1F8=EdQI@JdTe%)8$Z0-Fd| zup_tGGHn$^LCgb5`;$vmyz(x#F&wQdvts<_tZV_?==Zg~ zkua;NAkeK*H+vvSxx8lC#)bQPbBaB%)4RO+l(=OLP{tVLUCh~hJcHt%Lu3bHhV`Mv zX}}UHKzT_GE-L0C>Ms9`_OS3-${=n^U3iTOGj8cKpGTl0E0t7$t`>d)N5EfxSnV~1 zMwrozS@r5OA3jTBcsS0yJglb}H5!81;5OOWPH7fjPfEC|XwTdguuI?4E8&nzpy$(H zpd?P>OPr5}JRfA_@ae{a7wRG;n` z6C1~`U12KE3T|!58c8FpwNz4_Nu9(boR>w(K0}l8Kp;mEFLW-^0N%KXKWpv&(MC{t zOOG3MTw2~O!>)Ti%Hgz^m!n0j-qL>2NXJPqi(hx%l2jScB#Qik$nPT&GyX#V|8+FZYAn5Veq>}7p~h^3~cM>!p{#xU|v4~ z<^rB04X8f#`lVcuRP7A-L#~?_DnSK@C5&`diGr2V|d&T4pgYL)dr8{RP6N#q}? zs;Z}NdD-%t3bS^oE;?r89A3Gr)7aXU$DTH3!qaU@yv@#F+Q$7zcs$9(%a@QxM!^FE z|3++_zpEk*tcqJCO9z?syw2b9&8f_vgr!nMpf$*z#ChJV62absgWY|7V8EOmrD9sR zE4Urt5vG74SwVqi#9~~LFbZ1bn05?^WuG!bKAXGAKU|$>;`<=KYjLM+=WB z%@lL!?nVM5Pxoeognk7jy-|HX=VbRPToYG$T}2_$usM=4A3@%fyJyBbAUPTkzADN` z#>b3rvIgvJ!il^yUaB&Wdif$0DK!QLvetj%O$oSux)$}}EMRbB7{4X)<~q6{20Uu% z0uRzTgNYKs?u4b!Lm6V#_zIIRl?A76a7mCh^Vo4ZfuL*2vl8P9LQ zMi!GN6eZr$(Xo(`B~en2kFyzrz|dp^4NrKFH-C`nUj7(n;seQmWc2evSV>vo_4Var z60U~C3L_dn8G%`~@&}IuU*~0T*OK3zNi~La4R)tB_&%Dc1vw`%<5|7n3t@k6XZnt+ z0ELt_2)`I{2=5+>Xbe!<6l#7I5+jLz^<5A?B2-MBsE1f0Snjsnh-Ib2?zr?sJibhE zgX2eSNy#ch8ymgY2mU7N8bP=orfh^f|7&3zHa1S_l3XhZ{B@v+#K!x(OzSyUR0v7xJqasT>w^9&{;)jbz%vZ_Pf5oXWDukBnZsB#}CboNden zGCs{HVDKx(aX`V@BCfTUOtj1lBoGKf*MPT&(dv4+TBZ= zZYUt~i}p(9hvWq#xK{XomoDyO@YBq1B)X+YrQ^L@+QQQOju~`Dm8li8i_}}5f*r~w zYjeIOeE|dPU0~}6dPfhKAQ&y0i*Im{L*D<;-(6~SL_vKOsY;)hfGm;25*b&a>OqDq zZ}U(~gL;5pO#pm*->O++W=SQvxCQ^yH%TjRdiH|<0$X37AoAly@Pe= zE#Z?q)Q5gj9tW^SUkZkgbvecQu_8vQKE=Wlw|Mx(;0^`i z8t$*`2(f@*3!l~aYf}>sJRD7}3_ToNx*02-qt+!R;#rxOG_IDj3(nc zqd{K4b6e=|4}yE?JN-H`8Q?yVXcY<6Jj#TCoeL+g5;^SwdhD>oL`7)xXrUTQtlw)c zXDUic4ARs!<$`FI77D3o3M#7Zid?{jKza5oFdzVsCW^G1>OnGP0(+5!8i(!b=6Jz- zc1um2vFCvKb`M6&fY*`2*QuJX|G;bl!eni2ZP1j#xf3i;K6ykby2Ij#1rL{+xrvq* zI?a%IUolOOq&P9;0T0JNt)P9=PF?YiBR2CZ)D!OHEe#E@u982OGFH^vokDWwg}Pl> z0)KcwOa}HYKptZ6WF_+QV7XPy^R^6FoAq_(*j1U11NJtr$F1|xpD19lKRq*p`E?|G z_vP_bI**Wg%J>+^_I9{wpR`YUeJ}*b0jM8Yu(oMYkryGP^#gj$#?cejEq($FG(WBC z27hBHU>VNRKw;r3hDgC&4yM2cUoL??!X-B^?`YNv+T+o!;|Y_Az@M$QC|1n+NzU<^ zpE!P6w`VW~I#s>~1+*X_1k~}sTi*N>fgmI(D4WD}vpZcen8G)itDtwfsY8zY+u6k- z?yc?|V9DqJaRapZ1jIxAsP#rl0*Z=>*;$QaM!-GWnShmuqR#T&YZd_#kv}>f^C#xk zW(Z;mF9cAvEdlu@N3P_{P1I0eH_%fG2??P-e-5k$(C}G9@79ltuw~XX>ujsULM*}U zG0?^)BI5nhw&{7NW@Y48DCzQscba19Gmduy8LOd#s?xG!;+8)_Q#1&UZsU z=uaq#u87Ub>nwHxL23AarMzabZ)T&}WOB8|JWzfF6Y-RpPyYmLy5Yf;_;|^a*QY-A zz&zhdCV|~-wpzy5*Vlz(3umkOqjMYvE$7^mP9P8NxHqG5PnGKiftVf z#-1mMO{TL*1(Z<}!^0*cneS-4QWi&n=4*sr8pJh{l94&?PBj6E3m~#k*Q$Uba=K!J z94Qzug}FJ#_#HFcKcAzS1Z6#Sa8UQNgKCX%dr8CJ_yFR#w>=AqmZ2lg78(gpgs>Cs z=E6r=G#!~B_mzLHn;J{f5{gVK;|0P`4zJG=lyU|_gCkEFX%!R{Dlv)_geIsnfnw4w ziL1(_Wp2)XBr^{Q35hjKK{YTgO$WNr+}sSc>33`&w!i@-asTd zN9JTc;J9)-?W3WgIa*t%a$^B$Z?ruDd{lIeQx{eGhu3~o(Nrq`VF9fe%8Z7SrKR%% zBuWuwIbc71x|T$ZDS46h#-#{hv=;E5k$iRw>4)=5t)|6#DFC8$tXEy#Q*hW)jyyr^&jb`*5Y=)6 z_6+xC<5fB_F|lqbLPFCQb)}h$^L>5hYdr>)hn>U$lgEe2G%e8a=lAd5$$aRU zaVT3r$W1uPC*oRUe4Sy?YxpOa01d>0^mTO^PZl%4V|Qv+N?)KoeUzS+#aP!TsaS(@ zYVv!51P<<{|JHQE0W!vM7J*NujjoxoH8b<3%iC}Dt|p>6Vt+4A++W>!kqN<^murhfPU@B z`9rrvH807r$y8}taWPO?;_fec zF0G9xfBo4@V5iOcX<#xb{QK28+6lrui2+Y6pgTnhg`bn^yhmPd4AEz|8ErM}K*WwF zC-`=b0`8W;@LtSwa3+3mUec(@ZtaQcjZKM-o%;1FCUX@C{GP#_!Baik;B-IVZ~=D2 zdbf*a3^LJx18EiV_!;NxQ@no7p~9ZywF01z4u}VVe)R0;Nw+(%uND&Zk{YH|I&<@qYB} z*Wkfcv6fpve4JrUVSZ%kGP*894N zsE^^k{*h3Bu_YCAf*;0Y5mXERXDF|hNwgc2@Zrcw?W3uOg{QH`GWY%=weq<2Dw0zrf*s*;QEB2M!uA^2BA!2?z)PX~RHOEBhHZfyqqy_u2%dokvPebU+zIu$l6*6(>UOT!ZL!E!YT9_yzY z(b|NfItvAoRCeIJz{A1k!s9`aF;sEfLvJJYoWu^ZkeuhAU;R5u_l82l#MSx*=Rn*> z!Qkr%sP5s*Kd(B`x zeB3oDEnY(w-XS6#>$IbytD?dVQw8DGV~aH$59@^=UQ9xFULE(0 zFTvT__yO&VrC&z41AtuT)$hyW$8h`#nF;=niqh;=0~$l+L&L&kTI~_TT9;ooxIYwy zsn32|0;Hm!f)-Amp9GfIhJ=q3Mm7orMQ^-MsZam-W`&aQseKu#S&Xoo{`;o|UQT;p z?`ybyh5M-AHy?;RH%fg$Vu$ej;pmk2JuX(f!@Rtn79_~_LJ@e_S0$Ax_f*S(G%&UB zr7Z&e1=2(e4bA?~hr}S0k=OL(FdAetNlBi!1b=H`x1Ux}WKhfVt6|`%aAqk+`q+1p zf^P+pEbrg83mTDUK@k5Iv+FMxJ+Ogv>uY&9_e|q0RjoDcb8y#5Juyj2wyv^?{&}vX zsmoxrPe5gFkk?m04T^v1LS~8ge>cdrFEpfD!T-N_to%=DVi^+L?PqqY*({~HnS`2y zAHQF{n!UAZ#Yz8ccmL^&PR_e4!|Gs0dvED@W;&h5Bvo|$17OmA#s1!M9(GdVnMr0) zwGJ2tqnR`;TZxPd@WJsdS%t;cl54g_(Ty~AvkfGZyztW$3r)Uh>K#C_3KGhKDD z6sDI$j^1n951;z`p=~3zwF1@l@4#F%2&74mYH)q^kUZ>yn0L-|Nor*^0eGf;p1+oP z5cE9aS>khEC1)7QEFK-HR>t!IA?2qT2&UUSuzd-}GAQ%eV zS&JR>%5risk=&<3@1XMjLSnMf5;s!7O>eg?&+Z9aY9P?D$zvpORp%I8Fba#5exkQ3 zam>pz*qv0nFaC!HFWJoj!lqIoqyy-S71K3t(loH1tk_nulj^ zEDnWLybYBV9_})1=@;CRpX2g~a#GKDu{LzL;-J*jQI>6oEs4#EBqc>Ch0A7}9zuI4 z5_)>ubaOwJZ()=TBA(wnU%tBG3hFO3t-9EEIk*$*HopRbo{bFk>htspHsg`?n!E}f zM5yi`-Q{Hn;+=HNXG^UHy_n{a1Bdh5wSoETQRjIC22UckZ^cFm#3bab=6#nmjWK38u7!C`(4zj0TVIpnjsy=i~2?XcN}B%wdpOy-Svj`8yuc?*?? z+?MM|Re8a9>0&Cz@bJ5K%Z@lZ97c`0{gVyn>>3M;4V>^S5n< zU{CP#_LF->psH438wNs>WkjX;qN1WW9Xi}&vMr?ZC(QS@Dn1_}uV15WPb9g$=DNuC zwA~^;T!schZTAqcn6}YxZqSK0i7mD!{20H9K1xOm55J$zK_!GJ%`B*mTc2;e`@tUY zly9>02Q^pK+xIbOz{t~%Bb{XLSS$T_eUH0@5w>aS=CIWOG99+eBw`ERSk2XsRtu@t zzeZ+%2h{cFyJOx1az+_uQIV14F0_kZBy6hprdsKLS|S>bws&@_GM8!H530uGjf`gq zTz^bM*_todIr$ZH>gVG8XUql7Z!`^}vE{N^ouyGL5HWFz#pZAp$$C-FSCSxRbgjYd zaZG>G@QTy1d{x=)oH%_EQ7WPSv2S2t5KI^jZWUlMUy)jS7IFmfv>UZ2i0a=sKJThaYIu)#0Cub_lAvffp45IRHfQJ(mg z;tl%ikPx|7?E3r)1Zhr#mf(rfJwMs>(EoJVc?WS7ALPGi`oMiQF}5^v5zlFH>=!yz z{kj3{A-xNM(Ay8rhrbr=rIPs|p?Bts<=W-zTCUFvRf@L3U!3|_L&UAS?d=x=X#QPo zmBJ7=hw+Jt{pq5U?onLw&W%U#iw(11u|V#^-aO>u+|$+ZqV8hpogJie`Xq+y>XfDU zOQrqtZdCz}>N{rF)tkVsBR?&6&imcb$2YQCH%HE#C>#BA6?!WrSfb=0PERvl3PObw zAcFq8=3quF;+T0M#$z>D>$;7~UAx|Rf3OvCf7;1-@f+C)%MhjERsI>udcX6@AB=hY zN`IHjO{5cGjRGP{2;{p{N3N@Sjii%SFIayyXn20(m$xnh@f&?LQ|@yUd~4icW1ooz+co|3Wb$KH@f2EnPPRQWR*OHjo8p$m+e2Xy zO>Xwfwsn1GaZ9;2P>V%}RFJ2@U1MQwXK{3MdbM*ut-iaVRpW{@ACr2*0u7*v=y-c# zHC21oH9VRj`aDZx>AIggqw`+&VZAlv!sUJyczpfV1jVZ4ycOYs$NBOSxbBaSje-1z z>Wg0(^_{;?F?hgMoH9r}RKtyd`n;Nol=*tCf*W*Zr)hFVCFct~xJ)-EV`V%eQrUQ+ zRrfbeqkLuvX9umqzG&N>FV~qgK@VfI_s0fQAcjaX>aN8a_XqJ&0S9vs{*WtNrdX}r z_)$nag@&PbUqAphP_w%%!NxMO}`B0E>4%4$+w zRdu85Xp|3~z29Et=vD&C8ICg$k8{H3saJu-$H-A{}8_q|dNk%}e z9Lk$XgWy_wcX@}ck+3a%niD1cirWjvxp8-^0_Qo=kkQIwL7dMJjvvj-n}1ft24$QNLlOE+mbqA#pI6T><>Y6K5FQ29!Y%y19T zPDw0IP28Hc2hUplB7z{P|6u{AdX6MNuNxUr6VJ|0g92%#JaB;lhVYn7hxD=3LkQpL;Dz}!Fy4A-)`8EX!H7Tei4 z=1a+C1mWG^Tm`7=qrVZumBqpe_uJ!{9cju99kN!xbGb~_aN2LaKl~Q0gV5x9c-Sr1 ziVP+Gxk>x-+gAWT%%SpRMMSh_H1syPU9BeUjYJV>zi!?Yz{kU@GM-qS*lP$ES4b$6 z=i-_vQfD{nVT~E9P3xU?N(1Xhf5!9hhc_gMGDyofj)+g=Q|xf%qm!|!of>>Ld&6>q zkw-d>*Do+9D#SLtr0CAk8_aF*rfX^{#z#kAvsv~e!~8(7rU8AZA)Yp$XQ{zTJ#cR3 zJ%t>PcKL(fp?}RT>9%^4=`z|OjqmlxdhEkYWq9&8_IF_4mLQratvQTPnUtif8R(It*+nxZiMi>mE`Vh!QJ-5?=QSuBZ zz8J*(q8Z|Gs-G($qSg<)UTk6#5QtrnZkk{hm?~>&utp?rO(fk^)*Xxu3t?_eEqG@O z65U+&-ZhGt(4E!c*Nx?j0&~k}a9;_5g@Qah0)kElZO)CvhcI*X>;^I*{f{m9sEvqt z!|O4VqpE6H{iBPTETiN)9_T;C2lIV=#KaMS;0)NynVFidxpig&ef(8d_xQxxsUr|;q zs*M@aM}GU(d^%+^GlL(ehWD^BtY3_!5T&yuQU0K28Q7XF*a0zMLV_ZyydHr+SfeBx zq=bK338){^xZIWYQf{O`q@)58RAuq;@PqNWvkaRIv8d#rb{7H)yf|_9n}c(N+g=XA>h96sAJ> z!q*-b>t02Og{7xh>7EeG-Vzp8M;04M_-^&G+YUsqmX(zm4QSyXjeaJ@;&Bqx*z@+2 z-Q{x*^tg4`3lJ3%Su@)Y3KH45dPgdFzQOR5++WDV)O5&1lrsJ^T&R=@qXHOD0(ZsJ z3Zucx+c|m;i*K8iwrg{I8H8vJ0^ZkP$D(l_dje-*HuP0{XTs{*tbYVVU5&Dk&%4_; zoQ?3bZ^pjb;xtkJwyzzV0I5PW(bdHuPWiR5A$}0^;;8v)e~A6wT$)pfrB5>x52ui4 zc}2xOk8`8*bUaE-@qCj_^OCuZO-e;MGsV1<$&8PziMIEThVQq>qf5}cg%5T!tB?Ic zLWob4njNUSvcB0R&9H4SX%1&-P+3^K-pZj>&d*Fs!@xQj=gx37i9MFWJ>IeJ;@*c5^-nno4yV-0Mza9_UM+i}*X2nkuTZy(T>0 zn;{1ozB-L_wI*(p+TNfd!=3dSatt-xSZqS?#m$kic6U#=|6c7YjHWW4Ke81cg<>|D zPxHD&KUFS=vO_sMVl;E5{3c;_)*d4)43&U`WesdtpwKqIDSliTv~F7m&bT-2X)Xmi z9nu3mJu^+P6n8m1f`nDGeG}#Y*^@n6FmDh5XnT!m9va|V z5=n!3Z2gu?s-t#szWZaKL10#38r|0l{B1^~h~Mgel$6{KZ7k!{CwOlt9AFrPG9YdD zJ_vszCq)4>%_z%qu737con=|?*!Z}NTxT8$+*hmch*oa*-H*LEGO_Sv6nsGhY^na} zGlZ2EE9aNp6~G5)%}sZnE4WgZ!|~Rh{^J@PqY@+0^vq z4dS|)%a;1P0Lbwv9*g8t4D^{E&G+h&qejq5XbDgiH@eRks_Ct#MMs}6!8e`na#xxi zq`^ZPfXPe5AN&2)t(o-c(iRx<0&-2WoyGMHkIiP5T`HS7M`xXlQ8 z9YS#U=u%5756>(r9I(5(?HVpnwF17>$#Az*`5GE^)#mJ;!^Z%` z-dezc!npPiheumI@c4|@t>|%X=B}g!O1uUWr(zD)s?0cLUx8W;@8rt8S;nCNokaJ& z<-*o%12+#{)n!Or{~Q;)(+NITEmWyV9ry?1GHMTxjeV-HJqn!mB&Vcs+Ma4wq=02+ zM!s%1cQ`yvz43Xf3N+wON9AnS*Y!ZDKls=iMCX-lrSMIgqn(&X(TeiayBzR27b=O# z6_}3yAOtnx`^q05yGDE}X5B85nJr{hiHCKP4e0L5D_K+SZiE#R)q~lXnVG9C7gs)8 z6|{^S&t#GS)D4r%^GF>Sh4`EptxNf!6HPh&S;cz@L^3hz zZvRhOlLnY*MKi_CdLD`cjWc_Z7RUgwTy&AIvt3ggrRu(HmA&|Yh`tGfq9!CJnQ~7S z*zK-P2PND`*eRdDrqPj5`RrFSc8vJlpcBdrcFC)%GC8&bKZD6)?YX9l zp~H|c9yj~4sxoBM01n9dmzaUqzR!1i>8lr0!^~6|S%)SQPC7@xBVfmom>wM+8Y@1# zBdfjVBiI7`(9Jlxo^LNo9q&F=4a!4aV1e($NVq zOj>A!m744n*HvV9vCvcZZw-<6w)d)Td3fbXM42ixAi@25Pc7r+ot(o^O~nZ_qnkG= zO^u?9@xM0!W4{An>)0F3VEE@s6jlBO@S_7WUaYUSpu;PM-m#DN^|dvP$3P@qUAM16 z#gsFE_s$i>d*mxM&EO6aGM-Yt>5aJ+Fl+0OndSTbedIL=$H9@tRRebcv4(L77G*K( zcCj3+x7*xC2blWyoHZ<*+%##YV;QF?{6E5az%h&#Wjd>7??*G$`m@u4np5uAZ>bVWu~XJSjQ;S@Rsbi zN5*T7$w8gkFQ5&<2vaz9@9r`147_#u@qy_Ua$LK*x=J&;KAIE?68nlYnquML+#mln z#vf|)kI2Z#C@LzV2pf`Dxc}vjVP|&&y}SND%)M1W)NR)_j))?lG$@L6cS<*iw4ih; z4I)S=(nv_BG}6*aqlAJe-6^1yG*S}M@$DJ#zMtp+z6bw<@8Fw5M`o_Mepl?h_S$Q` zYNT>@YGGSm(Vm%U5WmE;Oto!NnCqd(ic3)m2_+A|W~Zg4 zK}{0ss+09TAXY5qv0wkMxMbz!)K0O#g+ z**H(oM}ZIpMC1jzx!b>eGwaRR^YG~Y_Ewwzk8`t&t1AJ+qec(Dg|d!-`&?$s&;Dr& zv;90eMvYXy0|5v^UfuN6jDZ6EF2#7BmHp0|b1F7+)lUI0W_+8#@Lmb(f~P9^oo4el zE^?8VBZcxu>c^ig)tyj_g+%ml%->!;(XFx0b&Cr?MQM>s2do;Hx>9x)FTad^8uZ05 z&d4+d)Lz8%_)Enj&3=_i+X?U+$rW%|5U}D-41~gTga?t5XGe;NijvafKfh-63U3FF zN7g>u+lYZGwdLxl_7=;B)Kv1ZV95 z+LHL%)c4992xt{IAEk&?O=P{`-s2<{cC+v88?-6x{9vnHRM6@KIlXJU)rwH zZmc2dNw-!V<_^~v`*WZs`D1vv+@e=8N+f~H)TJ{Ko7-HOSznVFmLrI_(<{a=|N0(f zCYQ)(Yh|T#`1}sa@0BP~cfcS~W_u3>`WJBaD3y*L3m-vkcUILBpA_O|o;8lAzv7Os zLtpA16_1m+yd(pma?&jjNOp%3&sGH~c8T>tKm-Vkfl|gdsO8l-k%N-%)XaU` z-i!+!1>Y++`Q7&wpFghznPJ8lI5Ed6?Sy!Emiw|4KnYW~+A*@M4EQXkufg^6e?NGAr_R=q3MZy6hP0F{gR~lMl*98ndquDEY;{yg+NtQFvx^JIbO?Ak4Udd$FEdu#659}* zjq10LF+vSw7vFw+Ie}I88Q6bCNXK)6zAzkIpfnaq?V0U-ZiG*v`P~3Bmviln#&PZh zx33=t;*^4L& zdP7l=Ndsc_=g*%*;wt94t;luzC2%#iE`#PO2)L4wJwDSafnsh&`ErSUuYo^U|lD-{2{)wRCX<2Ghpzy3v7QK^#xp(zPFG&<^gk>vkncYyz_T>ZcD z(FThIq~=|(_3MsPz3srFqxBVih2`|^`w|ZxOhz;IcfS@WM4m#(=}$|PPo2cQgytyd zvUNvS_l6|MHYY3s5rs_nIfaV1F-Yr)xd-W2SXI${d2<6qlq^h5W$rh?Dg}9d5>isd zc2L_Vz4-{6geyKaoKyg$z(YfC3|+19t#UZw;Nt_Of5Njz?WM-(?)???$-es6f|}N7 zxus8bb}Ws@_Oi3uLr7OT5_yYs`N(m?--A6RID3SX?ctxU5tO@yw%2`(_qQ00n-sMw ztX4*=%4q6;enk(iv5$VC5XS~e`Ww^D;h-f3B3B5V{wu&q*;*d4J=(7TwoivOI4+QK zPUSpX5st^E@I2&n8Y&b4${hTuyu7@i>-H$f#Bp<%SwDVE%$jJJ6=YY>x3THEJ>e+? z%A2rFKD$Tn+C=Bi8Ji5y{`Jhu;U}9qqpCfVH6xIHJt`4DNtf3X5IJEo*XGN2Tk?& z#?7~MYQarGm>i*A39qz_y=*l9+S>Y#gnkQUgqs`4944dziHH$v>w!mS;#1&bfF7Ic z!S)z16dd?T*^96nYZ2mD2;O6TeTo_1MeNr{AO&Y}OiN2U*xg-}+tCg}iXI0m;KA7s z8ce|Dd$hB?oU`~ZVU&)RAEwJX?+}yOZv}zw&0XqvJomDFh zpO(-*+ZU6k*4Q$%gP~s;7N!gwvmB*^P*K`$TBcL2xaj$56qNhonyKsW4cgppH0I1+ zpL?xpkoayX4-^-nYn!9?4;vE<9n=B4U!3=+#R$X4xb(bh%Z_0Iq_{wT{nz*>X4ZZ` zG524dQw?&?_@rN7UU}x|NRtsFPMpe~Yd9gNsTqC@I&N40M1dY2L`$!TLBr4;pao!6 zfS&L6ZwuGG4I;XFyV*eK#M4$}g*hjn>T{@vsAAtMp{YtQv}s$1+3 zzve#S#NMrv_wV0l%AQa;@BWl(X$nn>w+DfC8fx)glV%Vw^Oby9(r%(gBs~z<@KJ-c z8H0eXrusbyJXw!_%!A5pI=kJIVslI@t6u;lt#Vlv?r%n${&C1)Lrohb)oD5|xoiU;id(r?pC9 z&lgPI1Y(vn*e+>I8%A+QO!;hR=Bjd!9%D8!tw=lLI8gszG{7yUZ*;DT(Cz zbx_Vikih)dLOpJ84CWAcOgEp4VCCdohg%(lzMz1oU20;PB_Ctb0jQxAiidEKSfBL8 zr6C(Z-VT8+O4c5<<^u+@+LGVKUl@b95d10eR*%E&CFnnHZIuJ%e(S+D_5JhQ*q-kur8l%JGq^6yZ7rP(Ng4eFs9fRI8(u zxNfZMO!vpu)8LkY6)lJ`)+vVxbBGY|+7At4|a`ZV1E)MlGMiyI2J zEU<~DPP~67L|{Ai{V`bPfan~1esEhPb+d(ych#_OHaDCkaS;*2xvEn8J%4FuL4}26 z?iS|eP9RhZGI5D$0wC}ThgpGoK@&?uU*8l2=RgKpe63dVYy~t{5%?A~Qa-$(bfKV6p7Gg*-Uy~@lCiePPw>eh7` z1iSs)*=F;X7RR~9)-s*7RiDMmG#o`|Wd|?|hOv58GBuPrwD@avH7Ssa>OL}&y@G1b z;O;#=kk=nhE=D5aG6UzyopIY2%G1+gAQc{EJ2&@@Nl55mp?BKzsABxf7o&mP$SAzq z^orLMHnBxTK@1@j>`TCjE3JnXGBeQGGyw}N>DR>TAm(WGpzHYPpfmYE*2*f;$LFXm zMj;^@4R7bm6DOw{k0U|u0Sk%H>s7#jH{G0j2NxG?e_--zl^9tb?RO!RN3RMA{Qz?@ zy!FRMz;K1_CS=wjO?92NA^nuKW==fyU*dD%^mDYBlMX7B;bCFG4ps#veL(IEGs4fK z-0cn7=p!wo{PX3N^*%L5*y0;GaLu9-Dl!W$y&+vb%`)QJ#fpV7g&As$MNwG2J~qS^ zF&RO?%>7DMK5XvQvz6~3%)7_ERRDNbOcBmgy1^$|Ym#olBD-tsM4rNFOsrI-_?kaO zTpJWk>haZAjcRGKj)@yjMNMoe2r)09p6hHV(IVB?XEl4gnL_1^L)+{2sFewKFJ*zG zn1nm~s_PZ*&p1miD7O839ZsrlE@}hHZb@1p9GFaD{~i3{B2C~B>1`h~q_-38xpnuB z#%Ft0y4!yAN@ypTTAzzn|KMgHR_lkClzZ{Q`%~HBdLQ&2a<#$>Xu_WR1D*r9hCu&4 z9RALq*H4a$^80*y3x1!wGx1&@$xTz!*pdr(HOB1Ks(Vm}#@c=gP0(%2-g+_#nrGaW zeQ0p-ArqJ1kEKPrN347Cu!30yncIm~8cn?`y{8@fjoT4(NS-%8igN`cjA~`9CKz#j zphV-{auC(v!3$Dpn~TAa(8I8^KG&cwh`oW3p39it+#{f$8>hF}Ue*3!x5Ru(w%>V8Y7-pd5 zD_}&asO=I|nK3<#8c<+DD#AbWhUt6>a@4tklnRasLCm4Ci=2yDF2vV~oT}PKzo4c2<)_>dN4ChooD;(8RT>m0K?V}| zh~JoYd1|vbgS#Z-pt1C>hCPWciYXCWCc6J?pu-{F7bo$pLT8k;Yj&V)lV>x=N_$&v z>iwTz{2=heJXo0%Q=8?M2p=ALrX2?OTecP}V7s){JMy`egen~4d$Ph#iSh?w~i{(5z$f95F*Hz-U? zdT1~hTiv(HluG@!p55svl*O_CnURc4!nxY;48~x$Ds&nwJ3r(ToJBstzf&r4CtNMw zhj4w2d{Z!u8%A`qnOI!U56Xs=^k)VF(Jwm9UX7+xY;?7GFj5yT@V`fyVj`udAO{U| z_v(po^8KVXL?`S0*KU2XP>uVlAdsiOu?wO(^ zEnaxNL_;&HG|vnJYgtxX05c2X_?2&#P(HJpV>r)!H*I&+dKC|=t&JJ62HH#`2%07l zzXIj%MM{VGsG$?mvu(J-_0K1p;@*4{0H?v1(N|?rTvM3ghzI)JE<)wMYk+PNZf>Ya zorU*Xy_#rEvHi!=B#yu)158Z-M}-$|dyN8WcDzxLB}O(HlsVA45VV} z+X?NOXRJW4g{vz)f&&9vPJo*m@Uv&X78W3t54``pIXv~_lb=)BN+;0^>R7}qx45{j zi+Nl|YyqzgZ?c>KSQXQD#SJ)hiBxBg>iX3facsb`b`R>FJbf(cv`VUD05z4dDF6jq zGy!o~55D@MlzM3zA@xL}QX!-eM*X_l!Gck)6DS2v7R`7?a zDuK9mkXl!`bBAUkCUxT8)wqj&k!fjh&z?O43^0Au_4#whg&sCW#`UI9-2%;%JYcSX zSg6}(Wnd)!bx}74drOKD!|&X)AWvEMpR;=+Q1L!3t(Vkvg&%B0C5(qwXkp%2LAB$^q_u86?NF03k9Y5EEOQv*SF+CheELB>g}d)lk3kRv8Z+3d6AUFjAu9pZZl&*K}V*I zyCo$wC!FB{Z#vAvOmH03duk?)z`!<5d930Hd8ZH8nz!Un?FBEizpp<@a(PuKh&+%ImJ?y!>2iSC=5P zGl4k{`~qomL=W50&`v{Oe^jKj=ZSmN`}bkXd|<%^%F>9`)C|$fFa$&f-54(~MTaTc`G-lg)Gj zeROKFV`sO&d>ls0d`)P5-KQ1`#iKPf=g7#Pq(L)$^@GI*`1C~PKFai9v&(apAMev{>G!vaRJvu2FDn0Zv)6g zoF+eez(EIF&wH5^ox3F$EW;q&<>jfpEUhz;_D0(AF?LN47c~9PND=Uah4Dgf;y|U{ zS2)Oj?h_(5Pt0fh)=%8XQ$O$U9_q$kiU|J(tfH*FfY+pC{+ac&_7}WIc@UKY;07BQ zq|D6`Z~Z*jS)<)CPcOr>yXfnjo0SE9i7D~%4Z_d{rJ{Vy#f1J!F8zYxL^H^tLq(Z+ z*mw$>b3hE9T*QT@^HI-MeF%v|c}0bZUKSOJ$$c_V0N*MgAw7&Gar6nWaX3H(d5yJq zT^?~D#Hr1@Ykhs6o6oxd&;^($YP@o zlE(`$|26)SZ9hZWO^Pi%UVu8gYn%{$=uqChgTuXGo>_@k6p)|AI7UJj?640yj%CEL zu={=om|mJmZM?%*3h0REM7RMc7KK71D!WekWVhG}z-;nus9>O5Rkj0=A~e0^yrBW3 zLUtOOa#yERBSVHyyw~38)?~eTlOp0;>+Y(8j9-{uq{Oz1*x>DmTy+xZW~MG2Mn*Vf z=vC2xJSTM$U)n95PiMTy%NrI#!cWQzmk)ps&kwfsLFu5P;wcOipvo>`=#eTSV%At0 zbM>lHS_WIslOw0iKmvMDI;U4At+O7%G`j_6JfiL{DygEu0k#A}V6zR0oXu$ENOO)* zCmje_ufq1}c#Znv!C<}^$m0j{vvRp^hj>rmL&r`6caFjYw;!EeEW4q#xw&jx%R(G4gj2K3#c?O^L2l1tOKC)P4LDmZwep$)UchWF-U%uSXQ0u|_zk=zf z`BX}9lzTPK%Fqs%nN-^khI3_+oQ;I{6lYUyu4T?Y&`xY;uSG|uQc5`3;HVcJy#+wP z`W~u?h`XGg+T4?2a3sJg#h(TJ#Nr?W9RsHU^n3Zg>#pC{A6xXgo zCYKeL?Y}_x5aQKFaa|&!s?dOk482XB6%oqzK`49XZrnyu8)e|Af~J~~k`xmih$XE$}owLH#GUFvXp_nN{52gP!rW!@luwfFUjwV&T6r z_zT+VY|$NYKgQw)b2D0r2BPbTNb2b{1wClCB3^edYOFeIrJFECGf@|IAofx|F?!Xh za}tV|eX+gl6+#t6USo<3{;K%T?fvuS%*~SGcU#O?Av|4oDU_*>3F5^Uc!v=iy)mjz}lDJcfv+6(7OY-A**h?JY#NALiK zBRM1_#LcbRV}C1f^&a49UTgV!?+sa0n+g7xPh%4n9&Kw2VoZvU2eAujY3V!NJ;e{d zf)@^0($LV*K)Y@psP%vU{v9!_8m73Ft0E-KqU6jCuwyt`e-h4kbMm9KRSgW>ce$vz zaaTcsSwKMBxBWe@?ka!r7W z4>#KHJbJVQp~>N9_XJ!`U~$6gXemkQ(gHOB zL#o*(U~K$qa`Hj;YpAz_A_M#8bz27bgD zvv9$pU%1fg{q7xtyc7Ro1SBPrV`9|b>F~mx@LNBodz*Pp%a1k@Q&qzZfhC-6+O*f8 z(vZY8y8}MU0NwLE`sOxV{8MohqAYO9gmej9GP^oD^eU`Ot*zgJMI(e#3krbve0EZ2 z`@w!osOW=$`)5h*Jmh|^xxDcZYOi-%Ig@m@FZ;Uf8LR!86&Iib@4^ikra$&30jJgq z6XFbnm}uUoHP(Ux+GJfK54j5YjJJ%_uih8EEKOY24eJSc*>_s@AOH!t`&z3Ge zEbrKKx2UU)Yt>boki|++J%4t#7Vc^1y!6Z0^GN@^U7+M$2Ap%#Ol7-p>8lHp~-^bxAo9vk9`cO7Ho$1f~M z_L6-6SbHJkFLKgm2Y{(5PT0UeIrvXqCgpbo{|s=dnfT-?bYwaHk;I+fqNf<>Gkqih z-WM)je6}#zIc@riTpgIewqw;Y$WM>2$;E9gX&Y+h2wj} z?}7Ro!~~o>G&yvN*C4I^4DPJ}_`f)h4y$FT$S^g7Ski&Ouj$iALFGiA7twi;n;^;$ zI49sBV0xz^%K_q&ZiW`r_q<8ju-g_MB+)9!ngaBO(mLVKK@w;qHsOxY*?>?6aDD7(- zMr9~)Oz?682cF_$j-kOpA&7RtxMpWn6r#30Gq7&u_c$DYruzkJAh*b{HjijW?>;EG zV`0IN)M-XG4&B?!Bb(>WodXdO37?D5U4M&%BW=B-Bjn1BTfL*w&@{!S>Hd3C!hL@$ z$^b+qlEgeEtgOEGWnnEI$WH<6urzdXugG8wejL|gh*@W1A1y>kOOy4~&@v3=9~ouk zF39E+_AGVWd(zTHTpX81%4FrCC*rYCim-E3bhKW9MknwTO3KQzHld~X-vk%fBHzcx z-sRGQWv%PR42F%34G-BrM(A#}Y2Xb7)!yu-y@kj%@RtSpk>U_5q@}~fcv09aLeG{x z{xDhWZDb(>gEoxyo8*3dpSm~y!S(@M0(+|ad1~Op{TOmlsD1Ny8i8k5AGl1%vL^NC ztd6@sSsfi|4nJbjEIe#y7jybGpsK4|BaM|qf(hO}kh=$lg$exQ9)P)zLh3|4(vq@{$O2snhX; zG6{=TaXg#Ey?em{2RS`{uFuP}X2D$>deFdB9vbwQn?ip;Ui7_u(RP1*@g7`T99;RO zZhJMHUWm_--S!MnmOe`gi1UGv@{K%y(}fsF;Vv!x*if7DMNN4I@0{$W;u*1Cz0f1UL3<(xFgZ0sHcM_q^Az;hlS zmd-$=mc$$66wGO`pK zn|BM{kdcFo3mfjG`OYNcM~|fDtDHBtVPAXu__(`$R49Lvhzp^6U0t20q9O|ctRg58 zTlF`&cfA%9b>F`a{#`e2BmoPdWW%Esyt<3D`ybAASib$@v7#8#Jk2@A{Kk%-bUk`3 zTM2P%yEs3c+;p&TF*@9}<2hb8mVS1tyS-g@pSGzr5m9r3wJw-|$DdB{!gIu-hKk*eHkphANf31A+8tx6%KS%Rev}EKEjMs`T~u*VWc0(ldjH zwz$=qRPD6ZHovKoEGQLz2+ou} zW#)WXSPpXRmjwVhx_a~G^z5vq4cqm^EjSq6YQ;C+e!9jAe1jBjb5W&ij&(4d$3i=t z@Jua%Gs0US3{vC1im|$rw6Zd8|5vUeU1jFr|K6RWm=MjU@)$@ekA@2C-1hPTI#e{o zf!J+eAQ~btbaa2Cp^rVZcM1%~63sZF?F7?6`X2aSRv$wRKTG>YVsF|~mCcxo)K_^p z;%N{+^ln)3C2j)>1-Hx8>ai(*p=T1=C@D5tGN0+a&Cm5d{%fOEKR%0le(|+K$637r zmCw){AHWY?0c-2$?z%CYCE)I?WDX(`S%(URdLu18#id0>?yE8l0av2{M!jKS#%K$+Agqp-m1`1cM-Mo2|aO?Vcr1pL^wPhr}K!74Q{~7+_6S&MJmPW4fl{~12=F08k&F*Z_ zrvb#YK(9&-lvsjv_F%!M=j43wIQkBdYDdxsuRaE4Hm_w8IttiS)O$*Jl07i1>?U(D=FB#YddvNC-T3Nlk+cIxowIxgt zOuOVp{=I0;bbq`2gDNUW6$v5=GkJRbF<+l9cPO~+H9BQLY`nX}rtj@D(ls)o&68=j z(9`Sdi*}udi)$I^_khU*nN(xkj*&ZOc~-oUrrXH}awDMY+`aQ8T8gKBJ&cau-ge;y zgwsY$YmOK2NjDffDKhbSB6~Aso%RWEaqnn`RJEMO&Y0@MdD^i9V+6RGdZ%%-&+A&o zdde4gNbD9RD8_Y*DrS*F`q%Kqvx*?(^K$AgRfz3}k}EhDAj9zMbF!;V>|$|f-LtvSzo>?;?t_mr?N=i11EReMwaNGXkeLiB^aHeQBB2`;G)HMbrVz zbcm3>tvUVqKj;XD6K%*Vq<3G8cstG)4GW7&PD&=nEysylV! z$&$`^otiHLcJk{prQqMJc`+KAFwpvXWLD8(Si%*<5w2C9r)h_PWd_xeaF|NVAtC_@@%Ux0xlfm8X$UJ#f6Tb3oA z$|R)RSwwM)iRNFI#laT%rwksT@&$7+mqA)tB?utdCtGK)JwBEdtXuN7Wu-+q>T2>-1T zW3M0KNt+ut8l%gHRd2%VvYi4D$k{bPLh9mj!x^sPL0z2k$nOpcMtpal-M{EpgsX%| zJrWTaA)N6wK^T_<{%|Rwh7vdXgFsswt7eBvsQn(JkKW&Jy-4i92TOHg2k(q`0L9-6 zf1xr25Lf0WNf~^)cf4WmJWxPpb`0l(JMnK81mNF0TZ*-&rU~CKTxNY(CezGX^)Ji7+YUK`{~azHiHL}R^Ie|o+1fbp)t~*v zwlr54wPK%_t47D@AZkK>P!q~rMr+Pje*_|Cp!Xy%zc5_#ijGcQPhU3*6A)g=HD{r0 zrgrZhJ<5ihipm6-xpt>~dYzo?wDP^mc48djd4UPx7-mf(}i+(ybEeK7%o=}eM<;CEeZb!t;qZtm%h1P9>bXQs}6ejx=~ zXnhahf}UzrX#K$W%Fm0(^Tf*^#~mX&qG*j83rj&$QxoLjfN?DBy3ImO?E{l_INbK= zfL7F~wdR1jt&QarDAmiRN>$sQymRNyWeg0^$ne2<52LM6hcR} zdHMLTFfbJ4G@kqzL0A`L;5Mm^Qk+3KlBMfq> ze9aGqIu+pU>_D1uhvF|5Am?pd@7-8;bW2r5B{dd?P?Vmoouj-0LWDj3P+0Bh>jNGX z6RWJWG?}Pd$gMk2aDf^mm!}QT|lL_o!)UD-1pf zMn?8_&@%Cm5fVyYRh||Q5EvgfQ0xw?tgOtGRRN!7C~t6b^yIut15wfvavC0<@<&5# z0Fz4($i1N>82#vcBfL3tNwFQ!Fa*CC@Gc`N{n;6SQNU@PxV2f>(_o7eWYwhF;n{&< z?G6+Sx89*&`Bi7$?Xl>GcHvVt`_B5JV#C;NNbm$~$6x#U`T~T;XE6yR1b|ihGi4#K zu4=2zSyQ zJa&J30M$@&`zP$KC|d9*e%KOG56kdlKR2;?R5_NRR`G z+WiN~M}M}q77A(&q{_fJ8|rq0h58Cy_Jb{vVkev3b$}d1b!^Xd)n zKK4;DkHo8(nB>UFNLxytdesFiZmvGdIU`WDEO!HfZ*lQ&NFe%GoPpvCnP-J%|95EG znQm6Oh`kH~GzuRs=M6VFUrFb0rj!=L7O;HuNC6PwSvMktr!|lw(k|$b_+PnkJY={8 zECkg7IgXz=zx^Q%V)C{h@#r-sdU2OR&L)#vJ$DRX!2e*EK@nkb3Cs6Imgo0YZG+meN$Wq8<^ZZh5;mYIu7 zbw~#@C~ug5Hem_XAb^N3?fGo!QD2|(kRs8gtmcnM06)gq%380r15GpXY7v;V1YF|VC(s@N#N&%k_0y@ zD=1_tq*;2jUIwc11w-EFzrDOA!1B4K=EhMv76LyM32ak1+n67|ucA(t_5B~`j6MY%RI1M6<^751BeM(z2G4RX4pVbS4=Ql2;9w(SHthX zZ}{2~bkQ(<4-fj3@mXJFa{$pq#BDdC5ao!1YmQ~h_}9`BJx$B6QqabvtD06!H}>lH z#d{pn98PX*VL@{Xb{#9nK^2+2KaJP;um8zpe!f7X0v6=&9UN#(5V&<~`>X#BC@caW ze|hqO?c>KvLnA{wfwi>~pknFffvxlqx~h6&D<&1*h-L5Y(W`vj;^n1!b*n98bPSwz) zz1{lHNjW@9t*PV()JabEGStxl=-Sz`9;$d0rw~^t>bwyx%3Ku~_vzD7=nW^Jf3Z{r z%S-UEiD`5@0FF6t*q?-sDe<*NBdbF z>t*^xwUUZ=qHepmVOp=~u4on=zY4JRidv)XBkvy<-7)_5b}>FVxy*K=eqq64Uk+|0 z!z_8CMZX>%c`dEsuH+gJx(==`v03`I3WS@HN;^<0$OlaxeSW1Uuv}?qY5PMn;oMDg zoy`w|wPpCpr4d#VqPrC3u69QWCUUU{W>Z{6_5#0NkfeCX$$dBKp{o3+*L?y{ChvA8 zZeO|1L-G6ksTZTFhld9V$%tYc$zB|_l%k?{iCrt(%Qo5nD*QAW$%)Bqkp0iXkJ^h34fanD<{GN9=n(!EGVNWCSIxkG)!d!FZ?n`wc0zswtZ5_@}tgl2vFM~ak7)IFS z?e#Brl=5MTcE?@Zsj^ZuQ(7c5 z_$Sx7Jy0n`{9)Q<=aDO|+S+HFCPe)D717@Z@1TA8Sa81qosEIH22uaTjFVvdo8%C7 zxRbZLY)L@PuzXbI-()xIv)y$>j+_s1{y$%^Md8;N(m=@++%zq&?TjqmRY5TxOv4ec zw)(c4D^a*qBzyR#v(X3+_u_vP%qc%4ot=F-NMBRLU);q^-@Plta9X#8ECo^HM!e+f z@sEnNttne?^5UNHQ2%fdV*O1HeNqC6CI}=(U!6CljXV3EziE{zE_v3aMtY+J(Rl{u z4~Spe_8%#H)&qA&%&ai21rnWx7~GTfAzZtL;2`Se|6utFkSA%Hxigk&1u%=ArnDG^ zYLPe>NkA6{MjSP|j*6imY(*jDWPM2$?Tl0K^NbIU_%A55c;p2$N`63Wjv+y$^cSWn zaQ>2nNjpk{a_6feaL=*~;1J^cLojZ{UvUlYYQS{pZq7Jkrx?C8`iYOkP$6L~9cFQP zD|zp4n&th^!nio6_;z8!?H)0fooT}!yeAVORbj{}ZTVj!0#`uT zeQ8;i3`PmT3=7L_t7K)ff=%qcm@yalCo|gRM-Wl_5pEuFfUXCMCIU+M-VcWPe&E)=Fa2MD2wAL%|tL`DV z;t9wPXMtby&u)-ZEpk{d!BlYcM6+Xn(!?o%WCJ{we+H+eNRQo0qI32D|Fqw|{1=ru6(!1_z#ol>92hwQzdUww0Q78sg#M%_P0kPpWxstM@?r9S&l>xXJd3 zbou!K$f1k?Ibsf)yoI5M6j*%Cz(7Sqtz8}g9%8A}n#V>b>CKgQZ}TI$o^H;qfMvp~ zFks?>9t=MlTOpK8_V-=hEJr)O*n6*=P!!Izv4it`wH$5bM0^Vq!;Y5UF*Bh`9Z=t&BYzCky;T^|NwNaH zeht1W0FB1AlaFw3Dn2<)=OP8L#hg|@zIn?mx+N%8QUcYl}6 z%$NWnaeVxyHj8T1EOf3xi!@Y;fMvSe*Ql$Xd;mpDi2vcPR#Q`hAd!HC0gARPUhOCr9P{_xF#J zcd8DdjEJ{RO9=@|X?eNZvG8x8Q$oV9r7-)m_xs@B)8?=-Hnu;DZT%w#wQkO9<32!T z#=o=+6dd=XeIH${a(I})Fh#Ex1rGvX zy{RfHb}aTAKIUuP|Gg&wv4H0~RY1GC?Qro!xGx;vfAtT4Qk4jL{)>x?TdY7@RP@9G z5F0>!m(4kS9i5=Cu<wd>^EwvUBrdSDRaiB<5qvucQ)jQwG0{TD#AWSYRo6|D_gcx@>!8#Wr;U^C~ zJA z7(claEADU(s`1&*z7QX$3OLv-53BOj!H3n}4%NGeLxziwZ$H&wnMlV_+;!~Y;94JT&_50wbU!) z1w(9ll?`sbZWiMY>%I3B6AGPg->yzfbkWdw26_OHkU@cR8&ql>3;I^RWhu-L71e#W zxtur#p5-ui(om0wCvAzEt6i4Oqijyb`^!9$s>*)$*UFC_Ac^k6;z{JVF0etLXcZU` zkoJyI)qU#Y%e$|qajUDUfY*eAG6;wvGw#z{=3UJmA~I(OrV!NmIwfqiwBjpIWb_H} zrUB=W-`W6q?u1~ifp@w>R8&y`O&ZUYeRv|%i)<&Sr*9GxHcXrLKZivgzkFMir)!2W zU$cnp#to^m8z58mdk+;*p2v@&xcCUF!9;F@fPqL#No`&LXN2U01Q`da^24n>&(Q0< zZ(i|sB>TULin5#wF{5H(WtGEQC<6k(%a^yo{2~R>+Ulxe0;JLFWXC14IEf3C&@Wu^ z!3jDU-f9*Vf_6*DP~V?)-I{y!?E7pyI8&?RL1vHRb1Z^<&G;-{*p#%~>D8EhGHhv? zq0CmUIP|)ZlJTmA!B-%aK`8{nY9v^z9_7mV={XY(;!?yv#knc``?e<20#VS>(dS(J zfjbHv>*dxXddG1$uFZteO5jvkX7~~Vx(4cg+!8Je$~ina;yKmTemDh;fn_7s#KB}u zIlZ(!120W3va+st7hMN*pq@e>eIL&v@$>iZ`>h0Gwzlkx=9UJTt~+c%L4u|aZ;<&K zDohS;v-`bw2#XBhhGRfurpf~tzWjWGnY;%y2+gALYa%Xa{?y-($wl2N9p*)^0MV+) zKZdC)lFG!?bYo8d=q4Rf#?wr#t$B*tQE{sGrr+cQ$M#p+NhK~0mzf*t=u`&>J3t=Q z-r4zT@g*zY-gQ_Tx%?Y0No5VL!BdZx5uh$Iz6ffTzy|Kb%*=08R8$B1QrOoN7)_0h z>UnD5Z2=FO4%BZ>b3x4QB9M)S@XrDCM*S?oHKMzGL z0YO3FNr9Kltn2&2Yn|!Po3RNRI{;4sD?Ug2;@n6_2TL}^Y9t;!d(A}0J1Qy&yd=n_ zCOSGA7(}pBbye8;lpNsjeg9b3X|XTR;}-}NL}v6V$%n5j(i!!UfB_H4ni2DIcZ&a6 z9Sioz1yq$1F28=9H`RWdjp>ZA4)5lQaP>E;(0%o4c1 zm{VHjP-Kvf;nxf`x?zD8Wo=q_>5c2{e6^ zks|Lm0Zg>;HDjEag$1_3 z(LH6&c$@Bx-r1ku-qlPk-xj+384;;7$&PmFF$j|TBa2W`h$2)ax<3G(SFh4z`%{m! zKusR#A8U&vg@7}H1&cd~8o0j)jIW~%n8k1IenAU1Zoi8o;lt40W*iW+8)x(RtOmdcf==es5Rm$T%9F+{*A5rn(%V`0X1xzHB2I*nP_QK9^O1Aj?2Kth#6}Q-bJ_r_KKki-g5kMj+3nYj zY&niVRl8r7?GClsONXCUhBx2L77(}8myTNbUnXfMY1oNcEh_MjlTlT8bZA?r|l*1Ub3GndN0aDUAF<(=j z%w$-*7$#PB!#+}+Kg#R%PwbpPFABVO^%+z-Q~|P$R74Y2k3@oGvl#Zb-3LEZ=rF4V z-&epxJBK&mP1Sh*E0K*V_6sz8Jo`wYgq@LO6RwdlYdXrBR%;K#O^~YXUS2li`Z+Q$ z)-6mx&W@{vPe3qMfG{}FNXKlh*xLA7vyGQBT*T?>HQZL>CGZCjgnq}}-%emfR64R; zQo{LRT#%KA2Ql|3N-vkQax+FbH%9GtWE-6uF-uO2woyv_jL+Re&G~HiXE1XX8A2v9 zW&NrR2i+$VOBcom@{@%8C<8N~#MK^G2YBjWnK>AKm{on3EcL zBFd(}uM$V(?d?6||HVAjeYK>SY;7QK6I78P5esF#`-5HPzEYU-a(BFlxb>fFW*pw< zFFj#oG&HoA%X#yLhD0UVrQQ2NHz6URub>jPp#)f@T=dY zTag&mH7=hsDrn9nx`0jbIa%0QjlA>*MDO_cQJ^3M8cNfGRaL-@Jk|(q4*LW`*geCj zrAKPa%8mJ93H%}LK{a$yI}Z(nu6CtR+V+0*5+LOFLWzruqwn@w#F?!s0)^m(Oizbfq3~ z^5rV>RA*0ks7|7w>VZ~}gv5*EyG^XR@3$(=p!)v3JkVt>At%*+TcBoa)$V3Y0m?ZP z54?z#xcpe5wZKV!MqSPf@!(K}joPj<^cG#tq7BYbLQGO|uV5hDx$PDco12=>&d5Tb%Y~Ij6;HKUE1f>jZP3xlew8gj**20?`e)c z){Ogwhn3HA$A3tma64EppsRiQFfDVtX>VcS+Z0u9zK($bn+hpokO&o(qH4$lehki^ zx9v6e(M~kZp;*0nZpF_N_7Oy4zkmJs5mlWP3mg0Wi1pyA^skrO1tlNfr~lrnvadQx z=C&&~X^qMnnPo4;`qaP`m%CehlutyB?ArFstz642Ynpj28*#cV`()WW(va7|t`AfB zW=Y1z+qV?k#)v)fgyjMD1w@hfGx_Uz<7DG9=B`fs19meUTwOt84u4Q!hnL6HvSmRR zR-d|v#uZLy^4XE|Gps58j3JYVHxCROu?RW1A5nOuh=&OO`Wt<)SJQ4w<29D~+iOM}qR55EU&Q766&4qE6wSLd*aDl?w=YOhVLikTD_VSYc+10dvdMl)?{=X$UgZ= zoS-J>TN+Cx47ZwDDJoJfJEtO^2kUDmkMk7|PrvhOK9dk**V3<>IHV<30j&r8h#}1i zEB<{usH};S(0olMDg0{{V#v1;?~i@-`unu&doAr=2R+@=y+4oQ*gWhYr8u60m@cgO z3;%pZ7al3mG=IPmSs8i&D=AjIFZ+g6hh|_sumS|fTX8JUhoZ^r;se0w{k)^mq?x2UdIg!*(N-I0ka_nv?AP*cXBDLF?d4Q zUC?-S)c^6Vnz+IDwi7@Y`uW4AMqTeU6xN`C74QQHiq?!N1MtAa9idURz^m##80 zGVbkNSYLMsRDCEz=J$|c4*1zI@bJ*^@!3L0^x~qqNjfE?WpoF*@bkMidUHS}YYwfc zg@Dl>3or!zD*LBqb2uU{4HY(HOf)nz-ygm5!)b{FI^`3OW53_OW1-E~Vxl%VWB7ub z?Ltrb-kuDA+0o=Ux5LRmP!WYi=GlGgTe_y6HTI&|Ms*F#&PEE&sB;4j*kRBs0t!wR zP^F!4cbfU)hsFDj801kNehCF?dxDr}ipE$3zyePFO0UeiK1zx1^rYMRZua@S(-lkQ zvHWth(+#|WS5Gk?cS1&D;IUWix?1Jh5YQf)B;v9KsE3rP$MP^Ilw#u&Ha1E>Oc)40 z5U=wm+gF$TG6~(SE;5hu!B_QEFTkZc4|tpts_OU*29G;j@SkyI3qvy>uPE zrnITVv>i@ylm{aD-_-5c0*^?L8{q4k{g6peE%$XeZ2UYq83Cqxl?1B#G#X(tar|Lf%j`XSKl|CwbKTc{-Pc`Eu$p=e8$s^#HHta# zA{K5g4v%K2$rAizD&rsOUSrd%aNlV;b=8Me*mn7gl)^~q(kCyBaGsKiQP5RRPW9e$ z2$#GX(*14YBOIOd-;>T-XdcZMde3zM=2;>IHdi+ThB*{Z!w3)Il0dOFWiZGNkyTF0 z)LJ(}??EqAP#P2&!LLbPvB6OE`}gZ*loB?D{bw7T#_A}`;n~}Icz^wByJa$LhdqIQ z26h6rF-Gb=X`pUgeNLRq%5z(P-@SQwui3g3_f{6>!c%nN=#_^^R-d0rf0n*Gtr-q~ zrxX=IbmRd!(6y zFXc$B$G0v+3hB8(V}c;O$6B$v z$D`GzDX>7AJu5ouI_@XCQCZKewb~2it=M3anWg1=z9IJX>C;f}UX42Vn%1sGoR1dDNzN>%a6OZB)(~pE-3+3muS{_-)IRWj@Td;Tuh*&Vt8iY5jm# z=rA$<$QNwoviwaSoHmc~zXN+Wn}MP`A|f$!;8pPT3f?787B8|qWM@~+mtpGs=GCS_ z6D-mR?BtD3^*FWs!(p?^5#sSW7Xkk*0gGWp66dp$Do`u|%SfRR9b@=FDkK%`)acKi zMPCp|RvcWpa&^M})vIA{AN$J(T{n4nc(}PW&bmE(7`>9nLTU>z&&dqEQkEis_$1^1 z#pl7q!nLfr&%@0)4rP1+NU5i<8IMTPc6$m$mP+0fb*Rxn++`lNQ+VVnxKTx!Il2Ql zDFV5vLI5l&FVEZ%3x;iARt5eK)JGygTC_#JD=%+hsR|2L633$M+}`iJur0>~iMu0n zE{D#{IWR<|%`T5E(R1q<74}1SOzUb#ZBx@fI4L%|Wi4FLWT|0lq1hC6w<$6hitmd| zmxcf)x5l+-k4)R$Ju?ZN;f3}cxVT@Xr=Xg;bA^J9j)EV+c{xXT#{T!v9F-6k-;#nhws!rQni(TDHqC_>t&g z>B(W@I4km>U70S}U}~VrVcJaV1*#Dss|EJ9??32=?y<4bGXdDqRq%(|mnz8@RJ``T z|LN}(@D!=e{m~)#xS>x~$0`~9wAV9D{UTrN-u(nW$KVNN4p+`hmhdeX@-VfyM1Mc} z2U7B=P9(-xL@YG6fq>uc+`62Vqw$e;1M&|6||wbzNt zBxC`Ejhj|dV7d*FG2rFc%Kt0$d`%8LAwCQ@Y1rftZb}nAW`g$qnP}II^8Nl@Z@lxl zIa30dLUdT9l}|Ef$Z$lelaVn$<^-*(wzt@o z9)Aj|&LIdd3qX^j4d#0P1elPeH66(Q>8abx(PVul7$O+F@lExA?^!6Zvzz-lEuif- zT`)!vKb$6Y0u=thJeo~{lIjqmOc|l0)@bJ2TCm z^hooiVDr*m^D{d_*|Vkk5S&hti)@9{G|6FPwwZD9?h{=YB>I37eXFZIci#R!z)>Qi z>#1zlV7%LOU%hIDN&)f`&LJf=DsNT-T6GG97n>JY|NVL+LOvY;76}P1E-u|h5KSpQ zc@pUF4=)zK6}fdH!(#&jzjQI_4CDAfhYC%s=ty6-qu9~sFN0}FcQdz~s+yXFS(%5m zb&epBI2;c8iJogxm~wM`3lOyE^LOk)TH_?PcK*=a z6g6=HrVFwoot;Ms26}pbXGVg~YtS;-E!4H2ADnG#P27Ka;`ay63U@xeVaxxK?mfaQ zjr^9?`d=ul{RUHp{8PL7&%{C?a4CPLA0Qy~pH~sqQ}I7x#sBz2mUWl})P4gz92I9^ z4q?M_Ak%&jeWdMQ6{hF$Tw9+FEO%fn>0i*(7=$ce(gRXd>8-yG8akM1byTo7PIV+YcS!KzT3RBxJ!C&+-1-A-NwDQoB3_39RRK48 zVSwjTQjf!t{|;skQ_=Q>L4IMo&4%TDpyl<6jd>wJ=(OiMQG@}<)iDVDK~p&0?(wkE z%xsoJBfF)!8I*dRWp;fx{ZpC>{%3IZ#ft&<>p@{V0EAnDaPDv>I39$w($-@D)WK+| zy>%~17F`Bl$NSwo9XG3joy2qx;!IZVW9_1upn?L`N{JZ&qpPypn}b&k43bi0*>#=z zjqRCteymQnH3@gaF#rB-F+N^_OXUo3-|Ua<>5+g&?_g5|%Y-{=LkOH}f1^1uZlfMs zjg5`R`QO12Q_~Dq14SxFjX}#tfw1m(rpr5Kj*i7&^bs>Hhl3 zWkFL=u-u2K;kE$f9?&zf3xi70wkf#lw}_jB%Gorz`{_$ng{y#v$0sI?9iPwlc6?E& zECLLc{!8k#6k?q=In({KRBvle$xt2(K2%!P}SNY3!_bz`FwyP7_ zfAQSb_R*6k>kt|Nd2O~odsQ|VG}I|4KuVby8M(-5yAUCl6LQC(t}IH=wLCTTY2#|1 zu6#v^OOE2@09r_L7F5{HscUO#xu}MvU=^9T% z9sR@EyR9)EKH2&YrKM?DB`y1M)pxWtbyQXiTXG#Aw*&_UKG}KePI7FqDsAiJPW1D$ zJJDNads)BMB;Jumx>vZa?*1_DDIzv*e0*F}&t=D-Lq;KnS6Wzhr{%?ZLkF0`cqxHz zca3r)h3^7kYqncfL&!+nw9~7nr{bJ2R8S#T2JUegB|Q{ANC5PmavgqgMD_OvyFYxG zishPd0p;V35@2APxSCeq)!YZKd}85ZnV}Ch=lZp4Ph$DUAd?RB00dx0kWhyVfToTP zKLAA!X073q!N=4}apvt%fBaZp{&zj_qRGj1VPU7WCQmJ2tsLg2V>yJle}sx^)E&Bq zS@Z$;EauRxi3EiW&}`^*@RdgvDhvbk>PX~SY2o>+j$EQmb^e?VdmDLqI9J&$jnEwJV$Yc* z{fn9*QWIEKo7g*su|r_ci<2VFcJSUrHPUlw?`=<@1$0y2{#9y>>xG2Cb#`_>_rssw^GA zG6z!E+_!F>ZSR+vfL9TJw?`VNRLG#Js%0T&TQi9{?ZI~s6#CGyRv6<@MR%BC_O5;jkt*O^|2;Kz zoWJNlal|32PRnK`-vIjR8c))$+80Gh;=&uWD43bPJR6Ah^j#+L>FK>)T?FGcq}yY#0pj$oTK}O>_5-R+ zKSJeug4MI1nrAQZ7xyii$NLT#Sc=N)%e{|x#y30YgPLJ&ilnFVK_w*&NPv#9F#}CK zk=+ru>6v-cNizf@mUe)rmhMAc-DwqP|M9WMXQIx%TQfhqWYJskaaXIvvYb=q+BGT- z-{Ajn0oRW58~lM3VIVZsQdSl>?|Cn3g$)P{T^hl%sA9PVY*z#ZS zf)P<1UfhnB+Mij$CU&xtHz0+4W7sxF)uub@VFZMejLb*X#j2c|8ZX(ngdUDBY01#lOu@>7M}+B&mD_ii6snX^DUiZ$S7&OTZx=kp1zN` zt9BQTOsP4}{r%OJpZR@3%wigdzX2ycmnhP|J3%k}hP=Q&F)%OyE)Du~ zZGDj<-QnbfTA@mKVM>R136QXO4;womV>$8>WYXP~jCBAX1c4X!qemfhm9J*29OpG$ zS}f*zoB^nf%Z%j&^z+ydVJcf-ls?9C^<#}TGaCMPH5Bdd1C13I9-b=ywev_ls0iT!iHRZRbTp z=V1YdiAGdb<^?7u_q8^)NfRtE3l>@6+gEJK2#G54BIL#PULNv>L9=^14q%1#b&$Xt zIb8|X)xW3j@7Mx;C$+a+FLkiv1R4`Lx#kip{kC|QQoyHX&;&j95*bRP!l%pNLE)1g zWQ%t@c1RR%xZBy~x0S3Y1z(3Q%A)5!q#%`~to;3QhT7wz`I3xDo zrR2E;*z)4cq@(U}La19ErXVT0b!UawrI$lU zNJvr=93&Sn3hj&WEWHVw6a^DcDq1GX(HdRQzVKA@17K&N@OrCi(fE1zCYTNp!iKFi z3cC0>DNjxTM;L94=eFL{zg&riB&UDkb?7}Clk125+2Y={KyD5e6a&CC$DCJ z?JdxjF1HpYqYj`X>ZX#DEn@n8{iaEQ>I)Kg&mjCM7Z~GzzHiW3T3>SzNbOGV*Up@_iCHX?qclDF) zR|>;hz7Ug9)LEt>qmqW+h%9)nOFz3-?tO)9=uB4%XbZ#HzXRvT0kf?RHh1cbNx^(Q zSMg9e;kQ|Mp(H)m(Rh&j%^`rQ;do#5s)-Odcj2o89qI8z6X z(r(>jq`4#WR{k$#v8}fc#q7D$9;tK-9^wZ-dA`9C4@XFQKx|RHP=JF;r2%W_(?`V= zvLw-W1L_x6``^sc{WA2q*q8A#xYdX2=b`k{N8cVvaOgCHMMYb`I6-=tCPt4rT{q=@#?)K{TppeV&vTXfsmAOdG zkIrd1ukSkkk;ziX;C0o_YxYt-qvCIH4<>~i;uB}S3P&;ipb8YCg(nBFB>DxR%oOD&_-T(5jIA=ui0zN$yy@KO`Z0bxEj|6y9Tejd`Lp6 z&C6PEV&{piXU(x9VK*ztG5cMwW0|uTxvL*e5Y%;ceOrsD}!c)#FUBlE3Qir{lbs%>2PwJ4xClweX5OHMNd#7PG=}`$w-6=NjaF zx){kH>x@SZv-KJtd`*)_1b$TA5;^s-?TkZN{A(de<~HAb`7Plq4?1QzDzE7VGm@6o z%qxj#qA2-5uiEDhxElkyBD%YY$kAK_#Ur1&oyU7z3*&8Mb7bopSpSZ2u?feKKZ?lC z&8!_0FVC4F-9-zfremeAk0?c}&6*~(?u}G4G`0TDVy*Xg2vhNXOD+#VP=w*YCT6lA z*&#zeTvb{5>&rGG*ne;X#RP)jwwD3^=2q@$!`AYT%D`V_B`L|Ej^CU3UC?lYNQgtp zY-Hu?pdk$eV{mCjn9A3eEvK#a?XVInuTdM+K^9w_fvsOEY-cG zSG_+jei&|uU1YO(igl!%yI?!*`L4&+BB6ytt*Tp7*?ga#{PEh=H|8_jH@RI%Z$8XT z`uS6h5R)61E+kqr9lnoG(dwc*7G+rb+ECL$&0hW7#QL*Z4ODWJ`A2lHi1!a@`-r)a zI-5kIKx}K(3rXB#>vNbDtEb!7LJiq^!Uj0(}zn@6`2<*F? zkvX1VbIsA!<+vNdE1>TZ7b}^O>$p%eO`mF2w7O8LS|4z;ZtxOy@#+W%K6=(l6=y0r zw_BO(!sM&nUG15fw`7mt!1tMLU+wsQ<5~kDXa&Rp>3*sYbm|8-azp^hATE4J9NbaQJdD#R@!^dp`f_u_g!$7t{U=~CrMq-NS}-y3t^CckEE+-E10<+uzmc*KVaT0h8X-5LAz zit%M#;vr?F!`uo3`Fu!3TP&`qU(!kqb_)beLm~lMouxFI39Hv%N4RoAF6pajR~%ZL zSF>>xbx^IN77zrD=Aa0CSUda1OW#;!qNz{o-jA$~%X0?QQ?$47+YFOsdHEBPm@iV_B<}vZfBBK zo6<00W7_BF@=nsz3wrDpN$ri5PbVJKU$m*(Y`jQq{0UXPT*taUjY^0UB+!?~-d328 z%?v{6D{T)-@6YKjs%Cp5N3#uHp0AI%naN|$B+zFX$n)iBi_2Hus5zf&tSzZqCM2ts z!82(?p3}oyLFM~t9C_t-gMDH~s|`}sxA=BNF&^&wR~@FCpW}LY!aR9%b+IEtPoh27 zf7%f$4|6jY8%|p@eu#{eTpOZ3d?xJv6&^;B-HP~lofvm#{J>aBZqQ=SRwSd;!3x%a z7x>~G5sZ7;mKXG8E^ozEUP4%8O3uev5RLcOz5JqVgUm3UCp`8WN{ZIId8)UE3eIIg zj8>G2O*6sIIi}>+BpJu$4im4XT8`f2Gc)_bu0)El43yCkajKWSSUeq^=sxHlVNvv& zP(g=styfohQ1}|#DuTsG*)<@AnlxRky5cIGmSSAzhx2vEC+lZRN3SBXRvr*1LnWKj zS2o0T>}Sp9XgZdZGzZs1yoB_ESxeFS_yko_rT?ILCVFnXQ8Q1RPD6mod@?awFyuQ) z_fYRI)#2r^o|y+llDk{%d?-P~37?3y#9e|7i!bBCNd)3(z1+il>K>2Tz6^`czNIXM$OXdY+Gnpzx3_<_maXZ<=b%|!(V&8vW<6UFp~eoP($G6+#lMo>gY;B=p|y zz8gy1LwIrx1$>{j#sjtycegL{qD_r6->9aBHVfUH2_sn&!__Ij^R?V9R`e^K=XV=S z=8bWVOA^i!YeNS#jN;3k2|UyeKNc})gTSvR4~q3!DDlc_E1k#qTU|e?Hww^iD9{Sr zkwQ{Ypqoc)h)JJ)eF*AI?*uHnqj_n|tR4xFJVXylka;$L9YtjJ*<@&<6?dDFDMq&V zh^v!Y8DU}L5XaIgJbAjd@7rw?v!2+2Ibq#3zB=Z|v$ulplDoQ8P88fJGVzpho^)HX z8Cfmg`;=j0J~kRzRqEdS9>)_-_ZQ6qdsH4K->7{d#xRfM7nm4LoVTt%QzK<}<;IPf z1@yFkz+|&f@#>40`@PS(1R_rl#hybmSoR(wFnj_9-v|6=DLux#bkI6MeE;LQC5437 zZ(A#cBm$y1Band^LP1e>rlyZxy{fd*UwR@hwT9}Df{Jl*tGVw%iI#f#_6l{M4|BLV zbE6)!#5Mo}d0yk>y?Z4<2P3(nQ14T6&$sI{W!Iw!QL=)Dp~<3seHrC1$99-!*%#m0 zVlul0WY&JKyuRJLW-d8|L_f>QsZhy(q42Ei5%zGZGnM&BW^F&e$x@4T8@S(4WFa(*}qJIfcdD`RhOKdFhSsSbQ5 zX}?ayCb+X*D+u2^4ygF*1VnK^JU#mYb9K4P%?5OtD{0)<^LK82e)SaXi#A9e5Le5@ z(rIrzAW}YB%IUv^J7bv3$TeZ6#$J8!4XdTq&k~nVv>qr~?RK7zF-GbY;fI{0uG`Nn z6Eq(qqn;klu+VgUy$TDDz<#+ii%83~(<`NUw3c^oz_Xlsb)a93!zSm2yMus=^v1yL`Tn9>D%=6i978*#M0hz z4L=2?znRG2rK8L?P@_@9TwYhFslV4x=+@P9UP;yj?pDZVXM99o@&mv5_iZ~v%IC~J zG7mGV#2*THzI2e{=u{35{Nb6@uU+Dfr<01OLCs5jQjY@SM#C7e$RRTmplhAZ| z9K%xKX=xATag?3EcM4&*0N80)kK8z?)S*g4QvLiwg)mhxFx-$yLezDOxOk+=f>Hlcs&FB~}+5+wgL99Uhr(rMb`#JSrxRZS;oxX(FBb z)d_(mn;TqQBzSqxex92xM0rjJpjO>~hmQw^!I0 z-9HxaLEW2mDb2#Dkd9}?37VB@z43s&fP>7DH%C=GxKF5*zD%bt(sy6Ej``88gj!!x zDn*rN+z?VAYTrIsan{Jz5Eqbe9(I`@zb+5L=J1$8urwhKci~P`IWG3Bw3S9Sw{8I+ zu{?(DPE(Au!_;`xY}?13Xit>F*mg<1>w9@eP;lz!$`Z)@;ttfbwSN>CZ~yw6WXwU& zG*ov$e8*EWw}PyuTmhmWG-t{N|9T~mC~Dm;dMlzdt5Yl{b4wv(NacEG4Dp;MGkb(= zuqWruMg^p*`QV3T6_NUno9tHEEt-v)gU?P!cwel2vB=6gr#cZrXRO-O_d#xARBtyo z+d|zk$E|M*I=_32+6*`@6xw!$Ic7yw48>dQtD$L`PA1;oiw$W~+-h}Eei??cpJYuS z4QIQdhD#nSapc8(ebxUs=0lo-OyZT;2QP>4vk~dRHASGv4rHU z4kQan?QVxVF%w%}F9elV?O~;g%+K9K1S~%zPVV5Tsf#aOn$=91IlLXT)I3>%D0Xcm zC3Uvv;v_S1(%e2=>e-*S3@Kwn{okhUUe*k90=;yY0rQe?V!CIpoVsM!WQap{e5jqI zXhm^ZH0y)^ma>1%VuIXoK7^=ku(MKImy!MJKm52Q2+;Ca&zk{q-N)Z1Xy#%&=aGR;tLtxVe;-DOs}4zNG0NF@1gl08Fe^}8=6EuSk? zIv)G4m{XW+sbU%|iwlo5MrWy0gkkmbSDvSf3!YUOHLTq^y_WW(YrLv9(IJE!Ok+B+c!Nt-#@9W%AY6mB7P)fY$!_dY(sZI#R)$OL~WCy#baS>r)V35VdgcM+4;Id(0VEx~}h5j?o@FoBT<|B-_kbsg)`tb^! z7KSMi{JDBF51G*Kx8zEU@*h6@;`zt}9%G^BJ!@5!l|4JY;%#X`!t12}2bSq0TtSQe z8ibj%Li20E+m18i-h~unW6I2wj6DeNRk(tP2zwUz+~vcQYVv(@)Yb!U2s$F53oVnL z*UR+kAL#th&s#!JgzB5#{`wVI0)GS0u(O(%+Ua3CZDd zFn20$WE)dd3lkqVK8MEk$b~*5(>MMTto2_T@8=%F-f=Vxda7QV1&M31Zlz7gP^lELx`iE-)2|61M~iPO&2 zY#Cxh0;a^$(|GTKgTY$jef{|>4I}{NMFm6M_7(Qp2?l`{>-erO5~eDA7 zqp&c!&@Me#^#7xx#bH@S?~$V6X$Fr(XOw(d77~(~RIrPX`Z)1l6PS?3ai8#W-}FXJ z5tDBMnXmjpefC5#p9Nwr5=QnUL&k*uo0bMSMA&lyRpiq{qYV+Z7H)|BN7H5bd3ezn zRF0VWa3Q_M0yQQi*6aVqEn*E-9>I>Hv~l(ySV;d))|d0;^$nLX8KKJ09unGPn}enF z|62a$ka4=uCDZyHcWt6(Y9Br86?E=GZb}sQ())q*$O!9#P~0^(^S~Di|4)a@;o)KM z=;-j+*eLYZ;NVtX*GHN!aBy(sZ2@OT0p&2?nEI~wU z919Gk~%f948k0*cuAQt3ayjP%_uCV{Fc&w;7x22;`bsFpsX$f zw!CP08818PO4bQvJ4f@x`cL2mMRnd&61u~fjEuOXq<)V$#9TVp<4veQscC76hyIvP zK}=?5L4LmX{yPskrM^MvH#Ag$XjF(XZ)K~j6mQ@hCTUo-yxcl6V)5{VFPw4|Ms259 zv-3F3`MOAK`q0ZzXH3RY`mR7~Ij7jNOfHrDVcJ*lnTZZfpOWlYU^TPp;GqD8jf#pH z7TbKDmF6*C+6NYKmkBepq{ObuiWv4^0jzKD?t%&n&CN=fV02-HIjDLH%gV%dQWZoN zs4N5un$pvGkt1@6Ug9z{%L@u9z!5Z!!aHFx_KJH((5dsW8cqTk9W7V$`7~(YKztW? z%4GG&9})DZ7$=`jKDO5g&u5S>2#au(;ZaRUI^m`cuH*^dNv9_DSK3S^ex z6yDbO6=WMnLt|qF`ywgVR+9GF597;rcA=;7K437&`~6jcfq}t405>TH+#fWY+@`#q zXx}R<9d9|GHQh{e@%#av9=6;(U-$H7lfkgq5@;aXtUW^cVR6ZI{tkLJU7BJ>X}97! z{QL?_vU@2Bi*`un$dV{?0iS+=Q3BeqF}h^u@EH9}8>8;0UQSllP)-5^!@Xr>k6i;@ zMNOoMer7C!v~!wg;8fu4K4!u7cU@5fLqneV1xD!F&xPeK6kb=c-VABU$jB_*+T@g! zq{qg}co|YEDkuz$jnOizA3!e&dW}}SomAw92VXoJ3`|Ur?89kL)0DE(cAJ2RjQbtY zYBdlQxu}F{Np0@@;&g4kj##k}g@EgIuEfjNH*0qnG4xPELLw67W*5j`9;OPvD;a_? zjM^su&L<(-n>RI~s&3z2x$#sWpg%0>3d28cXe*^-yAPuzwRN_8+L&nKF1769TH`hg zh<0#ut%1^(0aWX_k#m&za^VNI9x%%gm_P)OH9+^LXAFY{VB>Vz%-*Ow?CL~;&} zjz%t<(@n1hAG#-S`W-r5+W4|brtp~mTVz+)O8_vBl|nkrzL((#&-Es z%_LjK5)YT(pvU>*0zv8_kK`#Q;6Mt8>shm~7SuPS2Ez{giG&gYv=qDEuJ&Kv1qij> zd~}2(-<{nUF>hg>L_-urqf7*8FQ%`W{o;n>Tn0{8o(`NZ#SjAoldNbI5@Q)WKlJod zHrHPp1WAWSEY>-tvDo`oRGe0ZsamX5Wr>)XnsM{Y5baJ?0+&>-6Y;>QY&SeXkB)&+ z8K*2o;7a~cBhmV+!>3ol>l1V1SDlx$M1m*wa@~n~N2Ci>na>;}rTyzg$;mCR#+Vow zf#IoWHe0v#5nc^;=Jon_op52VS{MBcd@Zhs40HkGN=o~!UmR=t$jOclO_(loknN6p zQm(KoT3sH7#^pLPGcv-$;#5H>L?GthIhG00@n+_R6vmG$PbfMa#GFX9w6sSD2ZslT ztk#Q-;#-qFDzxBNF>z_|&=`9(&+iRBhmoa*PpsKFIqIc_(#Dmy+O6Eom_Y-V&PR)b zHF@w*6*xEC+1)KJEgecprgullY%VL!vs7%;kfsg0@7cCjJu50LzP@w-Noz<)ugk8L34>#DpScgrS7xvHt<;TR$0srKP@=2)u<-QEWG4!fCJR zM(Qn!G0tDcNQz^dq?cQ(o+{6_2PKR473&YPO$)wwJb>1bmTEe_%LNbxp%YyKJi|oc z{qG0lTw>pO4p&w|LCL1nUsVDh=p%D_#F{Vz+-aGa4+~7JYOJ!Sf2KdKY^k?H@{P&D zunwu_ZJneaXDo3}M2Z!w8=~xW*1!pTf+$OBVkAgcW-m_;EkF*NvrhdK^%UAywSma9 znH5Fodk2z(4i?m{euHAw>KJjzx;{BDSQmpX3_td$qjSIYwPe)`7Y+AzSKC+?)shYY z^)vSCmlsE;5>XaZ;6t;MbXt&4Co2WxJ^~!)e&vr6ec%Px_Rfyvk!8GRgTmA#T?r2J zdL8WF~LYKq1x8(fk z36%ip10pVq9hKsa{AU?SMZwHrQW@+@83LZ#(UB2E;KgQ?A+2L=oo1cIm=gZ+B_$=L zZmY-1UGK2z1N6wcpx3R3z{A5cNsGS0MDKIGpK*(`4SL0*>8S!__hntb{q?1#KH8nDJ3pnpCtd`D{T_JuKD`$fG~&J?(Pge!7+nQyGH#R6lV9nd@l}5 z9^h?P{+Wp7LSvzd^wI`Vbu!7sz7)0+0Au`Iv{{dsQhFl4FPZzvhPFCvvs zKUY-K`LUvc5B_An%{urFrjR#)KX&{B2{jD_3W-82+ z(D7+23JSXLCY32ZvA5h2Gy(5^PW2%(XkF$p=@5`*JqiQ%x4*o%fx6=Fq6DK^0GYL3 z41cccYX^(6Qb{c&Q(48Fb?$D(@u`G6dGOsSyaM~pMWRQ0woCtU6A~dDA3H}WyUjP~ zi6b7RLtRI`?bMhtF>!MfjIP*g^%!}e)#>LU=?R$s0e zfqyzGVx!aQQD@l?cx>L^|BQ~l)l>tja!y)lbxiusQrX>geRIR(Qj2rQ@=c>>Zzs)} zeTL58Yxn^4aq@J7-0`u+ z8j|Yw%nlC-ap+~H=#mP3MEZgO#@=Oz=aKY%OL+JP#F%gbHX&Krq)+&ORv>)jJ3dbd zf&D&u@a4#3nqknM5@N>0V)Frx}am|S3Af3$R~ zdK3BU7YD>@Q6R;?q9B#cHiOx;tiFCWL~)PZdd1P^42xd>>*Jaoq87czJuE3X1ga6d zriaHnlGrZ|u1{&_VyD5b|HlG?n;p#}hs(}p+~cR{UVRto5-qZ zY=`C)QOw{Ftbr0*A1gvb+ zmzP(v>8LKaxS8>(8vTWCU>Uk%z=5E{@?p8CR>q-_NAS$F9MNLKRLU?1(LX^HWCf|X zI8rzZX&Fm5?w#$bXXD@cXCpKlIT_{Nu-TQ_Uv`Z)#7s~!XrV?0b9?lGn{?=`XtdW) zxSYgQH5J9>Lmukk_&pV-`ZPRR+q5ldWSI;jN@g4#9b_`NCEuX9@ti%GN+wknc49%+(y25kn=}2b8 zC-iv%`IB@?aCxKT50{J!#>H?emcH-oPfi2)O%~V{+#GdT0ca0jJ3M&??~?w7P)IR* z&n?P`oahRT`$ zXIkxtshXO`o>}GcYJ;50AMA~$f0evGZDPj6`1TQCm?yJe!3)B^@bSE+@wh*x*kdO` z>LU9Ti#hrDt`2JCa6Rv-)MRBD&L>En07`n!PH4~D%PkO_+4QV5Hpi73{jJS4&)JCy zBOca5xX*_y=j*HGQxs8NpFax;E8hBfRWAN8)2p?b$pS^4WifizmXsj!#E}obp019@ zVR0W**B99&VDP_F-$NHhKyrIqj`6q#-4$4Gu-t z*w>u?pRTLoC97fuUfk)P51#7FS5Sy}dT;Y$Qc{{6`&SDiv^`&3Z@aRLkow)MSDK(f z^jw1~N2ryYOp7~1behA(P@Rg^sHY}lf4OU%CC?oI6-ceN-a{7Zwtr~GaQ_nv?u*rG zd&Ai}6FX2uL8V~7XgbO;HamlFNnNzlM3Ml-tulgAhAdF_ftxx z-N5#$sFbT!|2#oNw3HKzM2TiUwStDb@yhd2a-1Q0+?Gp?EW$fkiXb3J8mGg2iGM~) z+Ee#ZHRk2UVyWtGXvTak0?*b3a+?G2n}xIle0P+Zz{np}H*nC8)xp=s_RPrU)Wg{qV_!iX#Wx9f5E|ag z`6lmE0*zNzN2*cdsns}EW(E#fL2b+H>XMD$AeI(DGsJXusrsCe5aK z+z`>K8)v!$jEaOs!0X{_)|-r$b2wv640d-o8)+{XTI_gz1}1~OP2>7!Dvh0I&bWEn z-8m}P`@wd0Lw8mAXIa=QmP4gaTr*~vv*&E*3I)GK#~+T9ob621cx|6sT1g}amkSzg zK)GmE=G0bM;g7qB1brpCYe`LIs++4>3Nx&(t6^{g5eHA#Cf=*3Ph5_sV1IAE6aq?< z{ZyP!n*D zm5IFV)j*|^GA1n4C@J2|P`t*)YjJUV`Cph3Lxx_0Qnk%amp$fhT&n;;@1 zQ!_JOIuOrU13tc$cVj5}qEVc{*`%S#!$Vw@o0EkW3qn4(<;N13UQ(3K8?HV~$S0f|B$`wIa z%0R`=nLJBckT0H-2}PT`Q=VZST((PR(I* zU4#;ik+!oy)+era1*aHOJ@fa1N*KuFLz6VgX{vcXQ17oG_=GCXel0mUUTRqIDJHQ-UXW5V4L5vQZwq}!Ntt!K9X`3v^=M4Rl4r{N z*m){GKEB!Uq~|@ZVtR#k>FzIL*s`*(rus6OoVznejNA;0fu<9!2fdO3s$!n698mm2 zqlwKhbcp{tadvuOz-HZhbz{TY&^brQ)qO4#7QHXZY;rz;T}iT`aHZLWfZtW+HuN4* zqrKH?xiO}vn8bKEKRNy8LFI{3h>ldiY))Q#-fwtClDku+ zAyR1oAi6*`ik>vcb#G_qWX0{b9ft;sZ8+{E1_fqoqf!GhVxiDje^SkXp%rMK z{aZ%6H5e6#Nay|GD#2oLEq=by&q{|Uo5E*Q$Mp-0d|~**>3VHOFA8L(2G)k!`)R_n zrvr4fxY*Z~R`Z$1(bCuF2s{QmP)KgB-k!{f^D*FjZw;{o@|Kqat;50WP}d6CU=}Pxn*<{ViuJPpDY0 zyivXc(15-uQ&(L*WavludSuA??04Xd4b|If_p=;h;v8FB+vDVs5hm5Cq)%mw9{U}9zBImjm8+AQxPusq z2#?`ZlP5fP)MyvC+GOQl+saMNrM;-D93x5$^B==FFXj8R zXZwhId?#sm=@k}f2c59n4q0r()Y6er6v804eeNW#oUgM7Iaz8Hygv~zr7GGu-o9Vi zRz)hsb{Lc)YwW(9~i^wAnv2A-K9h1!8_8zy@RSUm|MIDq{Gxb(52_&{A^DO^aP) zt7}JiE+JLgD=A@6&B1K`BG$|@YK09l2 zgruBNx+r2&T%D*tfhkJbEq5q#f;jII>Go-?Rs!juZ#(^=w|Fb}j-9P3QxtOLCxOzM z2X-rMz&R^z-h;*arxZoW=K-}_fcMDASM3#9(tg=g*ZqM(Xk2Lr+^!jH|JFbuJjo_+ zaJI)-Rw1_{f3WN(z)~TKUEMr3?r<#!v?;V0LqKEJvdtR!P8<%QAZtNZAZiq&<)VP> z=8W)&)}VaFz<4)Z>r(D9oq8ib#iZimMz=TJxLu^$+c@W)i!gcXUl=HIRYPm1sTh&gmBNJ*qu8F($%O|h4E}9d-P8@)^(F?pqGnC`TJ=mEU+=52gEJl!T8-any z>B(SHY7anEmE9u6@+2qvJ$%)z2l4um+(a{kX_w-h52m`OMlo9yF zaP>|RB!zEoW`>XP>~L9oW^ZRVGo3s^8YCyqw(Yv#{W}{OuR-cFzuOOp4kBVJ&J=~h zx0o2z02T_Avo@pBFGj4UF5Tx>1TgJ#-0fVxr0+kbw7>b5^Fd z$yuu#!R_6l7ZPDc!S@!movSY>34H;(=caF(UFsSt`P^Z7<>rd}>(?7xHa1K|c=_|lm|Aq|Z}fz! zh2$L$JBw)?*5J&R+S9Dj;l!``CvwYq(06UN+Sd{sK2O5g_JaL7TPK;*uwMgx$j3XG z=!&B~zm)a+N%x*j)l@a7%B%FRM6w)niEKFTM_W++++37c*u|v5YPDu91Nie|q7GWQ ziG`1QilGY<`0e7BE4T!A$Uk_@O!s!7t_bBc>vTegx$?Z~3;WEdXZmA=(~N9E!dg<< z{PP)2g^~Pc?R&b%oqk(%kn^pDtgvLE!>dl4PcB{uJNbhY#g$6udl(DaMGuNcv8E}2NUNxsP%GM6Dx7J7?MF^hP)oJ*3qJEgBzz$Yo} zpIhy(!xEc&;dP=*%4Qd_uv`;Wnm`Q7nYG&$!WiZ(*ge7!$gAjw0ee(Xp}b1abNE&P`33PBs4; zj>TrsMUb_eXk}VL?d_B1q1V36gMlC2zRh^*rCD51s${p`{D@1X9-SKx6tN*8S>*>} zG26}m!1W}!4Rm5sWi*nU`^wG2PTlC0qls;_lj;S<(J)Vol`hjs*J=X1E^oE-tE#eb z*X9SJ`JR5Q`ga(L0+0}9CMON_)kt*d?22%4aCto+4&w+>L1tAvI2}5x6s`M0|s6{GST*^QS8!o_1(osi})+ zN>W%K=kufyE~7FtSK~lg8jk@1n5+0g{l1~0G`%I;2sSova3c!~-vN2Kntpwf*3#l_ z>j5X$npw1ZEPpndoaTa>RpCSY4?3tlY|oX+^M&$gop&5<>~;xn*VNd*HW-0YOC&6h zNVv_TB*ofo`5UWh+G5(ROy-x&oqf$_89^6xX`BSyC&uQ6R%ZFKj7%f%Pn}200jCYPe97=kqHP z`sPmLsr-;F(GE9Eoz(qziZ&_I%E?Pk$DdGaKI4S4e=H2$TdXs)i)bGX@hO;wfq%kC zc$cAMsU7bO#;#?vKh3stweKq!4M3GGUQt>wF0M}`W?+kCU={2o!P5!mH&2M0hYBjNTZSm{!tlkXw3yZw6 zT64DP5hO-Q&uoy*xrPYpR8?!wgm$_yIIn2;6~7wa@`V?)98Hdu1z zOTqCikTf1gt~4$-HZ}ozw}`?54kB3V{-m7e&Rk7>@?EPte{_9ce29LsZ&1Bf^QPD4 z^a2#B?(OL#;CHN0SH!loq(ic=$wayS+g*UmdcB6&TX zhOU7WZ)5Z+sfE7x9z)-(h_j#Z@E9+1XbH6m=nBaIboho{<~V_O)*d4gLrM;vo+ ztxT|{zsO@)&qu%EaXy=wnX#BCqYhxJ)MxY9Xymh!8Rgx@us z%!Bgtzg8pdA3eibjbD71w;#egBO&2ktkaWl!+@IXfVj4_W!HFS+FeFF-e-mG$@tLh zYoymp>HNUd#i04dCDbg|r&i3s_lAgq9>Gf&n_5y@u@#X zt1vYF&iJyYilqZ`aH$NbCo0WSty~iG@=E0o1G-;cH(g;@R=m0+D2`d+QuA?BE~0#& zQ`+G0@>{m()E9@Q1D~8dE?X#@>Qbr94{4XVV+YBR+7i%-V$LR&PY&y4!C~baU+dt4 zYJUgp>+9iEu@fF^#>C8+QB#O|Vdc1>C%d~!1}?!kHuxMiL>cKUCD`hFQMAn$V|+B_ zQOGm3u6x$n-SomCnLsXFN5L0C40yZN_41v(ufq5KKGqR!qQtO9GU+7?;C}lmSgX#4 zZcN#Kpx9^MrXe8&SDrwNr3hp9dtLtVvH4hEuc;V0{fHkC(TE6HkPWP4`FcO7SPzS` zIa$<61kr4?zk9Xhxh({Nrlwzk?g}S&%V!{n^g%t8;sO`DmIX;yJjmZvA<%VL=Z3TJzVe?6!1wfe^pjdDU>b& zl}>?-IVw;(48saTLPCaLH6**)%DzH@i*5j+nzHha>;5o0T-(TGq}9aoOu6e6Nk@@A zWfU~s23e`?o}BbZ27jx+zylt{hvY?Na6S#^8e+`ZIMo^>tzu|i;7mLBXTrL=x|YzC z@HIOvRs$=07I1NJJUN`^tI+qX)_`LD7T91E&HA&Sl;oiNa;=(}6W%7P3=O_!HzvD* z(NRfh$%Z18MNbl{528#-5caeAde@U?BF^W1duLO5&Oxzpy`6{j z1;s{i;unaVl-Y9fMi>?YDykP0>r(FktjK239fuhVtiN${a}$lqcroAMzt%3KpwMn{ zFxVF95I#K)diL!#kev`xx&2(g= zQ8j+p9bU*yq4z9|I`z8Q?H(waKOZpRBcKDbOH+Lo`04vXSvg*oD%K${EGnsgyl#a? zDKF%w)1|KzpI{t;H_&D%NIWC;!3*Na%Ap`HkfZs8b-mdK&CDZGCUcfCZcofFWa=E- zT#4K?E&cQl$|<29xb1ae6t;D|JwAUOMK9!CX}8a!cgB9ce;F}F_kUXdF@y^zF_ziF zX|^06air>2i#c6tn}aNJUOQfj4jMZ-Uhp({m@A!ez&vr;SRMT876S6H<=IQBzaSx1 zb)yUo&j}8>Hzt2aC3GsZVQ65$qF|*PbN{duWIt9pi~(U*me#rQG`m)i-kFDn->OYiAyjtpvs#)^LgK=qG^Ok^XvkZF*jz)U<7K&N zwd{_MgV}#ejQFL)-(N{zU%ODMw})?lpjSYcD&Y-b$BGzo@xv>llR`b|| z1joJAZm6hE`CV#7IlzvkukqTX^r)`vH!*80Sz7dIK@*Ejw095c-zm}1U@^MlKC}NO zKH0?z$%;Y-FEJ_{>X#-cB~!FbYoI{+b7&qEjyq!%y3ePfZ=%+bbbp$up*N}V(D8V-6h6PJ6Z8TV z=#;g$hvSf0gKW#UDaw0_|s<(uAbzDKAQBi5ByfTwwJTrn#^f-3qw30G3|6(wN z(#v_u?_pr_v(7R~)*S53w2x4{V1K>D?02I81jK$+xgc-wJ4m~oq(!h9E z$0sM36c%a)zI=i4sIjxVq{_@K)pWSa{p#v=?afSVsVG^tC>v(X9xgK|W)v!{oyK8R zQ&<&qXSbYu~l17FEu76@)>+4h!n|C*!Zt_x$f;BOWj@V3L6~dkDp2dFZ%;| zHaS2x{;>F)cfRi_#6Q?DhHq0=21m(4;p+x&+H0dv86kFN9Eyjm>dJGq_*oWzsI79Q zP{vGuKgh(agoZZH_ahA`J-rnw99SqZhA;ok8|ZunY23HcuT{M%L^=bA_DZD%^T3MK zsyg3r<2=THNwhsM@ZA_d`!EaZw^v=TS0tG`tjgJ)IE2rXF+3keG)DIePsFjN-oNJrll{-+B(uHU zKPQC=@l>-pYU6Eu^mRF#IW{6Xnd=juj!BuJtW`;@CPDA5FzRk;Ib@9Dk0{O6zT51eA*`c&^bj6?V}R7f`9Fd@tvtdqH)gRTC|4+EowTO9@=*?4>5 z;v3EtOonwP7Ei_a-4?^N>O%-iZVXx`w`O`WTCM#23%Q?kg06Xc2GG6Ozah^ zK_mZGPC&nJnDPeu*^O^PWq~%q%Pk~?0-vmwiCa2Dde{jXG2#9;${vdS!`#cAde!q6X-)*<%1ui=2dv zjQ)TB!_YA67#Xp1Xq_i97~boX2x$51GR8767tgQJT{~c2xLZSqcXC%Qc6R=0wSD2D zR}-6|H6_pyzEH`|!Y*>&5Y-7M8T%qdh7$pTDu5-XE9X|DB0KAgk zq?_+2T&fXbEp}R~iXX<87X)WlywB7La@&739EJt~j=q_=Er4GYfbone(Ty$H*S6zT zCb@?M^ryT|Vu;Q3DG}Ct_YrK=g(EKFss9;aL5fWMXR7afh`)Q~-?e0ae{u*L+N)a& zSG$n4GyRo)KKR7@CMpAOY10pkO>c}iS7E=SMi`o^jC!V@s1E?cWL+XG%^)Sk0Kh-O zNBV@AtLY(I%l{3gARi47=lm=$lnM?@4WQ!$+%6O2zeekj?Eu{b3dq*zU*lx~yZG&D zNGKaz=V7UsezyXDyDIxk_P>ew{sp^fUHlN7|ghvb#+Zc=`( zZ-LZ-8ZbU2Lq-`KjseEgdP2!z#}05k);Ff_%1ZdiMm^BB$x*WY`fof4^#T%N0!6Kq zI@{R-m{gGh{`z^SV#!78 z%%i&-eU;T9`>rA@D%(Ou*% zcM!v{%9k(JsW4W-`f;mhXnXl-vL?|+way}l;j(A_-lX%tE)O;t;g`Lc3Z22uNB6D= zF_g^8J^eD=pS{tdW4r)hYu=csU`JQl*zln7cYpTtZ71)`Gz%4Ei$#|l0|M|0E;A{O zh!ti!-;Sp#jc?IujnmjDN{?S2fZYE`1iN+rHE{2t4Mn6#Us0K0tmk~?1SF3A=;=nm z_;U}kK;e(aMb~7fdg3{uB$wVU6>%Ao0ewh>9SR?}zajnAGVh^4pww5*+buo(udbzy zr+N{djpnf4uJ>!S9b@toA?2&A;Lr^5O;P z8n`3lpvu09mVUOSv8z2;mz2C>qP*hY)yy1kqxrKt7&&pOkcbV!_{)PckZFKy@j4KH zeb{9+v^HDhfMz=Wu4 z&x#|_aKJcK*|HB%-rqdH5plX{Dcc+VPOi8N9NoniMYs|op?f`%F6e5 z$4Z|hVjm|ywqI=5&~pJ$f&r$&PEmMWcO~yRV5zGOkPU7xOUmPs^cW&>YZvqOMzzsZQgN?(HuvP@&)+14vpPO7lB7%fDKh$H&f&PBBlN5gy z?ppxp%X1**-6-*K98Z{s+SgLGC`BKdf`L}~ZCJf9kKi)K2Yb%{R`wM!s{^xLyWo5k zT^#X|cReY;=0k-5d@kPaO=iKt>Nqt>W0tfbWKU$h{y$?II#i_0BN`6%;7UEkwl$WB z;lLhveXmE9pb*od)-J$WEX z*>-XFzg4-?lm_DXLWfd30tFoYUiaCPXidTal-H`?UMGJHa&xTbHL3?BSey-KO#!mn zOjiFIiTBX9qIX`lT^d-qq^4IarYpa;+7RJXd;_3XCPR{A7j4{u{a}(w((jcS^Z?hO zauKuecgr;7a8*nJcObDNL%~M=f#c)jm6p3nS{4>bvYj-JjcTN#U~?E!QJLrr*rt>L zu?JgTx6gkqtY`%KNG5YiCuR`ox`vWoIcnAH;FbQkkLM8&@RI4Fj8#bOouR}qTh5exeoEJ`j!f{WITwQ1V8kN5qv9EIB*(V_bW zBG2;@!xcyt2mD9+zd5AryahG*xHLySpYN&l^|u_4RG^{3%j=V#1is4+7smB;@5|9!RjD z`J}CNkg}t*wJ<}W1C%!l1TxsXiJYtK)@gHt79jO1DaXE*;w3zrGQu{UB2NF=BZ?S> z-EJzZH;l3?Da^O%cg^q&vYPvUjH7bgl>~>Pi%F3kEUJZ?VFK($n}UyNsK z9o9?rmh=DE?~QS=vx`efxm<3iK*50)Crgni!kp|c?}PFA+@5zw)4-X0a}yI*Q-!1F z8-Qb%*rcQmmy0at)8(9~TI(|=yKOlqg-V^5LfNe2(^L9Z7pTJVxZf@ZA|EBvYui5E z65zTX&)Yt>xLoAAUU_OYSSgRDvMuz0WHJ`(E%!3{crY;ekuWDT++AGod0qCqgQyf0 z6>;r%!=Vc-KU?$q?I~z~#QDD{D_Cg){Kat>IlFAh7t4=?YG; z^Tj~I_X0=EL5g)93zq2T=T;u))7#^PmHGMLBhy%&5-4jDu{Av%9}_bHho$gjv93_tN3I;_LwV;!L}x244YDcB7ZZ7+P~+VK z*ll#dnIGXEi+TWoQ0a|IqaD2X;1&9C*+VnQlZ~QLr|s>a{hiIp@!o=og_`!|$xJ?X zpDuD}VB1ytdrUx3Fi)Ab0i9B2VyU%NOLy%mgWGX^SF0Mc%%BolZ<@6lx{V&<61#|X z0kF>NL_gx(U4qgwxoi*bWHSR9<;M0ura`8xL13L9f0QcQ?BhS2E_SsWN%ApcBpa^BM&v?+76vAh&<hEVVFMRSM#9}7UkebgIE zUywRb`4##PCs|!NmDB5%q^r%>tAtDpb0ed}i}l?cpuBuiU~{pHc=1p7Y-0bweN2)bR3j`vX2VPv6)B zvNf@lMFFh|?{ItB?fI7(vxYTtMK~|I$!u(7q|mx`sqAGXrKIGrM+h55I8ThG_H&RZ z`Mt-r3p7=S3hURzgmIBGxB4Q=ZnfC1caTdFfyY!Z-djqFz5WHN2nybQ)DyX(dC?+i zk$eXX92BIaq!bj1DxvJuZ3Qm{`ARN2bQk{#9qryB2h4EzGo55BOj80wT-NXdDryw) z38k;E?|l?_Y%DidLr(5jT%7*P<4w`IEU9k02X*@H&M@|}jfqK7WMq9}qLbKwJploM zv$He1!{w&%1kum!?A+Y0y*;F;gT=b5HupQ(3~uOSr}yRqO$5leA8LkdmW$A86j>J! z1eNGwT(X@~67Kc{r@4@x3td2SCq^xq+r=f@-E4t8>_)NDFCuO`UmFKOXoAGJPbRq& zo=Cjo3JYM!YBCUc+Q(jWypI` z4mD_L2LIQ4Z9O-xERX9$N40)>pno1-m&tQ>j{fH6W{N^^=&OY!lxGl3Ml~v!y8BWq z#@S3PXvz-wbFsw#HfBrdqATs)BG5AN>!+0x>kLj#-iKE7&~zYB7AE($IL8bP(Rhew zXxWHF;!B|SN2E2H&t-ah$j@9uI(R1L{UJcTR~bt=?>|V*mO#6x;vGWmhSHEZlpV;yPHjSywmIQzMtoK z|ABAtg9pb3H+!u$W6Uwm^D~am78(&{A&`TG#(MXM)*|)#VG&i;_{+;n#bS+-l4kdo z`#VJc+aH`CKJ+g(IEE)bPPh*CZ~H_ ztwGJUHyVelPzgV20sWQVY=(y0d$WA-PRSgOOLy_)&@X$dO&-kMx5-$aNCg9`Z()^f zIy;m3eG!Q*!rrMk{QREb!WUj#?yv3+rW@mxOr)bXq+#mkTW2>?=_a68xJ z2A9=?BOlD4D z?hmF-7Rzrs1gxx2KwKakgjCi5zBp`r( z4k@7tPP+VU?RC+zu-8(DtXdPQ%2HIMp+2k$Tv<)!kULN>DMht@?s&iAugTkWZYqc= zudIA^TFS*_YHCmuR39%nFI2&~mr=s93x8mHjxU&xjd@8A@>IH$Yx;h^c z-{g^4VLa5@97QuaJp9wTiib{3g3d3zGl-D(!Da3BO{uM4p=8iRp-Ra+1{Ex)O5-u3 zjf&@Ly`v^B|4cWE??aFAtuUX_eE0yOw42+j6SMg`Sv3+Ko`=Kbbm(A0Uw6tes;028 z^X+A+dYuD^-!PSvGTO(or7(!3I+8#%HX4A(p!M>%Z<%O<@Z7HV z*f>5qd`;GL*X$Is2Ns$}hmj$<&huus@Otw!TWXeFT#t|V7)jw_Vbh^TMn-5^E_Anz zrIwa+hxwCQfiZ(g`_o%bM<#%+N%P{~llOdo)*jYAcCtQIhWePN(8Nm2D1@844T6Dz zLCM~O((H2938Y<=iR_ie_xHOlM@P4^x{u>FffU$q35e$wd*CS*402C|IYxr0>(5oKk;OG@kx*Poj# zv}|nKTO&FP3-XQfg41CRFcC^~imN&r)^_hEhxKvSmX2~?FY&euBF+_`T!rMqDXi$2 z7<4Qwa|45~W@ZyrwteD?MV}DtbAefMwoq_vm7IG%8Ay`tkJ!Kl=x?9X>Ueh@bSVe$g4 z6i*vz)#`eiGCv|p?$4J~Lw15t{~1-JDes-jDjsWrJ0zF|hD=^tp>EplkfkOUPD@pP_kc#g{BHhR&8&|GecmLtNmQI7+a z+S_!32WFy3YoA=hTpxrxy>r-PII_h@BA6_jO~qmAJ9M9|+5C?!q2ci)ums3hhcGxC zA~nlX<>`D`b~6%jaY=KCn^SXCWF}>b3{^!8VV1s+9^@N{tlsIK64e%lxsdzq5zoeM zEQ>iaWU%P~$xYd#QL{Jjz0|8U>O3tyCdaldki0->rq$|tqSjogb8(;QiCAI|yqRb0 z1tlo%M`_94&M6UD(CR^;J@MPmJLx`RP-nLf3=Y;Kx7{5T70*A!sj#!HT3d@~5e})XMsM31PcL~Sqq}ya z>89E%i91}3#iG`{knP3`QU@qN9w;IkM;@tAU9sc?{HZ4g>Q6ZE(a`E)ORtYk&+Tk$ zmirX%el2(fWcT#-)w``SLni#|bB;?YO9?;O44)6|{&86c=_VuIBK!>%e*C0vIVxpU zVXQLo%M+te?dVlJhECE64G*lH%Mb4( z8kK>|r9|Bs#cp?ff4YzdBCCKP^>jHfnQ?bC()hu2n(st6x%horkaGY#5uRm6R@NB- ztM=mebn``|x(}H@hJLjyVNg%9*k7nMoSJ79VA#uK;?S?uKirkB^w$Qc{dflX zY<6No9(Jaxeg}1>EB(cg6`V|O85lSu zi{Cyq#j6__A`Sm2Dx})L8V-aOV2j0Q`XrEW*vLRy0+ww|%nf1^yn1G}!_|v7W7Rgs zRc2@ORn>Ba5|Vw~Ev@M8GU6$~?26WjabEe~k?;(X^Tc))j`VQd`W{HbYYBOKb}GHq zO~CQ%0al%SOPjRyJGGGCl>-7nt9s{CS}Jp6SEyK|gnxZGxb>P}V1pU34VJ5EgK&Qr z0?og8JhSCdvy0Jv!nRykb!Bd9VWA*NWJJVPfDrNpo2v_~T$m+B*21Qw*>-=fU>nWw z9^GzaY%FuRoctW%GemB>hg;#z)KAU`r?S77>zwUGfi+Nq4=%^Cyz^wUzsCBm&HbT3 zx0mlZru>|FbARTyZtx6onJrfA&<1Lq6^95^Cx{lBB`>#W{(FD1=@=jjT{v#%pSDrm zF^aLHWx@({QA548LmMyFheSvR1!ZKOcXrnO3tWPebFs3eSNYJU&PnX>DDz!H=+EeX zR%SR}ANm8`f78PN3)IK|?Qh)LE~#EpR~PH&7s?H}cT=kqO<9KCz zd9xN_V>W1ge-ZLMDY!QG-3h|gqGN^L!!+{%-VZ6HkRSKkLy|Oj9C>*Z;x{#%hHDph zum|j|=sQe`?CGd)75tS}N|jgAx^fAwg}4&(vI@pDtM4Iho3c1OT6lH@?nmrxt%E+H zsx~Go4Wtju#=6BtS-t3*+wP1*n)THemmQwjMO0K&8i&n&G!KewmA$zp?rOcB6@^qm zfz)2Tu8*I5TlxF~jb^@JI?6V%wYd#dtW-q&NSd{(@dJBreZCC=y5g zcNBfltw~6nVq7*C`d1q2PFQ$yE=Na`hS)sm3N@i|rL_9rD zA8BSrA}i6yv}UYA%8)LOja|B?+jS?SvoqjTrqEqzZ|%5_I7IAyH~*hpfZM}UhGtGx z47e2CUb2?gnCCuPmlG2cFL^Qa^^KQp`Ze;-tMV-usHLWU{kkgp(1+^>gl`Bhy8pxnN2 zT^e8Vv-M@6E5szvY&I&p_oRi@tnO8-2p7G0T_jyIqI&i zYGmS#Tx3F%&B@Wp$4#foqDACjDG80U-H55n_Qozc-$vKs!p!NBdHJNuEJuwRyNmN* z*++hqokC8Db9gLP1-~{Kcy3Ee8|#hhF_EWrM=bpU6wH#D%)Ui>8|up8%TIa<3yeVb zjg`+}6AE^PSf*V5m$z^haJ z6u*gDf)-1uJp<-2dZy8A^{V}rX^@lK-Q7_;ZFWDoUnR3(>rXQWc+ z$dnFT-M_0!Gmhxm7QkCN%q<;s(LHJJBvqDU*jXR%&8mw%3~O*Es<*i_!@V>gqP*Un zFLzaOB4dFnn7F(d|1{b%*YPl_x#`3tjJ!u|dGRUi*e5WsH#M&F8wcMuPAs#6J{Vk? zra9aig%5Kj7Z)($v7|fG^>bJ=UON*_RxzvNa_7TFpx@=IzO2*+F(MWQCO{H&JLU|m zBB#*``9fnT%z$7zd68Iix-pN)+?7=4P;Pv)u|kvG^l+E_XnuBNy{RGy2Q8YyR1`R7 z4M@})uRfB|`e7~rW6!SsAaS`KlGSA>gPS>V+NBG3BBBA^SbwIe#_KobpX%o`3TqO4 z^D5Y09P5h2z{Y1oWl9+l0XK<@qf$;@X0W@T-Hvu?JK(V5)Mj@_zaq$FB>lHepY!9 z<0wf91L2PSNz)jks5m6iE}!$NRyQMYF(c7eykr8Oz0`!u;XLGO)qvAEII~~Mmq(J_ zjr;B1zArFPK1 z&Ze^B>A67{`W)wIvvK@QYpTt&-EEh1>vPukfhe-G0Dhg%C=ndde z4#t}_OpVR*+RO1t4CsE=IrV8BnNuEj{c!URNoedup9}izZWzDEaR* z;=C%0@!S1s4eaX0j*Eyr)9$cKjN>&H!>*=VRr#4LG_w6CB5b$7i&$}30ig)DHyX=; z>~_9{llw&a(&9ZiIi2SHMG#&!$cB$KyYmPOw@L|050C9m7C#*KnHQ>5Ca~Lv1%H`V zqjoWFXsDx6yR;4`BjwR)a=Ef<^P&WK+KKUTy_Dm$Fi!Px1n-6{IK)Wx$jHboc3vsP zfTG#pgxaj4yq&Mm@X*-CyW!gJ^IV+{v$CZ>XBm9Qn&T+O|040SY`gXOV!O5LY;Q{vFK;n+CJk`?_K-+tD_QiMXvR&7+wm za_4J^fE(O0nYM7-1NgwD<&0O8&_8XLce_~*PfAodJ2erV9F{w!XJJZvvD?sa3qFzp!wA%Ruhjbf zcumO<57HSXE$RR}5|^toS;zq;=3!TIT53v4fWSc-`e}7`jO@H`tSW^+5g=yyT?#0# z^m-N=+TQEy;84>(q*9pFTY|y+BG0>Op5z>+KuD8_k9{^~EYC z8KtL>^>}L%s>+MMPNcHiL*kSL)rE08C2@KB5yc;y2wA~KPU^Er;HUTB zI*&bve(m*dD?DBA86qWFy(Qb{RP(6Z_+3`o-P4n4RbLqCxV2kd5eLW~Q=;IXeIo@d zWllp7LTyqQW>;!LgMPZM@~eG*gOQpYyd#P8b@50%{yZ`1lhEh*0%{h*^DP0sPNPW} zR~B8Ko~fB`im)ZJN)j3J9(l-?B%3>|R9wvo$v`DG{(OH_MN&S}Lyy??RD`qd6T&=1BfDh9L$%lK`(kM|UguthrDe^M&VUP0*(<_pv4mfyQBhf|k zb`FDHd?qa-tkBUjID{F45T&@-VGW1-ox<*Z5Ta6SK3hy9Uai(3KH?&E#_PRAVmpa% z*U@yiFRRWAl;swtfjtgWnz)!aoeKG;M8Mg?g#W)aGL zHA3I&jtLeaj^L!INv|_sDs8asiztSl`d?H%{SV~wGLQ~YM~<{$+#)wfc|TKGgB549 zDr9YBM8xA0PYiPnTxg8NRW7e>3+pS)?KYPg{1{RL2rse*D+ml$he7;XFxJ5c!TcZ* zWQbFNBqlxDM7m`5s?ZS7T_|E*_i@dFNNpN~GdB2*;>&eZP$nBYO{XlEp-JOHq>nk@R%%%Y0FVxxXR5K8A;eQ@tzNU0xHf(Jd zGqDSf9bkC2V~scIBi9XQhd?L;R(t?P({L?(xTJkem$pkbHNP9bFRbQMCe3q<7K(3M z%M(8)5+{ODNM{U)85-=UrR4=9X;%zC8N4%;F`g))usX5Q)z#^troH$xm!ZWAW@}2m zJIvcLY275G>7*{_^a;W3TCR;o%Bs@2p2Q_KWntDYex%dwkJU2Kyc=(2HkQ-8d`TA6 zKG6Mzo`ET2Nrc3Arm=BuTdu_nF)N1wF8aG*lStC78a@4>`RL#;QYB-QeX?ui=Z=&V z7!Y3n-kl@A9#P6%IUORxqtxKSObczCKQlPzO)_VBnKeb0RO<&M0zmVL(`I~4q}>r- zX|yAN_$-l;y}~MW{^+{!tLjLPmk*+iao5JJe$Za+X%D5L9COz6=pP`L)vOqEZx0uj z>IiJ3`+MTH@3DN%lB)lW&iiI4T^q?lm$U%yuw+P*fYCh`z{NkA)&L0E-<3l{!RJLYwmf^K>EP*y7fN2xQU|{Do#&XsFw*>4Y&cC!SI_= z2f`hl%k7W!L}Yoau6EKb1H<9WmTll+%JOd9jT{zyKKo>*T{;Abc~2xSF};1yL0fUm zzfeSKs5`FS0iGj_7Hg|F@W$?XwBgnz?+Ka)#15mNUovOOW_2Naa6fJ&lZFP~IaKU* z^6(^VYNG1%i70yQrUx1x-Lp^&WP{vMAKJ3o+zQzyH~R&_&J4FXZX0RwK`+3B%iWS3DZ_t-sjRLt*K=*+L_jJN!xg;?V?3sjIJ^i3jUaql^TOkCF z^i;>9pXl9oajwv7k@Z}>Ij;(zba4xxXHlzhZj;~&>x zrb1Lf;T)8EC|fMG$~m|GlM6WAIs&R5U|dua7Ot0!Xa41TLqS1-=_1oPQ~99Q>?W?P zoD1YsaD5{qv1D#|#p3(?FNtmS>4+%!oKB}A!ot2i;n`!VaboVO)pp4|?kLFb-@osV zzxWkSE)^6Mgov^?RJc(NeFxNlY>bR@!ot~t0hnOULS$awN)IG*EcWIJiHRNm^8Fqa z6@@;wY$h|^=nN}Vtp@9X^;&l+sDNTc3!#w(GJO2_v$089W_akMYKZH}eK_FW25rGa zZONS2Re*ODz)->@8T53MK{{taxNF|+aIO97+Y$HtE+%@0BWLQaI-!)1rAIvRT&`DJ zSD)VXQr>_tF8l8^HawgiDoUy^nl(PA!CxY{>0Y^=XL?k7;EZNY-S%+rVb_t}f8c9f zWJ7D{pi{-+n;sALuK`Td9jrPp?f96{54lP1f%b}7tX_&i)XxRF$ch%-`s)>M?$sr8 zS+9>J+jnSuPmYhVh4xz0IDPVXNx949u*VZ8?yx&p9q*sY%QIO}n2Z^du77$$)h1cG zc7ur%;YEm>mhx6h(?wS9NS3h7K3QbpXOxUL6Ut8Xn18Q?QjmbquL#kEChF6EcT< zL#q(DrQ)OM%8q#A`=jZ)jx+XVhRV>IEf&fYrA75C^1E*vboYz8b6ADQka+v=Zl^Iv z2hGcdes!oAzpgaRyO~GIC}S72Gg4B>HzlEPmrd!db7J!2L-X5|RTgtqxjAig@hgvZ z(MQu@Ufv&0hltPOZkR8)ri^E3jusIPaP*G!cz6sS5npzCm{U%Ngt}aw*4OGTu=q^lctsY=J=Se05){FAOZa&2letZ&K&-;kWw zw{D{1Q8K4H8V-i@)ipfQvdsDucsO~9Qh5C7X=T97YQ8BdVWFh*0;P37q}zjoLK9O{ z@cnd}AOv?YFEYLucD2&4Wbt?%D*<6_Y|PcA!O-l_&nu0GHU?%XCU{pBi`39?uUD!U zF@f0})ZRkcaKJo)^4y#Zy`Y>p{DpebKaj6pYqfs^+ccjl>FgG1V_M%H%Wl3gOGrv0 zL{I<)%HjOz0`5aeNlD5bzE8pYzUM}Uu6epC=Gi~s1x0B!&sD2T5IUUt`}?(Zbb$I| zbz@>BJ9%S_EKLxYJmMIj=Up%^28YA9NE$#d)ad@8Y}^Qf*U4f{_nxpt!;WaW2jjk| zeK5Vl!DM;Y5ESTmzq5aMuOY$5q+@FQqR8mlRF^S25Xo&oeW(1FT>9nKc79!$3X%)= zra_+RMpK7~ru2n@Ne@ApJT$kn7b9}h9q6YXS*b;xw&+B(74nf0=vSW=Aoof5iA#$deS5O;iJ_|pmkvqG7&lu>8M~FjuudD;FUiaAFOBj;^$cn zPEU!zY%iTb?Ax|Y3CqmL67C}PFG#7N;ayBh3>T+kb~@u`j|%ndE~S)M(T_+~iVqfx zy!yPTs_T}d+MjJz29iIz3BCdK6U0W!@nh$l?)$-}whVFb-kayiH3gZeW{TpcbEKTtc<5>o)s0q` z6^pH}M|zHk1V^AL0>x3q{(8T>sz6~43Sexrwa*DBBiE;oZx$a?)#g(cp!0DQWA8{# z^JzeGrc?0;jBK<`c~ML#Ns|2LiGiM3FEgJL{+lYTNlRL^$Gqb*dzxdLL;TwS zH|A|$d3CK37!WX9ch(%skWK#nJrJTY(7$19D3%u=_M0h(4y`4aDk)86ibt!fBSd#D zEWi`s<1;cc;e-vv_^7CjsnT>x z0oihWNvWYPQpwzrjZKFGz5TszAl`RXZ@jYDuOhK|)LI2!&B%axs+5$(CvPVw$p3sQ z1TbzhtB?FVqJEVg`B^>}?er1Ue7x}7<^1sI#r1TP)#~yXmU#|mCpu>Z=_kraf2f>* z;}zL)Ofir8#n@1DQB_n`)Kh1?zWR$&=U|PW6WU`Tws3Uf*KYO z50pZ+Z58q*vyZO+Md;={F1QxeCDD&u)d})=*es5%gcL9ppBc_eSfn->E^Z(GFsS>I zM#I}Du}i#94cxmqQ;x@N$YP>`x$xx0y1JNTRXO?zaZ!U&Mh2d0NS8MD&oNwc&7!D< zZOtYl8W+8o>4P$MZL2h|%SiWC=hO&t_gq)nY-a?9G`uvCehxo+rLM(dZ^>zU^c10k zV!9`Qg}c4IT(ME_hxV(Az6C){Oa(m{x7M-l@2DVx>4YBixu1f@z1_Y=8{lMmfnP5@ zKPQlMJCv(98&}4%uB7r_1?+5e5d}XeoJDgC>{&jpZQPNAJaUCcH6f?r*yoB8#lwt@ z0s>zba8eT=J$Jbx`#d;6O@b#*vYASyGniBuKlj4P?O-P{BZ9Fvp~89_JHvtygQ|*v zmH&}4dprV<^T2}cd(#&&vDmqJ6gTrPb&;Ito9uR9lOTrbly?tM=qPl|*q}cPzLbtR z5w}~3^sP9F4?MB3oK$&|_|A}H&iS-Kp_M;+D*`Y2r^F+KP!v||*ncuBIOI~xl2ju_ zB%6k_vgr3Me$)|_9|rtUGB9bSbh@T4-(M5^-$=hd=ndjEdqZQV#FvbM6FZIm;^S|u z#%htZ59Iuo?t!|mu0~ZRqU4hQ0C1^v#S&FZjF=vWWyqoj*^ywg1l!LhBgy>+~z zNpclE!+B?T-gQWu8?80BorLYxaiso09FUW>cXch+*&6r9$m}_sN5>nxQ4XXF^Chxl zOoYi;w9h)jAwT(#?wFA>wHgaPU@TVV+`9|ubAjy(B%$|pKd#XZ^|1%<{gYA*%R(9rDINQj9O6BDPMWktZe zQz+IDCpkNP(kySgE9Qz@TY8k81#@z$d;Z*mn^LcU&ZRsDS8Kj1J)*UOiRsj);@ZY; zGYMg~F4p?9Y%h`V#Yd0q{1}G{o7BaxxLGTY)alBqS;k~mcXY{Rv#CO%@nq1VxtRtS z<9NE$g~`xF0`?YYX)(TeXxQxSZknkgDSQ*h#a{6h2{$hI?7(TOVzF{Iq|Dmtsf}^< zev7hSd8WA6bAIPNE;qxTICZ(%v5s_QH5aSU7pvn{u~Aud5BtKtU(_?H9k|qKsJ5&i z>Ipl;-hy{^%xa6rakxedo#)vvvfS+xQ~I1BI`DNSVH2Kk`I?i=jNweGsMJ(cAQ`K* zn;7FE?E7+O8;pYK2F|8De^~(0-1hfP6qxL~@Z`^?RT@C&^baeFC40H+cz$g9z`^Hl zGljb|trTgw`YdayS7N1Fy5mv_W@ck+yF8<=Zo2=aP#qLe3W~^Z%l2kF?$#VL)rD!x zLi`e03!)v&pJiqgWsOc0F4ye~$A%2iM>O8B9_z~Gp-PGNv09zaw2momJH^r0S`gvW z=YTUkx9cnXnR1AD7AwW91nxro%IgQ!MsYrw)M#iQ>L<(0+=B3$_d%e*XnVfCdy$(K zMisSP^2Yt!kD{uL-!gZ>H`m4UPF_W|loc#S($K7&bWc(+ht(~wP~>r}tBTeCU^goG zhi>LmE7RM^@g(s!#}esx&OMvtOi~gTvaOAARKJytl{6(~(=BpZzWdfIqRtg(9%*Iv zn#{@siHA&KMn57;DZa?Fo;orv=a%1Dw=?FRl=Y~Wf96WE)3K(;n5HPI5<#&|PDY`u zNMqkrph5%@|HHpI(tl_2eT_cp5ZF~+$}KU^~0zo_dLZ{*R6hee{^iT?dMMB zh4TEjo_SPM5rI{9ttD1cLkcU%O*@mhT4vhq+@4Xc5(VvxPB;SRa(}Wa>rce*Zx!eT z&De3sd7bJ{4jq}TgndVc-4dscp9vNVn3W)W6AO%vrBqBl!S`nZR;ZhcMXb8IIw4_U zR2=%YzP|947mps@`}p|mP82FoUkWx@Zytp3(6U;tlmJ~tg`J0Zbddv)O0@~!s#`0)eQ6whfqQBh~R3&<}#j>oza1#G@3`$2ww1VCRlQHVFtk2I9pd|M{1 zs%pN`pZmu{)8L4h8QQo84vz3oUt(?>EsT|~o^C@7|9En|ZtMG(xK*h%DrK4Zn9M^KweP93 z-rGDS>gML=eph+j9SV=2Ds+5JABk|c(`~@t`$f+^R+oh-l8Xo@E$vNU&{2lQ{_kj&nglcYU&3{GTLTe?oLYxjE8n9R4CO{Z?41tRH4yJH6T$!;1hESq zgTBqp`TlS22~hw)?(fZGpG@GxEeefQF(PnWRfaT5dXf%3sad;TZgeRc#) zBI6+uddj8?1^xbw?k`}m{M-)+`tvd~nd}cP=CH53i`1R}917~{e1O3TL?E88{UzU% zW7%01r4j$QJYJ{ZRaJ`7tlt=F)Pt#}hD!K#D`~>zSh(CBOjMPAWccKAPJCNq&f7;8 z{4&CuaXDibr+BabX}HOH~y!x~9X~PGn(W;lFb3sUst!9U!%wuCw(G z4gEB`O*Yx+?di$y`E*b`nwE&gOv3Z&7l5mard6*h&lg|y-gMddy&Xj}@3l&a9-Ru; zwce-L*uXwstO@InwGP6o-`G5*PnuQ=kI`?~$nRT>N;tZo!O5h&nUVKt$W_qE_ z>`a+d^u$XzbfQ7v*_CFCc;7NoX&eo*`7*p(=Xb#3h>MN}9lqH0`?q-Rl{&w~vD=RJMj8W_U-aqe>0lyPJfr3KD3wdtM4LB{ zjGXp-9dc>ur>Lm$v$Hb+Tn;ZEpC+d>r*#}y!mm}W#b*jj8}Okg{~N$qME)7924bGaHt#$!ey0R2LQy9Nq7xYz7?*#HQrt*ot+IUVi5 zbnc5Tt}>Z+xjxOEnVB&mLJ~YLmwqhQ^U-Q;s>#*aZd*1Gilm?xY2`YC_>Zv#4lW|{ z%~RGXVm;oLGcMDlw=&tEu?RTzeY0tmROKk-6W z@Bl7uWR(nmrl#h9G*Br85mYTe9m_5uBh%X3E5`Z=GVv&w#k|e^YQ4Is0n25@4pt`#zZu!?h3#XT9@)-XO+DAQtrEg+SAN?f*P*9U@|Bvn}-UIIMXNHq= zcxLghGz*T`+xP!>FUuKz`Xu^aQQ9*L-bj6ug1*06w2uGxTyJ)Z3u#{zO^Cn0_TQ%g zCssK1C>Sg^`%|Uy&R|o@7hC|`z;?I@&z2V9YP0!&op5k)?dTjWS~?ICPEJl9o@5`0 zY#3nQE;PCJHA;&7$bRyYmkks(xm~X-l9PG2h{Y*`z8yl5nWU9=Tj0e3Xj~Crl6A}pknj7uS8`Vmq<(-|+ zM9-03-7XG1f#(HKb(u|Pz?PnvaF)Q<>CGg%ph);%dfh+ zzYA5NDk4v*J}4?qo}Lx}uja>>L-X@Nf5-i0&eS6mLBWolamtD#9}fic1utb~v%A79_uDJ9UQ(R8+|Tqp?F^;!b7fm&~J_3aGA(bCr+eD)ZPt}BE{^7Sh-vtkWz z0#1ji*?JxXk{o?*E-szDsZfAUd;R(~5|YORDGtu|&Q9DfU+2uMtOCvEk>TN%&Q3CF z>e=y1E!YBA5|6v(N;@TW{!Ug_)=)AJkK3*8#ew?%{;sSC@EYeGW|JNr8tm?K9fE}& zlyhP^9n%1$5*|Z=Pt8;F)#_4xeYNi2L4XvI5iVnFoL^E>&B*IsSZFNmvOExf=uvHM za5-P!4S4uw^MLom2!j8e$!hmgikQ2U0C9f6{M=-C_bwHE9{ZCvg&+&4qYAvEp3Mk@TU%eDP~p}WxQ?5Ht}NAzsjmAlxH=u zQBgwQU;2}5Z5a@;TEr}LwhPS8%m@hy0d+hO8J78hl}uY(drHfn!-8S?C}y@AHZwar zfb;iWT(n}x79IUMThyNnc^B8|TUv*=BeZI+cwzJcR z@(176?(Slft1twj3VO7#v5|uOKA267UGLw$`%e6Jg<<(5_7U(bvD&2;qCLkzS3Q1i#K6yd$!>mFW5&z%8`L^Ia4Qq_YkIva@bK~D7+Kb@a zc{fq0${0rZO!Otufi)C*&`Ug~9nO;BGq zW9%|ZGJYZ#67PYI{-m@luSvT@jEMjDYX9EMDNSSDL{RAx_%F3<5 zW4~Dbg=lMwOq9$A1{6RHaJgR3eTVn01OTj;r}cLGZM@#L;BSK)?BL+A^!hJUko&3$5nsR-hq}A%tc?{4uDV}g zM1K8WJBK1@zznCU0ix7@8tH;NBKm6S@1*^P1dDGHM4+l(SxA#DyXyX?D);YCsE{lE z=XX&5vo*uz9R9EW`u~NOza59gCntk;K=EBenax*MF5K{%FG9K~C@Cl?M+34&+%eE& z$~+0w$$D-7Vf^vRe}hBhtrZ#S9q1WYJ#vs!fhfwSjmCLA4*6c})rNxZlmaK9@P#`( zJUb&NCsl{_Z=v$l+k zteo7Ep6+Jv!|S~*x`HQvuZ|4Fe7(G8Zf`k&F!Lpp~AzW)AQ>0A_wbf{A!ngO?e-{`}?Z$zjmTI#UA0EB&IwUg={il<;JmzD3IK6NF8gg*q43@I8R)A)#S`DkU z=}3OMWqaQiNl<@20w<|WL?*QCvjjj3@#ywv1)bD-&-x{+pI-+(x^o^Ca>m96P44jd;XR8IIp|4`f6c{8)cOSl)jLd^4;JLspW|QZ!{Z;r z{vG9I76C{tSdp=@py??;Jo@0*bUZK6{0Dh!dt3G~8#FR5?tB*}&c*2*JDxL{r#=Z< z;aP_VC$f&EhlF?sc}9K5kn|69X|^8v4mwNPJ@CB^N}bFqtoMT2-W=Th_|Yv-9mUE% z1uz22)z=}2s6;Z6+>&*UyJjtCs^VA=+VYKc>hm+rw~Ww-Rvj(#wvK3Dq@Dx9`8?%m z0B9zmxP(L#gN9T6aVO=*)~MFI*TBD&Hp)%%1N|^jeSXq(s-M)E@Hz}L@NS97y(sI9 zo-toi$0sO`!t{moO8o_)e@J#{Ye@1Z&G^hrZ|6SDB!bz2M6QlZ8P753VEHNtDxv-N zK(vRk&pIPHSCwXZ3|bi*lq=1!qhNCyjn4$p4$PE>dzs7BI>Y#?H5MwY4N)qj0J&!B zsPP*4Zh2D>0_`tWZ}w}n4$ySFAz@K$e10+ZpIktZaQ?xR>o;P$U%oFnoo*#!7vG-+ z{k`!L!yfQU0RVTv0ot9ow@CUyfVk#4NiyiuC@+5&*@qP$ADh(dV0#eKEVGpXKo)U5 zha=$=#rM{;S(SdGq5!rJ)y9q+-Wow~0Cp&auVb$}4FzVgM6OxHrwH*e*lfS6 zb;v8NjD_6ciMXnYv`BBKC$-G4QSd*xbf=^vRxLI@SX>t**NB3z;NUYdDg43~2&==I zZb%b%b)vHC(`?#-jrVw7=}W8mF=z2NwwZ2mnIvR-Pj9#@PAg5aTHVTp zG_vDb|Z(-F$ShuY4cCFo|ke!KKE(2b3Tpqpa24_^lr)q2@mPZD3A2d!G_SYv0 z<6tw+KW9nw($fB}@K64E1hTTS%=!8%`B_2jSQs~vk@9N^k)Zixf^s^Naj7Hm2jX&1^%unLDKlA`3K+WGEhYnCvkSNT$jbWZ#oY}> z9FW7C>Kb%s%E`-TPB%Rq^68?0#ejjCS*6b55sd`!O}jSSScd25a`Z#L0vg87I1RuT z!IT#7*J7<1-7bHAANs5zy`1cGa&lsSz_!Izf{HBM^zhZ-D{m_bGBPqW`x>oFoW`8R zXlt&@R-B3o@;#Q|S5$)Gdr9r4s>SFJ(AmBkXB&uu0wK5DFk;uZ@uP}fbT=zoLjwbY zk528cqJ5B2@G%7v4e(Ho4i>W&S!UY!5m61+C*)j`RPFahMn~0ZZ{3B2e9UO#XfKbP zkpe(SSO(#!GUpHdV2350vkUyfdzV1Dg3Hb!swM*3ev0-`p7P-yjOE`<>^sNM|6ikB zR$kt4xTRDEz9)0w29?P5^3_1M>12|pT$3yMDJvVB_X4x=j+i@T&d6w?^6ZyJC)$Ld z>U1@ssjAFT62TWKsR!pXpD`#Mr`vHLF&$%PW@w78xZo)mPIfRZ0R+J8kCuf%5YN#V zS+;&R&4;`I&CJy&gZEV@^OVbtH-p*PXGWtEOtE+o^v`KjtuW0#9AcEZTC{g|!e*>n z`pUTk84cEeA%1Lh)R5yvcEv}Q^yXp>U*t2KmLMCO96C4au0BPrLe)xXMMdbM9n~|5 zVUa3fB|Gj9IwZd?h|;E(bHh97WrxpUv-$C9X~f9(upKtnb5wLp!@ZfGlpKr4|8C*E z86Z70n0O*DExk9)XSKQs#PmF_*Di-kVLXFyOL(Qw#pMohp!7{{Hdc~nVPH_Na~bFv z+nf>=Ml}IAoZlzISYScCU*3EmU@Iw%RDDel&1_*e1v*FGT*#n%j7(0JS6Xi`R+9%6 zgH~0{w87$0v9Yn>SfXV{%Z*6dA+?D{N-AjPFnFuUBOq9;T3Jw+$u>|6U3@n^J-zpy zYA6Jk#df6a3jC41@$>-D7bA%A)2BMiv;HFp6aWxTZo-)PHxT( zW0*xmBr#ALsnQy&LOI)eK`hOw60JCNz(CsZl!P0_-3~?Z^7c91ZCc&BgqRq&)gC&6 z+Mni%c(v9S-^+l3dhzf-x99z%U_jvMYJWx*3r=_-ga@<{P~A?A@u)Zjg2gO)*}f>V z<9<#Gip!-|evt3mnl4A=0FV+zaB3_mFUMmvfPw?JO=3{08;=he@hFQ zvH8q{Luy4q1$%Fwu=%##!EP5Ibr6s&z}a>U>nueQDPnjLz_U9#J4@ByS6G^}xlV)t zfHVQY-u8aww|HQw8Q^iF?CPA^nY$97n?*$6@+&i&t7Fg`RRSw0V?tyk#_d{cBQRQp zfzHcZ>WQEqW)iFI!PdptZ3t#|5Fy(au|)(%hDQ%p2hL)N0~V zQHe|Ux#_>zuI_8<0*IjC;NZrGx6-#ykyY__zorfZ& zVYek0S0lVLx9!>WlGb4vM*hRaY=}uo?n~Fzf1^UG?{CgqNAeYifDStlk0sA=j0~Kq zxtvZ}A$F%{$RJk{nw;F#gp7nl&s5mS8yOuQ?s|P{$iw5F1xQt2j!v2f8=t&J6uzmy zAnNQQ2eh)=tI3)2ChsF}aw-0(LZv@OYGmKW=cukny`5Q4A<}Lr?l-g`>9%qUny0ue zkB)<;1-A_rM-{OrzNk1#`B}(8#p-=gd2;}*a|ooYpdT7zr8&$Dq(F#?i0Eq`#-OPI z(MA0O1C7=ftxl<3>Cy(jU^BM3Qhec-Xiy}cZ2sY!T>A$itPfeRm6;fPF1NVPgdjt~ z=+Yr|$cMK}Y*wm;;MxFs=6qz33vnuY>Ie1)!=dEmEQkg?nKwXh3ryN;ogen@=lAG13Po4;pe@YG zD(D|Qn_nLd0+Ko7Hda2h?cotL3AW45zY(NOW}Tp*qTyqvnfE zXxP~37-y-VC__lNZ(>pa#2*B5OSe}q&d<-TFJP|sw;ex!4uixFSd_WAIArfqx9BM; z$8)sKEe{MOpVl(_W6deg!_7XCvv;e($WSuRRiGI)2ALD3MM9d%fE!v)3kv? z3zRP93V4U-Z4oP!laA$zbuwf5G-K zh_7FRv%v!V_3e4Ho_YiO-rV)j=z9Lf{ULQnJP15YO>}wRy%*z8^{=V%0w@n=liva} z%8Nk+0Ft5?dZnbIlEBsk%hrryt*yhu!L`{P^<%aTUA#YD-$nc$A|S-=fPK2OoSylh z`j#`y&JuLewuf;s+8kDoYk&Fgaf^odWubzAf{1{Cf*>K% zT`JNoB_JRm-Q6LGNJ~kx=94c#lW6ozj z@rhmqwI8;Ww6rc$KhJ+LVxpqL#3V9Ip$UQ?20oMP1dLFUR%T#0U{63yTxz}k1)QAW zNIpDLK|{6Y(rSIm&+idRJqYqBg)r^_bRN6Kg5mB$JBT}O?e02k&)|>^^!0H$9cM)- z7MB=}#$o!K+{97%XnF6agq9D}uNI)EUaH?rPfu@5yp8=&OC-c2spz?t92pK-1Bna~ zm*wg9r7e2PBmprJny&YK*6q=u*n6LOWBbyi6NRx~(cW1fElouMud1U-| z=__Grz%i@Enw#Xts!-f|h(mKEO_|azj?czPCnun)I?t7;k%55+Tus{`z==uBV}EhZ zXPgtKeoH0SCn;&iH+Rzm8s!c3^?BTg;^wMz`Po`Ug+F3IC33&)+vWqDwWZwncYixMSy&}gZ02(mRWL^G9KDO0wg?H>z>WHva+&| zZ}%YUL$0BduJ}ao9)0lO0ja+pB}DPR-YQ6oh_tn~w9A~6a7(?8XzpllYg;r( z$DI}XN_gfG`r+Seq+d2DHI2@ktJKZm(1S~|)ioo%Oj5cWoL>Y4N}`ktHQEKgFgSXn zQX@Mhg<6>Uq;=xlzKVCy^~ulT>|RNN_SV~${eL%QUfnYNsUTC(RR6$+hatqy)qU%4bxJ6&9VJ*rvSv_ta)C%fyTGrQEWGsugdz9NLO!Iqj_f^a8ku5|h#%+e%Z4 zUol#%i9B_5hNhiy>{P=dBUP(+@@DT9&I-fOroZd%Mpyu1{7)=its zOZ?Y(tNOXRsa&t8$KJ8H6RBA>5s!`7JvzOV5F|!{XbJ%N!3?cckl&5|*#W55?+;|GC#oD^ zYN4g6DPAXu`~tOWXEZwi;n!P&pOL6p9&A*B$g=xQObwBEyWJEZrkI)OK`XuarP*~9 zo&@gxB0bH2Yhfd%EXaQFCoAN}qT{oH9_km*no9eAS`v~3n@v?Q#WJUp4oF1cbR5W0 zVdUU|tC4;B3W9ey1Hal}n)EemHn2DcG2H}$`ECB^+}xnnlQ=ci)X^bv=f0qX#6BSQ z3kwRy#+CotAs}9h^xyQ%$%#!&j7?5H%8*+f8&iVV^46Awj*fzAg|Aq@pNnf`c<3EK zAvn{19_`|;t*`#soCGa6pZg_X?~|_BJJ4H5igW0(h(|=Uv9<fCF`VT0r@-}VeWXA5UnwA)?N%40fRQXlSVZUkmHcF^ zte(HF@bFwq_qE@iNiNiJ0(KBB7b`Y)F93AFx`dOR{cAnXo317#x5GrLB(11u+t3i` zEPZhqEr}r_GODV&2^@a?#SU&D7x@MR)VN&4mmVOLV0wJX#GQ@+>XFp-&u0lR!K0+6 z1`p=V(Nb1`l;4vOCf*##&fKD*0>wiXI=W~&_4nrHt=)-DchJ#?4{vyIH3kp^+G%%V zoYPB9oRXO5&d0bz)EWF!w}&^0*Z->exXQ3dN`uCon6Pllrw25Vg_>!P?N9c{VUTNK zQP^26DIBpXlL(xS)qyio25PF}p0QXSC+9yIaFAL1JP@aRL$WOj^%J9Q9_whm`!5pT zxB%Zu*c&h>?#W9`teXY#LvA#XlZ|hFEg*mr(3BYG-Yiy7SSTke8!?mkt#!2Y9uLn1 zDysK=V!fWEZTFj>3|;FQlAoOL@A+ZXD1odGHFeY{EXuD2QkH91`CEsa{_P}ykYc&w z4~A<-Mn)-*>$gCc5c)QA3*ZthHLUtn3+QM6$B_n;Y%NJ|)#oqYe4H`kj``Z`1-Y5V4xXP zm${q#CYx98+r$N(0&YtKd&?Gy^?zn$8p%b&Z*PCsz=m`GaBJG#)|F3APFzuu{lyCj zHl=^htDjDc>jANJ&)eG@wCH`2m-9bdR$)oWfNBbQdg=Xjx*1E$A|MxlXd^jiM15`w zx|X)Q-zTi?-Ca-^Pw@1-8ENwGg^v*YMCp-~R38^Nb?v5qUxwUhPY($#ZNLnMyu3U< zeq^p{ReajbC^2PaYI=Gv!j8^PRt5$pR#pXh`M@E}8_=$cwX-W{vT$*6LFJasRaKcO zW!0NmS+wypa=AxH2)WGi+S=hv!|JzhI)+zKsk_7|BJUCtOPVY@Ff%cQgvh>G&@?ar zHj-PQdr1j7jij?P5xPRAyg0NcL9Vk%V2_22jGct!_0y`NTxWoH7Z>APYgk;wmAX5w z8-Ve5LBD)=TiNsxyLpIeWaJ16iaj9HtjzS%;^IE&>noKSuR|+qd3hP1fB?MHCwP0( zr0c|^N+!xJnkK4Mkv!bQT()KEbuLOuO5eY0&sfMyOIKSgP7D`Tv$M0qkRM`s!m!AD zl#II6!(RZT3l0t*N=<_~R=^|ny}e}%pb(}U5j27p2g z{66F-jlX%GZjHi{U|ODy_7zzkuIT}LwcGx*X<4R@S3-y%sUhYU+lbYE{Qe!Z_wrR6 zu`!a;>H0 zJIGrf*Ul93D(^|Y`qZS}iVv4)|G!-#p;}gfoi{4Nw2V0W2UjgEE@7vLh<9u2nELPY zc{G2k)9o|{2g6mbiaXxC-B5o|;+`kW!Z7-*E zbQb3p7N{w@gg*6Le3?FU%eZ#=f&aS4>eFqqtCxe_x^w&Pd!pA`TP^QU7tVUP0G8?0 z_!ZgXXg?3nVPeMPTIg`M!<37O z$=)AUqg{6Tx`npEr-`7-39x&|)@|RAL-(h3-Ep5q zi#v5}nH43uyiJ6I4B46e{32Q;A;5IZW{0ojBQxD*iOlTNn#;!8{qA)vzK&3PB`4?Q z{9~v!6YKQy&Zutv_s)j4$QdyLW#X)|-f6;>)q+oH#k{;55UUHjb2-L+KoFaHOBFlOun-Zgl6J&u43A;d zPvz0cL~DB-Sj0p;yA9atd+|ZX;rVFJ#YvA)xoWTd8k%IWbKiqsd3d>EZeKnI%G6m= z7B2D!aYZ}3yLhkAsHYRZ(>l5j3nplG-MvRe=w)}@I~|vn79Jcd=;=F^D#io9&hzU( zOTU1(`XgBGxV}Al7{5pj?&Vw}N3YL>w(Ypy%*X2=4J~|MC5t*R3s;Z|Y@l^Ue+YN| z`oFjTr76r1Y=xwhZg`SNmfcP;<>kQgD_gIvh58KV-lGjle=~S|6>PJUd)PKyzRz<6!JPa$|K5c2P=GKX=Zb$~ok&$HgW z5kPzUai~571{5|Zd$8g1echy!aY4raKP}9_(h@Y$rlj5Vye=;#Iz>8bRSP$?PXB`b zTG(^t{;{fHJu6Nf1smk_Eah&mP)gctQS@`={4f4aC>i0c_KtyHzY1}b&9VD`b`eQB z%bHtU?7e3X#wx~Q?1ZV*gNC}j@tnv`ohgD#_>f#*MQD_V9Wx4|wKj~a`uw2DTY5I7 zuP9%&WG+mO`T=Nlf{Dxu4Dup+wlT|&4pz|3$x{$b2@P$$Z(0Zd3>|=adV)cec;y(t zOHGar-l28uMK8`!{El^I0RS@2l5^nzlAA08{Wfb8$cPAj8OUZvN{rK?HG~LBZEeQw zQ~akFVDFgkd^+zE!%<|=*A+pD=IG3G#x-7k;7TAP(XnSHe&^F%v#ALGg z;`z}XRB=*Uz}EIflo0?H4AL8!<78?W?I$i9-W_CE6gz~94s*xy}6f})H2{v zm?#goWv2JwaTxaJP;)*aAmIF1Z&SR=nxm~XnJ^tU4;oCcIWH_htl-Cjvit#TI6eC%j=e5962AK>QZQB9#) zo9jcsqQ~b$uel#kUe3;BJ`qsU6V}+!AfsX5Z(&?ZUTt%6h31(9?eCY!ZkX=SyUBbq z2_tgXjVrIjjf$;ieey)Tdqzve{!UVTk({T0^xb{(=ucFdAdcJEC;+p9l=Sp`n~QtX z!m>wP4jXO);-iu828?>+Pk!;c@1-8d?g8FUU=r*!vw$hiC~nR?CE3on3@%Cn8F zf^5N*1FDd3tP;9W94VF8kyh9+j^4xtF*bSOmb>5Vdo6r{92(;~-@(#P>TDC8bx?QwC*} zjM&R9!+nAYJxBW@qlRjldCA*^Y#ahCOj~C^)P_9buZbVBX$_KWNpHcTU%naXY^sp; zJ`9Q6BsT5UIkW|#Lc&V4$%)tUxuZi_a?^zRHRtCy$ri}1_E!!mmb{v^t~uhsnJJ6d zbvbtWaG_dzkemJPcyz(Y;av5M)^Ba3Jkj5O|J+@${G;s5PL*l78j?t?HXzzl$AqJ^ zBbNSX(u&&B%Cf2g^FF(Hq4WDK&h{GqLUMw^3vQ#jY2TOHE8^x#bkpbGdD*a+O{i9{b9j9cO(9k6 z5u;0ougxY&O6T#bnIMHyCYW{4b=C}Y^*^N{l5r$$l4$w;`*_lX*Z(1h?m;ylpnFmS zH3iV|7$6HgkH9`zD%P6S$H%*Wn#8y046r~0g%>dgXL|su8L``k2DQmRy;jR6ugp~r zoV?}s_Ka>W0N$*3Rns_GucfI^n@o0lSP2MlT$4jMB;8pW)C4&W`KU+8*HH;ZIcWehZAGtgv3TJZ|7*a)mdLlVsOyg`o)2?PuMr^Hc5n6Yn?#*<2l}>Xy)2* zwu|HIu?w(4?)zHHX|p+5br5Ij7824&)+SGTYG=GOJ6gm{E*9&wy3cLh1MUqK`+Ebu z{mN&9LFI+1<1SliqMfe{*+o8<0HOsuf=1Rp^ze`bm-Yy-Fi8mst^ZnF4I&bR*qo*Cm}k)ESu#4vQI&0h z!wNO9aaZ>Bi9tUv!2rzxULTUMjyXqpYn51EKO?MzP~E^?4?}#6=CpVthI`p9$Wy&WMQM7;GFWOl$%J4gG$Df{QM*qVa50 z>kamhs3=hnYW$7C(k3wQ#w22A*FXGVFUtRzDrd6FtahL=a>9D$r&ugqBnr6WEdEww ztFOE`W_BrHj3;#?{1&)9r+B%nviH~4SsjtV#$gQ-KQkrJog zNVKrb;_nMR>Cpii2g}`+;c-!@{GZPA5|pN<>KsmRfqOOeyEUR10%oIU(XLk?`M8|K zt6zJDAIq)T(>KT;r>es1_xI|^j2OH$ z5vuKN{!6%o@gChqHp~}$PD8pRpWYzV{Rzy#QaZrfcs<=+>Dwq(bEbIbg8=}C0U`FP3HAi31DJUrO_;@eOFi2*~` zk5dMGkT|2p>1pB`4-E`_@#Zkp!Z^!uJiP04w8CuN^NFu%DWkc$`Rl#CEz3l`S%F{k z(GrAQIjU%Pmjk>zMr3VQ0m^e27W=2FuSOEHWRL&;{reSG%a$}RyD%n)TR#0dofMC> zS{U;%DRies961>@4YrRSEXf#+DVuvGtDs>CdfMCMAbG8IPllxVu!6B*%A8qMI0`2t z-ciwkZXLSK{@&E?(V)phV0e&z@)FfI2LoN*p#(+j8iOl63yxb`+Z$C*`hzA}>1ryI zMGsWQzj#!s$DzVJ{6ox0aU_%Rn>wShaga=r&i9P5TrNOn`YSwHml@+v7P_!#AvCkF0*%W2qxyA3@;MR9L*h+reY@aS)Cn!>sJ3Um>4bH^A_N zN<2Tb!uPRxDqEaMfa_}M5a;3~|C>k=ebxI%=hx0~kg{XG&m=~=Y22tjwcg! z{`I@Uzdc0^IZJFRGr|R#>W`cI!v$vTZK(%KsoyXnAbormL{7yK>HlV$9uR;?+Hn6Z z`kbt+ZxLj=D4oXM=M#yaB{8lHn$%!8h|VTzYKV*RLm$?R%`MsiA-XP-qlKILvOQ1lp;H zcloUU+1{Gg&|0YGBXL5)XBhzj=eRb?LVe`fuZXCqCcq!f&lfl)nWd%i&6g6XaQ;~x zm6qg`t(Ak zEY-X3TG~!zk>nEwh-?o&;B&HZDBT^QV`RiZX2-_HW@PBg%KC0K!(iHH&!jazvq#>z z-8qssiU1p%py?&fPkHi->W}PIpPOLWO*Ppu($M@)Owg?7)l&Tyn4n2^o2TCAr;aOc52B7Kfi45D z)p4!MnrO3d|H=S!hDF_dM|vhYjVS|abu^5MeN;4u#I&@nIpJ~}9OYb9ZkT+dAf9d$ z4YAVDoOK1tz_GhJSjlPgEUz$_yZ`9Tn>Xf^0WoZ|YqDx{^6CL4G)Io-v7HXn+IA2w z4xB$=4}RI8QNC64xfka1C1y4dxOuj^HNDocu22=_nmaNZ%m9|%;egoGEB9#fTbV78 zkVyKq?u*$SKd*0*HpNlSj-VH-i5`*%RLRjuit~oDLCUn?nx;*n5i=u&*uQvhQ>S~Lk9vO@}L1ST)H}F zqB}lvcDNl39c3WGCDDTr9OkVuJq?Y%f@Fd!fc#Zk(>x|8|H6h8_8|+3T7pK#o%GGMXhjl6Drx_ zw?57hgTKcM%PR--p14OpTJP;)=!mU^08`<(oKVht8WbKfHe8q(8!PelZR@~#C8yO^ zoM!ZuwD;Am+w&0q!AFNrKy0kQj5*Z0WPPmEX^_Xu2lw7lwSx&VcDzcdf_bn@D%Bv@ z<$V13u%*y%^+su0SHtSvP3O!3V^iyl#kF@ATlq3znfRQ+vv=`D4`s-AKa4pKpYQ0q zcpnT%ZPQ|_RI+`2eJ@JZvSdc(<@k&Bu){7Q-x8<((+fB|=4*O!rtN&88L$yUSv$ik zukLX}It>O5e%+e!0Zb?jBD7~Ob#KEIC%oA$e*8F08XwsG^9jw!ScTccPXES~e zh^yx`=Cw;CN4dPXe^yx6)+?JK%@`heS9Lpp&1~m=X->=+45FVM)^VLf^P(>O{oUu= zVHaQ&m7MmjktTh5RCYOlc4ngBs7??`^ih?Zmkgla(d;(0$${8s?*@;C$^~x2hSAwr zqr@ifObMcF2!a!La{`UaY5#O2)CK-6in8p+G$1qOk(-2vR3eY2Y=!RK1lT?Jw}S9g zO1k1h6+o$s=c)7Qet@Yb5E~0ia|P)O@tHFm?5)50`-2RWckaCK#+XC5|%ou2!E74tE=vZ`lQy0M4Dv^ru{(8UB^307jTU5m!ol?V5{;=kv-;!5z zD=V0Om@agkA}>lECUsF$%2@PG8xF!heTqZFtdN?h+?`Br8(drCjdqT7-#CE;3Y&m{ z(Yfb0kY7ee`rZ|5Y+syzD^uO>@u0$bb8!hP+# zf=$PtJ_W!ICQa+3GHi{Hi))@B18xy$<9I-eh$8KkNhaEOYW=N^lDF38=668O*Tx1D z17l;fwA6P~TtdRy{5XqUX14J~U|=Ap%?1EFO!}IKdwUB*qOMgd8jkt_p06wZRnslp z@eh}?>uUNo`7FJmI39;$$GxF+hl6Vu=ess2h>ww+4l7{P)f9k7-jmwT3S66BgDb2o zIcl{(S>r&)0iHehsl!ej=3#i3pP%0|BK5w^R9Hntv-w*s3d-?)RFGErB;1d}d2+#7pu{f1+6dOot<+i7d3+&_<_djt87?Nyev z=~ajBhU9yk0-~afw6wfHDFKKfTXLp>JNB~<4o*%Wd?zO-YkmBfl&TXR{xCf~9cXOS zy&9A_;||!ex*HqDFkrpEU}|B|c==2ngP~~Uu6l@|NWqhNMu#Jwim_xrOYn9LA^72g zRHA?cl!#+AOn&&xTvjV$pr1sRVlvp_1kT*|M}U#T$jt2h)s{B4v882uMXm8ZTIv6M z>(i%yaSVu;vrlUO`D=X4+kXXP5M8!H{|LjlHuAY$`};9WL|BgyD}mp>i6LAGhN=-| z?(1l-obU(lhnN4L{xPYE4iA$IE9hK`DDYUTgyFZ=fw$8CR``sYskJg@&FU!%+Qf9F2_`DoN2P}Q1U z0K|l*h6av1m?GDlvG_c|AmW-^T~(;CT<(bB{7xn3?J8ggzSEFFH2M>Y|GgR}!D{SB z5I=k#&=eOJgTLgP0UeNXGW)Z_3}Z4iwaKVg7E+Z6~25CP(-5Mx^?j@r zzA6f=BMH;PBkt5*qfr*&r^8$h7w7J;+)?({N25xt1brW#O}cC#eod(;Ia zhX5P`@*(;&LH+7>w;XUp|P-Q4ad zoIVjsito(Twq{BKiSQ?gljUZ!+E8QM{`&+Dc4DUP`ZQ3*Cbw&Pyk6<Hf%k~UepwL*RLI4Kg2)3Rib`1z~wI2Rxb83@4Zz4ZF} z&&39;00dHa+!a6U?N(I_!R4}1;&|kpV+}yXMn3`wp1-SW&F5Rmo<8luNwoVmkUjnd zgHQ+!MB#+R#FX+r&}VBJ8<&_&8Xx~674(nhb)kRyGzpRbAa8xdMqP8bI6Gk9tBj)z z1WL zMNTMpM%^{L6Ot`U)>66kdUD4rZ4AcB;vFeLJAJgmO3={o&PmGO_s^ERaJ>l@P;^X; zqZ6_}ZF;2KLXfZS(M?zt{6J_}>Cddxm_=$7J_N}jnG`YjpzdgPMm_Aa(#ZvWK|#=4 z_sF(b?n#Eo=ugbMKh3wP)plNHu^8*+wGGI6umrBuZXdyCelHUCqJ3bj(#9o<1=vo< z0BusKcGPlobcENaTcEkvF%By3F+3_95M|gRlOFOqGZ`%sbK9G5j2&glpVg|SZiC1P ztY=V^`&w-}-zrzsz+<=b24AgmpE$BUemIyxO9URBJuxv9SNIF7=pZa25zAF9^DGzX zjNiDpi$To8quIDtbFS1dD{Mi`q(g7Nck9?+^@nZo#}-g+SksGy%g7c9OIsVt-`&>S zlumTcc$>V_n_3M8`Q2OBfwuSo3CoC9d`32HDmpqktL!S6Ni*@jEl46eF|Bp8^X}|1 z?D&En1##hpq}|--oarQCnfEH0C$QO(E_Gl2;2?`jOH+rDAaUI`_%{%`a_UQ%-n#ya zh`XYfH1;@ViK?zT0&?6XlxtyBj63B`nf0pEH~NW@MN>U zqoaomrm(&Ic~)cN<3n!w`7LuT*j(J4y&)pQ1^Y}IA3q8${^S|WQ5nU4CUH;kzz+@g zg&!ejg}v1>+x?nzj(ov@YWAvE&r;V;@*p)WaI{N3KkHRxvtFa1&3$oxZbBuOAw&$A zRL=#iuUW)zz|!~ZRJH56XU`*`eM5l$;drEB&2}ep`}Unv+lAkXcCXd9rmloi$tB$2 z=5}hp?cu^0n*`KN4 z>3s6WIu!M+V9E34-vwTlx^Uey0Yc2#{%97tIKI}&bIw~gkezM&!;(lhdUFq^e|iBb zDk{Sr7_#mte{}8LP=>Dy4j0T1vDE?fg+;6FyB2XzxuSBIx36zdXHz$Qc8Z_h-j`wt z3I8=d(0FQYzE$!F3kx9Ss<8aNeUoC@nQy~osa4n0BSV6|gWj7rpVx-(a%9V8q7bAm zU&9U4KXP(^^$Pi;3Rz;bn*Q((28K5FBjA0YwlSk&UVOL0V6u6PzD7DsAmjN2A3Q%)QI*ON2nz}ifAh~1 zXi|4`$MQ0N6j&si{LjwQUu;&qDY0Q~c&zFdX9PigZR| zUO&ZZ=}TvXVjB$Bxgx3K|8QC^g#-l!@mMr8VCBCHs^i$QuyL$uP$<}0M4 z+9CBOAt7mRZ~vEK3|#c(nM9!=F`_c_J7O0|QaeCPA~E^`vS!V}XtlMqZ{n}<%QNXr zFD#S*5PG3KsMf7gc0tLOdYqBEwtGuv z61V-M**G~pK7S@BBU@h4RiX2ZEbB$%=5zpsCj`_1k0nPP+^>i ziHYH}WJwm{!1oG^i0p4Q&!6XpQY$bpGdulRDW1ue14J}Pi}rr&NULU&;ZuN;%XoJY zKrD`C>2H(g`bkJhj~rENoSl{?q00SH9 zm~J~>|I0&ESM|d{*7DA+C^9KCqZ@(bxX+={3cSl2NZsJ5jEtl*H%sR+9QrU~nQAl} zN*xBC1bS0HgNv>Jmo5>W={=0Dh2zsBi;E+jSpazh^Wd4-!_to}1@G0E0Vn`RyjTQ- z_~W}wx>7aLiC>20Wwuek(_=78u~@6EWYRebmw-Sy=lw7inaIcL0dfLvdrLL2FA&$y zEA@gGjeBMaM`5vcyOM>4MWokKSFQWp`FAVbVpt>)hnWfs;kbc=tml9%m&No$4RAfA z6C1@N_ZXwz#>1_wtVIu`&cgD7e5*~5axiCnYiCEcqU7%raU~Vg&_HeBjjV}l@d1sx zi%l1oUTcS!FA;(MXNuoh4nYe~Q&W>v(ln1*uJfk%J6Nl6uj*I~-|6V<7uf$9fnG2` zDw^9e`354J{9}fI&HwqfChT=*Q2hdty^HOpf|cskf;Jr{?X?Ba%6>$UeDJYdoO zeF*OBE6dC2@BDt8vg`y=Qg7u_Jbn7BrzhmT+Z&R5J`Lcrey5I=p+{luj8x6;$d%SR zu`a1;d-bMx)5n84ZQ)r|f~)Gppt&dXlq6nC2s58dm9ble4 z2tUfQ*rqnu*FQab@`)G;U5`%QfD7Q2czAe(DV)4=Kc@1>N=UQN$(7HJBp?v2QPa^$ zopeFYuC4u0uUj}gtmpSxDCFfi?ks)x{H5@l81Z7JqaQsyqFvrVJer_JEvR+hK zSyCvcCoqn`Zt{KfYj!(?>g2GR&EdRYld&cM$>VZ{zkei}mXp)fSP|lcSE=TLH<~3} z7yaG*&+)pur+?Ls%gIh?O<ndV| zU+|`vsO#n$RPi9Xr10CAqQY|CKC?sx!2H6o&^3%&U+aRAhdKS3XDrlFdyX0!GdrOMbC{g= zC1i+iE?h*p=E^2Z>4I)&ctJFbo&#Qf9qizl*v2Ht?!<=%^z&= zCcL^}HDNUwXV#vRO2!>XS1D8sMzPW#6cQ5Ra@lSLh{U{PyIPUPmaA$>$-r7&0I~bU zaFNQn;b2E>m4mh4SKrgg$tch_3Ox_v9aCZOi(2P!J#Nl8zy#!bV*?Rr?OFQ4k7I7?8v z782&?dk0>&AmZ`GNUP>ogBw?`(6Ac6%T;PGN9}(a2tV20~dYc3R@I3o7 z`R#(hx5*5RBYF8H-@`72UoPTVl~dAN&MH}{wOQYBSK3kX+_`m1L5t*Yc%{10RgE4s z9v9ul3GYVcyl!^b{$boP2Gm&jd|g4@=Dct{X$?n9rMboH)P2cy8BvdL$4u+mg;(D_d{MB zw?V%chX}>#^bNw94yyI)PSuSerw=o8iaQ_v=!XthOCY3}obj0>s@UdB6>H=(-o+wx zZ~W_a2XZbHR5vDyNsaH^-WrFDKv^*qs9~DK=i7@T_E!1;gK&Z!d<_!Bh)ETR15T6G3G;QewmHH;gsTB8 zi8AM17Mi|Xg_AI(;5~%VCkU?izOh#-9shheod(S9ZNw=;yYrA=wc31LTX@vFqR~2; zP=z>2E%?{*-g2RL#QQ2|hwXy?9Z%}8_F&ARdw0XrS3UF1Qlbn%puM%tdOFYMnB#Gtorbre+YjkDGkBJcQ+kTCm@ zj+|UxHAOI|`OD7$&E}sx!t_XuI$kb2Q>)1waU!GfIY~c2z0GH`UPrK5FEtDA&xEjK zEBiiV`G(NN^}W2Lf%8H#GBOVu1yk#lzI?(fuEfF-4A#5OQ7I4M%m!#}5byKNxwt*7 zKp`fxoi#sY>pV;V4m4W1fHIOwdZZJca)T#*etrZI)l;_C5P?#c!|7Ys-KavTyK4Mn zjiRXr&vwW{nw?LhO4d#-8XT`8-lLxE`_a5FvlJg>J8`N9F`p%3n(II8G>nY+5llDj z;Wa$9*FQjtkIrKX20Z#bt8RgE*iFc3#zILceBRZPpEUy(8fL00H=7Fypl75bvDOZq zEzlA$G-BLfJ2`DIXwX5{{I=B%FhifZnUa_|`+h?BT*_I74qCu&uY7L3BwZhJdJNHv zCMqHV18>L^@v=MNS#<8?57Gike{j^+8CC~G+9oh=^wdOEkdyi!p zg^4QU6k@i87S~vB+xlqxSt!}Vyn1H`;8ycz%f5Q+29U2WMi;?S? zGiUWWQ(gK0Jp|^UCzLr}tzvL>tBTukjaz>$H(Qzcz;Lp{JlUHo*7AM$#f0-a5cfz( z)ST;I`H*JldbMTnJkwfya8g^mg(Da{wWHPMt<`FuhG(<%4;oxo0SZ|+cOnI_N!S@? zthqQp3E+9IR{bM0Q?$uHcG#aFR{)BDqC5(E<1t;Ojh^JDB;na8W&;85WN5d6MW%Dz z7pv>u!EivgoNS|BzdA&p9kj4 ztyXr#$yxdXrpvvBXjfbfFrGkico!l3=9TOzJm5^@n8@J?o6VY1llj{igDY~{;z%I` zlTgO~-Kzxa~6YO@!7%|$yF6@qWv`8wi_Wt@c z*4o-rm#DHjVEmL9Qhyln2zbkbB^30Epf!26u3YBf!JjOGBk2D~wcw-huPSy6=hFOq zJVx!OCwXsf@9xaU+`i9fFsNfWxinr_7%C@yx{r8Cqd%CDuXl!&%ancUQR3M0z5-Jz z?+`OkC~5EhnU4C&_u_0NrYDHVIFWg85F!gk>Hw3RBK#^j8)y>e+in*?4-^A0DDZyL z@@)_>su{(lQEFUVWzy@R7(8O*;PA))1IAIP+`aRgn>EnJgJKzKlCZg9w;MIV34e|( zw!-Oop($m(I*<${)2mm+dJ>DNNP%4CgD^io8cL=1kGJkWgX2iG+*;<`^w&@zoNAu+ z^>6GVIv170qgsgpZjr5AII}(bEZ~QeqazYNOt>?=KS#Pobsb}UTu%v^cCC(%F+~DO zvHe=Urqc_Di{G0bUS4MN-W{RTa^ZCQ&p7YsK$LZ^s>CC}Q>k=5Icgi6kaS!w;j_T| zPA1y3tBga; zTS5EF(u6GVBQJ8k?c(&9*|Nbqd8Zlf9^4`hp?N%78aA=TKu2fV_C1?cts2X#9-7;D zti~bW5z(@N6$*mL`w5x7rIL#ss%u$1F6UgfTU#|3s_ScO(Y=l-l1M!hH5PL6<7F9Q zPR>fujLK2rv{~-)is6%z>H?B7^vq`G=F-YWfbbb#I8tF19HFSx83>52cs`emclk3j z4QOAPx_k2_krVK`he*se6>N0u;jV{xB{SL$@0Ka-5Te*HQq#O<#QS8269 zcoW}=X3(4RXKzW-*$cHD8|3Lvj{gt$MH6VN_KEvXR*o_b~yo=ZjDk^_u(BlFhe$Op-JgB*q>aTdRRh>*f1?~n$LJ_bLo|7a&~N0sLiUo z8dI_hvM0+j#row$Y^LAlu*C)X(q&dRRF=0kVYUNp(`1l;eZLJV{D1}0{v9rUk>9-U zu_t3{V^d>IGyAK<2RCL#>$MrqlGE+werahdo-s_{%emNkzw>e zia*d6mWcT=H;hhF6^zsaCk?&ml!rsj=`yJmHk%bDlM=MK{?ziP!ZJAJ)ucy};0UTL zB7%Bm?Q*0wvoStuzqeH4eAWm$FFhPGspnt~qf)WAyC~VqtE6T&etx903tIuE2z+XC z&UK6pXyF0JZI91h6bq$SQJ;`n3Bns>pFGIx*4x?GHygJ));< zhm0qA;sgiKk|lWf5G=FeD7|1hGjAZi6e4e7Y8rRr_yrd?_lx&sZ)Cv%lEY%*j*}cj z+oeo$v4=1}ab6n|-d6^*R`4E-tsRdtskKS+2Cd2$ie$Y+mOF0Ap1{x9C%I;yJfefvcb z6ayqxx;rGKOF%c>As`^#-MLW&1f;vWOS(a%yPFNt-J9CfnLhrW@9(_td(If=jPahs ze+(FV?X}iibIx_&*Y&xi7+hu8*~h+pd!&&j@eHx|9Rz!>>+SCDf_j`zfim5FBqZ18 zSjW*smR{{YK7(y8>7QcbVbdC4D_Qt!b%=S3=M{j{mq_~fjGj5&V|KlOZ($FlJXbGI z1BgMu)9Ai3Hx*n49X^g$+sk7>`6wvA!BifCkUpmsgddvJn2eN_jFEbR|FDEE zbXTviaLYd6U}C-kd*hdcta`PIPCk=>u{?0_k_S*G+^;VJ8XEYRVj0vXr_R;w>=YMt z_SYx3!M5WT2ro;tBAuJNr#!&aW@%vHfPmM5NJUqd${vl+M$bSI%nfvj^YUn2!(u{- z_$JE@rL^71m%u(IQs`=ZfIw3-(W4kJN4%JsCkj=If1#rw&h|7fl|<6z$$N}Qu?P|s zIX*8Rq?Ubz-c40k*UeA7BYj@q(C~~z&;VB7lAPSPSZDtaF9_TclQa*oMTvDDotOal z`^IfEaR0}qrgCN6CMNXsS3qxUre!Qw4otfh5|N!28{a~RvSaN)QdAV@L5JQE&_i%q zFW{4qu}8>c^&-#Jk!^= zE;@P*)M~+sJ1A&qVbN$bPypJjpoP;2;tdiQ=oB&@q^9;g2052{pjR)Gz+qtBqTzj6 z37V=`XUSl~>B+w@lL@4+U^h7cPU`xMY5v!*3_xxKQ3Gm(=@o!t1#Y|Mk59JoY}vWe zm)Y;1O?ebc0Evp}f-Za$O^lNh0@T(A^AZHTgKe1uphC^h&x)fN)6vHd?xP`8g(y5Iv5m2<IFi2U>%OrKsk6x;w}jOn(2YnDqkSG_9D?l$sZV+y(m1&L9+!s$X9$K-0{ z;VwhKu}{EBYvc5<8YED=x$F0^9_U2wOqWGAH}78@W&SG*FbVkr|2?b|ZgcV}FU z*;O)RD1f)pLV2veJq$^hH=+7N%gP1I$>b<}@yusq5ygx8veNKi%YKTupoHD?==~~Q z`ZQuC71;qBJD@$w#MdME`m*ND1K~p5tNWjT1V)~)j;qkp%1jzb*pC?V_Vj=Z*%PMo z9*%HS9n52*@>SV;X@xsf=AI$<=9zQ3$7M~=_} zsNee`yH~QZvm(sqSJ^`=P6oYM0M;%@-;xqNmt|AMFP}P8(W)9v|;cTw#W@t`Y6}!Qrpj#zTYU zxLuC%lT{on8Uw;x8+H6Qw;rQ;S3kPM$0WjDtdTPR9O6(j-T0Y-K*;sT%D|!D+se`; z-pVR+qx8;wWN25(Uv|;u0#3zL>1-~%qtm>0(>;}XOC$vCs|p;;yQi(r)dY1)`4$+U z*=)t)sp34euEM~b)Z(>xaQaX%bwi`hgKNl0oQ#5%-Ej(Een8;$ahCRl$zN(WIylXDjCMn`3hq<$;1V(Khla zE1~z!QV!P6>&G`LiRgog{9xV>Mjr)y=AR$5~_)kA&yu|UNk z?bXAHQ&W-G9E=|87qgCk!8-^b=!Y2u zFN#IEsEqyDVEl-@fhdMBAz31r@lirDbbw??^+U7Q zkenxj%Oo;1LKDWk3<8c$23Uy{~1GUS8qpZ2_={_4e{M;>r$$OpETv zU%*b8VlVu_JYmFoCNC%avsWnkz?Krr;)8BuEF40_n)$^K!4!^$wtcC#$mlZ;WBL@{oSzGp%#a=Hc=6E3b6&2I%H zp=b2lQj`(5u-UkTmy>xK&2IDROFks$~JSx^JG;t(mi+@)}NU-dc6=ssvJ)| zPtC~C-#bR>UIyPl!A>~?SDpqr!NN}-r!4K71;Bap)+wsz_`e|EK zSyfhU>`D0>y54%IGG+Mlgv`)y-;G|9<2>%|DGx@%e>V5Ro4isHi34k3paSJHx)8JA z=WY!Xc>l=_4a*!y=}Gqn?m+$+>45VGjkxkRTZ8=tUwL3Cy~bF?uk>}z7$4se8vR=f zI2dk4;1h4{ov*k1&K-BxeA`@qrX}QYU2e1!Ui-PzmvC{ESMOP2w_{>cnBMZ4>Gcn!#F8BODx%ygT zD&$bVKlbN%D0dE}%g^7fMP|gNXcJ7oU z!Hh<%$;i*s-&;GCj5wR!kgU)kxu~i1hXHUh_ebIowVqAeskF#FnGcrC@NYMK?EDJ7h z9*(5$dVEBqi_{ZqV0t@|?iIiwD9|u0rmj=lD=y^}dOa*wuMQbCrnUv1NpHc+}T-ci%Kw zIefEeccDLcM^(svjo7jRbIUPGXNzaUqK3LK!f1rm)}fDTRQr60JVQ^V2nn{YsQAu; z$PNAKbdFSgI4#RJADyIueb@qwg{ZWL553QRpSV^PM7M0**llkvShSaaotbXcswU!> zno~Zj&Zd5LK&MdRT!iLq6Fa1kBQa+e$xrC9nt`*BEip$a3-vBm8{GrNvRTX8X? zWL#pcs%^%a<6LN0pW=ngPMvb;t~gTVH~G91){C7b*Rx(!wc zjk+V@j~*g>+qi3|rw)$K;HRAfh`wRrQqdhh+~nTjPh$vXs1>)(RXU0Li)afjfpa6r zY9j82y`OdaDFXs9{1TWKy!l*86j_U)5bJGgIv9{6*F-Y+=|f<1SyojyG$m)7!+Ohy z#8-0KS`B-&;bHqk34GIY4ks2)yYqW>P>bvt(KBX@nf>msVz2Xz8$kN+D3OwBVnjBZ zbB9_+DNUApNxT%-$Z=_=+nB-H_JIGlB7&7~w?t^()L?s+_7y1i$Sxsr=u(t*%B1CE zEd}Q5yd=YVu9ueGP;r!$(8pwCn83!8^?Xx8tLy`ihV{SpprG*V(4|{tC>^HKGG&Tq z#6${i`@iUccW0t;MvK;c`d^yhmpiRwn33a@5lN(Y+2ZZw5nFnC-Up(;RX`fv9e zhuu}uCW|_J+6FWHb+`G?&|rx`3W+0f3j350Vz0u#AK4+kPlUkMZ!cS%-bMAM4trg; zwA}vkWAk4Xg8cW@h~MH8S!n2ALI~1KUnYQGghT#rEz@e{qrtCH?Y=>1-bR5zVOf-9 zU4M@(&zL7^)9r_YGW9Ejt0ujV0SretJK7qbfrg-QF5r~*q`n=nRj;;q8;=l*pasUFLaU27#zgMPGCq&=oo8oaS>>Gj}RC(f&Y;ua4+8WNS zX@&^*&X->ixSm~at;XvMK4@mq>?F%olW2eW_3ya~U#(61h(;x>m`5yAF5VAtxIm`! zJM5^&z0g?mm4P@P~ZPKcNY`Zb_t0de4!@#k>T&AtYWb?2ba;~UAd7DOQM+1M>`Ef*(KUi z%(P34=Kz;O;R@YS$aY=6ihja$^FFbfZ}qiXTrppr1_z71gG?4EI8Xl^`KN$`gcMlw zg_0ZJ;l-#>_NQI0E!SgPzF={$D8@GHQ8`*XR?OCnE}F=9bXk|eWFv&Th#3vg3di zQ#urSO+NF6s^nb6C&F+Hfx_RA-b`IPjsZ?8CoW1uA#v%p@4JgP?E~o;o7fom4l{t z7$H>e9t52RyWrxZfvfSFUu8$9+K+i)u+cq91e@^6h?<*gJRindeBN94MuJyl)XgAg z=S>7QC6`l<$C+Hfil}I|TSS75LcFsYMhTeEDsWr1h9c?)U_b(LWY}cYc8xl$! zXZ($F*5y1&AYxwZTnumjK6Pm?8OCuRw3c>U8Ff?`rVHw8HUGAk`WEitlF+L^ihtsa zL49*hnk^yJly3fX#~FKJKJsEg)52)r1J9xMqC|Mh_5chg*NvB>0rLeNPdVvA@8?Cw z5U)^I)ceTm7o!g(5Wgc>tSRSISI>2E#;fW-dqEDVM#&+_PhY5gIZ!{@$$N$c=&v-!aMJlL47$`nEF}uzG)`ru!}_E@YYD z`Lbs0o7a5~s<%&~6AV(z@GEE^K&cUptu7Qd8&y>$5}vk9 z@A5UORPS&2)%6QqRBayID?9d=c_`k%^G&fklATV=2!C2Nsv1CtPFg&%1RLUZ<)unR zM3*8fdV;US8ODq_Jzaf>M+NT>G3RK+tV|umOqynNe1bv!I{X^3)nj8FOKm3d=Rej{ z8=4>XDvw?6vJ}iqPXr2Z$E?n(c2ACQt#j?>OKF}KlnPwpQF__4bkuz=V1?j}eDqVN z6O@sAuD=d@B5oo3km%bCXsdw;CsT9UjXkNMUU?t2vYMy5Dx&tSvvdhV*dwY0rD+U2%JNk)>b8aJEi;M=E>t{3rk zRHK(3WRq*zC=@5(OQ;ak(jErPu*c+rK|)tFFrlP;DG`fW?wzpsKs5Zs<7+pqW zs~;~1)!J2jKmAcU2(_iiH2)qVuR){Y$%T%7{ZcsB(QMLv@9qoU@^W^efeGneuwvnR z0aM?!bD{TNd?Z?0pCi#K_T_xudqS!SwvvCnLfeZRz;BZ`{XzK7WT_9DVSY#5N6rfm zT}}0P`ov$OzAKhSP%vJxA=A4_HhW0V2g@)D`>~&q^i~0Hsa5v0>ZqqZ%S4^726EyO zYlx%6>|Igpjb4-y#nsdJexfI0`L9wLLv<6-pO??zO-w&{Cg)%FV`VElw3J?#%~Jv^ zoN^~wvakNweUSZ;M+|~V9KLE%P+So*#nJR};TBB$wIV9|^hcRzx##3$eWg@Y!m58*sSHld58q`` zCU+L#ZJPOy$w>jaTq-ZXUDS8t&NAqrIW=vKm5GHiPi^k}Jms3ASy73I-JDp*-@}Ez zxB5~mKYcJ*OM-d-%MVh81~k(;q$hlx=Xz#lY(4H%r~=p#31W&$kK9kfzsbFlQ;%9l zK?ELyH+dQpoL6zjSbFTu1wTT`2{tSQjTs;*(U}g8$R1)ecjDrKk*h~qU@p^Z4p54S z^MFOf4AEfOYIshh{>X?pyI$osrIy-ad*wu(P9ml<1{Ki}GT1J|K9Ue&r^v}3Kf`e7 z!BX7_g@p?x@fs7t2xC~T6R%&#%`Y-&iXLf-GLvu+MT+wH&!XJW+?#uWBlVwK{;tx~ zuu$ITJN+d-=>jXyerq_6B58Kvp|m7hXlU-?MV1p}DHB_Qr8JkiQG)nqx9vFc>`%N;={hH#6TojBtqjI$sc=MBM?40;mR(VjxUt!~52SVdkBKhs# zT0qabyXx!*s%ibts(pwjY706NkLywXmz}#N%2uoG!KcTT1PmyqifP(vpSh#?xf8_h zRxu+;s`wpCtz?S!sEX#^05gLQ3kix<3(kcNTmOe!m zSJVW1%yffztY%%jtVh&BF-(HXNwVu852kS5aT*18hAhm>S>06#EpA0xYg5w~rmxbX`0D?5n~@>XSKv8OhD;*8N^B;ca| zPtxQ}eUrRCrI+8n8j-*9Q<#-zvmSbSW|MXq$Jk<~I?Ix3MG;shUiJZtKuW1cFI~PH zFpqlqsj69M&;DqWAO88y-1E(L(fSzZ&FGkGHqRc&XvS$oSg_>US~3-lOM5q>PB}b_ zS8Y4Z6nW>je!1DMV9-u3vC>@sRmxS`GK?`Zwctg=Wf>oR0Hq~|`becx zv{TB!22=^RP|?fxZ=F9m)V!;C8qs{2=M5%c_CHq3f27HdRyuXPtgX7AI@`alxx1Tl z=GmOH`10EqTv6n3eo#rUcez@+)_ci1FcqFbs#~LYsT!~SX|%?SdWvBfi6hA)i({)a z+3G%)Z-Z)2FP6l;(u(#o6mW=tY@oGmu(Rl3!iS$L>ATo#S|?HPLs@>G8TWUKQ!bB> zf4}NBSP-Fl+`vrTSAm4jazm4BdJw&)P6q@|eD)oytOB8XPmum|R~z=EstAWF)l4!d zB;I{N=136^JxOJS;k?U;8dq+Xr|nM4G9Dc}i$BvXH=ZefAmKJ_U@G(*-V}aKD-*G> z5f*-PVK~%tD;Ijwu7{bL8hVsl=K0nZRDb?{RVAFc*vH_#(X4tZ7#$NE=~lvu!i86JiF}7zGfdhu87?qQdD|pao5&}K>|J{ko7;;bg+hH!md%w zw~L1Nr5rLxQV6rI%U5#;^VO5f{QRDPfF4Q#Y>C{*QxVx~j1@l@o^T`_W5hKDDmLp! zX6eZH$O6)zqGH5WXGCe!3ouej{xebn^{y1(PJ9YO2w6-Eoq~Kxn?scE={x~JKk-oUdwwP@9r4K7JfBgOiiF?)Ov=Nf zKt8e}MR!J^K=@Yja#S*hR`t2JlBAGDwoU_(PRmM5%ScEt{@fb?SNF*8->ciUca|-} z3_C|bd{1G$H_Q!pW*BG5NcQ2E7;+Mm78lP*EG&9Le%0u~KIo85<)oE{g7aQX-w;t% zq4`;yGQtw4XO}Z#5zY@TNA-VSj!##LU|Wm^|L*!8@2T!^C^E+b3Re2o%oa_>uhT>? zWk$!IvD4A9va-foRX)}?G_I(V9KL$ap@S~8Qn}b@@jGw);)1eXVc6dRnQj^-2K2sM z{{bNm&M3E ziHH#L{<1UlyL-uohOQ(`BCo+{dA+u1j|lyUFCl}Qot2f7^$iPqWve8}3LiywNFPjE zX#WoH+;7#TNvm|NZ%6Pl(@`iSjT9r=^LxaS=rM!Dt6d@98B|M0uTnzbIYRffi0`go z2>+(h2s{w#=Gqwb^(!|K5x7jNM;E-jfUiwcm{xg8E`xzDt=ml-!AV&yMvgTi#0ql{ z=vdC8bSt3uwZgQpYJRPzrg!ya8TCJ3fwrd9V(m?9Yeq4oIgCd0;N$yqL$!!W1ugce zOK-fsK5NliQTjuVK&%D~B|#9-YFo zG(USxWykz8`Un4WyK5JkqyFe&jdrVhwJqM~I_T$IF;zR`y#*nF+PlnwH#dE9D*2vM ze;!7fe;>x=jG5*?73`mAmsa1qM`Io|Fw+vRHn3hsWoG|bH6H!@<#J7`S@3{tny|P2 zl|C2hY~T(|MOF$-W*ztb^L&h0>ws=0PJYH>_q{-je-H7;x4!-5f3pkyf3$A=ecM7Z zX$0j7;Baa(9%{b0cyN{u`tTt11t@WgHLBLiq(zb zr`vt*ck==XX;p3Hpatx=xuLhqApNW_rplNEh$DJnFfB^SI=9n;ASp*qaR%TZ&i{#q zE+q8F`JBw_s7(4if~5cY@(B~8DLF88Z-Wub8f&ui%FidhRYCB-uf;mMhpU3GgM9Fy4%^= zd4Y##WMQFP@{z~cxh955^SL9p!{*^y-_&HOPNb4=T3Q zXXh+%H^s9VE-x<&dR+ruPj+uK1IX{e^8OM*f)gkX z2u+ad$HE+w!w%j&f)#)3$cVyWmEj#uev~?-bf8OqrUNdN%e$64#jNTFkcon5kPMP5 zo7^f0$U--3v8gI8tEDZsITFE4nw&9=>Waj>b2T&ryFb=IhuwM>egM31z-8@)Fm(Ys zPt!x7DFVU-FQ9G*`3=3XEJ9}Ea>q|}fy&^Ij~Zl^mFl$QE3}=0tT?gB)~~X_qPDWN zMW-VPRA1W@cMLiUM?*q)z?FP_@$)nCDlU-t-L3XSdV7=d@y(?OG;GY4el?N?S{s7r z&q1uk_WEQ25Iw*Xl{6R$LTpTLwOcvYPiKxCK=$3T~ct_4U zm#(I!MmlH3&Z#wn%5sqQ&gMI)4XT&zTO7pJpEr&xswm@^{=fF@i_s3QKMJ@ z7Lc=OmK)4eTWW4zg$4&_U!o&KG?um}2tX3he7T_y5Xd5=oI+l)yDj~4qod0QVwr(0 zEZhY1M;M`T+_qozoJ2vcS)e@a^;&EI(8IV~93BC=8^~(jnJAR9`@MH#08HZ?Pn1fv z|8NNA0CKGHaxt93-&l(wUET%(f93jRWxg&5mp%&OB+B;#;_ncYjq=&ss?D^8xdZua zwfBk$7y)l>xzaxvVc>1uBNK=e!=T?BQ8p_Ohxp?hzr_4!=mwE5Kn+#G9zX)H@PP9= zmCdkTkFv#C;qv;D9TpJ7Qfh`X3JmG0Rc5#vKg#Id*e*u_DOW;w9(9}QCaeKq*R7Rk#9w6-QD5(V=s6sfuHh^-tJ~b94PR7z6gS; z0qnH&fs8eo&)Lq|Inz){X$BUpdt-AwxUQI z84X1T$jaQ6lgrWIAOVj4eH-~-Rz2K`50JczM&)oMaIWOT;7zCv_Lz?xD6zB^BjCy( zbVD%9?m%J0k*@bftr|>Ho0G*-{}2hd&X&^NA{VF@UxVT30ek1$wK7KMjtP+kQAN{V zfGju}%qyc09u8#f&N=@cOm2Al_APn1!)l>qG<}bbVmff%(tKYY1*t^9#VhgzJkP?~ zEB_~PoL+>*Ag*#h^Xu1#GwebMb@EUBe0|GqlyIg3bA<4@tVnli6+pt`6&e~Em>0;? zmZpPZwnoxFNW)K2j{AyM=bT~$Ww?34$q|+bxJQ>fCwm2>UFCa`Qo<}m_BrJt_LB!F zuab%XxtE#bbI`RcQZ#f1hC&dJMY}hrr>7_3wV$B~ban|k7uS~+fxK|a`Fcim{bo*9R<}-ui*L~5vopg@_*m}mK-}zPZlId`sHJ7{7D8hg z0A!sxGlB8!xpUJW#)^8u0}t+hrk)?i9>+gc`j8pH!pc8d96TlsPB*xm104`+rc2SJ zEg6WG2T)@9S>?rZ4St7k6M}QPuaEI+3SRzTK6Srl=bPj@ItNb^f3 zchQkw<}psebf~SZHCKIVaEFRch=m({MWh@3qJO2Xu5ND;O3S!gsK?L4BXPPnnJNTb z$db?pJQQHRAnCd~C$TG#7<;T&Mmv`a4E_ej#_kt~Wl7psyYZ<(Ql~)N{v*>JK*3}3 z!0Ipq9_q-(d@uoohmUNHZ2)Y~#?pwNx1SV=y*BR$*>zb7tnAD-_*>_1^$*9us>DRO zs6*?R`Dy5?yRgSefZxeOUc##!a~cZ%_M&zHnlB&rpsUFp1K8nzcwAzgu~1Tu4;`TN zv;bT0XbqR$>NKq3e4*L%9IUfIl=e!{BWichfF~nA7-*qK`%wl87`5uj1qB6j6M!`i zXlj8|wp{R?P_OMu=X|59`Aj*kpkVXvVylloSUZ3|HUPf;^1pR(bXXYT|JDNjYT~}- zjkiD-|Kv8p>TdvjPOofw?y4t*sKLHKxp6(m%iiWWwTem;bZ%*~uj&39S8ygO(|bS8U{TbY(!>4!pTM z6Gean6B(&rW7Q-PGuPgmQe3CoHVedcB_HeP85yM+V*uKM-~Fr~06j*H`5a70@bP)w z;14u%W|Fj<-2SjnIy3;MGI-q$tS$D-?Q}#$%}t*1U_ce-4WYl8t!ApXHsP1R--3nO z6-0V9OZ_5d1M|eH79vFSmo{g_p4Zp#1@BM+r>I^jh|Zt2!dr=OuSHoO3tQ>b+c8gH*aK$h>L&C&ZeUyoSC-B=Xjfw*)}#ZA|WRsX$M$!bYLY5i?nbi zjQ2T)1v{h-N8wV($%LrgpTP`h z2LeanDSvA_Xk`Mh#GkO#hcYXRqpaeOzfx&-E|DT3;b^;s>Ri&orQPDg9oxfx(6k

Ur(FSIKuTFI~i47ljmfxF`MQVSu(zAb6Cx-1w)>M{w26qP(K* z-jkkG%N)mDZd_n~;puV5BJ-S5$|L^!SZUxa-7*k>w}iaNSuO?eInghxvIu^FmCZY& z!zP>=@;}0}z(0VW|4&WeE)Rf-6F4ScbFiGOx72)P&xt|5(`*Q^u^2oEN-CVB@?HOs zk9Q+XEt`^$W4$*L-O1;$FZy_6Hh2ztTP4eyQS#;?5^W0j^V>Vm6dq&2OESY7W1-N9 z2&~wW3!OG2OR4DmRgGNryVlGYMjaU+cs|G=7^~U6Y4^uT$A#WnDpgBPx_EdHYW=~C zM*f((S+4)QBah!8o`EXw;o(^t%9 z?iAh6iFllrpWZ`~$NuLVU_+c7cSgfa9{K6=3uu6B<76IZb>eyF-zF%=xxlgb`spnZ zGSbE8Bz{jaUS6Y5EhzvKrQPTeY!YP7Wu?>Xrr$OzgvW9*P$=4aI} zVt=_^Wz~clYipPPq3O1t=j2_6GGe?8J9kUTYetC>Mm;~}k0FmkTmf9UCvfIl0& z^i-&3XJzBq9qn29nXZ4_T_WHQB5LTuvHE;Mn^K2Qv&`3&>zod5+gd!(dQSC|Slw?U z)(TrZpBTH(soGjnrp^JXf#a{0Ba>9g$L62I!`{@HxT%0A zpTy>ARg7RNmQ=G?+J9rc#CC>2-|j5tv*d%P-K~y7fBIi@?2ks9Tkz*Xrx%-`a(5%D z^H;+>!`}|rg(B{tXItnY%u)LXyL}CAj(c*>LF$WBU2>^g<`lZonMYXUxd2=Jo@rC^BxLz(&c=lgqY2kNC`^G7E#-y)Q&*|$cT#@Jh4z(YM_271^ma18z*4dfyUQD%CqrvbZeh*NrwPFv)9Ic7QLf*?DKuaLjI&jc8d))? z=UbB{3<7$I$9)&UJGUHfSWgePZ+@pcPsR_WG<+LuQSfm~oGeY@)-@0_7{9{!EEWSZ zneO+A$-P0GAl@*1y-#$02K(|w5Nk%g2;|mG+7wq`rnXELt1oShaKg<$RGSlHW6xD> zkkZijS3#MqXKNR-bc_#X-NQQl(f42A;{!u6o$El;!w^4z$W!8Ju3U_CNX;Oi9_xjk zY}y)p8txJlXrNb3>(buea|bHst#TvjWcaP9aG{eHcra9;--VtLyiU`>KuDwr)TQ29 z!Ybc4uM;(#F(~E0XIo;xBhF`Z9R-Nvz~3Ps0K-1rUI5I-G|dfkkO~@EGt{9MZoJXl zC7ZG}k=Zv`yi@OZRBwNP>F(LJRqakdLP>~maU&hAs?2I%KNTfd(sbTUmvF`H%eJo5>PlI{T*PCE zW491*(Q-%MopBZ~yASJN)G9MtupV3FhYAZls}HKX`^|ba9-stvM*HSQX4OU$qgzVU zZbC<=yYmo6JmciO5Us0dqrz1v)SBD*w)cWnXdqdj<8A;-R1eQKee~o>PzsFCaxp$! zF`eeI)9x$;%)b^JN+We1H*Qtvgpz$KAPcGyG8H0{9_GnJK*)0zTg-QMx;^3u+?)dm z90T(SEC|fh z=7ElbWR7Ifrq2Dssp|^6%U_x`2P2$!zg#QqwCvraD@?{1L#vAo84hGIzQz<^6k3;- zgrK8aYSheA3%uO!pUi@&SurK@@$jy46aou7$fhUeb+eoPeG6irOlBJMgJp7b#Wr8n z6{ye?@l{58PIvTWY82;~Pg=&G?YQ8QP-sPtIyv3{>`nZMuK03WAZYrN=6OuC7Qu*q z1M2C{b5L!XDQWUoQc^NK{?a{oeK0L^x5xhmutX4HIWJA@Z$@BJA?R!0;Hj!?Ui~(I zU}-6w_6s1qm+q6g+qM!7;=7p^r!l|f1Ao96!L?Nu;f_{dg~%yB zx5ds4zH*)I=XnB_veEoZ2#B81(h|r{xU-nWBuTYdS|9^oVav=JkU_n2ZqXfHR2Z}t zCA|4);@Zoy5|Yf_boVeRw+8bpBjGZ6rfopPYad4!iP(-fHLTslo}{02Mk> z$eH>k?GFMY64Tc9m1z!*oF|}))LT!j126xUX#H63)ebJS_V^_s0V-xMu zSz>7Rm(G|ZB#=htby7X*Vy#xXn9~!!KNH+# zyZJh)v+_o-+h353(*l*NG>#QZaX?8t-!5sX-OhjYaOkUsDx zwE7h!j7l=fWISylII(6eJV~(A;uStVhvj@2)7VEv*lg9UJAp7j)nqqaVW`+J>y%nC zY2@q~YigP&sD4VVw>y2GJX>PnBpSYWq6T0XPCK(?JM(ro{#sT1w-pDwt0oo}9Co{Z zpgf>@S{O$*z}D#HWIcwM*qg1azdJk{#>6fdH|amKREGavTJbyGDK~u-I5cHzKBN3+ z_8GS+W`=%P=m~&z42P1_ZjSvRyIGZ+iKSC2wHlan-Y=`jJ`fkwthZK7D8_QG!3#Xe z*Dmgbxnr2bQ||y~%v`fQ%hmW<@)e->4A&~$?5>mTrM(rZ1o<1lBQ%ov$W5c8S$c~) z7@t2CK2&pbo7xUwN-Y1{~SSEsIH&iY_{iW)o>+H3x#vk-YhZzaR@E$0Znj zSCI{kRLqJnjVBI?s$yx=&OV-9yj#ko5omDMD+CkB#|*KY<{T<1jo}Kj&u>$4d;q%* zP|4if5Ry{g0U0DRb}lZ|V2P+t!PtOeI+=TXjL~4`dTqQoICzH0URkiUE&jI_K*h-T zJ|zBQnVSz% zx34k?^O4Igv)>J^+Zj<#?Maj8M(Y*EC^mAsPFqnC+dx-uZ^WwfR%T`c@S9d*O22jKRi4f9v)UQW`0V->zyYylsw#Yc8lBvup=Ui&7;#9;Eh#v7N)5oId!s3Dqy4m^N0^9}&Su_EhCa^QE^W3wTGy^Ov@K&-0b2gpz zl{MA&>8}5J&EfD<2Wp+f>Nwi$1L+DTE~rFTpD5VUsFLgei;Uym{KC(l@&Fda&JO59 zfMmQ9=6QXgzQu+IUaisR)b&S3qv3L_&E`X`Gwjr{)fLZrtSZGdXN1PnbwttfGhV9Q zU^F!Ek}7#IN*eg6M1Cpwkp3%$h@f4sZ1Lm;h_w-IZANY7q>L4UuabmrQLf^5) z%+1fQ$SQK~M~MNbEIDJ*eH&`|9~D@wjLbxGRb!|Cd<18u;3|>U(;@YXkNK2?VV^*Q zBS_8YnIyW}6x5)_0Sx}=RloIOFMI(2=rP@xbd;1+*U+Hb+c#wkl8r{^0WZ74Z{2J@ z0#h(x2TDl1qB}Hsb=d|WxS)~ZwKb~piwZP&sx1XnlDt)x0A?3n6_}}rY?9a9)X>$@ zK_ckpcGtQhQ18|g%Jfl-e#~Al)d2FcXoY5|zFt|SUU|CM5IaDuoa==4yvWH?L*V!< zUoF?Df;t%I4rk|)Z#`4On=_NS*{*Zf>Km`Eez_!G7O2bF^*e|^fTHY9`};hEr!y=% z|L%^9oBq6|56$Crf*-zrwrx}_ZO;YRQ)veZ#OcP_C9qnpu_}#{6Al1g3GK=!L-H1p zkdfhWSruD(e>#1SVhsvdAz1+!^?W81)})(%(iPW)*t>uXwC4vzjh51wq7ruwRb z1!#B59Ys0c_m9nPk*jalBnQ`$2|YZnqs|%~il=7;y^f=ZvcGhrS{jxTfJo-CS67Qu zx3@z&jaI{H%vbU{i=TuS_d)(8z#<4QYHI<+p|}-qrL*;DUMtu)RV+NN@iWIo+z|7H zAIT&O+P{TBa?RFy>jo#gyI+%~|Bk0aLZSlNpQV+PMu|v2`}O`92p;oC5oZxVT#kjT z4}pSloeuDr+9#k5uow`WADVF4>ZPED^k&{e_N#5EA?E&(swXceH`I|`FC4ZqC-k45 znkbT&#aizym+Abc{V&N&j(}Vt*CoDRbsl3&Ytu+;T zHGCnX>8LIHcjV!sJfLiQzf`Mbq7d@JP1S7afE}O1eCkJhSK2+#j|FNWK~i9|8j+H+ z-@EoCcL*WCMqlsQz-eSet)RdPQW#8k_TPs-sVGS)D;xPm#Q>OT@YB=O%O>zrJ?-ZR zJ$CUpn~9*3a@gCgbG{2d+ne9(iz0JL19_qBgiJ5|THTY$w{4wwh`yHyn}g=I zBQ!LixtPKo8^J6>=<9+I(=URk&O5UsBdvm@hhsfT8+=YCd_QzpoT{6yLO;}bH0K8# z?Jm9mARwaDp&?T%%nEKh1ay4U zK_kO0g>x!p6|SS%A?u^gq|+>R23-e)bWj}!+!-@VX-jsO0tZp&GDU>Hl9Sc+AW?+DBZoW7!zq7!qVw7AR^01DPH5yTbZPv(<#g)(vX zV+>jkoWuwWV!-R+mWg7~xxPx%)jugOki6SHn6Hn?L8?BOKVQyn33klS3EnByt`oO? z`ABsqWU33lQ)D{WuzO|9;jDax^)BAwzFY zY1+czdRAP1gAgLVKv)AMHFc3z!xA8K z06dVdNUQg~8&E#q-`L=CJ+1_sjK>dtMMXt{Op}hzP9|;s#qRD8wwN<#zg+-*HjWh= zop4%o0}2iS;2@1|r@XAJJtHH!tzYUE7Ua}QJ{BK(b-4hyy?o6_C;?pENsJzM!Y=v0 z?O4+d?vyk%>`!%cbhvWOW}1WUmsZET60!vtn;K7&#W3d`P|uBkS&>h9>WPR%0OieN zUw&da)0g5@(@@+pBP${>Fbhuid>0MCiNKuIDdtK<(vcV6^#O-}IWH3n*znQw)3H z9~~VH#3%9W?CF#dSg~g z-%LqQpJ2oV@FD(jgp$>+A9ufxP=fAECbO0%Wy~Mk2hgoyav@cWkg%{Y zt%fti#l_dJU&}FPs$QP~Aw{ad)j1GCsOBqnw6>;$RF&ghKq()iS1o$2-#Pxp(Xgzn zEdOKOD2TjC02v4slmI@>q5^XE!hi%Jqq&&|e3x01{oQ@r}faDS>exTh}+t5(9@*R+^`+9oHG`Y4sD{K-!{QV-{ zB!rQ1aS=nYVStiiIa@g}Q`gA%?5{sd1cSy`i(0OKE0Lpyk;_4`jtjNSkyvsy0)gpLv7VxSP05~K(V$NSsd z`mbl+KQU2LTKXCscun6RE-sE%71(S+kJZWE9MnSo{?w|45ff5k^tii5pbBu4(rmVV zCJ_QP@&};T1@4-uHpah!E#4%L{~sZS$0^9j;=zZT;Qwiy3Y-7Y*Ac&e?-odL+iQJK z^c#Nxhs|pc+XB|TE`yqtPXM>fhyP0uL}`*<`u~xX<*8uqwUqn*|Dt~T-xBW<_WQps zCer`QU}vEl?N> z2|)=(B&0(@kZuGf-GU-5N_R+?bV!3V2vQ>5ElP)ggmfbfDh-o5_f*&3`}?lzT!+7F zIp=(z_o;h~agXtcg67|6rzK#eqGC{9@tF=`fVY|GZBd$yR|h`@!RWkO*8Anrws4~d zjyBlr3p8bua9LtKJX$bw#1wMM6 zbm{BSR9hPukbw){-#@I(Sxj6o3k}AR7ARA;dj^QCrco64Tz~gzeb~%1ip%yhF2~mXQo6KL zD9XV&o$r;gqEAW5!>QUmoif8Gepok0O<{{65psiA7fbW25#XF%_OiVYV0XwIL5Pc| z2)Qr8Q63Z|9TtX*ed{Z;`T*OyH-r_O9sjDFo$Z6%gO_a~nReN4ra?p1;Oe-2WOBD3 zGD;BdW8erg@|YtqTYn6==c`Y~-_nHYmXf$mWwAk0IIs#<x*!A>IJ^*FhlVmV%ZHk4ZiD27h)tfPZJNauu{es_R0%=ic__?(^j{|#WP zd^$cU9bGoNpZ9lW-{^~Y}Jlh5{L$xnw! zp_j)z`u5;x1(9K|z#%Yp_3Jxv&7^0nA9XH48T=u%miC)b$wSs$JgXejwp^iRw!J}>JU;q0f8O^<$K`U~8 zc8P#KjQ$E_o2;y@QF(SO8+B2vtgJ8ywB~f;`0JG#5+kT0=fwmCwVjf~Y*LJ*#USS?l1HQjP*95QFP*7q1~IW7%+P!>aS!!ai0j?}Gp zQh&(V3?^yY`4U^g^1-1ehIOad*Gu z_2>pMacdidQh?Q?ZDP*Skx1m$))r#8MvSSqqh7#uX?gx}gX#Bs8_g(H>pQqj;|D!E`_|O7 z@jWkYJK&Z3_OS!nwx1!s$>ZkE&Y@u~ql$I~ zLNyD9#uppySTr`3>Kw=v`@&%UeyuR{IMDrcpD+i&gW&Q=LFf_kw_?m61;q`GuvnQ7_m#*Hi@j`07`6Jk(CmG~ zla4DyS%+XX!HTN0)<1BWR4q7uLT1A(NSi-<4WnZ1_a`O(5rgih16s{RjS8yQ&VG5W zuNZN=BMBe#r0_WxJS!>HsJMr7XUO$r^mt>;E<-{(U{3$Ge>P@ss-SL_C0WU{(wFU) zSsr(7?+Ur4F}?jI8tmNNGmX~1MW(L0skXn3kk_g60{H`7jzMzqf4X{!6oH6 zfviC`gRWxslCm<0B48G0Jgbh6j|a!Prklu+QEm-nuiZ*2D$+d>V83vN zVIrX@ZeV6+2A7BxnS8eCe(-rBu5?l65Uhn) z1)_xBenS_RNNA@>opEw{Jm37|M&pOy&1A>%YioG8(0d$d^6;TvsZ~UuhhvDjJ6o3k z`Mh(GkuEzD+u0%U9bq9fa9`-Bx-!$4%44&Qax9eybvi|czA$AFI7#yJyTVuwn>8Ih zJq-G8QRDFF_;?s8uc%Pd%Hh^auU&)e6PODp5FG~g zlUa)2|6N+ErV$QFqYzIP;O`$sF6bpCiJAK?PpuEG@}=xszm4s{!w%ZWQU4QsDh?T+ zF{F2)vdi++azNyZ5;~7eTmYT?D;{?wMC>DK<1CJdhxGdK6Dj&QUgci?U{uS%-;Ln{6c67`?A ze1~GhtWvM%Jx#KnZQc73&l+T2vwFlfrg(MnB<_jex+lxtgrrT-fWUj^_?8yJD9_!p z(UO;ah1zvxLJ(QWZ!*vl%bbKK`r|zDLwxv;_Mdp{?nt>rodi}r(hK&`h2ldNf!GVr z{M`6HA_7BJR^>4U#{uK5HzsspdWRAc$ZHvggwmc2;r9w?qiMHO0L@?R&j2i)$DRC_GMuH5<}EpWb2Uk%nV2sIg28ZbKu8~ z3l~L_%zdsXkUqerlZg|hxhAbk)pg~5o?B%%(suF^ zLfFl#HbMADCVZ<|=L~i#w`6aRe|Y#%$hMiWo3nyF*=&kq#G_# zkwBs~Qjd*!lSrenHgMCw;j5qau5z%e3}wk+BQwZCg(R+AL}oF z80=Q3W+1BkN@>CsLVsNG8za23S^2?p-jPH^9>tf)3`mi?@%qIqoCE-?-n{=*PC)@~ zw`o~fw524lQ-~`fiiD#Nf3+es9>7Kj!BcGIox?Su6a zaA$1`fAnQ?0_N;ZYZO$fdy<6nlirOO!t5$`bx27cOcn+?nHX;+w5D1C?( zxlc>nqZib4bHhZX<@+uARuoS+DNUnO2btbX-R;eK;U78kRmq#p$e^(JAn3W|n~rKD z#1clAAB){6o8d80$1OVP#+j=y!I!t0+68U?err~w9+${ZgnY;jNAG1nBCG7x|rXDL-(6d9v~+ic~4tS6ueY zk!o(XfJWniL0i&n)=WjD26~zAU3}NwKJtT%uLcRS#GBSPRlJ?j7kt@QH?67G&0gDj zb=Qmxq4F8v??dUj>3ncAW@j!!QA(nG@a|e+&Kv&Errhd-{F`6tU9;~PSa$h}5jR|u zTH>lfltA|``qsr(UJJ5rjoDINY(1qvzX&yRQP=B;2D|WRH8NR@lY7~V7f#*zTe)}D&jjE474AvSKqfsj7JI&Iov~dBGxJ#|k6F(T z9wgelUnoJZ{)`b~(52cKkznoLlc|sV$-1a7Q;zxfBZ#L10?&C!JzLfIqCSLPu@aR$ z?ZtUGDX~RmRu*I+ju~wbT&=N?ER`h8k3>t-)GI3MAXR3d*7?knbAWvNK#l54IY##Q z<-gWOjIEJ~(7gmaa%A(w3O+;FK-lqEY-xV-{^j|#P&)*d>*|Y`UR9$|3Y|lqxoiC2 z0_x)~bontXZ|8G~{1Lqd*Nq}NW3iiKUoJFyEeAR zz38JyLyVWd7&Rl+zs}Ybn8&R^lf3sss&1^vd>=~%vulmXZrn#~*Z`4=CsuP~QOnfy z=ATg7v{C1kceOvBpLA2;F{?dje%IfOS_*_-$-5inZZTfFY!kO7wF+!{3ssbb977qo zRmj8h&MZrH&}nk1guMR40xZ-`gsEb%ReLMw=2&H)>=v^?+4TBF%XzfZQTpKoOBS~@ zqjnVcr;JZ(?vpfE*dq5>Zj_zkj_!QlST*%yn>P(6D0Hzd<|XuFjOG-Lp$=T;SK!(B zLTTo8zkDD_lz6MYeMc!e+TyqCzjx*rt~cKZ&*(xyCOPyqg7~|RX9S65)Wxxg{jsHz z0v`3e_&A$Si4=v?JVs6etA}v{YY`S@4WYwsL83ptxOQ9SXb#?Y(Qm| zMO~Q9<49xTE-|Wuel1$;@|I{jrsNOehhs(J=^;*f^JCxteQfBBcZf4A*j zsq*&ijzcUa-b3g=r~-yBW}Bws|ID=iVkCJ=5iEwsfKYlWnNVg`9B)M?&Mp4uhrPZ# zQH>^VC?fW)`6#=KtxKsA)y3u2LpBC5|7aU+Z}vA+hL3@(|dMe zM{ya}iZ@zz$om@T`tSYObzWEUdayWe=9V8f4%^?7+bq;AVETEEQ&it4;Bbn3N zHoe<-f}9jTi?I`5M1aNSJ7>_-FAwg*URM5P;rwwNsgx6WyiB)2!68BY?)b=;Q{D7O zxA)DB#-;np`>B+c-pf@-O?$i?n3x+AS9;)A#l#*sLe`!& zrNl9on;UeIEaZ;l5yK;2q34J08}{enXM+mQa^mpRUM6?{ioD^B>(|NUZ9CUZx<4C{ zxtA5SM<7!3c~Q%3n`cw*$q#4(laiBz_|G4Jii%U&I5}MoZwH}9mJKTI;h&}i)ITWn z>op0>Me)4w_8GSan-zMd9U+!Q!W}rdI1C&nmjvouah`wNXS~o6B&vH!t|l3O6DW*! zV2}WE(aDlAAR|kOk2enTEyl!k=6(2Z73vw1J9q3?zNHen0`4X3`18acXMKC`qw~(K zSO(=6kXeiR&eB7cXZ1+L$zslc4v7TvYuZy6AI&>gJOvP05WnH|e0<=x!A>>N#p1CHkM-H?fze(v@)kyASt z1xhUUovPCFfbJF5jtQ^|U}cbC3DKlh)_z7u*bk$ z^D$5<(vc*r&!vEtU zB7W3u?|VGXdn(`B0hAyZ9QrnG9M{#>)Br@r2O*y06(pV~TRD&w2Z_xwYaG#(M;!Vc z6e!{Y>!PT*nIdhf8-={Z3YOHnB^Tj}!7|qQ!l28ogyE7C<}cK-1vYUng@`($wR|U8 z4iv}`i76#Xfa_?fT(vn_nJ|IGoD5bBwo)%@t-=rX_4oIHXq7HP6UETiuh9|@J1gY| zZ{0Jb!p}k;3M2_QN7av9nsD^#aFGD|k@;D1d@SUPLi;1=92+2o@fe8=M!f$^QFaXt zanR7{1143j08LS=<<2{X5T2X9$Z7WU$pDEX?erD`)29ojC7r5DyOz7wI%WhEaz`dsEYkqitGXwd55QN18LHU?LeiEX3)0^fK0^`=0T% zthzsipb1{XuPc7o>tMhD&#J7m{LYgL&@`Lec81y z`B&5vY)2XIyw(dmsW(JwvpSn6CA*W`*tZh**4nbljelKiKqJh)Y`_`IE9yx;fJa3r zVI@-Y9CkeX2aWCT)&38$`nG*aiHSv{oMuG#1QLzZ3Xn6EDOT)_))hvn+C+| zWU<~Uq(2-ZgS5hjj#3~w;(rl6FOm6?na)nX~4e!UVQu+3AASSX#kym zBT`DMNJns=GQ7R$NuC3=Mjsc;B@EO+6dwlxhs*9ERKw8w42cil^n*8KP~{S{hzbcw zpNy*ojjYwV4Bs$8*uemc3O+wS1665k(z9tU3arh`G2gY86p$BDn-3L3UdGp(W4|tI z%FIDw^20%e$#llzT3C7O`U+1sJtrrpSFhSWkebu^ z-69uq<8a-dJUpz<7o>xJTyDFiN#zWQZ}sT%cjev*O?-Z&+xq0*wur<|Q(HFS>4l@? z^LE@?k<6+ZckL{h?^?Pa4o!;3tE{TbMp6RV0uu&d%W|2K@KLYFSX-#Y^A~Y2FsH!nN~S4_JOuHpaR|R z-ql3{*$@i6SzWcN(Xq(r*qHm_@6-YlOP!{srjt1_D~+??bIp+3yXZ`ps5`rJWHpoh zJ#z~>qLT$(Q#}+pFi{||Gf|LOK-grR<2&@#8DJvi<>gJd5GHSpMfH)p2@uX>fBs z$8%ZGMNZKAw#C;qd zbGxM)FXWzGP{1sIa3l0>+zk40U-sHvX)jOl|1pVf#O82}{U4KP&mW8Ti&)R11~hD) z<}aenUOhl&IQ8Tnb}MFGrL4U;&`|Od#}G>|LWPIvC+hM* z#=aR^i@(=01;={CMulYH$?+zVCs~MkzD7)?`g)|=AHRR0b4evEyFzB{f$3N2@a_9a z(W%-pPY)}KWxpctiwZQ_EK23gxqP48<|Eagwyr+!dIDPH>YAF?mKKOZRHeZuAxY|3 z*k*gzw?0u-m85z9;^goD1wdhtL+6PokjMH>_QEEn&5z=CO|!Y{LsR`qVpASn?1}Zr zPQ2mAh1R7N!%GwC5spYd5OIK`M3-(-&yy1Rr(`d0dAGi$*2o5dqM+cZkh{zN&nXpg zGP>*Z)9KH_#G_+*hhBm7*X-;g_%4o?p5@%6K6;_waoJoeyb~VGyZ*K7iMNdx=587b zUy0>Wg{{X+jG!#10O&V8uW+!ESXc`^q2DfJs~y|H?rXnv&*YkwY}=oeS~ve3 zOZ=xow>!8ZBYEd-meTD?DBMaDyR_W>Od7=5;%EbxRU`$N{=)*;Q>zlk|6&!hh!RHtG^XbGY@0Xsa`u%J{NCbI z?9uXP<50sX@x~=;p#OnAl*&HvbnbUcyB5()v`Le&-rP78o7&^!eDvZHhtF<49bUIT zQ3NjgXWZr2Y}^KgnR}nxV-^_a%C83GCyBUt-bKyttnApo@WIg1Aoo@*c97&x8rsgE z15ZcPS@R1lcTCsFQpsLxmT&fS^Xzmw@H|S0W^;79rnoOCBl%_)Pd!0DC{1gHEkOO= zSn^Ft^Di-Zxem0ve{oMvr&HznIjyEK$<7V2gG6s-a^#bZqsxy5S+DUcAjUD=%r6^+ zbtUT^6ETGFN1RGs4kThzsHURa)_s#-bfN1C^Rh;?@jpyPsn<1U<5S}9Z+WcaVo8Fj zIg7mwX6MU~#5!wlWgf6e^}V`5r*hfGz6Fz8zlW=GM=*O*p{DzB)*j1q)&4XnypqhtHF#;REobpzv0xylNiDICml|vM{~yDH)B( z7A@JcN5hSe_TNY()Ug@q3nE~H{Xr;FQ*7{{Q9R2WD_Y2bY5pj9d0*O;_z&ttQPe9W zGi}e=d2Dg-xLD`P7YgpFr-ir02+Oft-7iIZ*w^L@er6DOgZfPoVYrb(RN3V>R$V*qD;D+sPGj0L#To#rx>Jjpt%J;iw%O*7 zLi6%-4aok@3xkrNsOW>}>(eqW#56CcZ_4gmEg#w(einyj%rD{KE`#J;!-tKTps=Iu zRz&6GK%a7V7mA=L{i-A0lwNL7@<#rCSsX8A>ax=VPF{JHn}RTc<&z)*fvd-F*H(eQW**$Zha)0$(lVk1;ou3FsE? z*pCq!^ma`~Fu#ub#1xdz1h9=dnL*e$aaOGE7f!zVOmP&c;ZByE75P`MK1jLp&9=Y4 z|H((y6hYVCZ|Tjzs>p^DzP1tf0T3meLT{GVebi#(i!$UBIsjjJjlIemVAry^OkFS851zamCEjJYo zY&aw#W>n+7M96lG-i>97-kZ`InqeK;Ax$x9`MqpaGw%^mTm~p~s0wzL_WNiWh2V%{ z{$+GDo}4a~*G?nXyPyE)G~-Iiv#$$VR8&ovXoU3FXEMoa-@f{3BN2yFb;lck^(3|C z2IW~-m&4y)1qIrmxiYSi3{M4&1fq?8kt7{pP122`H>LyHBDJP;w>nBhFP5gi}$N@I1wA+~1 zt}y|SGVCNL_jPx5aMTtIoiNY{FTvu@aE!V>rQmWvYY`L5M(aFvXWhXnM+5t+s*Buw9_5oC&MCpNL>5|vSjmU>qIi!k4-kY{q=xA}Ze@r+Q4ScN=Tq3dLI04`%a`WHEYR~= z7(eK^xS(!UI8)Nq73Sm9DDh56P>1{P`Kco|GgReaB-|pcliL;N_(7L*a|fP^Xp}wk zw}>t)xB~ay>o_=}gVd%(c*LoRQHlMHkErTsBud&ZkVvn~iQZtb7NDf`%J6Bf<{?wQ zC!DdF!kFGfk=j@NF|ofth?0^jid*>X3>%`-U(EGqNJ8;O%5IpSm1Uq{FB4bE&dm*x zX=m@n-+(_zr9F?p*hMtbCfi27rUoHt%5W`jFlP`fisZh6wKB>2y5(eusoXZlqS?zSXJ>?4g+GM z`q~@6op) zzP_7V{XjzV=g+UlE7pfTDEq{MJDTls@8;*vpHV(X{8et7jhDS|dr3u2*N~{-=48pJ z-(F=$Oo<*M(tJE;g1x=G$U${4;U9F-%iQ=wek18E;E3|_O$-fW4Ja(Et}0m-}jG()v?;8=IS}7pR!6rt5>qDF*(Rn0@rJl|E8dF-lQT zuJ%ZPx=6WIC+DSLx{vx(*N1e8R=z?JK1l}7u&K+EG0pHN>16^i~a)6Ds zN?te{8-pfuWxV3PJEk)DIV5ezqID&|#pJ%t72RaWhat^k6q=?r$~MO)PkZbqbsK%L z>8)L!bU8MNfJL{%Pt#P@D6sCDcr9PjU}9;+Il7+vc}JP@p9S#6(*td$TlA=2fSC>{ zT^sI#;*nU%x5JOI&qXf0{)U%{`hRdXQ@WX+5sJeZv}dm&O*RF*#iw*;Uy) zYNP!(_Vvuoz5g`{ge#A|Y5{8;q3)N)g%N_yL5w+rf+`_3)reO5-5u|%BZMT5V~PgC z1})eGjuqu+1tPLdUh5OlI$E;I$e!Z+k$Y9X)oayji9P68dF4vKXz!8!Y_#=*CTgfz z?mdvU?cw&z=CTw}Om9awFV4hVP?oF`6HJ%o{lSTbCF)@Q0QHq4Dutg!$Q4UQ(3Ruw zNsM+sy}=@vJQ)4GR5uV|BU6gF(I=~A57peC%h)&8bme(j#m5hhGbf!B`f&!O!flEe zf9K~+RI@q!MDuICu|G*S5i83g+s8*$*vq4)U#9TOfFd%SAe}HW4v)n;**Y#e$}@rH zltlH63$IU8pi1#^_lM=%9U~**9xosg6QxeIUNDF_47S={%wc5lVQU*HW?QVV#;dNu zN_;YyO8M==r?L>cBy((vhLCFH0;!C!a?g+3(k#?E@jN5?-BAW@G*|{GTBZP?c2#d` zUf$KAT=uKDq+x-9Y1d_AidSICKntCps5S~)Ub8-UvUc+wVd6N(8C~qv6pJHfZC^{y z%hkA79&O$>nb36EW#N}B_$C?=-WLFulS%iWkQa3d)7Sa{5m4F^#f^)=_RkEz^+n}2 z@5RNFSi8JjZLIj&z|c_81);RHkb7<{k)c*Z&H=f|Um!v8*Mwu5Y+SF74jEX&V8)%C zoB*EZur{RZ^+?J?3etSzIZWKj40`}^eB{wWAGzKaRfNpea*f50j<69>A5ni4eW$<- zV?$=vg8?l$l6-5>?ZwhCkF*(3X2U#=GZc~UQl1(|qlnoL)xO=>p_#JsBtxph>ykPW z0`Infp^O7pQDdx^zoy+^lvIla;5N9}8~u`22!(#N&#yh8({H@a@%)qf@$v2|tl++6 z!-2{Iqe`3W;SMP6qv_uE;+q0Z$IDmTLqbCCcS;jEeM7-+j$pX$Uol$<~^$1Mt_fNSkC!duY!(n(6 z9e2aZ(((+B6M$2MoVQ=~Bt8)!ubOfpVmd@&ARl&DLOF7`X6@H5oy&1svY0fLzrw4u zy;=St>A zJAYb%_(VVoL=FgSL97T)VygqKpz~J9Z355*BwaS!KOpS9x0fsDsJd2$;O=fT4hF{T zmeWQvba#N5dU#|+jRqemFgwUm&d$!}NR5v#cGyHuOv1oIG7w?`@Ck>YV`~i{hF??o zS-7|?;g{^@gR6^N$+OOXsASg5Fu7o}cNpW%Lld@?Ed{tOEAvO#CySV`Qr|{Y#`>Cz$>~=u6Jp9R>|I#HNx1MA}hD2^FK97?XFph+`!V5&; zlf1LEyGN;lSB;N7|1TZ!UmF&QeD%pz`{S%F`P0+$H(_C`fN#7o3We?_ZS6c^m)$@* zp2V4=Qy4Po= zB>k+&TcM)#_Cvzi+N5n_jn%F z%q{{!Z4veB@>O{bQX#jHC5l>yO~`cap_UBmdDr-@xA!U2El)7dg^+LPU^W}hCq6AX z-y0bR9lNGx9F_CZ&!26#{{I+_V{rUkp#nQVN&QvK);MS;7~JqrNg>mI@UG~@!P`f8 z(B$pCw=wey3X3edpX5-mc>l;qqst!n3jw&i_T7xrY?zgu-4!{s+<1PbSz}9_M7*`V zW_^B!B0xG_!%qIP8aM5fv?hZH=)2<_*Jc`7j;fv4rk>5S?}Ra9WRkdOz*m$~(I zS#}EOCB(jdotulx1*SZ7Nx(T}oc)0(sL$0zqWM7V<4nNN?@>9Ud-hi{W24hN#8c4U zUM|FyGDSNvrYR*zE-f3}X8&F)bRpyx8@=8?=~k3CZu#nV)J zAjcbL#5;ZC%`@07fb)PA=Cae)s99wRWK@({Tv#e|}S0AJsY@rsf5c6u6`m{7N)Z9h65jY@MN*Zp5W3l==i3ik04%aFkNvDySq z&rkB7>9vLAj?vv1dj=8n6Ahx^VHhXx3|@}a4x7NS1tM}e1RuhNc?UW{RBUa70|Mga zWx<@Q)Q`^+z!0;P|2pXNm5N5x}sSj5m=s{Raxz9Kxb%MKW)@ zfI;U#JjQY+2d4_k?`nx7QaD%y_n%moFV*q<*6vzWWeG^VgC@)~?h$R#RO(Pov-ue7 zuY%=g$HcVErk!6PVoSXR`&OLwFpol#&3H94O6%8Mrezr@&N73EOHbea_I@0hkefMd zI3!##E2AX<3?G9s9DnRJ(eXZ+fb+Kgv(YLrrVJx4^WL1m;L0a&5(Iq+Hv zp5$e}IN6*y-`MOfHsG#WBA{0Y3JKZWaeZ*>4KS~W%!&M#jt=0}+{X$(D#e@BSD7!T z1=8sza#^ss?uX=;eN=8<94#RLHa{dh9QtwI=iTpdJnTpLL_et}zjPB=a3+DL+h=c9 zQg#*PutCh~rTdos4z2ucqN`+F8FOnL+(X|BsWH%)>FOF^=O4wcVmV;WuZ4kPtO_^& zqo+(Y_2x4Q?O;(N-(B3R_$@m`eOcKKj2#v)pvl_f+}*+Y_cEM=8vrD%4_j#0zQPYe zfW7p4=JQI635WIJvEgBfX>987Pwv>q+q5ms%~Nd>d>pHAIw9VN_=p=3Yk*yHKLGnC zY<{88^(4l0>lC@ZD}44Wk+WU7##X$x5-Jo&d;7(mK?yTho9*q(V?R0x2G>HD!RjE-m$;O0H=t#c}QcUCulZpwRd2oK4{gHa=eL5PR`^&U_?&I#1@`|`Jc(K zzahtpOQB9fTT@d$oc}PzJg!|xFGWZMa>Q-LX0v|^q*^HD{WwSzWomHcd-oPB)-2MQ z%F7a%SH4NDPW?8pur&$y0Vd}=5q)x`yAN<1ajBY=yoehtPRZ61$WuHW3A1GV@~^~D z;}!vSP$3H|voG}so~_*D)CDL@=&dOM#R4x_tb=*@stSs1ofw|_{F#D|&TyhK1$vc_ zclvI^YwTP|?&*D48Be(?x!JhvR85L z+_?KL!XiveQj!(Cy@8BO6mZ7U=037`VKUw{)lgBd0nJyt+K`dnmk{m19s{-8_YZTm zZbw~_)SfV*XzA!;msp_olv)@obQ|VnSR3t+rsDw@U_R^Tp3s8=l_@mxfUIHg^XCXd zvE-DjOWxPW_yRlpv5!Otq0krezXqI{l4{dS8p_D1BIWnrq_#$kF;n~{g2bq4_4?;o z@aD~RUs$&A#LV-L|4zw2!W|aSsH{RrTzYgrlQXm|wa8F&H8794chOPGcGLZJ?wxEc z!(4rNYl)~xOGM4TBK8;+xQ92>THvXihcXAjrH zDmhL9fMVaA%0f7cz^p?ETxe2MD3j+4I9-DlP3enZx3c$KF z8eCe33;01VAin(E!a{z;;al1nmR;Iv&@DhMxHvhLH@F{KO(%n_j zPmk$ve!^gv5&umaNb>cm>2{dLKeTKr&?ch!aP+>;S(b{f@QwHz4lyRpW$4} zPOHAlNThCYvFgjuL)CLBtdsj#m_fzm9JGFq?A~!4|1aA#RW2&Tp~9x%C2{8S*je@R zg>O2s+4YYN1~vfY?IzGvEVCy(A7%?bdT;qjw~@)iIwRh*Qk>nsq&!uMUq-yeYPlO~s8j=r(1=L5>RQ(YT9rJkK~H`zy?dkjv}qlAhih z;1M?e%al+k{#Y zFf%rohyxdU!8unpsUq(&x`F!5rph+j zZIdMok@{krpPyPdOsHST44}wz5Vqyg%^k`zY5Y}RqcKGNzMWd`>19qM?*B*fEjIo_ zHc5D5;We`n0ZMTk!IQ)M(q!KkK*x-MQf(Zz?7(I2R5AI@wJ%<>s;FO%#>OgOaxCvL z8P4Wt5LPURJJ3d?#DD^DI@scvU+z1K4qUa->jr|wX?nh9c<8}@UoEvB{cczR#hA>= zwY?=w_=oxvkFhcjcn?(EWLU0BHk04<{`WP6`g~iRX9+x z+(E9X@+&65cSde$yu(9(^`U5J`VnONIiYM6n+6d!M3^n#$(Mr>pAd8fD8c4jck+z5 zIAs8p^t#P!r?dH^qTDf7^gmsaj~v)sZ0@IEiGyGmx1(2C_MD~e8|6zGrKffY_vN_M zo;~mCSIvX0X4Fr82mJeo#J9(;;BMRw2Hy*+yLK_{FEw3IBG<9Fj$$L*L5(GWD6S_VD1t9e zEm!I`c}6i)47NsZ0iDYqsXm%9^VsQ?K!k&(ibGXk2xykW?R+g7v)+9+WXndu0lG`Y z7EESd-Lc}gy=}jA*9^;@zjFW+In={aTvBxDRdK+%;@WuKS=}bMI&t3j5^(|{2m%{W zgR0l91&zXeo_bw-G0iMn{Qvzkv2j5{T9bfNlTB;79b#pa+{0g#U#m4nr2e9fBphZx+U%o1#rzIz$M|u_dPnL`Qn7))tWlu_L26JH3=@sQD zC88(|1xsjmI^l@SpkOpJq_!JdVV^U{`kVB5FNoF0GO1YtABKS08~ie$vd8Xm=Jh z6lfh3W@QH4xOcnOSnL8fwLa_LL#^u%Y>t}?<#n9h*{FNHnY{l3b4g(Oze$ZQ;Zo1S zzr*NGBZ6AlsWjb>390e@*hd42D0;QKc{cgz{AfEocu~WCtItYa{=dnI1q3Cr!#($8 zWeKf@Evg7v*>JrZEc(eR38nW;1aWWl=?XM*a+3XRpg?8lv~Cd^Z0>x`P^CgC(4B%0 zkhH-E**&BpHP!}A#Qq(HdNOy#MWdX9ojevkn*PrY!IpUtN*{mgOANL-u3tx~>IM&9 zDVH-BUS)`r*TQ5)80`i(V^4Z- zWaMGVNRz&nk+0Jyc#F0P*+|^~Hd}Q6O^3c{oGxJJ!s%pq-3AMj()en zsL3#l>l?ectf)-x`kNX{2|0hyE@mGvi;sO15+VR$8BiEO(g$3%%?%B+RpF`+i4#n5 z!W#owW)PGE@WJPUeE`pDu`~GD=ySH=zeB#bid*d9Y1~PEg*lIS4DWq{Ue;~1`})gy zv{B~NK!9I)L(r?yQF@epK+su_yUhp-*+>)gXYUoP^PiR2&VD{aY21!p{phf{pBDuk z@^r1wY;6a?s|S?kxf#?HXwJ}f`(G1uu6Iq%5g0wdP2=-*YQQ=c+Hj_7?V&`QDtUPf z0YD%i&Q=YmJ5?KJh&Q)GGr;Xmj;vZF{BxdG%AvYQdNOZNo&Ox5LM=MNz6z)(4{wtK zTA^9*l<-&=xZBt#ym*VuBpvp_}ewEs069ool<9%#v7 zNiwrv5C_*EumGTmP&70&1UicA;X^fMY_CO?Ikj+TuhU7HV1tE@ImUL@W+^#A7BbVLrV*wlJ9Gc z{(|Sz^)stRnsp8-APv|#as#mYxAv7QSH>nL$_&8n7+fX6Y~n48u1*QA(J_n3jK%8V zMMP5=8wHsS#iolrN}ztD4n>xihTb>=04NQ*;3%7?8M!tcy%`-4D4#3Ta#xu#NPiDpt0)|YE#@HLdO2YFLTFq)b z&xN4MdXn4;xn!%|4fT9C)T<)FdEk71Vk0mxuqUZ%IDhpH>B(3}N3q$+1IR(jxXU=D zIQSf@7^hOtQ6dryGu|Yiof$ z{y9|#-r5J?8kExoiei8XuyW8w)^3S``4h~l?Er8g*j17#x#)OYlu45kL%45=7ecd{ z$=Q`0BPRB8&kwQrN!)Lc;3Z9i=cf%hPU*$~+3Y|>y3y77D}n9p?RKxMZxb{tDlD<9 zx4r$Wx;fak_%s14M3f~08e@zRT^c?(}!66rj=n*32 zwbc>|0Q})PDbMH5&Ml~WU||un8)iOZ8V8Ss6ut`J1W*{m%XS5D3!v}jJHAaqGN@*t zBeo6`A7Txj2B8ykh?7r$}K5^#X89H_5l z*wZn?%yr`PtnIh(2p(H2E^}4>c8pDrki&TljT8TVb&2! zk3ViP_xJPgLomg%UK0`cWs`W)lSL=}#y9{)c_nHMpTAF3`e0^YaI~9i1^eC3*|t9p z$>`|lH_-~yC$NZOX?arnDUv#VZ*QNh7jX_G-={R_iqZ3tF;4DLiMg-#fP=%=kQZ@>FxTPmVxp<^mBcDJh|ky)zWwkvsmJ%%}+majvE&yu^jrO?=RD{(J^Vor{$JYwxAdW@Ok;byEkF; z{aAMBaV+zB)VzG`$aCW`FxY}4v&_vE%?x^m zQCo;ayNr=eZ)ANpp{dUGpfDkUxQF$V=eZ6Iet$isAO}o6V~B(MbCkO|?zVidFjHIY zWQ9aKcmaUHy=fS9#@lFgy2i4paCc*mFy5AH5ue|M3ToV%)dtFUCB7h%j6oHng|*pF zI~~2LIXPQLMp!sG6Uel^Z{E7~v{YZ3Je&nTYq6JrAllx3uQWw$SV0aF$&{6q%@Yh) zf;QgEMBUvW9YqjEE=uO-GwpC*O>BaFY89GrR66vkjCb^Qjozu?{#LiC07?^3w~^i2d);&2)$T6)E^y^hFc)c^P`ZEp69 z*r-$FMk9@Y$@xXTx2ZcnIyEjb*eTN`M{;7(Y*^zVP^yla2!q}M)expDvC|{38Fu-f z6NNcVLv2DcP7CB3DB1@qk@@N>i|Vg8Cme-5Hl`{qk{)L6>=Uqlm_x9k7=J>wmEA8~!kN=guAE*=d~s z9nS-;>SyWRb|CRt?gi^FScE)`xO|KIH|M zJQbBP*kBWzSRX!o`v36u)=^cqU$-zKAQmCr2nt9eEgce4BGRRxbV_%Lbf+{@A|fJ4 zhe3BK-QBeT>9e-+d*An*^T+p%@r`f)^E^7-``-J$uWMav%{Av-DM2|o^WOs^hPq=r5SOjwOou(Vs1Hc@A} zNaGp9vS}wxXMc*SZ9)xhpfFlTO-&6jUbQQPjc0PM4QxoB=dm@OrZpBaLebY=LdklF zyn!~)H|oS4H*O4nx@9U$0F8{pz{N)@?JvlYUF=_i1*+}-Jg?^{H~_SDIYMYU9sGpJ zn_v{QGuy1RRI-!o%r79IU+riPNB>Us+h_N8QMr!mtj1q~yUW88e((u zU~AeC+N*XpHuko*y}hj}R6x3J09^OwE6XV)8}mrmz;x?3X!_MIhpsYH9D9x(=-;aVR*C+jg5=;hU0`RNt0C$=BGyr zaAJZUZK=c5Hw)IEvT`rtI}j14m-H%=W`OSnw8s~Ab`p-r;+(I6@)w?b$dfh#N3?zm zDe^Cm3st_6EO*}8zD(2_YeGcF!QnqgHJW+f0bTqq1`bS`y<#+3caebU-r#%9m@n9W zI5MAGDg)Ma~{HsFJDS(Xf13SFx*B^0hJJBgdObew6q?LUQ}~TMUf+FT5?tg zV9A*=ow(*m&TOjlR9a#vpt$(*jUX8Y{5Fg`Dh?gNrwL{;JdI7a4XAYS)gstu3?h4p zChN{l5g#0lUwK9@pAT1Ke_?ocf<0wTCzKr(i6(h|q3)_z$UT9BM z@0F-TS=8_L%wy2Nxi1+NPA!8*Lx{BGMnp^L-dp{v1R*4@P z-!ht9{*kU)r16zE>Tjsa+c)F&O9}Gl)O$004et%4tTRNN^=epO4O+>C1}RI0MmfXZLHZJ4k^#2I%B@ZNNuwamyGIzD|eZorHCFxy*=i=D# zh&XRF-h1fv&`1*VWQ%KFnXJlh5GUm(<$?KGyKJp(Lx;h)z&CHiLKi3=8e#5q%@0rS z&0fU}7^)mHiKASnBuB9ao0~`)6q`C^>?D>p4{oyZXIu&&u!z4P(f!|I{NB{}T7`h)B^*$RD4~7)OANGs~+VO)MP!erV0QOS}19W^Nv| zjz6$ehKmmy7^wWg0-8-RN0i3|>7V0p29r!Toa0vRHmw#=wuSOYP4o&@*AdGZWqU2VbxzyGYrbn{s?6N{Omdp$WbL*|zdNFUW z{{80+vIiWE(TCCOSuB}0lETuKG_-Y?bE$23YWA~|*{gSO1n`h+^4~vS3hL*@?6Vc= z-tHr|ZeTX}hLz{xn9oM9@bzh=*jjWVN%$gn)_pF!&zLV4FB=3Hwnx0X_U}qdbZ-X+ z__++Pti_LSchNy#B#E zcP~qDmObo4)W6B1(bhZTWrv){~!jJGibQ?hYFvi>izmToa(zq zYPmPkHKwwe?yRsz!N6n}g(k0%z3T~Ye{U*=KwVx%A{ry&5?Zx<%qS@m%%hyUenZQN z-*6P)Wm7u}mVL^t%7A7VyfQ?w&l=l*ekOWv*lN{!t$FvTP^Q=xqxdRp zE<}T1_vM*M+VuchN3I2yBH01-zgJo^Eg+LDo(sL(QhlpCya#75X`$j)XrMZ0tK5ZR zyy7_SpsEMt|F)A!^E6X@6Um%z6n)21x$o`v+x@uYbPKunA>M8)$9n}{|M#nf4nYt{ z>foEG+Yv5U@9iih-zbmP7Fx+2RnF1<0xh88{_>_W5gu%VP8WD&7HF3i{w#_fygBoM zMwNkJ(@T*~wrT8H$xfm=FM@{{X!nMqoS2modmk(Dx$@}sV-H%XkE#!jHIlfv?Wp~{ zwc`6Xj-}aezs$cKP!qBnIwAJvj*4l5!5x3vl-JjG6z5;p2FzgMstl?m)-IsBXz%1A z-}kq9fyh>A_T{Sgu8{0tG?F0oVXIB>&0sDdHbR?AZ7-=-cyBbIl>qlQ&|v}H!(`xYr;YdZ28xFJtQG);@2Rm~06hFrh!Z~Zk)BoBDe3O2W9 zRXA|$u(spHuUM(4DD^U^9H`#3c1mMU{wfw}6#S#s{K)B{=tNfy7u)+o?#m^sp`>Vk zH}TTtDs%GE#YkMvkjrE#x#ukVtqwmJZH4dSk?4lj__;WG80wn$TIrI77;D#IZ;MS5HAl}!Q>=Sv zuQdD)1MeqfP3y4V)JFTN=&1VC)FPLbyay4Vp4Vw75bg}>f#b72dcc{_W?gq^rKUz` zJ2Gl*p5EL_Z~tTU_a-WsvmI|sl$iM8;%F~)?>X|xxObSL1(Z>-K52EA9cbJ}Z4M?# zuia@g2TFUqx`6Xm4`}A^r~J&49(?=Ou_0%F<8*m8LPXW5t-T!}%NIcDgR##TXut3V ziw`be_q1m95>ilhJ%afG{l6QN+9~c~_c-`PsCHP}@Qk=dABB(L$Q$8Q(-=vtxsfsd z=TN(gMaZ|adzDqUYHgt@{hmY8KnR##!Yp>&gq2a=1t57H9=g0(Sn4&*%zO$Sp;fct zH_y&4G&rQVizn2l<-UFQ4rCdY`T5AvfG8-ONwl8h<9LmADIXF(ghe^&rjh7&E4<5N zIDT=2JmG=_t2c_r4tRE5Bg?t19TehhZK9R6Fqe8VJ%FF@dbEv{$+XVR-S#@$wRrpxtdLbnUD8RqvPY% zw6q8*#=&K-n36K>5>_^3?M-Mc{IfnpH_S5GbuB@yS8*au) zIl?9E#;d|AD|zoZtbk%=YJQ&E>~ZwG*Zn6?o-B5yg0UcvL4(bBJ(Cbm88$B4WtXk5 z$22rF(7!MCI`>&yvlu~*j(!EqE|`@;w7u=I9FkW67hi!#nJjd?Ju;F+!i$j(&g;L zf3!SAyL9WkKOOlV8U;<@<;ck~J$mHWE7H->koh}Dz0lcWnMQtznPxaP6PG#o16A&<8e||!j)Ef^18{mlbRP?Mt9W)Z>`MO_{=gCa`&`-yF|rY z?{VxHLdYWj0TNZ=r{5|7mhDX3j!Y4>ooN`zk6EglT`d{}UMI+k?+Kj_)p>}BY<=;# zdGjlHVCFgmU!tl>|L{7>cDq-aYI&%b7I$$fGTrAVDy7RaxA7!#aPAb zI(99tOb(COL}j3J&d#20eaG$-`5qffRe$c8hfrb{UBzl`y_%C)xAW=KBPXYb;NZ5i z)7f14*UlSbo7!dW(q09K10K(liW1}1lZjiURZ*!P>-l*`39(~(0NS&l0PYvKu-v93 z#2peZdQ?ncGsQOX4CxJOiUq*KiD(hYYgHvkOo91CW<1rjZRq1 zJ)4)rzqe)A!f;fVB2$;Hb}RMfleBSi@{yHSj%yJHb@Fo*9m(1%y4+aO_iDiycIH>e z#8c8tC@o7?n**+qj7enye?+L{)2ARKEB{$8*{dYr>;iTXLLNuK&+5c3JMhY+G%$f%>%o~ab;uEkJlsTr-1q#WE*v5QA!?Oi`l)W*kt zjh1u!>6pmt^LzM95}4@H-Z6a!gGRMR!~oDonKDbU!sm^0v!4mH@~tV@y>{nl`lZbH z^Y4G6%+r0TV5OpUAG}9-3E?#!F6QPp9BiMVU1C84Uk8P%iA)Fu%gfELuqX_UBQ9Z> zEk4y`{Ls_TIPWqt!+EQXU83$l$4Yz;G|pQ zybWrbJ{y4~TRS_>${p|xQhBuB@tSkAQ(^bG8(``3P4y&ZHnyxu4D{PKI2jq+%+20O zi2LSPOGs!F5X8w8>m=AZIWe*P5bTCQ8zwp6u>YgnI-#k#8Timz+?@$$2C6DWaPD|F zy_&T*-27}<$z7cB`{4XYD?=80JWY2O^5NmIDe69QmLn2v906iTF5iZXqld4oyaO=$ zvAQ~|P9ifsV9mqtRGHG83i9*g?#|E0K`5q6|N6^mX>n0qLxZ6ankteh6n;Yzt{8`Y z(LbfXeG|7l8Lp@p>K4%-&ytQ9837*go9mS8EN{4DVQM>=emhLmy&oe1lt~MlMJ)X=TEw#&oO;)rmjjIEw)`6ThI3m-**8FOFxq*XRS3Y=omxoq4H`mkoe;$d4= z2D#C?@D_=By2vvt9#Isp8h#6P#KQWXFY?iTJDQ#8k!|03LvSMBL4fyAapEFhyZH*n z;@g+w32#B5F!KGo$MiRJWPm6F9ys`|Cz=?IVRp8(WPF8lNHI(pczK(CeP{A+p{LOnRn z7{c^AXwqgIybN2qI#gm@)N1e*EMwSowPd-}!_kC(O>vQqxhzsZbUqk1){l*gb6g{i zi*pDItbYj%cUJBF;`DU=XV1v&$f{hqS3xrjRNr`Mvl>KS$ial$0e$*=iyw?blOh?g zdml}VZCb7kxqJ4E7Mo&WibAz@Or{Y@tmjFFPC; z8~alGcX=h?>->w&tKP+IX zt-T=d^!*uSb}p+|EZ>P>xMs;hGT@(0jg7b_4Hl+kFC=J^R=u$j;#9$FX6VCWF%0n) z;Tot5$-LHs?GO74gs$OsySux}p5NNp^okdGoa4x=OJI&i55hLrk^F{&UUAmVzVh3* zpZh+XU0%MU_{b=K(C0Mac}U$GY4VxpgW}`*7Y)>#*+k}6XP1vC!PTawTZ!UB%#4Lq zOUr1?COQ6&2^*cq2ij34&i>E+s7s7@UQv0pj7AUs#Oo{~5f~|v(7$B;!3^X3X@aU* zg~Quh0s_@lS}N~be}iGP_wFTmSHT1lGId_$2Go(SF-yf;wC!PrvSE0-p>rvr>{+A} zb@!SsetP0;_Vt|_L>RGM%V=ErQw3F9%lzD%sR9SbpPo}~*JwLC4@qDPO|7rP1TlE6 zHR7J?67XYjR40DSTrRmh!g~X-DYfi!WeYlA8mWo8njhS%QbOqA)8FxykzhM0*RZ5* zo_l&QU*;t5Rn1>23)E1(ner5)xS!*xD+FaG{2z;;U?_Yz7gzgnQAY<;y2rk$qM{Q(l*_G^|F%o}oC8NstW@(n>c`3(E_#(d%0hXz@WUrhw6uqG4 zV_kXecM(ONk%dLK&Rz3SU(5Xv!v5Nxl^^<~?`?pjF68D2GE1|sz6nblGmtoeA2AAP z(vQ=G>S_TNvyI+$1* z%Y=rGzPE&Xx}2BZQvr9qTW~kU-xwy-+=QefWoyd@^`{10{Os&(v%Vg&^5ebVrG5xe z%!0|f$3>s+mhUiTWqH~OU6@O4SzL&9cDs>Kt@%hpO)GcugJ?Eu>QkvvYx)*}6tjjC<$&q1Gx)W2b9<^yIm!$nOj^m-Wi_5mV`%<`+lSTSyU7*i#7&}Ud__vxo0Ds zHgoqJ*Xv{RcpQuc2;}B;6Nu1wqzUPG)XWjSp`0zFsf&pe>it-}8{4lM}?{p@5Er4E z@D?gKX3K$F3DjnAO`4i|62mm~`FzJ*SxwL1*V6;jdVLlm&CQ+it2OS{dU3(zZEmu} z#*t2Usc>+8x_kuK+>pIk1AQeG(osyY_uhnPR0ICBR2<46O+~It0)VjWRobWm$ z6I1=Eqa}!uD_36CHV9nBIi;Y%DGAZUMI3y4%pAtqBJi4?*)&bJsy{$jYT~TsGxz1) z`es*uKwKCXH#g4{>Q5X_j~t-*Aca9uSrNi?^Zk8&Z{U@c1q^vTJw5PcJBMR_Z)+>@ z?(-|BxRA5iSQw}^I6YjeY|YwM zcm|;Lq9(5WZtu$I6@vhS0?1*ff1en9=I8?XpW746`A-Bh49ZHcSKfsGBb=L?3wVWb zgT6=UaAZWwbtZ6j?Bv8fmTG4wM18I1y54Q+0aaFX6qJM+`E|ZqGn#y-;jd&S<6~nl zi-+aq(Rc603s4SuY3{cT<`DdlbPBr{e=UJ+Y_omlM%-0&7yUHZgjLe)4aYd-nU?A& zP8lA4`x^PEro*9zs*3frg2AwHw)C;aV7S|pit|rb{y~AZHO^yfYQBMhfH4>yP~NyN zq7xp5Yv-9DO9R$wPPT@{Ah9kN-mC zD4Ul*C==muh6DW8H*DI;hie*=5)|r)e|bl7Zn!qY7m@Imr1_?$9NE=iVnF41-nVWk z6`y7P#WnNR+n2$yxis;J*hPH-J|o4m4D%aF*VoK$9PvmeMkS4Ddm^R4tZN94o&ANv z5BYa1LaxC6Lh*(RW1sV9-3GL+#PyO3jfwG5k5vec9m#K~oXbywZ+W~SdOdpGUuqBQ zFJ3D&QZm!%B6XQZrp(j6 zVo)eiKNsobk#^a%BM{$=(AU5;MQmbQupjFaSKlSMpK@TOY!a)XkyF7 z)1T+8Cc?7+4>;U|hyAP3oB{0u$86$d#hRC>?XDVT?{MB@_Q{uy9D7hh^!~-5{I40( z)4w-Xnu9gZZ#Ji!LT?e6mxG=hkGj(Z`y*h6)BIJS%)v3T&$6zp`rrF3qt_Rmi>2vv z@IPbE>BbpLa$XH^rc)mcnstzSF6EjW<;2*%B9yH?<*YbCi0o2X{#TbuNO6>Ga7;qk zE>|l&$Q$7Dmn4b;UqqQdw6s_KUUNp9nL}E0ma@gv)Ye`TD@iN9e_`w^Bc2sOt3RTU z`21H{uBq0$|?RL|s*Xio6zQ>+| znp*tVHd|ZUjg1XcQ&W2nQn^+tud!Mu>lLAcF^^^n2_%GRKDYrstnnxp0T*d&X9D{U zQRJ5$!Y#^->{f>EkL(aMse(&}ecfLNL)&OL@#89)+_>#Ynl6sETkP8)KlJdE6JHQq zg7qXA$_K1Az?0)*W)4Mh zQ&o)vB)I@so$~V3$V-QsND&USabvxyufJoE(A;5iDa@pqw8c+q#VDKDg?^{wUUSmV zOiPw3PrjQFn38vY{(Pp9ZH5VuHb5aiGbN5&T3Qf@*%v;v{4TBO=O-!kUMOMZmZKZ< z^T2-W1`1=JH4&@){`PjN$hkFFS6dqZc^*I$l9x{}Hk7uT8L0k$1}U3n-n{vgp)+0B zBT1OQQ(avhkRa>Fj|Gmd@mf#llv^2EZG=Ic07OS;7nd;7I7#A+WC6Pc9n)n?{YgTA z@(Q*5ZBocC>>nL$>T795MSlu8lcl4>Lgv;e%qGo5IE^|N17A11BuOJ$r05w7QX6@b zks6eth~Z+q``%b^s-abDX*7ScKyH(Hr)&{UD^{#CH@FESU|s+QPm^BtHplyxb_MnXlo0Z0MKMO}qa?8q$0EGgW{#U#H88HzNcp%uS6!D(| zdAWh$Xn$QoPLK-u>Ky@UwW%#Wo1>mje}fR z7^OnimAv7Jn(~Rg`;!gBG>9KzRmd|N^?3KZ&Cz$L7#TIcZ=gy$IQ#&+;dcd0uv_9| zU`<=VSkLb29|HpqUhnKYU}Lk6>3-t=ITgT`r%yBSp*>V;^^QOaF!As8&aLd3bqwX=iCxQKiq% zwqeSs)nuKJk85nwQHjPlageMO5=_NaJP|26Wux!P3QUxhPn_o478*c&DSlRopQxJ8 zrE6X6zm>#63ul<1G~JHlJWEkNt0NI#Uw;I!kHKcMHH=?ac%yo=EP|B##Z1GtfKx8{ zJ^Po!Fcp|+xffsrpm?$Iy9But5g5W4M*u@1kTS8btWMO9#@@jTN;TP=n-0hWovZEl zS7A_ygAbOild+pIo{|z<%&s3k{@k7BGQsl`DnDOe%!+Sc{Vu6Ia=d-}c4ie4_*gSp zn2O8|>hYAb=fql4bfMXNBPdtJWPsT9Acn3W^@8TOpWp0fAv@d1Is^udBQxYRPrXu= zJ!XCv9NE}++L>_PP#w-}?eYPC&>Dg}DX>N}a7%J5TdoJ)bN^+dsX5=1>9_7mc@_Mv zBqY|WiQ7<4P2Lt3f_#0Bk5@#5DqKbQR)XaG`>zF$NAo~nQGsu4)_k*f?s`{kueIER zHnhwpP*|j7cl5dNuhlajPsYRATFYAfUNT!-E5tuzmT=|j&vVa58$+hYbHN|dTCeS3 z&5a&kQe6KT!fxYg{o_YJ_lWh&Dk_=4_29xnlGG*K;r`El7x^5I*$M{iMn_@7@rs#3 zF&z`r>P%xOtOV4R8&p)*I^)RQV8gxKQ`1#T@EO|Z-JF?~;`hnvX;{$jTd?}oTy&Xv ztUl1D{Z=U8jwO2W_u|josRi_0_rk~S=h)tJ+JO0q!ZR-ATU#FkC|$pdj>4o+cnt;h z>txg5n8sfSCpw;c#JqPAQq{EU;alWvI6eTQk|noOZ)&7Hj|vVPtd}Xg?{wS$%_;Tf z!-)alC+Qp1swnvw{@Ri}y8rvrQfO?dfW&ay7@FhUF5fWnNOZrvcDb%m2BQsb7>Sjku4FRn016kD@rX5S>e zYQTvL)Sl6;x2|uEvHs9vh;Wq5t{AW>;uJlW2I9g##)?tqVuO=P63aIRBx7XpbzJ@} z^7y?^k8%I979QdRSebt~C$i7t$0-To=gH#Z%O7kCIkjQdWQAVC6rb+y!~O4ZDpJ(e zZb&NHf6~4xCQ>fx^FYm}mn61K8b7~Bf4o(O*Z7F^!b@nioBtP?Y00+lB%x}eLbSBF zP8~8!;0@ThV;&s}^YxS1nbTNnr?E3XlAXt^{A_ae{f@z$Wv908f0D!uEK4WR!GwON z&yh~1Y4ZXtm-`4E2$=r8^LDZE2hCuw$Uus4nw3xkBkMOBLesIx@~Q?9%1 zwpHJk@!)T`X5i?&edzM0l8J?F|2xzG7{w(V{b|LyO_^+OaZIs@o?}3L{d4e^{5jz& z**|uOtOrje?}rfdIn6neUQ4-h2NjY#Qjp6FRB;Gbg_%m9E+ymc@XQxNp!~}OBEC^t z%O^%;2f19rJV~#G+{b9KiNl(G@f&n}r`-5EA)>#bN{9da9=AMf8kV45AW$uk+}&_Z zUJa$)9H|MxbXaU@h#r2Vi~KFf!O>p3orBP;g8tiUA-A&fa$X3DCWu5Z`&;K;p=nq% zkeYjno-~n6%^)z-7D{X`y-W-HlEXGT!m&D#BJ^_cC%WGymC42fIXNKE#>aBK=zY_O z@Uz-mVS_*>n$>J|K0lwyFTP+8v_6-#-6wXRel(9u?4H7L z1vXpp=brQJlv0I;@J6*k#FmT##}Ay4asP?uA(D{q*TB5<03V z9`!>1jMHGn7jKau#)8?MYe=v(e>?0h4sQ;H$CJBRAyH23RBQ7RLtB^UJ}2LltaoX^ zYKo_{#FZRCLw@!KFgYNxeFTu>0(A1?;*ddwl-KdN=vUW<0b95VYhyS{K+V zJc7>U!iC?sR0sy#9kU9*BH;SNr7XH zb;dh0RKOfEDw*!w$u#~lXYJwc4lrOxVMN-=4ii%#yvxRAwnmo07-bqgj&yV(t{rQi3GA82*XXWuIobMy1bODwh)wVIX;onep}Y?dyAVHD?&aJ3z?wmO?>}4`=rV z5>iq%Ot@cLd;k8u1?lE+2|WPl3^KZQ*J0Qp>CH)!hZQvGMU%PfaxPPPzI3@5ZosYguwucdcUB~fz0;3-YK_F)4TuTH*vj;j=q7~ zQvsVtRaF(&`<31L$lky}Em{U;TzvculNp=X98_pZppjmd zCnsw&;G4lF`1tYTa0!)m-3gxSONH}RM6b*4!pndFJR5&dOC`jsWoNI^yp)qCe~?kv zBT@?E9-&@i=XZ&!7NFbkX$thj_d1+^2h#*ZPPRIr_lf8I>Lc7Q@P7gA8q~nFD0?%& zkD&58>9y-Vjp_{&eR5q|lTox9{03OEzNYZoEwOrw`n{<5VjKID)a3cJA9j4dmgsLH zlGO1&&|f$!BHu|aSa^8u4M-@;^MwQrIKI8#};-L(dDF_ zodHC@4*&of;3JBBZww)c5WHJrx2QPFWfFAIak4;`i*AL`A%T3qnCm zY`--%ZC$T>0Bwb!#qmdvJhRWgKvmxV0w4JBE(m%e`3Rj)xEnixdxcNglWSa zAE>DAKC>YUD+AN8jkxth~CAl(2$dx)hn5 zEKxwFUStFtiSE{|Uq?sKJS{OF>}nDLJ0_5nvXL!91M`#3)1CUW&kQWG16S6AGtmnQX~czK(@16GA}f3jBT#;+BxFchG3(@>fUYe8 z!F;KSbb%n+N>p+(nWZ4w&@KW|vsq^f>K0O%zFXxmnVXZ-dof>NQyv&DcRh$OMQ24L z)|S7%Oh_1Xn)CoV*hXZe8kh6Qwq}JI`pP9sH^f|}Ab~6BSi93jc%KDQ1W2J^?QU(o z&Chp1^foW?&w)}iFc6c9iYj~-lgpK_?a?Kz*<7~^Be!*cP6m5=Nq-WyMVhNw_&|aG zk+GAH-~M_8g@CfI?wwAKo0Tw`3%GsG6NS0CYHhIz>PcknPjvd;G^6?Z`#)ku($i+X#BicaaD?ai0U;8dxnL>qgRxHeQBz2A2+JZ8y3@xvZcRq z8VQE(SY`QVF%vMbvgU3k)q9CTn{`=VM^bXm|7yMUSVghJDvy%L={4Qy7$srg=N+CL z?q23Xt7cQ?{{xSF^S>G$L-ar&LAwW*^OBsiQYKPT7`^Vl^aNaXVoQaE*0Q-nMn*`l zc9W2eF2e9SSR;}tmRvy6@YKNjKPgHwYtrUih{+u|s6Shc-(zQgasevDse_3W8WTX%PYrx|!0^r@KAVQmPf6{@Fm3DC6=6CL(TbhHxp_91&#r7ovvpES&Jvj zl&>js`>5I2*toc^{Roq~iaa^os*7rNHi0Dt`fXKDHXCRO!fW0fd;Mam2lt3Us4bRq9W2Gu zgpQ+Q5?Y*;IS9$fB(%T$Z^WiFC%6AWY%)<87$1*{(sdf+F!}M0yL4C3ZUF&`z8XO8 zOPg>B2_2g${M-;3#~~j!YsGj+Y@RM(ScJh{My zA5+N1!Lj|t=M>SfP+>c35|ko5x4uq+gCmMUoRQjko$Mz)J)7C^r}^50iQ_H->mr@Q z;k98UnTVsq!(QF<*PO;nm71tiGz(f3h2|;39%il4`V!(=LdR=%atU=_Kvwzn%NaEc zJN(n*Ct$+|o(ONKHW5nEKS`GL$0pCuBEaY}P2_w9gz&C=EBCodZvS$2bv5`o^y*t& zf;#T8EqO5*h1oAt={D~qsiik_nE!N_2*HJR1{x`c_Q@E) zXSj;b54+8wl8X@B34n}-MSFkR`@%)Qpu{KVv$LZvU+#3N;YV_@tbiCIzh>-^wR0tY z(0&?Urj&YRcD8fZN>fwQZ9f}MZmj1eQeE+7nL2qut5jBwTKH)Xiqd%&Ek3tFGS^U< z2m}7`0|bKI`L@)hKn&7Zv?qw{0~lIoa;RDL^{J#J+G#snmsyDDv6j{Y>|rpqRf?F} ztX!l-?Gf$x+?R>4*AN$nuL?)y7oUsGVPp^$LAF0q?x=tBDC9>-&e!2;bCDQJ;w9c>`D`ot9}D&|Ddq2+nuDN zz<*o0V*@MZ*?b!toCI)MDuHnc>@T0D&I!~2!*0V1SIC=|di!96oyqw_VQdmo(lXP2 zsj8x!+p4ZrFzVB^qJoGt-e2v(>IitUka3wjeD>_tg(t0@VUuN++T!0*``$lQR$d1( z;RK9_$e-+kzs)NkgJPWh$%h}Ug9;L~0TAj3bDl&)c8{>ZF^L;~BimVXGY%wul$5Qd6D}^4`i73h!(ioL#c%IvBaq^}! zOf+3n+EAeAeRzE_G3{x_u@UOSh&9U`6jtL;xP6LrLRF`TekJs68d*~s4;R!IDD;^> z@=?M+B~<~|Fpt(39T-^VH1QfTNMnGHAJN_YL}3(X^>~2O_hJ%x=oIz!ML2bUsk;EQ z*Q5O~6*bxAdjEJu0f!X?oR3S>c!f$3l&eciErvcmMa%Haj1%|EYJLyl%1TR13tD7L z`TX+|a9_ehLPmkOwOOCO2`p$wM;HhtqL{=r+}<8i0YX78>FeVwU(i3Eo2@JffMWm= zP&X|GSz*@Ny#(~0Dk(S{A#o^!ZjaTt>y}`-<2tX?IJbD347%iXeqV{ym{9SUC!4pj z!i6HxI~}8YfsYQkuvfbK&Yk6Zdc*MTMUL0{FI>a`UB_L{-L^sfqDuQaex9GYQw4b0 zd-C=1y=!|OWPa_%a{@OGFg|sLcEw%j&Q%$8szcfcbTlSrW+15wt%pD;gwZ)MJ1lcL zXfdHY0$~|Hd`do^Kk`u$K1^gi-wKevo@<}&a9t}UiFb1u2)y8YZ8-RWYU$E)>>ZU? z*!3Zz6#t_tUqP`2DL%r_aA&R+0s8m)dPwzwbX;LG{q1c zUomWi`cnnFfMWy#0Zm|txFgg=tnQW2=))EfjG+v`Eqgd+;z%l1aMAeb`Hz9PgoN{1 zDlf0~&*wl&?te4@Z3t-btL$-ICX zN(G@->Xj$(4SK)Puw3Y`Z$Pe#?zHinHw#U+e$AEul@5BKY@N5RhmkByd1HX2OU47J zq-T4B`alI7Ej5+k8a!B_;@1p5%) z0|wLsxeD4aW;e|V3O5x~)7SN0UtoqK%zO3R7#mLaJTiqIXm;8YEUZ0q_d=|-SEmVA z;268d!qWNuyWdYXK_MY9e6^lr6|!6CkYlS&NlaXtpRa@k2`Mr!E8Qm&WH@245%m-E z@%yN*t$lpB$i!=*p|7JO8PWq|BGORb2u!Vymh0AgS}t@*JE!GIKL#efsp+L9dcF&9 zo!}V~gRAdxRz&oBiTL@g z98$uejGI@bWzQUuyT_*Vau_?h#DWcCJJ_ ztS=lU4vgNX>MGKcMbF#gdl{Pwit41!uWeS$hEsXTXS!&GnT=QNwZ_nAk2dt30XFL- zDvIKhi$axvViupC&I`&Ft?M})r@ubDeVgH?bF>bJ9r!shpn zn;8&jsv(6yEORck+L2zmbg94J9zqxwWUlV}YxMCf$B~rdaO^){?4nS~n+VXP{Cc}* zk>LKn=t~P zh>KX~jrdRo+cJ!UMA%;#Z`@xU?1mu#ZT;BWupP(UiuNUHfbZVhE3~YbQ1>f#_k+Wa3pcl!0Jtr<90YaV^P%Hy>2#FEQ+KoQ)lY9L5 zZEyL#kEq9N1R4H$zP{oj^@IpEP@P@a$(slB5m0Jy*i4DigjdVT$S^Q6f_YTlYnbkY z_fbMZ?+vom^6mwUJtANd-rL`2Wo9N9@pLzsVL!|q8ykb3IivaplnY~(yO^S9%r!oavU0*{k_$5E<(ngJt?lh9dL|0TDV)Z_;wUM8YI&$bUfd2q`h}Kk zd{mUOme$Az-GNXN&TJCiTL^2FL%P15W=JbBxwruv7FCUB)88L5j9}B)>VqT_WLdU@ zA9Qyl5Rwm*d6Muj?GH|XdLJJb2dS60+Lh}Y1x0Gj4Gn!#zjoTMdckz05yMO&`Q=PV z-ho)^s*{|aehxWgg755B2sTn4 zZl|tY2b0!$mo(VwekmQ{Trj~zhM1nNxhUEZDH*q9gvai|mo5fF9orkEc>Ho}TR1!E7Yi0BM9| z4?qx2jlf>o6>!^^KR>&etCYU6zP?sZ`o$4>vO!z??1#F(=XN7esc~PSHa0AKh@43L zg8fMbml*ZB3(WNT))@u>iiBd9!^p`(S884jd$rG#=w7e!^+t6#D<7q2Ijsx_S>O@d?YMl6BJL&io7s175<3Oc$YoL z6SjQM-q?L*U|FqD9CF6~a%&wZ`Y}a?klV}&CKVh^da?18u?Y<(fox@|=PUFLwzjr_ zxlFLt`iqm)1ndboD0- z%*H~L+NhW~Wcb4==60$r2a=2(mkA5A9SE<{(eKRnE#T5M6f3ge7tQ z%P^R0n?T4fHaQs&7grW#g#yl941Lc^vyMa#kbgn?os+i!iFv=CP-k}%Hxt|DPmnpQ z0eEh!C%XKS(tEeeV)zCDfdkB%g?Bfw8(C)_8@9p+`9%cUECK&R{7qz!Xs3>)U z5;wMPZ_o0k$1gi_UJyGutk!wV$9V+=l(RB1d7K@J;621mzCs>)6l@?JTgK5rIc#A% z;gL}OVS$q(-@QcLE-AY zKBMSlgWCp)f*%Z0nwg0PJ^ND{)OUu@v9PYzN^4i;?g&O<=%ZlB^W4x*3ifv#datGB zxc?bYMAtb)8`HV4`!5J#?#Sd)wie&j{F%r*%gFzFR*+xt;X|Fnsv1-co=11~eyE>E5LRD)tDcHW-sE>zheIlhME5QV7N^y% zNA0$`GEX=7RqxO*g11#YQnLN^5*18gr}}%{UMUSQu5#XfgG5T9{Io*D+?#^R430gy zc;?*Hf&_g%hH+ZLBtp3BRjY$`?(Xiwzoy^^*W{&p@-V)mxUlz5Wy+f2&+h<)PzvS+uso zWtUD!R}c*EreTOq586}6{a|uB^PM}Uz`ui5$egLPSRt~vc*?ubPJ-c-`%&jtWOtP| z#aEwgiq6+8mJ-A97A}B0h@x|+GpD4f`Dga zA!VdVKMTKK=F&ulT5fJ`kcX^87u3h~o6Y%ikI@9S#T(uW54e$3?uQgeZ=sAkW)OK` zMar%7@SFB{rRH%ug^%yJyf85GY&PPiN7Z<~GhSXiPyF8cLmcTm`gT|8h&9lRp!be^ zz4i5UWr@+1ytDl9x<98gCH~zngt z-}$QNpdCJby(Obn({lRH2lWh?ZoHwv?*C}c^DxFkhTnAe2U8Lg1}2w^!-R)6h7K_{ zc-h6hXJH|^|69$5f)Z!Td*Cg`);o1}r!C<9#NxMa&N!X!m`i3n>KL1KELn`%FJ85p zC5;&rVom*qCP3Oi|naLGGg?}6`0fJF;7$>3e)pY3S! zW>z-*;W#^Vhk~bg4_<~ZB zD@>WA%{ZP9V~?JVyzE+L*LCBz!KU5Sux{SJ*DI(^#Y@Jl*a zkbBS%0M!8pnlN>1k{U+Q{Eh+|CouQYSdf{8jxXtF!iDs?4404fHHmvmui1=ozo3L)`4B4p;u z6_9!UfRvjzzqSalr0brRE;bwjzPpgR=%=(Ii+1jUL#smXOAXp*;;Ltjq$QqDNHaf{ zz8m#xf%M$)O1woHjqj%F#QSyjCUkpFF`yjQ;Y~n+j^Gse&S+|NwT?l7!hU<|FpuVK z_EJutZL4pMN#nw9yzR(ZYQ?S>e`xh9dY>gZ`yIpYT7cTJ7G6`g9+Mo6{iD;^cQ4dKuf1nSSD|DJ=H;mE?W9AJOldPUdMl$m z6lZn?dr{8LrzCMj-jYls9-Qlca$NHbzysu*Qi_PIO%F4OrgJkf6(9Z9&KnSM%<*N& z)>^%>camc|<%;Y11e_1XkmEn3MhH1CeiI zX<1tjFFW6eCUN>y2e>4hXw+SGW}3*h3gY4r&=+B9PirV2*4-98f_#L0@0&f;Q6S{Q z_&8L(PKEHt4-Huf25k(J4`s7j06A$I zWv0%ZLRk^gRY|?y*lifc@BbFV)TZO6RaEFz@QDz)M|Ifk%cXnkp#A1w=b0Yc;u(+P zsGomoDaeG)gDJ`m05;fbZ8=A?7g*{*!S#LC@AhbbjN5STdYrC@#xVC|-=}(LrUD1a zhwu1rda}FY@}wx!6&aC{&-BWzKqPn0Yn_Zg18t`0`z$Wgcomafa=Rn1 z{2f@HeHF=@D^O#59a@d|u*oh%r7ENY;2v83X?=g&Jc8)=rB_zKSGj=S3$RpIPsa>; z@W@?3LC+opy>2ZYL8LadSv-3B-Ola|5a)oDcj|$C4cgu!CYVM*W(AhMKS3J=d*b$h zsBPyoy<4)ThN0brB8VI-2w1u?!TuuL20SH8eWc3^`Bd8@fta!{F;UxVR^)C ztZKKXYY)vJ2xr&-Miit%+rfR@*01$$s-m-TbWf*KZ|Cy*XCiPZ2S0YtxW;0s$!}^} z-j>hbbNU&^EhC{7v$deG(KVR^%}1lH4qCyrQ|EC9Bn~ZpOA`~_cru!-_+l=m9FK=9uzUMHIhh!~oday5b@1S7)I$V(Q-js`{zd6xjFq5sf^L4>K zSNw@Mp1NBhc@5cE+pD4+mbYE9eQmXVIi3&ygDUqqVsryUh!;Pg3i@}au`hl8_4fJ%M z7!wm41vAuyJj|l5lwojA9&2W z-TzN_GX{4N+`CUKv2J@;ipoUVzVCX|M?P%3;U{zFB3I`9=0dg%30aH2A71&9%f|4K zy`h~I@Lh3v6UCUyb7}tij@t4Q1Bpa>P!xg`+RV+8%^cc{d@~t&%8wna9cJst@yR*2 zpa973NgFXSq;kqW8#QaAx3exF;N@999!3jEhl}g2;@ZyHJ^OczJBYEj;;$Yd^4QX7 z#^U=gDQ`VZIWG6b1OuutAnemaLTj~wvTtX8jmtsjF2ap`X9ebzS5vd!Zs8B0Ii}OI zYf^X-?p@_6;zB&jrn*+eu16DjgF>N{j~q5f<$8D=1jYYC)cQ6~N7}oLzZ@*H1N4-e zQ)J7;gW5R&YiIhND_kQ-}s2Nm))(Om0FR^Qm?L9Pi7Q?T_#hjuqyuEX3egN6Lt0KI0C_23^ zr(utstcQ4Uiyh|e>9a7TY92qki?+OHS%VT&(K0fUqAzsUzN4i=p5hLEh5{Xn&TX_Q zT#*);)o^keDAfF_S>dj*`C5}yPNi>i#~n}T`7|%(#iOx6lo3i%?1G)$vS#Xgt4DJ6 zx0{Yo=Tnh<-jhW2y>#IMux=e3XMGk&v{DI#+H?I9p@&r)&yzbc(X-S44kZ!BKKFbM zhdk}To$p1w+#XlM@4&>5xkxX-8IHs;J7}z)XmM!6^;r{|M+qE zo?-?Xko&QG@?UhT3nm@;C;KSt}j{)tHyNj|)n;X_EwIZT7Zlo|l12(G} z(pvItNLu9P=0#%_p__ZT8NNodGo?La%?k%C5#PLNG~M_fkHjg-LbZwn&k`GGW4;%u ztAe#!oPgjN*%G!sRaj+S@Bu559dXU6=QmMsp}%@d*W4_az18{*ezkw6?%vz<6k;gQ z7X0>ISlECB0?f}3wR`!~dbV!#Q;^B5zJQCz+1-~*(C$u-V?)&V~A z%a60_gaRkqb}`sjoDUW{!c~_@>`z|Px$&zH>q^To%R9E)4c5lDqRPHtgb0w}THou= zZiDM{*Fi=9Rs)UIZl?jbo|#=TWa}N3i?@5G3^LF~q!5WzJbAaioB&gV*{gS(%j$!$>B=V2ENx`jmdP6<`NotDk)p!Z zMZ1PMp>vj#C$b~`eQ`fjqr-xA;+u?&g`=%2_AJZ?yOb@E`RwGvK-hSe+K7+ER(ER= z*bF?HA+}4Y+g`aSaQDTVWJD8}ch=X`FEuNHn`B5kYW?fPfF+e*%62mi4%LdeW4{N= zSyYG5zuNvi325#@?_{hz{ivT=8wo?)I(Y(_=<9iM9} z>*zQo(#fOXsuAjRi(Th%hZrBDw8lTyiWCg0w4aT+`W)N4gYw#DP?ybEm=8|f=Q8r# zhxA%NAjuvfH>{(tfhNrG!eNWdZ?L>mo{>JjnOjEe5-=mhFkkU%L7i;gZrM1j9-8AD z!1&QB`9PV`TtK;B!wN!OU}=foN1Pv)NEyai)&EcV>DZ7HqmzMTCB*yewkS;>dE)<+ zXb9_y&TAp({>J#$=wZZ@+$;x^CEc{ZbFGs?CmQw7n#R{re8PG4*RHp!@@F*cv8Hjy zv)5Opih|?9w=^Omot%o(dFf=(r#lngn8E6837*G>tOC^Evt6 z`|w}j|HW$Cx@9z1FJ;7x*W@mL;g_2jVa}Rlnz++qhLB-PmG;9#U3_zEOSU_h->aNG zB0W|!JO%Al{N01+fCUcG=o$lQN%9pwJlRkSeFiF`CFi)YF=>9A=?|wAv-^YdTjUz - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ui/apps/site/source.config.ts b/ui/apps/site/source.config.ts deleted file mode 100644 index 93e104db2fa..00000000000 --- a/ui/apps/site/source.config.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { defineConfig, defineDocs, frontmatterSchema, metaSchema } from 'fumadocs-mdx/config'; - -// You can customise Zod schemas for frontmatter and `meta.json` here -// see https://fumadocs.dev/docs/mdx/collections -export const docs = defineDocs({ - dir: '../../content/docs', - docs: { - schema: frontmatterSchema, - postprocess: { - includeProcessedMarkdown: true, - }, - }, - meta: { - schema: metaSchema, - }, -}); - -export default defineConfig({ - mdxOptions: { - // `remark-image` probes every image at build time to inline width/height. - // For remote images (e.g. shields.io / skills.sh badges) that is a - // build-time network fetch, which turns an external service's availability - // into a hard build dependency: a shields.io hiccup (Cloudflare 520 / 1200 - // "Too many requests") would otherwise fail the entire docs build. Make the - // build resilient to remote-image outages. See objectui#2695. - remarkImageOptions: { - // Bound each remote fetch so a hung connection can't stall CI. - external: { timeout: 10_000 }, - // Warn (don't fail the build) when a remote image can't be sized, but keep - // strict behaviour for local/authored images so broken in-repo paths are - // still caught. Note: in the Next.js bundler pipeline local images resolve - // via `import` and never reach here — this guard only matters if that ever - // changes, at which point local failures must still be fatal. - onError: (error) => { - if (/https?:\/\//.test(error.message)) { - console.warn(`[remark-image] skipped remote image (build continues): ${error.message}`); - return; - } - throw error; - }, - }, - }, -}); diff --git a/ui/apps/site/tsconfig.json b/ui/apps/site/tsconfig.json deleted file mode 100644 index 0fd53b29bb8..00000000000 --- a/ui/apps/site/tsconfig.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "ignoreDeprecations": "6.0", - "target": "ESNext", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "react-jsx", - "incremental": true, - "paths": { - "@/*": ["./*"], - "fumadocs-mdx:collections/*": [".source/*"] - }, - "plugins": [ - { - "name": "next" - } - ] - }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts" - ], - "exclude": ["node_modules"] -} diff --git a/ui/apps/site/vercel.json b/ui/apps/site/vercel.json deleted file mode 100644 index 805dc13fd27..00000000000 --- a/ui/apps/site/vercel.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://openapi.vercel.sh/vercel.json", - "git": { - "deploymentEnabled": false - }, - "installCommand": "pnpm i", - "buildCommand": "cd ../.. && pnpm turbo run build --filter=@object-ui/site" -} diff --git a/ui/content/docs/api/index.md b/ui/content/docs/api/index.md deleted file mode 100644 index 39825c00798..00000000000 --- a/ui/content/docs/api/index.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: "API Reference" -description: "Complete API reference for ObjectUI schema types, components, and utilities" ---- - -# API Reference - -Comprehensive reference documentation for all ObjectUI types, schemas, and APIs. - -## Schema Types - -### [Schema Type Reference](/docs/api/schema-reference) - -Complete reference for every ObjectUI schema type with annotated JSON examples covering: - -- **Base** — `SchemaNode`, `BaseSchema` -- **Layout** — `PageSchema`, `DivSchema`, `CardSchema`, `GridSchema`, `TabsSchema` -- **Forms** — `FormSchema`, `InputSchema`, `SelectSchema`, `ButtonSchema` -- **Data Display** — `TableSchema`, `ChartSchema`, `TreeViewSchema` -- **CRUD** — `ActionSchema`, `DetailSchema`, `CRUDDialogSchema` -- **ObjectQL** — `ObjectGridSchema`, `ObjectFormSchema`, `ObjectViewSchema` -- **Complex** — `KanbanSchema`, `DashboardSchema`, `CalendarViewSchema` -- **Views** — `DetailViewSchema`, `ViewSwitcherSchema` diff --git a/ui/content/docs/api/meta.json b/ui/content/docs/api/meta.json deleted file mode 100644 index 928b6664b52..00000000000 --- a/ui/content/docs/api/meta.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "title": "API Reference", - "pages": [ - "schema-reference" - ] -} diff --git a/ui/content/docs/api/schema-reference.md b/ui/content/docs/api/schema-reference.md deleted file mode 100644 index fe92e732b45..00000000000 --- a/ui/content/docs/api/schema-reference.md +++ /dev/null @@ -1,1443 +0,0 @@ ---- -title: "Schema Type Reference" -description: "Complete API reference for all ObjectUI schema types with annotated examples" ---- - -# Schema Type Reference - -This reference documents every ObjectUI schema type with annotated JSON examples. Each schema extends `BaseSchema` and can be rendered by the ObjectUI engine from pure JSON. - -> **Import:** All types are available from `@object-ui/types`. -> -> ```typescript -> import type { PageNodeSchema, FormSchema, TableSchema, /* ... */ } from '@object-ui/types'; -> ``` - ---- - -## Base Schema - -**"A component node" is named `DeclaredNode`.** That is the object half a renderer -receives: a node of a declared type, keyed by the required `type` — the registry key that -selects the renderer. Every schema type in this reference extends `BaseSchema`, which -carries `type` and the shared keys tabled below. Use `DeclaredNode` for any position that -holds a node object: a slot's declared type, a prop, a type annotation in an example. A -value typed `BaseSchema` names no declared type (its `type` is any string), so it does not -fit a node slot or `SchemaRenderer`'s `schema` prop. - -Reach for `SchemaNode` only where the wider union is genuinely correct. `SchemaNode` is -`DeclaredNode` (a node of a declared type) **plus** the primitive members that render as -text, so it is the right word -for a slot that also accepts a bare string (`children`) and the wrong word for a -position that must be an object: a renderer that narrows a slot with -`typeof node === 'object'` before reading its keys drops those primitive members on the -floor. Naming the union where only the object half is accepted is the mismatch -objectui#7082 had to correct nine times. - -### SchemaNode - -The foundational building block of ObjectUI. Every component in the system is described by a `SchemaNode`. It can be a node of a declared type, or a primitive value rendered as text. - -```typescript -import type { DeclaredNode } from '@object-ui/types'; - -// The definition `@object-ui/types` declares -type SchemaNode = DeclaredNode | string | number | boolean | null | undefined; -``` - -`DeclaredNode` is the discriminated union, keyed by `type`, of every node type -`@object-ui/types` declares (the component schemas and the spec-declared -authoring nodes) and every type your application declares in -`CustomNodeRegistry`. Every node slot (`children`, `trigger`, `content`, a -view's `schema`) and `SchemaRenderer`'s `schema` prop take it, so an inline -child is checked against its own type's keys, and a `type` nothing declares is -refused. A type you register with `ComponentRegistry` joins it through -`CustomNodeRegistry`, an interface your application augments with -`declare module '@object-ui/types'`, one entry per registered type, keyed by -the type's name. The SchemaRenderer page's "Component Registry" section walks -through a registration and its entry. - -### BaseSchema - -All schema types extend `BaseSchema`. These shared properties are available on every component. - -```json -{ - "type": "div", - "id": "my-component", - "name": "wrapper", - "label": "Wrapper", - "description": "A container element", - "className": "p-4 bg-white rounded-lg", - "visible": true, - "visibleWhen": "${data.showWrapper}", - "disabled": "${data.isLocked}", - "testId": "wrapper-element", - "ariaLabel": "Content wrapper", - "children": [] -} -``` - -One row per declared member, in declaration order, so the list can be checked against `BaseSchema` by reading the two side by side. - -| Property | Type | Description | -|----------|------|-------------| -| `type` | `string` | **Required.** Component type identifier (e.g. `"page"`, `"form"`, `"table"`). | -| `id` | `string` | Unique instance identifier. | -| `name` | `string` | Component name, used for form fields and data binding. | -| `label` | `string \| I18nLabel` | Human-readable display label. `I18nLabel` is the spec's **inline locale map** (`string \| Record`, keyed by BCP-47 locale tag such as `en` or `zh-CN`), resolved against the display locale by `resolveI18nLabel`. | -| `description` | `string \| I18nLabel` | Help text or tooltip content. Same inline-locale-map vocabulary and resolver as `label`. | -| `placeholder` | `string` | Hint text for input components. | -| `className` | `string` | Tailwind CSS utility classes. | -| `style` | `Record` | Inline CSS styles. Use sparingly — prefer `className`. | -| `data` | `any` | Arbitrary data attached to the node. `any` because the shape is defined by the consuming component rather than by `BaseSchema`. | -| `bind` | `string` | Data-scope path this node draws its rows or value from, resolved by `useDataScope()`. Honoured only by components that call it. | -| `body` | *retired* | ⛔ Refused by name (objectui#6771). `body` was a second child-list spelling `BaseSchema` declared beside `children`; it is now `never` on the TypeScript face and an alias refusal on the Zod mirror, and the refusal names `children`. | -| `children` | `SchemaNode \| SchemaNode[]` | Child components rendered inside this component — the child-list key, and since objectui#6771 the only one. Whether a given node type renders a child list at all is still per component; see the note below. The keys OTHER than this one through which a renderer hands nodes to `SchemaRenderer` (a dialog's `trigger`, a tab item's `content`, a page's `regions[].components`) are per type and declared once as `NODE_SLOT_DECLARATIONS` / `nodeSlotsFor` (objectui#11170); `objectui check`, the core schema validator and the SDUI parser walk those positions as they walk `children`. | -| `visible` | `boolean \| string \| { dialect?: string; source: string }` | Visibility control. Accepts a boolean, a predicate expression string, **or** the CEL envelope object (`{ dialect: 'cel', source }` — what `objectstack build` emits for every authored predicate) — the renderer evaluates this key rather than reading it as a boolean. The string-or-envelope half is `ExpressionWire`, the one wire type `visibleWhen` on form fields already carries. | -| `visibleWhen` | `string \| { dialect, source }` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Typed as `@objectstack/spec`'s `EvaluatedExpressionInput`: a predicate string, or the envelope the spec's parse writes (`dialect` is `cel`, `cron` or `template`, and `source` is not blank). Evaluated **before** `visible`, and outranks it. | -| `visibleOn` | *retired* | ⛔ Refused by name. `visibleOn` was objectui's own spelling of `visibleWhen`; it is `never` on the TypeScript face and an alias refusal on the Zod mirror, and the refusal names `visibleWhen` (same predicate, same polarity). | -| `hidden` | `boolean \| string \| { dialect?: string; source: string }` | Inverse of `visible` — the node is not rendered. Accepts a boolean, a predicate expression string **or** the CEL envelope object (`ExpressionWire`), which the renderer evaluates rather than reading as a boolean. | -| `hiddenOn` | *retired* | ⛔ Refused by name; the refusal names `hidden` (same predicate, same polarity). | -| `disabled` | `boolean \| string \| { dialect?: string; source: string }` | Disabled state. Accepts a boolean, a predicate expression string **or** the CEL envelope object (`ExpressionWire`), on the same evaluated path as `visible`. | -| `disabledOn` | *retired* | ⛔ Refused by name; the refusal names `disabled` (same predicate, same polarity). | -| `testId` | `string` | Test identifier, rendered as `data-testid`. | -| `ariaLabel` | `string \| KeyedI18nLabel` | Accessibility label, rendered as `aria-label`. `KeyedI18nLabel` is the **keyed** form (`{ key, defaultValue?, params? }`), resolved by `resolveKeyedI18nLabel` — **not** the `I18nLabel` that `label` and `description` carry. The two are structurally confusable and each returns nothing useful for the other's input. | - -Two things the table cannot show in a cell: - -- **A concrete schema may narrow an inherited member, and its own declaration wins.** Many component schemas restate `label`, `description` or `disabled` more narrowly than `BaseSchema` declares them, so the unions above are what a node gets when its own schema does not restate the key. Check the component's own property table before writing a predicate string or a locale map into an inherited slot. -- **⚠️ `body` and `children` WERE two channels, not one key with two spellings — and the second one is retired (objectui#6771).** Each renderer read one, the other, both, or neither, and `SchemaRenderer` strips both out of the props bag it spreads — so writing the channel a renderer did not read rendered an EMPTY element, with no error at authoring time, none at validation time and none at render time. That is the defect objectui#8284 named after seven cards had repaired one page of it each, and it was closed per component by measurement: each schema narrows to the channel its renderer actually reads and **tombstones the other as `never`**, refused by name on both published faces (objectui#9254 for the components that read exactly one channel, objectui#9256 for the ones that read neither). objectui#6771 then closed the class at its source: `children` is the one child-list spelling and `body` is refused on `BaseSchema` itself. ⇒ **whether a node type accepts a child list at all is still per component** — check the component's own section, where a node that renders no children tombstones `children` too. -- **This list is exhaustive for *declared* members; the two faces differ on *accepted* keys.** `BaseSchema` carries no index signature (objectui#8347), so on the TypeScript face an undeclared key, a misspelling included, is a compile error in a typed literal. Its tolerant Zod mirror is still `.passthrough()`, so the rendering face (and `safeValidateSchema`) keeps an undeclared key; the derived strict face, `StrictAnyComponentSchema`, refuses it. Absence from this table does not mean the tolerant face rejects a key. - ---- - -## Layout Schemas - -### PageNodeSchema - -Top-level page container. Defines a full page with optional regions (header, sidebar, footer). - -```json -{ - "type": "page", - "title": "User Dashboard", - "icon": "LayoutDashboard", - "description": "Overview of user activity", - "pageType": "app", - "object": "User", - "variables": [ - { "name": "userId", "type": "string", "defaultValue": "current" } - ], - "regions": [ - { - "name": "header", - "components": [{ "type": "text", "content": "Welcome back" }] - } - ], - "children": [ - { "type": "card", "title": "Activity", "children": [] } - ] -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `title` | `string` | Page heading, drawn as the page's `h1` on every page type except `"record"`. A record page's heading is a `page:header` block. | -| `icon` | `string` | Lucide icon name for the page. | -| `description` | `string` | Line under the heading, drawn on every page type except `"record"`. | -| `pageType` | `PageType` | The page kind: `"record"` (the default), `"home"`, `"app"`, `"utility"` or `"list"`. See [Page types](#page-types) below. | -| `object` | `string` | ObjectQL object name this page operates on. | -| `template` | `string` | Template name for page layout. | -| `variables` | `PageVariable[]` | Page-level variables with types and defaults. | -| `regions` | `PageRegion[]` | Named layout regions (header, sidebar, footer). | -| `children` | `SchemaNode \| SchemaNode[]` | Main page content when the page declares no regions — one node, or a list of them. Spelled `body` until objectui#6771 retired that spelling. | -| `isDefault` | `boolean` | Whether this is the default page for the object. | -| `assignedProfiles` | *retired* | ⛔ Refused by name (objectui#9409). `@objectstack/spec` retired the key: ADR-0090 D2 deleted the Profile concept it was named after, and nothing ever enforced it, so a page that listed profiles stayed open to everyone who could reach it. Both published faces take the spec's tombstone: any value is a TypeScript error and a parse failure at `assignedProfiles`. Delete the key. Page audience comes from permission sets: gate the data the page shows with the object's permission sets, and grant those sets to people through positions. | -| `aria` | `AriaProps` | ARIA attributes for the page's root element: `ariaLabel` (a plain string, or an inline locale map such as `{ "en": "Orders", "fr": "Commandes" }`, resolved for the display locale) renders `aria-label`, `ariaDescribedBy` renders `aria-describedby`, and `role` renders `role`. This is the spec's inline vocabulary, not the keyed flat `ariaLabel` described under BaseSchema. The page adds no default role. | - -#### Page types - -`pageType` takes the five members of `@objectstack/spec`'s `PageTypeSchema`. It decides who -draws the page's heading and how wide the page's content may grow: - -| `pageType` | Kind | Heading | -|------------|------|---------| -| `"record"` | A component-based record page. The default when `pageType` is omitted. | Draws neither `title` nor `description`. Put the heading in a `page:header` block in `children` or a region. | -| `"home"` | A landing page, laid out dashboard-style. It has the widest cap. | Draws `title` and `description`. | -| `"app"` | An app-level page with navigation context. | Draws `title` and `description`. | -| `"utility"` | A compact, focused page, such as settings or a form. It has the narrowest cap. | Draws `title` and `description`. | -| `"list"` | A record list or grid surface. | Draws `title` and `description`. | - -On every type, a titled `page:header` block in the page takes the `h1` instead of `title`. -The page's own `description` still draws on every type except `"record"`. Any other value, -such as `"detail"`, `"form"`, `"dashboard"`, `"report"` or `"custom"`, is refused at -`pageType` when the schema is validated. The layout guide's -[Page Component](/docs/guide/layout#page-component) and -[Content Width](/docs/guide/layout#content-width) sections show each type in a page. - -**Related:** [AppSchema](/docs/core/app-schema), [DivSchema](#divschema), [GridSchema](#gridschema) - ---- - -### DivSchema - -A generic container element. The simplest layout primitive. - -```json -{ - "type": "div", - "className": "flex items-center gap-4 p-6", - "children": [ - { "type": "text", "content": "Hello World" }, - { "type": "button", "label": "Click me" } - ] -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `children` | `SchemaNode \| SchemaNode[]` | Child elements to render inside the div. | - -**Related:** [GridSchema](#gridschema), [CardSchema](#cardschema) - ---- - -### CardSchema - -A styled container with optional header, body, and footer regions. - -```json -{ - "type": "card", - "title": "Revenue Summary", - "description": "Monthly revenue breakdown", - "variant": "outline", - "hoverable": true, - "header": [ - { "type": "badge", "label": "Live", "variant": "secondary" } - ], - "children": [ - { "type": "statistic", "label": "Total Revenue", "value": "$12,400" } - ], - "footer": [ - { "type": "button", "label": "View Details", "variant": "ghost" } - ] -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `title` | `string` | Card title text. | -| `description` | `string` | Subtitle / description text. | -| `variant` | `"default" \| "outline" \| "ghost"` | Visual style variant. | -| `hoverable` | `boolean` | Add hover elevation effect. | -| `clickable` | `boolean` | Make the entire card a click target. | -| `header` | `SchemaNode \| SchemaNode[]` | Content rendered in the card header. | -| `children` | `SchemaNode \| SchemaNode[]` | Main card content. Spelled `body` until objectui#6771 retired that spelling. | -| `footer` | `SchemaNode \| SchemaNode[]` | Content rendered in the card footer. | - -**Related:** [DivSchema](#divschema), [GridSchema](#gridschema) - ---- - -### GridSchema - -A responsive grid layout. Columns can be a fixed number or responsive breakpoints. - -```json -{ - "type": "grid", - "columns": { "sm": 1, "md": 2, "lg": 3 }, - "gap": 6, - "children": [ - { "type": "card", "title": "Card 1", "children": [] }, - { "type": "card", "title": "Card 2", "children": [] }, - { "type": "card", "title": "Card 3", "children": [] } - ] -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `columns` | `ColumnCount \| Partial>` | A column count from 1 to 12 (`ColumnCount`), or a responsive map of such counts keyed by breakpoint (`xs`, `sm`, `md`, `lg`, `xl`, `2xl`), e.g. `{ sm: 1, md: 2, lg: 3 }`. Any other count is refused with the set named (objectui#11491). The flat `smColumns` / `mdColumns` / `lgColumns` / `xlColumns` keys are retired and refused by name; the breakpoint map is the one spelling (objectui#11505). | -| `gap` | `0 \| 1 \| 2 \| 3 \| 4 \| 5 \| 6 \| 8 \| 10 \| 12` | Gap step between grid items; `0` is none (default `4`). Any other number is refused with the set named (objectui#11474). | -| `children` | `SchemaNode \| SchemaNode[]` | Grid items. | - -**Related:** [DivSchema](#divschema), [CardSchema](#cardschema), [DashboardComponentSchema](#dashboardcomponentschema) - ---- - -### TabsSchema - -A tabbed interface for organizing content into switchable panels. - -```json -{ - "type": "tabs", - "defaultValue": "overview", - "orientation": "horizontal", - "items": [ - { - "value": "overview", - "label": "Overview", - "icon": "Info", - "content": { "type": "div", "children": [{ "type": "text", "content": "Overview content" }] } - }, - { - "value": "settings", - "label": "Settings", - "icon": "Settings", - "content": { "type": "form", "fields": [] } - } - ] -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `defaultValue` | `string` | Initially active tab value. | -| `value` | `string` | Controlled active tab value. | -| `orientation` | `"horizontal" \| "vertical"` | Tab bar orientation. | -| `items` | `TabItem[]` | Tab definitions, each with `value`, `label`, `icon`, `content`, and optional `disabled`. | - -**Related:** [CardSchema](#cardschema), [PageNodeSchema](#pagenodeschema) - ---- - -## Form Schemas - -### FormSchema - -A complete form with fields, validation, layout, and actions. - -```json -{ - "type": "form", - "layout": "horizontal", - "columns": 2, - "validationMode": "onBlur", - "submitLabel": "Save Changes", - "showCancel": true, - "cancelLabel": "Discard", - "defaultValues": { - "name": "", - "email": "", - "role": "viewer" - }, - "fields": [ - { "name": "name", "label": "Full Name", "type": "text", "required": true }, - { "name": "email", "label": "Email", "type": "email", "required": true }, - { "name": "role", "label": "Role", "type": "select", "options": [ - { "label": "Admin", "value": "admin" }, - { "label": "Editor", "value": "editor" }, - { "label": "Viewer", "value": "viewer" } - ]} - ] -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `fields` | `FormField[]` | Field definitions for the form. | -| `defaultValues` | `Record` | Initial form values. | -| `layout` | `"vertical" \| "horizontal"` | Field label placement. | -| `columns` | `number` | Number of columns for field layout. | -| `validationMode` | `"onSubmit" \| "onBlur" \| "onChange" \| "onTouched" \| "all"` | When validation triggers. | -| `submitLabel` | `string` | Text for the submit button. | -| `cancelLabel` | `string` | Text for the cancel button. | -| `showCancel` | `boolean` | Whether to show a cancel button. | -| `showActions` | `boolean` | Whether to show the action buttons row. | -| `resetOnSubmit` | `boolean` | Reset form after successful submit. | -| `disabled` | `boolean` | Disable every input and the submit button. (`mode` is retired on this node and fails validation; for a create / edit / view form use [ObjectFormSchema](#objectformschema).) | -| `actions` | `SchemaNode[]` | Custom action buttons to replace defaults. | - -**Related:** [InputSchema](#inputschema), [SelectSchema](#selectschema), [ObjectFormSchema](#objectformschema) - ---- - -### InputSchema - -A text input field supporting multiple input types with validation. - -```json -{ - "type": "input", - "name": "email", - "label": "Email Address", - "inputType": "email", - "placeholder": "you@example.com", - "required": true, - "description": "We'll never share your email", - "maxLength": 255 -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `name` | `string` | Field name for form data binding. | -| `inputType` | `string` | HTML input type: `"text"`, `"email"`, `"password"`, `"number"`, `"tel"`, `"url"`, `"search"`, `"date"`, `"time"`, `"datetime-local"`. | -| `placeholder` | `string` | Placeholder text. | -| `required` | `boolean` | Whether the field is required. | -| `readOnly` | `boolean` | Render as read-only. | -| `error` | `string` | Error message to display. | -| `min` / `max` | `number` | Numeric range constraints. | -| `step` | `number` | Step increment for number inputs. | -| `maxLength` | `number` | Maximum character length. | -| `pattern` | `string` | Regex pattern for validation. | - -**Related:** [FormSchema](#formschema), [SelectSchema](#selectschema) - ---- - -### SelectSchema - -A dropdown select field with predefined options. - -```json -{ - "type": "select", - "name": "priority", - "label": "Priority", - "placeholder": "Choose priority...", - "required": true, - "options": [ - { "label": "🔴 Critical", "value": "critical" }, - { "label": "🟠 High", "value": "high" }, - { "label": "🟡 Medium", "value": "medium" }, - { "label": "🟢 Low", "value": "low" } - ], - "defaultValue": "medium" -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `name` | `string` | Field name for form data binding. | -| `options` | `SelectOption[]` | Array of `{ label, value }` objects. | -| `placeholder` | `string` | Placeholder text when no value selected. | -| `required` | `boolean` | Whether selection is required. | -| `defaultValue` | `string` | Initial selected value. | -| `error` | `string` | Error message to display. | - -**Related:** [FormSchema](#formschema), [InputSchema](#inputschema) - ---- - -### ButtonSchema - -An interactive button with variants, icons, and loading states. - -```json -{ - "type": "button", - "label": "Deploy to Production", - "variant": "default", - "size": "lg", - "icon": "Rocket", - "iconPosition": "left", - "loading": false, - "buttonType": "submit" -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `label` | `string` | Button text. | -| `variant` | `"default" \| "secondary" \| "destructive" \| "outline" \| "ghost" \| "link"` | Visual style. | -| `size` | `"default" \| "sm" \| "lg" \| "icon"` | Button size. | -| `icon` | `string` | Lucide icon name. | -| `iconPosition` | `"left" \| "right"` | Icon placement relative to label. | -| `loading` | `boolean` | Show loading spinner and disable interaction. | -| `buttonType` | `"button" \| "submit" \| "reset"` | HTML button type. | - -**Related:** [ActionSchema](#actionschema), [FormSchema](#formschema) - ---- - -## Data Display Schemas - -### TableSchema - -A simple static table: it renders inline `data` against `columns`, nothing -more. For row hover highlighting, striping, sorting, filtering, selection or -inline editing use the interactive `data-table` — the static `table` deliberately -does not implement those (objectui#5474 retired the keys that once suggested it -did; authoring them is now refused by validation instead of silently ignored). - -```json -{ - "type": "table", - "caption": "Recent Orders", - "columns": [ - { "accessorKey": "id", "header": "#", "width": 60 }, - { "accessorKey": "customer", "header": "Customer" }, - { "accessorKey": "amount", "header": "Amount", "cellClassName": "text-right" }, - { "accessorKey": "status", "header": "Status" } - ], - "data": [ - { "id": 1, "customer": "Acme Corp", "amount": "$1,200", "status": "Paid" }, - { "id": 2, "customer": "Globex Inc", "amount": "$3,400", "status": "Pending" } - ], - "footer": { "type": "text", "content": "Showing 2 of 156 orders" } -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `caption` | `string` | Table caption / title text. | -| `columns` | `StaticTableColumn[]` | Column definitions — the static subset: `accessorKey` (the row key to read) and `header` (heading text) are required; `width`, `className`, and `cellClassName` are the only other keys this renderer honours. Interactive column keys (`align`, `sortable`, `filterable`, `resizable`, `editable`, `cell`, `fixed`, `minWidth`, `type`) belong to `data-table`'s rich `TableColumn` and are refused here. | -| `data` | `any[]` | Array of row data objects. | -| `footer` | `SchemaNode \| string` | Footer content below the table. | - -**Related:** [ObjectGridSchema](#objectgridschema) - ---- - -### ChartSchema - -A chart visualization supporting multiple chart types. - -```json -{ - "type": "chart", - "chartType": "bar", - "title": "Monthly Revenue", - "description": "Revenue by month for 2024", - "height": 350, - "showLegend": true, - "showGrid": true, - "animate": true, - "xAxisKey": "month", - "data": [ - { "month": "Jan", "Revenue": 4200, "Expenses": 3100 }, - { "month": "Feb", "Revenue": 5100, "Expenses": 3400 }, - { "month": "Mar", "Revenue": 4800, "Expenses": 3200 }, - { "month": "Apr", "Revenue": 6200, "Expenses": 3800 }, - { "month": "May", "Revenue": 5800, "Expenses": 3600 }, - { "month": "Jun", "Revenue": 7100, "Expenses": 4000 } - ], - "series": [ - { - "name": "Revenue", - "color": "#3b82f6" - }, - { - "name": "Expenses", - "color": "#ef4444" - } - ] -} -``` - -The rows live on the chart node's own `data`, one object per row keyed by column -name. Each series' `name` (or `dataKey`) selects the column it plots within those -rows, and `xAxisKey` names the column on the category axis. A series carries no -numbers of its own: `ChartDataSeries.data` is a retirement tombstone -(objectui#6896) and an authored array is refused by name at parse. - -| Property | Type | Description | -|----------|------|-------------| -| `chartType` | `ChartType` | **Required.** One of the `@objectstack/spec` `ChartType` families: `"bar"`, `"horizontal-bar"`, `"column"`, `"line"`, `"area"`, `"pie"`, `"donut"`, `"funnel"`, `"scatter"`, `"treemap"`, `"sankey"`, `"combo"`, `"gauge"`, `"solid-gauge"`, `"metric"`, `"kpi"`, `"bullet"`, `"radar"`, `"table"`, `"pivot"`. Any other value is refused with the set named (objectui#11521). The single-value families draw the first series' value in the first row as one number, and the tabular families draw a notice that points at the data-table and pivot components; the others plot the series. | -| `title` | `string` | Chart title. | -| `description` | `string` | Chart description / subtitle. | -| `categories` | `string[]` | An **alternative series list** — column names to plot, read only when `series` is absent, and ignored outright when it is present. Not axis labels: the category axis comes from `xAxisKey`. | -| `series` | `ChartDataSeries[]` | Data series. Each entry's `name` (or `dataKey`) names the column it plots within a `data` row; optional `label`, `color`, a per-series `type` (`"bar"`, `"line"`, `"area"`) for combo charts, `stack`, `yAxis` (`"left"` / `"right"`), `variant` (`"primary"` / `"comparison"`), `dashArray` and `opacity`. `chartType` on a series is refused by name — it is the renderer's internal spelling of `type`; write `type`. | -| `data` | `Array>` | Rows to plot — one object per row, keyed by column name. | -| `xAxisKey` | `string` | Row key holding the category (x) axis. The bare-string `xAxis: "month"` spelling folds onto this key at parse. | -| `xAxis` | `ChartAxis` | The category axis as `@objectstack/spec`'s axis object: `field` (required — the category column; `xAxisKey` wins when both are written), `title`, `format`, `min`, `max`, `stepSize`, `showGridLines`, `position`, `logarithmic`. Strict: an undeclared key is refused at parse. | -| `yAxis` | `ChartAxis[]` | The value axes — a **list** of the same axis object, one entry per axis; a second entry declares the right-hand axis. A single object or a bare string is refused. | -| `height` / `width` | `string \| number` | Chart dimensions. | -| `showLegend` | `boolean` | Display the legend. | -| `showGrid` | `boolean` | Display grid lines. | -| `animate` | `boolean` | Enable entry animations. | -| `config` | `Record` | Additional chart library configuration. | - -**Related:** [DashboardComponentSchema](#dashboardcomponentschema), [CardSchema](#cardschema) - ---- - -### TreeViewSchema - -A hierarchical tree component for nested data with expand/collapse and selection. - -```json -{ - "type": "tree-view", - "multiSelect": false, - "showLines": true, - "defaultExpandedIds": ["root", "src"], - "nodes": [ - { - "id": "root", - "label": "project", - "icon": "Folder", - "children": [ - { - "id": "src", - "label": "src", - "icon": "Folder", - "children": [ - { "id": "app", "label": "App.tsx", "icon": "FileCode" }, - { "id": "index", "label": "index.ts", "icon": "FileCode" } - ] - }, - { "id": "readme", "label": "README.md", "icon": "FileText" } - ] - } - ] -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `nodes` | `TreeNode[]` | Optional. Nested tree data — the spelling the renderer reads FIRST, and the one the component's own `inputs` and `defaultProps` use. Each node has `id`, `label`, optional `icon` and `children`. | -| `data` | `TreeNode[]` | Optional. Nested tree data, read only when `nodes` is absent (the renderer reads `nodes` first — objectui#6939). | -| `defaultExpandedIds` | `string[]` | Node IDs expanded on initial render. | -| `defaultSelectedIds` | `string[]` | Node IDs selected on initial render. | -| `expandedIds` | `string[]` | Controlled expanded state. | -| `selectedIds` | `string[]` | Controlled selection state. | -| `multiSelect` | `boolean` | Allow selecting multiple nodes. | -| `showLines` | `boolean` | Show tree connector lines. | - -**Related:** [TableSchema](#tableschema) - ---- - -## CRUD Schemas - -### CRUDSchema — retired - -`CRUDSchema` and the `crud` node type were **removed** in objectui#5373 under -ADR-0049 (enforce-or-remove). The type had four declaration faces — a TypeScript -interface, a zod mirror, a branch in the schema validator and a `CRUDBuilder` — -and no registered renderer, for the whole life of the key. A node that spelled it -painted the OBJUI-001 "Unknown component type" panel, so this page was teaching a -shape that could not render. - -There is no drop-in replacement, because a CRUD screen is a composition rather -than one node. Build it from the shapes that do render: - -| What `CRUDSchema` promised | What to author instead | -|---|---| -| The record table, with toolbar, filters, pagination and row/batch actions | [ObjectGridSchema](#objectgridschema) | -| The create/edit form | [ObjectFormSchema](#objectformschema) | -| The single-record read view | [DetailSchema](#detailschema) / [DetailViewSchema](#detailviewschema) | -| Whole-object screens that bundle the above | [ObjectViewSchema](#objectviewschema) | - -The `defaultSort` and `defaultSortOrder` keys documented here were `CRUDSchema`'s -own — a flat field name plus a separate direction. They are gone with it. -[ObjectGridSchema](#objectgridschema) used to declare its own, differently shaped -`defaultSort` (an object with `field` and `order`); that key has since been -retired too — see the note under [ObjectGridSchema](#objectgridschema). - -Authoring `crud` is now refused by name: `validateSchema` from `@object-ui/core` -returns a `RETIRED_TYPE` error on `schema.type` naming the migration above, and -`objectui check` reports the type as unknown. - ---- - -### ActionSchema - -A powerful action definition supporting API calls, confirmations, dialogs, chaining, and conditional execution. - -```json -{ - "type": "action", - "label": "Submit Order", - "level": "primary", - "icon": "Send", - "actionType": "ajax", - "api": "/api/orders", - "method": "POST", - "data": { "status": "submitted" }, - "confirmText": "This will send the order to the warehouse.", - "successMessage": "Order submitted successfully", - "errorMessage": "Failed to submit order", - "chain": [ - { - "type": "action", - "label": "Refresh", - "actionType": "button", - "reload": true - } - ], - "chainMode": "sequential", - "condition": "${data.items.length > 0}", - "retry": { - "maxAttempts": 3, - "delayMs": 1000 - } -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `label` | `string` | **Required.** Action display text. | -| `level` | `string` | Semantic level: `"primary"`, `"secondary"`, `"success"`, `"warning"`, `"danger"`, `"info"`. | -| `icon` | `string` | Lucide icon name. | -| `actionType` | `string` | Action kind: `"button"`, `"link"`, `"dropdown"`, `"ajax"`, `"confirm"`, `"dialog"`. | -| `api` | `string` | API endpoint for `ajax` actions. | -| `method` | `string` | HTTP method: `"GET"`, `"POST"`, `"PUT"`, `"DELETE"`, `"PATCH"`. | -| `data` | `any` | Request body data. | -| `confirmText` | `string` | Confirmation message shown before executing — the one confirm spelling, addressed by the translation bundle. (A structured `confirm` object was retired in objectui#4314.) | -| `dialog` | `object` | Modal dialog with `title`, `content`, `size`, `actions`. | -| `chain` | `ActionSchema[]` | Actions to execute after this action completes. | -| `chainMode` | `"sequential" \| "parallel"` | How chained actions execute. | -| `condition` | `boolean \| string \| { dialect?, source }` | Execution gate — the action runs only while this predicate holds (boolean, bare CEL, `${...}` template, or the normalized envelope). Declared and false skips the action; absent executes it. It is a **gate, not a branch**: express a branch as separate actions with mutually exclusive `condition`s. (The `{ expression, then, else }` branch shape was retired in objectui#3917 — nothing read it, so it ran unconditionally.) | -| `successMessage` / `errorMessage` | `string` | Toast messages on success/failure. | -| `reload` | `boolean` | Reload data after action completes. | -| `redirect` | `string` | URL to navigate to after action. | -| `retry` | `object` | Retry config with `maxAttempts` and `delayMs` (milliseconds; renamed from `delay`, objectstack#25762). | - -**Related:** [DetailSchema](#detailschema), [ButtonSchema](#buttonschema) - ---- - -### DetailSchema - -A single-record detail view with grouped fields, actions, and tabs. - -```json -{ - "type": "detail", - "title": "Order #1042", - "api": "/api/orders/1042", - "showBack": true, - "groups": [ - { - "title": "Customer Info", - "fields": [ - { "name": "customer", "label": "Customer", "type": "text" }, - { "name": "email", "label": "Email", "type": "text" }, - { "name": "created", "label": "Created", "type": "date", "format": "MMM d, yyyy" } - ] - }, - { - "title": "Order Details", - "fields": [ - { "name": "total", "label": "Total", "type": "text" }, - { "name": "status", "label": "Status", "type": "badge" } - ] - } - ], - "actions": [ - { "type": "action", "label": "Edit", "icon": "Pencil", "level": "primary" }, - { "type": "action", "label": "Delete", "icon": "trash", "level": "danger", "actionType": "confirm" } - ], - "tabs": [ - { - "key": "items", - "label": "Line Items", - "content": { "type": "table", "columns": [], "data": [] } - }, - { - "key": "history", - "label": "History", - "content": { "type": "timeline", "items": [] } - } - ] -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `title` | `string` | Detail page title. | -| `api` | `string` | API endpoint to fetch record data. | -| `resourceId` | `string \| number` | ID of the record to display. | -| `groups` | `array` | Field groups, each with `title`, `description`, and `fields`. | -| `actions` | `ActionSchema[]` | Available actions (edit, delete, etc.). | -| `tabs` | `array` | Additional tabbed content with `key`, `label`, and `content`. | -| `showBack` | `boolean` | Show a back navigation button. | -| `loading` | `boolean` | Show loading state. | - -> **Handler keys are not authorable in JSON, and this face refuses three of them by name.** `onBack` has been a named refusal since objectui#7344; since objectui#7804 this face also declares `onNavigate` and `onAddComment` as objectui#6124 **runtime slots**: a React host supplies the function through the TypeScript interface or as a React prop, and the validator **refuses the key by name** — the message leads with the slot's label (`SPA navigation callback`, `New comment callback`), states that the key "is a RUNTIME SLOT for a host-supplied function, not authorable metadata (objectui#6124): JSON has no function value, and no handler key consumes a declarative action object", and closes by pointing at the node-type spelling (`{ "type": "toast", … }`, an `action:button` node). Until then an authored `onNavigate: { "action": "toast" }` parsed **green** — `BaseSchema` is `.passthrough()`, so a key no arm declares is not refused, it stops being judged and the value is kept — and because every read site only tests the key for truthiness, the kept object then reached a call site expecting a function: `handleBack`, `handleEdit` and the post-delete redirect call `schema.onNavigate(url, { replace })` in `DetailView`'s own body, while `onAddComment` is forwarded as a prop into the comment composer, which renders *because* the key is truthy and then awaits it on send. ⚠️ `onTabChange` is a fourth handler key this view reads and it is **still undeclared** — and where the `object-kanban` board's third key, `onCardMove`, has been a **tombstone** refused by name since objectui#9342, an authored `onTabChange` is not refused at all: it is **kept**, read through a cast and handed to the tab strip's `onValueChange`, so it still reaches a call site expecting a function at the first tab switch. That gap is open on objectui#7804. - -**Related:** [DetailViewSchema](#detailviewschema), [ObjectGridSchema](#objectgridschema) - ---- - -## ObjectQL Schemas - -These schemas integrate with [ObjectStack](https://objectstack.ai) for automatic data fetching, but work with any backend through the `data` prop. - -### ObjectGridSchema - -A data grid that auto-fetches from an ObjectQL object definition. Includes search, filters, pagination, grouping, and inline editing. - -An authored `object-grid` node takes its props in its `properties` bag, whose members are `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row. `objectui validate` judges the bag against that row and refuses a prop written flat on the node by name, naming its bag member, as the spec's own page component does (objectui#11276). `SchemaRenderer` hoists the bag onto the node before `ObjectGrid` runs, so `ObjectGridSchema` is the node as the renderer reads it, and the table below lists its members. Base props such as `className` stay on the node, beside the bag. `description` is a bag member: the spec's row declares it (objectstack#20694), so written on the node it is refused by name and pointed at `properties.description` (objectui#11227). - -```json -{ - "type": "object-grid", - "properties": { - "objectName": "Contact", - "description": "Manage your contacts", - "title": "All Contacts", - "searchableFields": ["name", "email", "company"], - "resizable": true, - "columns": [ - { "field": "name" }, - { "field": "email" }, - { "field": "company" }, - { "field": "phone" }, - { "field": "status", "label": "Status", "sortable": true } - ], - "sort": [{ "field": "name", "order": "asc" }], - "operations": { - "create": true, - "update": true, - "delete": true, - "export": true - }, - "rowActions": ["edit", "delete"], - "pagination": { - "pageSize": 25, - "pageSizeOptions": [10, 25, 50, 100] - } - } -} -``` - -Every key in this example is one the grid reads (objectui#11068). It used to -also author `showFilters` (now retired on this node, below), `striped` and -`operations.read`, which nothing reads, and `selection: { enabled, mode }` and -`pagination.enabled`, which the validator refuses: selection is spelled `selection: { type: 'multiple' }` (`'none'`, -`'single'` or `'multiple'`), and `pagination` declares no on switch — its -presence enables paging. It also authored `emptyState`, which the grid reads but -the spec's row did not declare until objectstack#20694; it is authorable in the -`properties` bag now (see its row below). - -| Property | Type | Description | -|----------|------|-------------| -| `objectName` | `string` | **Required**, unless a `dataSource` binding names the object (`"dataSource": { "object": "Contact" }`), which the registration lands here (objectui#11117). ObjectQL object API name. | -| `label` | `string \| I18nLabel` | Grid label: the table caption, the export file title and the record-detail overlay heading. The canonical spelling; the deprecated `title` is read only when `label` is absent. A per-locale map (`{ "en": "Accounts", "zh-CN": "客户" }`) resolves against the display locale (the workspace's regional default when one is configured, otherwise the active UI language) (objectui#10993). | -| `columns` | `string[] \| ListColumn[]` | Columns to display. Either a plain array of field names (`["name", "email"]`), which auto-resolve from object metadata, or an array of `ListColumn` objects whose identity key is `field` (`{ "field": "status", "label": "Status" }`) — never `name`. **Do not mix the two forms in one array:** the array is dispatched on its first entry, so column objects sitting behind a bare string are dropped. | -| `filter` | `any[]` | Pre-applied filter conditions. | -| `sort` | `SortConfig[]` | Default sort configuration. The string clause (`"name desc"`) was retired in objectui#8221 and now fails validation. | -| `description` | `string \| I18nLabel` | One line of help text drawn above the grid; a per-locale map resolves like `label` (objectui#11068). Authored in the `properties` bag: the spec's row declares it (objectstack#20694, objectui#11227). | -| `searchableFields` | `string[]` | Fields included in search. | -| `selection` | `SelectionConfig` | Row selection configuration. | -| `pagination` | `PaginationConfig` | Pagination settings. | -| `operations` | `object` | Enabled CRUD operations. | -| `rowActions` / `bulkActions` | `string[]` | Action identifiers for rows and batch selection. `bulkActions` is the spec-aligned key; `batchActions` is a legacy alias that takes precedence when both are set. | -| `editable` | `boolean` | Enable inline cell editing. | -| `grouping` | `GroupingConfig` | Row grouping configuration. **Server-side**: the set of groups, every group count and every per-group aggregation come from the group header query (`dataSource.queryGroupHeaders`), and each group's rows are paged by the server. Rows handed in whole are grouped in the browser (exact); over a data source with no header query, a grid that fetches its own rows refuses grouping with an error naming `queryGroupHeaders`. | -| `frozenColumns` | `number` | Number of columns frozen on scroll. | -| `conditionalFormatting` | `ConditionalFormattingRule[]` | Row styling rules, each `{ condition, style }` — a CEL `condition` over the row's `record.*` and a CSS `style` map, the rule a list view declares; the first matching rule styles the row. The native `{ field, operator, value }` rule, its `expression`, and a colour written beside `condition` (rather than inside `style`) are retired (objectui#11533): `@object-ui/types` refuses them by name on `ObjectGridSchema`, the `object-view` `table` slot and `list-view`. `{ field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }` is `{ condition: "record.priority == 'high'", style: { backgroundColor: '#fee2e2' } }`. In an authored node's `properties` bag the member is `@objectstack/spec`'s row member, judged by the installed spec. A grid stored with a retired rule still paints it. | -| `navigation` | `ViewNavigationConfig` | SPA navigation configuration. | -| `emptyState` | `EmptyState` — `{ title?, message?, icon?, link? }` | Drawn in place of an empty table: a Lucide `icon`, a `title` (default: the table's "No results found"), a `message` (default: none) and one `link: { label, url }` the user follows (default: none), opened as a `type: 'url'` action opens it, through the action runner (objectui#12270). `title`, `message` and the link's `label` are `string \| I18nLabel`, each resolved against the display locale; a map with no usable entry keeps that member's default. Not drawn when a term in the grid's own server-side search box emptied it — the table and its search box stay (objectui#11068). Authored in the `properties` bag: the spec's row declares it as the list view's own `EmptyStateSchema` (objectstack#20694, objectui#11227). | - -> **`name`, `placeholder`, `rowSpecActions` and `bulkSpecActions` are retired on -> this node (objectui#11068).** Nothing ever read them: `rowSpecActions` / -> `bulkSpecActions` were second spellings of `rowActions` / `bulkActions`, and a -> grid is neither a form field (`name`) nor an input (`placeholder`). Both faces of -> `@object-ui/types` refuse them by name. Write `rowActions`, `bulkActions`, `id` -> or `label`, and `emptyState: { "message": … }` instead. - -> **`showFilters` is retired on this node too (objectui#11068).** An `object-grid` -> has no filter UI, and nothing read the key. The filter builder is the -> `list-view` toolbar's: author a `list-view` node and switch it with -> `"userActions": { "filter": true }`. To narrow the rows a grid fetches, write -> `filter`. Both faces of `@object-ui/types` refuse an authored `showFilters` on an -> `object-grid` by name. An [`object-view`](#objectviewschema)'s own `showFilters`, -> below, is a different key and is unchanged. - -> **`defaultSort` is retired (objectui#5861).** `ObjectGridSchema` used to accept a -> legacy single-entry `defaultSort: { field, order }` beside `sort`. The installed -> `@objectstack/spec` protocol refuses it by name (a retired-key tombstone), and no -> renderer reads it any more: a grid that still carries it renders **unsorted**, and -> `@object-ui/types` refuses it on both faces (a `?: never` member and a named zod -> refusal). Rename the key to `sort` and wrap the value in an array — -> `"defaultSort": { "field": "name", "order": "asc" }` becomes -> `"sort": [{ "field": "name", "order": "asc" }]`, as in the example above. - -**Related:** [ObjectViewSchema](#objectviewschema), [TableSchema](#tableschema) - ---- - -### ObjectFormSchema - -A smart form that auto-generates fields from an ObjectQL object. Supports simple, tabbed, wizard, split, drawer, and modal layouts. - -An authored `object-form` node takes its props in its `properties` bag, whose members are `@objectstack/spec`'s `ComponentPropsMap['object-form']` row. `objectui validate` judges the bag against that row and refuses a prop written flat on the node by name, naming its bag member, as the spec's own page component does (objectui#10859). `SchemaRenderer` hoists the bag onto the node before `ObjectForm` runs, so `ObjectFormSchema` is the node as the renderer reads it, and the table below lists its members. - -```json -{ - "type": "object-form", - "properties": { - "objectName": "Contact", - "mode": "create", - "formType": "tabbed", - "title": "New Contact", - "layout": "vertical", - "columns": 2, - "fields": ["firstName", "lastName", "email", "phone", "company"], - "sections": [ - { - "label": "Basic Info", - "fields": ["firstName", "lastName", "email"] - }, - { - "label": "Details", - "fields": ["phone", "company", "address"] - } - ], - "showSubmit": true, - "submitText": "Create Contact", - "showCancel": true, - "cancelText": "Cancel" - } -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `objectName` | `string` | **Required.** ObjectQL object API name. | -| `mode` | `"create" \| "edit" \| "view"` | **Required.** Form interaction mode. | -| `formType` | `string` | Layout type: `"simple"`, `"tabbed"`, `"wizard"`, `"split"`, `"drawer"`, `"modal"`. Aligned with `@objectstack/spec` `FormViewSchema.type`. | -| `recordId` | `string \| number` | Record ID for edit/view modes. | -| `fields` | `string[]` | Field API names to include (auto-resolved from object metadata). | -| `customFields` | `FormField[]` | Manually defined fields that override auto-generated ones. | -| `sections` | `ObjectFormSection[]` | Field sections (simple groups, tabs, or wizard steps depending on `formType`). Spec-aligned key. | -| `groups` | `array` | **Deprecated.** Legacy alias of `sections` (spec defines `groups` as an alias); normalized into `sections` when `sections` is absent. Legacy shape: `title`→`label`, `defaultCollapsed`→`collapsed`. Not a member of the spec's `object-form` row, so an authored bag refuses it; write `sections`. | -| `layout` | `string` | Label layout: `"vertical"`, `"horizontal"`. The spec row retired `"inline"` and `"grid"` (both rendered as `"vertical"`), so an authored bag refuses them; for several columns set `columns`. | -| `columns` | `number` | Number of form columns. | -| `submitText` / `cancelText` | `string \| I18nLabel` | Button labels. | -| `title` / `description` | `string \| I18nLabel` | Heading and subtitle of the drawer and modal presentations. | -| `nextText` / `prevText` | `string \| I18nLabel` | Wizard step-button labels. | -| `successMessage` | `string \| I18nLabel` | Toast shown after a successful submit. | -| `showSubmit` / `showCancel` / `showReset` | `boolean` | Toggle action buttons. | -| `drawerSide` | `string` | Drawer position: `"top"`, `"bottom"`, `"left"`, `"right"`. | -| `modalSize` | `string` | Modal size: `"sm"`, `"default"`, `"lg"`, `"xl"`, `"full"`. | -| `modalCloseButton` | `boolean` | Show the modal's close (X) button. Default `true`; `false` hides it. The modal still closes on Escape, and on the Cancel action when that is shown. | - -#### Spec alignment & extension keys - -`ObjectFormSchema` keys fall into four classes (#2545): - -- **Spec-aligned** — same name and semantics as `@objectstack/spec` `FormViewSchema`: `title`, `description`, `layout`, `columns`, `sections`, `defaultTab`, `tabPosition`, `allowSkip`, `showStepIndicator`, `splitDirection`/`splitSize`/`splitResizable`, `drawerSide`/`drawerWidth`, `modalSize`, `subforms`, `submitBehavior` (plus `formType` ↔ spec `type`). -- **Component-contract keys** — not on `FormViewSchema`, but members of the spec's `ComponentPropsMap['object-form']` row, the `object-form` block's own props contract: `showSubmit`/`submitText`, `showCancel`/`cancelText`, `showReset`, `nextText`/`prevText`, `successMessage`, `navigateOnSuccess`, `resetOnSuccess`, `modalCloseButton`, `initialValues`, `fields`, `customFields`. `submitText`, `cancelText`, `nextText`, `prevText` and `successMessage` are `I18nLabel` there, as `title` and `description` are. This list was read off the installed `@objectstack/spec`'s `ComponentPropsMap['object-form']` row; nothing re-derives it, so check that row itself before relying on it. -- **Envelope key** — `className` is on neither `FormViewSchema` nor the `object-form` row, but on the spec's `PageComponentSchema`, the envelope every page node carries beside `type` and `properties`. -- **Runtime-only** — non-serializable renderer concerns that never appear in view metadata: `mode`, `recordId`, `open`/`onOpenChange`, `readOnly`, and all callbacks (`onSuccess`, `onError`, `onCancel`, `onStepChange`, `submitHandler`). - -`I18nLabel` is `@objectstack/spec`'s label union: a plain string, or an inline per-locale map such as `{ "en": "Save order", "zh-CN": "保存订单" }`. `ObjectForm` resolves a map against the active UI language before any presentation reads it (objectui#10993). - -**Related:** [FormSchema](#formschema), [ObjectViewSchema](#objectviewschema) - ---- - -### ObjectViewSchema - -A complete object management interface combining grid, form, search, filters, and view switching. - -```json -{ - "type": "object-view", - "objectName": "Deal", - "title": "Sales Pipeline", - "description": "Manage your deals", - "defaultViewType": "kanban", - "showSearch": true, - "showFilters": true, - "showCreate": true, - "showViewSwitcher": true, - "operations": { - "create": true, - "read": true, - "update": true, - "delete": true - }, - "searchableFields": ["name", "company", "owner"], - "listViews": { - "all": { - "label": "All Deals", - "columns": ["name", "stage", "value", "owner", "closeDate"], - "filter": [], - "sort": [{ "field": "value", "order": "desc" }] - }, - "my-deals": { - "label": "My Deals", - "columns": ["name", "stage", "value", "owner", "closeDate"], - "filter": [{ "field": "owner", "operator": "equals", "value": "{current_user_id}" }] - } - }, - "defaultListView": "my-deals", - "table": { - "columns": ["name", "stage", "value", "owner", "closeDate"], - "pageSize": 25 - }, - "form": { - "formType": "drawer", - "drawerSide": "right", - "fields": ["name", "stage", "value", "owner", "closeDate", "notes"] - } -} -``` - -`{current_user_id}` in the `my-deals` filter is the spec's context token (`CONTEXT_TOKENS` in `@objectstack/spec/data`): the `object-view` node resolves it to the signed-in user's id before the query runs (objectui#10506), through the same `resolveFilterPlaceholders` from `@object-ui/core` that the app-shell host, the charts and the dashboard widgets call, with the user taken from the nearest `FilterScopeProvider` (`@object-ui/react`; the console shell mounts one). With no provider mounted the token is left as written and the filter is never widened: the ObjectStack server resolves the literal token for a signed-in request and refuses the request otherwise (the REST face answers 401 at its auth gate before the filter is read, and where a guest context reaches the engine the resolver answers 400), and a backend with no resolver of its own matches no record. - -| Property | Type | Description | -|----------|------|-------------| -| `objectName` | `string` | **Required.** ObjectQL object API name. | -| `title` | `string` | View title. | -| `defaultViewType` | `string` | Initial view: `"grid"`, `"kanban"`, `"gallery"`, `"calendar"`, `"timeline"`, `"gantt"`, `"map"`. | -| `listViews` | `Record` | Named list views with filters and sort. Each entry is `@objectstack/spec`'s `ObjectListViewSchema`: it needs `columns`, takes `filter` as `{ field, operator, value }` rules, and puts view-kind config in the top-level block of that kind (`kanban`, `calendar`, …). A legacy `options` bag is refused. | -| `defaultListView` | `string` | Key of the default list view. | -| `table` | grid keys of [ObjectGridSchema](#objectgridschema) | The grid keys the view hands the grid it draws; any other grid key is refused (objectui#10976). The key list is in the `@object-ui/plugin-view` guide. | -| `form` | `Partial` | Form configuration overrides. | -| `showSearch` / `showFilters` / `showCreate` | `boolean` | Toggle toolbar features. | -| `showViewSwitcher` | `boolean` | Show view type toggle (grid, kanban, etc.). | -| `operations` | `object` | Enabled CRUD operations. | -| `navigation` | `ViewNavigationConfig` | SPA-aware navigation. | - -**Related:** [ObjectGridSchema](#objectgridschema), [ObjectFormSchema](#objectformschema), [ViewSwitcherSchema](#viewswitcherschema) - ---- - -## Complex Schemas - -### ObjectKanbanSchema - -A drag-and-drop Kanban board. The `object-kanban` type key validates the shape the registered renderer (`@object-ui/plugin-kanban`) reads: bind the board to an object with `objectName` + `groupBy` — the lanes come from the group field's options — or hand it rows on `data`. - -> **The bare `kanban` node type key is retired** (objectui#8802, ruled 2026-09-09). It published two faces that disagreed with each other, and `object-kanban` is now the one spelling. A `{ "type": "kanban" }` document is refused by name and told to write `object-kanban`. -> -> ⚠️ The **stored view type** `"kanban"` — what `listViews[].type` and `defaultViewType` hold — is a **different layer and is unchanged**. Do not rewrite it: a saved kanban view already renders through the `object-kanban` node type, because `ObjectView` maps the stored type onto it. - -```json -{ - "type": "object-kanban", - "objectName": "tasks", - "groupBy": "status", - "titleField": "title", - "cardFields": ["assignee", "due_date"] -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `objectName` | `string` | Object to fetch records from. | -| `groupBy` | `string` | Field whose values become the lanes (maps to column ids). **Optional** since objectui#8990, matching `@objectstack/spec`. A board that omits it draws whatever lanes `columns` declares — and holds **no cards**, because records are only distributed once a lane key exists. | -| `columns` | `string[] \| KanbanLane[]` | Swimlane definitions — an array of `{ id, title }` lanes (one per `groupBy` value), **or** an array of bare value strings; never a mix. **Not** a field projection (that is `cardFields`). A lane's `cards` is optional: an object-bound board buckets records into the lane by `groupBy`, and only a static board writes a lane's cards itself. A record lands in a lane when its stored `groupBy` value equals the lane **`id`** (the option value, compared case-insensitively); the lane `title` is display only and never decides membership (objectui#10069). A record matching no lane id is shown in a trailing *Uncategorized* lane and named in a console warning. | -| `titleField` | `string` | Field used as the card title. | -| `cardFields` | `string[]` | Fields rendered on each card. | -| `swimlaneField` | `string` | Record field that splits the board into horizontal swimlanes, across the `groupBy` columns. Declared on both faces since objectui#11355, with the type `@objectstack/spec`'s `object-kanban` row gives it. With the key absent the board falls back to `grouping.fields[0].field`. | -| `grouping` | `GroupingConfig` | The fallback for `swimlaneField`: with that key absent, the board splits into swimlanes by `grouping.fields[0].field`, and reads nothing else in the block. The type is `@objectstack/spec`'s `GroupingConfig` (`{ fields: [{ field, order?, collapsed? }] }`), the one the `object-kanban` row and `object-grid` take, by reference on both faces since objectui#11216: at least one entry, no undeclared key, and a `field` written without leading or trailing spaces. | -| `filter` | `any[]` | Query filter, forwarded verbatim as `$filter`. | -| `limit` | `number` | Fetch window for the board (default 100). | -| `coverImageField` | `string` | Field whose URL renders as the card cover image. | -| `conditionalFormatting` | `KanbanConditionalFormattingRule[]` | Card colouring rules, each `{ condition, style }` — a CEL `condition` over the card's `record.*` and a CSS `style` map, the rule a list view declares; the first matching rule styles the card. The native `{ field, operator, value }` rule and a colour written beside `condition` (rather than inside `style`) are retired and refused by name (objectui#11522): `{ field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }` is `{ condition: "record.priority == 'high'", style: { backgroundColor: '#fee2e2' } }`. | -| `navigation` | `ViewNavigationConfig` | What a card click opens — the spec's `NavigationConfig` by reference, the type `ObjectGridSchema.navigation` uses: `mode` (`drawer`, `page`, `new_window` or `none`) with `size`, `openNewTab` and `preventNavigation`. With the key absent a click opens the record in a drawer. A block written without `mode` takes the spec's `drawer` default. `page` opens the record page through the record navigator the host publishes (objectui#11293); the console publishes one on its custom pages, record pages and list views, and under a host that publishes none the click opens nothing. | - -> `groupField` is refused by name (objectui#7322): the renderer reads `groupBy`. - -> **`quickAdd` and `allowCollapse` were rows of the table above and are not authorable on this board.** `allowCollapse` is **refused by name** by the strict authoring face, which does not declare it at all — a document carrying it fails validation rather than merely going unread, and `@object-ui/plugin-kanban` has no read site for it. `quickAdd` is **retired** on this board (objectui#8285, director seat, decision batch 91): the Quick Add control is gated on an `onQuickAdd` handler as well, and no `object-kanban` path supplies one, so the key never drew anything, and the ruling retired it rather than add inline record creation. The strict face tombstones it (refused by name), `ObjectKanban` no longer forwards it, and `@objectstack/sdui-parser` answers it with the ordinary `unknown-prop` warning. `@object-ui/types` mirrors this face and agrees on both: each is `never` on its TypeScript face and a retirement tombstone on its Zod face (objectui#8801, objectui#8285). The Quick Add pair itself lives on the `KanbanRenderer` component, which a React host mounts directly. - -> `columns` is declared on this face since objectui#8913, as the pair of array shapes `@objectstack/spec` declares — an array of `{ id, title }` lanes, **or** an array of bare value strings. A **mixed** array is refused: the renderer decides which shape it has from the first element alone, so a mix yields a blank lane and mis-bucketed cards. A lane accepts `id`, `title`, `cards`, `limit`, `className` and `collapsed`, which are the members the board implementations read; `id` is a **string** — the authored face keeps that narrowing, and since objectui#8993 a non-string lane id no longer renders every card twice: the bucketer's leftover sweep keys membership the way the injection already did (a lane `1` takes the group `'1'`). When a lane carries `cards`, each card is judged — a card with no `title` is refused. An undeclared lane key is accepted and dropped, not refused, which is this tolerant face's posture; the strict authoring face refuses it by name. -> -> ⚠️ The **bare-string array applies only to a board with no `groupBy`.** It is declared so this package does not refuse an authoring the protocol allows. The renderer reads a bare-string lane list only when a board has no `groupBy` — so on a board that *does* declare one the strings are ignored and the lanes come from the group field's picklist options or from the data. Since objectui#8990 made `groupBy` optional, a lane-less board is a valid authoring and this arm is live on it: the lanes are drawn, titled by the **raw strings** (a grouped board titles its lanes with the picklist *labels* instead). ⚠️ Such a board holds **no cards** — with no lane key the records are never distributed — and dragging a card writes nothing back. It is lane headings, not a populated board; to control the lanes of a working board, declare `groupBy` and write the `{ id, title }` array. -> -> Of the other keys the retired `kanban` arm alone declared, `cardTitle` (objectui#9606), `navigation` (objectui#8652), `swimlaneField` (objectui#11355) and `grouping` (objectui#11216) are declared on this face. `grouping` was the last of them: until objectui#11216 it was kept unjudged here and refused by name on the strict authoring face. - -> **Handler keys are not authorable in JSON, and all three now say so by name.** Since objectui#7804 this face declares `onCardClick` as an objectui#6124 **runtime slot**: a React host supplies the function through the TypeScript interface or as a React prop, and this validator **refuses the key by name** with a message pointing at the node-type spelling (`{ "type": "toast", … }`, an `action:button` node). Until then an authored `onCardClick: { "action": "toast" }` parsed **green** — `BaseSchema` is `.passthrough()`, so a key no arm declares is not refused, it stops being judged and the value is kept, then reaches a call site expecting a function. ⭐ The other two keys are **tombstones**, not runtime slots, so their TypeScript twins are `?: never` rather than callable. `onCardMove` has been one since objectui#9342: an authored one reached **nothing** even as a function, because an object-bound board substitutes its own mover, and the mover lives on `KanbanRenderer`'s React prop of the same name, a sibling of its `schema`. `onQuickAdd` has been one since objectui#11234: a supplied one reached the board and was never called, because its partner `quickAdd` is retired here. `ObjectKanban` renders an internal board that takes the Quick Add pair only as explicit props and supplies neither half; the pair lives on `KanbanRenderer`'s `schema`, for a React host. - -> `data` and `bind` are [`BaseSchema`](#baseschema) members, not narrowed here, but this face requires **one of** `bind`, `data`, `objectName` — the renderer's own record-source ladder (an external `data` prop → `bind` via `useDataScope` → this schema's own `data` → a fetch keyed by `objectName`) — or a `dataSource` binding whose `object` names the object, which the registration's `ElementDataSourceGate` lands on `objectName` before the board reads it (objectui#11117). A purely static board (lanes carrying their own cards, no record source) authors `"groupBy"` and `"data": []`. ⚠️ The record-source rule is **separate** from the lane key and is unaffected by objectui#8990: omitting `groupBy` is fine, omitting all of `bind` / `data` / `objectName` with no binding is still refused, at the refinement rather than at `groupBy`. - -**Related:** [ObjectViewSchema](#objectviewschema), [ObjectGridSchema](#objectgridschema) - ---- - -### DashboardComponentSchema - -A widget-based dashboard with configurable grid layout and auto-refresh. - -```json -{ - "type": "dashboard", - "columns": 4, - "gap": 6, - "refreshIntervalSeconds": 30, - "widgets": [ - { - "id": "revenue", - "type": "metric", - "title": "Total Revenue", - "layout": { "x": 0, "y": 0, "w": 1, "h": 2 }, - "dataset": "sales", - "values": ["revenue"] - }, - { - "id": "sales_trend", - "type": "area", - "title": "Sales Trend", - "layout": { "x": 1, "y": 0, "w": 2, "h": 4 }, - "dataset": "sales", - "dimensions": ["day"], - "values": ["revenue"] - }, - { - "id": "open_tasks", - "type": "table", - "title": "Open Tasks by Owner", - "layout": { "x": 3, "y": 0, "w": 1, "h": 4 }, - "dataset": "tasks", - "dimensions": ["owner"], - "values": ["open_count"] - } - ] -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `columns` | `number` | Number of grid columns. | -| `gap` | `number` | Gap between widgets (Tailwind spacing scale). | -| `widgets` | `(DashboardWidgetSlotComponentSchema \| DashboardWidgetSchema)[]` | **Required.** Each entry is a widget or a component node. A widget (`DashboardWidgetSchema`) names itself with `id`, `title` and `description`, sizes itself with `layout: { x, y, w, h }`, and holds its content either as a family named in `type` bound to a `dataset` (with that family's settings under `options`), or as a registered component node in `component`; its full key set is the spec's `DashboardWidget` plus objectui's own. A component node (`DashboardWidgetSlotComponentSchema`) sits in the slot directly: its `type` is a member of the closed `DASHBOARD_COMPONENT_WIDGET_TYPES` set, such as `metric-card`, its other keys are that component's own props, and it is placed by the same `layout` a widget is, which the editable grid's Save Layout writes onto every entry. | -| `refreshIntervalSeconds` | `number` | Auto-refresh interval in **seconds** — the renderer multiplies by 1000. Renamed from `refreshInterval`, which this table documented as milliseconds and which it never was (objectui#7783). | - -A widget's size is its `layout`: `w` and `h` are the grid columns and rows it spans, and `x` and `y` are its position on the editable grid (`DashboardGridLayout`). `layout` takes all four numbers or is left out. `colSpan`, `rowSpan` and `body` are **not** widget keys: `DashboardWidgetSchema` is strict (objectui#6002) and refuses all three by name. The size is `layout.w` / `layout.h`, and the content is `type` + `dataset` (with `options`) or `component`. - -A widget's data is a `dataset` (ADR-0021): `values` names the measures it shows and `dimensions` the dimensions it groups them by, both selected from the dataset by name. The family in `type` decides how the result is drawn: `metric` shows its one measure as a number; `table` lists a row per dimension value; a chart family (`area`, `bar`, `line`, `pie`, …) plots one series per measure over the dimension. A widget never carries rows. `options.data`, `options.xField` / `options.yField`, a metric's `options.value` / `options.description` / `options.trend`, and a `component` chart's `chartType` / `xAxisKey` / `series` are not widget keys, and `StrictAnyComponentSchema` refuses each of them by name (objectui#11228). The widget's own `description` is the subtitle under its `title` in the card header. - -**Related:** [GridSchema](#gridschema), [ChartSchema](#chartschema), [CardSchema](#cardschema) - ---- - -### CalendarViewSchema - -A multi-view calendar computed from the node's `data` records. There is no -authorable `events` key: the renderer builds one event per record in `data`, -reading the fields the field-name properties point at (objectui#5667; an -authored `events` is dropped by design, objectui#4433). - -```json -{ - "type": "calendar-view", - "view": "month", - "currentDate": "2024-03-15T12:00:00.000Z", - "data": [ - { - "id": "evt-1", - "title": "Team Standup", - "start": "2024-03-15T09:00:00", - "end": "2024-03-15T09:30:00", - "color": "#3b82f6" - }, - { - "id": "evt-2", - "title": "Sprint Review", - "start": "2024-03-15T14:00:00", - "end": "2024-03-15T15:00:00", - "color": "#8b5cf6", - "allDay": false - } - ], - "allowCreate": true, - "className": "h-[600px] border rounded-lg" -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `data` | `any` | Records rendered as events — an array, or a binding expression that resolves to one. | -| `titleField` | `string` | Record field for the event title. Default `"title"`. | -| `startDateField` | `string` | Record field for the event start date/time. Default `"start"`. | -| `endDateField` | `string` | Record field for the event end date/time. Default `"end"`. | -| `allDayField` | `string` | Record field for the all-day flag. Default `"allDay"`. | -| `colorField` | `string` | Record field for the event color. Default `"color"`. | -| `view` | `CalendarViewMode` | View mode: `"month"`, `"week"`, `"day"` — the full union. `"agenda"` was retired in `b55a34647` and now fails validation. Default `"month"`. | -| `currentDate` | `string \| Date` | Initial calendar date — an ISO date string when authored as JSON. | -| `allowCreate` | `boolean` | Show the "New event" affordance; clicking it dispatches a `create` action. Default `false`. | -| `onEventClick` | `function` | Host-only: forwarded when a React host supplies a function; authored JSON cannot produce one. | -| `onViewChange` | `function` | Host-only: same rule as `onEventClick`. | - -Nine formerly declared keys — `events` (was required, and dropped by the -renderer), `defaultView`, `defaultDate`, `date`, `views`, `editable`, -`onEventCreate`, `onEventUpdate`, `onDateChange` — were retired in -objectui#5667: nothing read them on the authored-node path. - -**Related:** [ObjectViewSchema](#objectviewschema), [DashboardComponentSchema](#dashboardcomponentschema) - ---- - -## View Schemas - -### DetailViewSchema - -An enhanced detail view for a single record with sections, tabs and navigation. - -```json -{ - "type": "detail-view", - "title": "Contact Details", - "objectName": "Contact", - "resourceId": "contact-123", - "layout": "grid", - "columns": 2, - "showBack": true, - "backUrl": "/contacts", - "showEdit": true, - "editUrl": "/contacts/contact-123/edit", - "showDelete": true, - "deleteConfirmation": "Are you sure you want to delete this contact?", - "sections": [ - { - "title": "Personal Information", - "icon": "User", - "columns": 2, - "collapsible": true, - "fields": [ - { "name": "firstName", "label": "First Name", "type": "text" }, - { "name": "lastName", "label": "Last Name", "type": "text" }, - { "name": "email", "label": "Email", "type": "email" }, - { "name": "avatar", "label": "Photo", "type": "image" } - ] - } - ], - "tabs": [ - { - "key": "activities", - "label": "Activities", - "icon": "Activity", - "badge": 5, - "content": { "type": "timeline", "items": [] } - } - ], - "actions": [ - { "type": "action", "label": "Send Email", "icon": "Mail", "level": "primary" } - ] -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `title` | `string` | Detail page title. | -| `objectName` | `string` | ObjectQL object name for data binding. | -| `resourceId` | `string \| number` | Record ID to display. | -| `api` | `string` | API endpoint to fetch record data. | -| `data` | `any` | Static data (if not fetching from API). | -| `layout` | `"vertical" \| "horizontal" \| "grid"` | Field layout mode. | -| `columns` | `number` | Grid columns (for grid layout). | -| `sections` | `DetailViewSection[]` | Field groups with `title`, `icon`, `fields`, `collapsible`. | -| `fields` | `DetailViewField[]` | Direct fields (without sections). | -| `tabs` | `DetailViewTab[]` | Tabbed content with `key`, `label`, `icon`, `badge`, `content`. | -| `related` | ⛔ **RETIRED** | Retired in objectui#7997 (ADR-0049 enforce-or-remove). Authoring it is now refused by name on both faces. Author a `record:related_list` block instead — see below. | -| `actions` | `ActionSchema[]` | Available actions. | -| `showHeader` | `boolean` | Draw the view's own heading — the title, follow-star and copy-id chip above the fields. Drawn unless `false`. `record:details` sets it from its own `showHeader` on the `detail-view` it builds, so the type is that spec row's. Declared since objectui#11355. | -| `showBack` / `backUrl` | `boolean` / `string` | Back navigation. | -| `showEdit` / `editUrl` | `boolean` / `string` | Edit navigation. | -| `showDelete` / `deleteConfirmation` | `boolean` / `string` | Delete with confirmation message. | -| `header` / `footer` | `SchemaNode` | Custom header/footer content. | - -> **Retired: `related`** (objectui#7997, ADR-0049 enforce-or-remove). -> Author a `record:related_list` block instead. -> -> Until objectui#7997 this block carried its own `related` array, and this page -> taught it with `{ "name": ..., "label": ... }` columns. That array is retired -> under ADR-0049 enforce-or-remove: it was a second entry to a capability -> `@objectstack/spec` already governs, it mirrored no protocol schema, and it -> drifted from the renderer it fed. Authoring it is now **refused by name** on -> both the TypeScript and the JSON face — it is not silently ignored. -> -> Related lists have one declared entry now, and it renders through the same component: -> -> ```json -> { -> "type": "record:related_list", -> "properties": { -> "objectName": "order", -> "relationshipField": "contact_id", -> "title": "Recent Orders", -> "columns": ["id", "total", "status"] -> } -> } -> ``` -> -> The block's props go in its `properties` bag, the spec's -> `ComponentPropsMap['record:related_list']` row: `objectui validate` and the -> spec's page component both refuse them written flat on the node. -> -> ⚠️ `columns` here is an array of **field-name strings**, not column objects — -> that is what the protocol declares (`RecordRelatedListProps.columns`), and the -> header and cell formatting are derived from the related object's schema, so a -> field label rename reaches the list for free. `relationshipField` names the -> field on the RELATED object that points back at this record, and replaces the -> retired form's `api` endpoint. -> -> ⚠️ The block reads the parent record from the record page's `RecordContext`, -> so author it on a record page. Placed anywhere it cannot resolve a parent id -> it scopes to nothing and renders an empty list. - -> **Due/deadline fields: `dueLike` and `settledWhen`.** A deadline is declared -> on the object's field metadata, never on a field entry: `dueLike` and its -> partner `settledWhen` are `@objectstack/spec`'s `FieldSchema.dueLike` and -> `FieldSchema.settledWhen`, both for `date` / `datetime` fields only. A -> `DetailViewField` entry carries neither — the detail view reads both from the -> object field it draws. See [Date Field](/docs/fields/date) and -> [Overdue Affordance](/docs/fields/datetime#overdue-affordance). -> -> `settledWhen` is a CEL predicate over the record (`record.status == 'done'`), -> accepted only beside `dueLike: true`. The detail view evaluates it against -> the record it shows: while it holds, the deadline is settled and the cell -> paints no overdue wording or colour. Nothing is inferred from a field's -> name: an undeclared date in the past reads as any past date does. - -**Related:** [DetailSchema](#detailschema), [ObjectViewSchema](#objectviewschema) - ---- - -### ViewSwitcherSchema - -A toggle control that switches between different view types (list, grid, kanban, calendar, etc.). - -```json -{ - "type": "view-switcher", - "defaultView": "list", - "variant": "tabs", - "position": "top", - "persistPreference": true, - "storageKey": "contacts-view-pref", - "views": [ - { - "type": "list", - "label": "List View", - "icon": "List", - "schema": { - "type": "object-grid", - "properties": { - "objectName": "Contact", - "columns": ["name", "email", "phone"] - } - } - }, - { - "type": "grid", - "label": "Card View", - "icon": "LayoutGrid", - "schema": { - "type": "grid", - "columns": 3, - "children": [] - } - }, - { - "type": "kanban", - "label": "Kanban", - "icon": "Kanban", - "schema": { - "type": "object-kanban", - "objectName": "tasks", - "groupBy": "status" - } - } - ] -} -``` - -| Property | Type | Description | -|----------|------|-------------| -| `views` | `array` | **Required.** Available views, each with `type`, `label`, `icon`, and `schema`. | -| `defaultView` | `ViewType` | Initially active view: `"list"`, `"detail"`, `"grid"`, `"kanban"`, `"calendar"`, `"timeline"`, `"map"`. | -| `activeView` | `ViewType` | Controlled active view. | -| `variant` | `"tabs" \| "buttons" \| "dropdown"` | Switcher UI style. | -| `position` | `"top" \| "bottom" \| "left" \| "right"` | Switcher position relative to content. | -| `persistPreference` | `boolean` | Save the user's view preference to storage. | -| `storageKey` | `string` | Storage key for persisting the preference. | -| `onViewChange` | `string` | Event name dispatched on `window` as a `CustomEvent` when the view changes (`detail: { view }`). An event NAME, not a callback or a handler expression. | - -**Related:** [ObjectViewSchema](#objectviewschema), [ObjectKanbanSchema](#objectkanbanschema), [CalendarViewSchema](#calendarviewschema) - ---- - -## Schema Composition - -Schemas are designed to compose. Nest any `SchemaNode` inside another to build complex interfaces: - -```json -{ - "type": "page", - "pageType": "app", - "title": "CRM Dashboard", - "children": [ - { - "type": "grid", - "columns": { "sm": 1, "lg": 2 }, - "gap": 6, - "children": [ - { - "type": "card", - "title": "Quick Stats", - "children": { - "type": "dashboard", - "columns": 2, - "widgets": [ - { "type": "metric-card", "title": "Leads", "value": "142" }, - { "type": "metric-card", "title": "Revenue", "value": "$24k" } - ] - } - }, - { - "type": "card", - "title": "Recent Activity", - "children": { - "type": "tabs", - "items": [ - { "value": "deals", "label": "Deals", "content": { "type": "table", "columns": [], "data": [] } }, - { "value": "tasks", "label": "Tasks", "content": { "type": "table", "columns": [], "data": [] } } - ] - } - } - ] - }, - { - "type": "object-grid", - "properties": { - "objectName": "Lead", - "title": "All Leads", - "showSearch": true, - "columns": ["name", "company", "status", "value"] - } - } - ] -} -``` - -## Type Imports - -Import only the types you need: - -```typescript -// Layout -import type { PageNodeSchema, DivSchema, CardSchema, GridSchema, TabsSchema } from '@object-ui/types'; - -// Forms -import type { FormSchema, InputSchema, SelectSchema, ButtonSchema } from '@object-ui/types'; - -// Data Display -import type { TableSchema, ChartSchema, TreeViewSchema } from '@object-ui/types'; - -// CRUD -import type { ActionSchema, DetailSchema } from '@object-ui/types'; - -// ObjectQL -import type { ObjectGridSchema, ObjectFormSchema, ObjectViewSchema } from '@object-ui/types'; - -// Complex -import type { DashboardComponentSchema, CalendarViewSchema } from '@object-ui/types'; - -// Views -import type { DetailViewSchema, ViewSwitcherSchema } from '@object-ui/types'; - -// Base -import type { BaseSchema, SchemaNode } from '@object-ui/types'; -``` - -## Next Steps - -- **[Schema Overview](/docs/guide/schema-overview)** — High-level guide to ObjectUI schemas -- **[Quick Start](/docs/guide/quick-start)** — Build your first ObjectUI application -- **[Expressions](/docs/guide/expressions)** — Dynamic expressions with `visibleWhen`, `hidden`, `disabled` -- **[Fields Guide](/docs/guide/fields)** — Deep dive into form fields -- **[Plugin Development](/docs/guide/plugin-development)** — Build custom schema renderers diff --git a/ui/content/docs/blocks/authentication.mdx b/ui/content/docs/blocks/authentication.mdx deleted file mode 100644 index cd30b8d9239..00000000000 --- a/ui/content/docs/blocks/authentication.mdx +++ /dev/null @@ -1,86 +0,0 @@ ---- -title: "Authentication Blocks" -description: "Login forms, signup pages, and authentication flows" ---- - -import { SchemaExample } from '@/app/components/ComponentDemo'; - -# Authentication Blocks - -Beautiful, accessible authentication forms and flows. Every block below is -sourced from the canonical schema catalog (`@object-ui/example-schema-catalog`), -which is smoke-tested on every CI run — if a schema breaks, the build fails. - -Copy the JSON from the **Code** tab and drop it into your application. - -## Login Form - -A clean login form with email and password fields, "remember me", and a -social-provider button. - - - -## Sign Up Form - -Two-column registration form with terms acceptance. - - - -## Forgot Password - -Request a password reset link by email. - - - -## Two-Factor Authentication - -Six-digit code verification with a resend link. - - - -## Customizing - -All authentication blocks are plain JSON — tweak any node to change the look or -behaviour. Common customisations: - -### Change Colors - -```json -{ - "type": "button", - "className": "bg-blue-600 hover:bg-blue-700" -} -``` - -### Wire Up Submission - -A control that runs something is its own node type, `action:button`, and its -props go in the `properties` bag; `actionType: "api"` sends the request to -`target`: - -```json -{ - "type": "action:button", - "properties": { - "label": "Sign In", - "actionType": "api", - "target": "/api/auth/login", - "method": "POST" - } -} -``` - -### Adjust Layout - -```json -{ - "type": "card", - "className": "max-w-lg p-8" -} -``` - -## Next Steps - -- Browse [Dashboard Blocks](/docs/blocks/dashboard) -- Explore [Form Blocks](/docs/blocks/forms) -- Learn about [Actions](/docs/core/enhanced-actions) diff --git a/ui/content/docs/blocks/dashboard.mdx b/ui/content/docs/blocks/dashboard.mdx deleted file mode 100644 index 791c6845c10..00000000000 --- a/ui/content/docs/blocks/dashboard.mdx +++ /dev/null @@ -1,79 +0,0 @@ ---- -title: "Dashboard Blocks" -description: "Stats cards, metrics, and dashboard layouts" ---- - -import { SchemaExample } from '@/app/components/ComponentDemo'; - -# Dashboard Blocks - -Professional dashboard components for displaying metrics, statistics, and data visualizations. - -## Stats Card - -Display key metrics with trend indicators. - - - -## Recent Activity - -Display recent user activities or transactions. - - - -## Overview Dashboard - -Complete dashboard layout with multiple sections. - - - -## Usage - -Customize dashboard blocks for your needs: - -### Add Real Data - -A `card` fetches nothing. A live figure comes from a data-bound block: an -`element:number` takes its object from the per-element `dataSource` binding and -its aggregate from its `properties` bag (see the -[Data Source guide](/docs/guide/data-source)): - -```json -{ - "type": "element:number", - "dataSource": { "object": "order" }, - "properties": { "aggregate": "count" } -} -``` - -### Customize Colors - -```json -{ - "type": "text", - "content": "+20.1%", - "className": "text-emerald-500" -} -``` - -### Add Actions - -A control that runs something is its own node type, `action:button`, and its -props go in the `properties` bag; `actionType: "url"` navigates to `target`: - -```json -{ - "type": "action:button", - "properties": { - "label": "View Details", - "actionType": "url", - "target": "/dashboard/details" - } -} -``` - -## Next Steps - -- Explore [Form Blocks](/docs/blocks/forms) -- View [Marketing Blocks](/docs/blocks/marketing) -- Learn about [Data Sources](/docs/guide/data-source) diff --git a/ui/content/docs/blocks/ecommerce.mdx b/ui/content/docs/blocks/ecommerce.mdx deleted file mode 100644 index b65f0599561..00000000000 --- a/ui/content/docs/blocks/ecommerce.mdx +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: "E-commerce Blocks" -description: "Product cards, checkout flows, and shopping components" ---- - -import { SchemaExample } from '@/app/components/ComponentDemo'; - -# E-commerce Blocks - -Professional e-commerce components for online stores and marketplaces. - -## Product Card - -Product display with image, title, price, and actions. - - - -## Product Grid - -Multiple products in a responsive grid. - - - -## Shopping Cart - -Cart summary with items and checkout. - - - -## Order Summary - -Checkout order review. - - - -## Usage - -Customize e-commerce blocks for your store: - -### Add Product Data - -A `card` fetches nothing. Records come from a data-bound block that names its -object in the per-element `dataSource` binding (see the -[Data Source guide](/docs/guide/data-source)): - -```json -{ - "type": "object-grid", - "dataSource": { "object": "product", "limit": 20 } -} -``` - -### Add to Cart Action - -A control that runs something is its own node type, `action:button`, and its -props go in the `properties` bag; `actionType: "api"` sends the request to -`target`: - -```json -{ - "type": "action:button", - "properties": { - "label": "Add to Cart", - "actionType": "api", - "target": "/api/cart", - "method": "POST" - } -} -``` - -### Customize Pricing Display - -```json -{ - "type": "text", - "content": "$299.99", - "className": "text-2xl font-bold text-primary" -} -``` - -## Next Steps - -- Browse [Authentication Blocks](/docs/blocks/authentication) -- Explore [Dashboard Blocks](/docs/blocks/dashboard) -- Learn about [Actions](/docs/core/enhanced-actions) diff --git a/ui/content/docs/blocks/forms.mdx b/ui/content/docs/blocks/forms.mdx deleted file mode 100644 index 7c347c09bc5..00000000000 --- a/ui/content/docs/blocks/forms.mdx +++ /dev/null @@ -1,126 +0,0 @@ ---- -title: "Form Blocks" -description: "Contact forms, settings panels, and form layouts" ---- - -import { SchemaExample } from '@/app/components/ComponentDemo'; - -# Form Blocks - -Professional form layouts for various use cases. All forms are accessible and include proper validation. - -## Contact Form - -Simple contact form with name, email, and message fields. - - - -## Settings Form - -User settings panel with various input types. - - - -## Newsletter Signup - -Simple newsletter subscription form. - - - -## Payment Form - -Payment details form with card information. - - - -## Usage - -Customize form blocks for your application: - -### Add Validation - -The blocks on this page are built from plain `input` nodes, and an `input` -declares its own validation keys — `required`, `pattern`, `maxLength`, `min`, -`max` and `step` — which the renderer forwards to the native HTML input -attributes: - -```json -{ - "type": "input", - "name": "email", - "inputType": "email", - "required": true, - "pattern": "^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$" -} -``` - -`inputType: "email"` already gets the browser's own email check; `pattern` -tightens it. The browser enforces these constraints when the input is submitted -inside a `

`. - -An `input` node has **no `validation` key**. Rule objects with custom messages -belong to the [form component](/docs/components/form/form), whose `fields[]` -entries carry them. Every rule there is a `{ value, message }` object, and -`validation.required` supplies the message only — whether the field is required -is decided by `required` on the field itself: - -```json -{ - "type": "form", - "fields": [ - { - "name": "message", - "type": "textarea", - "label": "How can we help?", - "required": true, - "validation": { - "required": "Please tell us how we can help", - "minLength": { "value": 20, "message": "Please use at least 20 characters" }, - "maxLength": { "value": 2000, "message": "Please keep it under 2000 characters" } - } - } - ] -} -``` - -`pattern` is the one rule that route cannot take from JSON: react-hook-form runs -it only when the rule's `value` is a compiled `RegExp`, and no JSON document can -hold one. In a TypeScript-authored schema pass the real thing — -`pattern: { value: /.../, message: '...' }`. For a form bound to an object, -declare the check on the object as a `format` validation rule -(see [Text Field](/docs/fields/text#format-validation)): the server runs it when -the record is saved, and an object field has no `pattern` key of its own. The -full rule table is in the [Form Plugin](/docs/plugins/plugin-form) reference. - -### Add Submit Action - -A control that runs something is its own node type, `action:button`, and its -props go in the `properties` bag; `actionType: "api"` sends the request to -`target`: - -```json -{ - "type": "action:button", - "properties": { - "label": "Submit", - "actionType": "api", - "target": "/api/contact", - "method": "POST" - } -} -``` - -### Customize Layout - -```json -{ - "type": "div", - "className": "grid grid-cols-1 md:grid-cols-2 gap-6" -} -``` - -## Next Steps - -- Browse [Marketing Blocks](/docs/blocks/marketing) -- Explore [E-commerce Blocks](/docs/blocks/ecommerce) -- Learn about [Form Validation](/docs/guide/fields) diff --git a/ui/content/docs/blocks/index.mdx b/ui/content/docs/blocks/index.mdx deleted file mode 100644 index ca989b2e75b..00000000000 --- a/ui/content/docs/blocks/index.mdx +++ /dev/null @@ -1,122 +0,0 @@ ---- -title: "Building Blocks" -description: "Clean, modern building blocks. Copy and paste into your apps. Open Source. Free forever." ---- - -import { SchemaExample } from '@/app/components/ComponentDemo'; - -# Building Blocks for the Web - -Pre-built, production-ready UI blocks powered by ObjectUI's schema-driven architecture. Copy the JSON, paste into your application, and ship faster. - -## What are Blocks? - -Blocks are complete, ready-to-use UI patterns built with ObjectUI components. Unlike individual components, blocks are composed sections like login forms, pricing tables, dashboard cards, and more—ready to be dropped into your application. - -### Quick Preview - - - - - - - -### Why Use Blocks? - -- 🚀 **Ship Faster** - Start with working examples instead of building from scratch -- 📋 **Copy & Paste** - Simple JSON schemas you can copy directly into your project -- 🎨 **Beautiful Design** - Built with Tailwind CSS and Shadcn UI -- ♿️ **Accessible** - WCAG 2.1 AA compliant -- 🌓 **Theme Ready** - Supports light and dark modes out of the box -- 📱 **Responsive** - Mobile-first design that works everywhere - -## Available Block Categories - -### [Authentication Blocks](/docs/blocks/authentication) - -Login forms, signup pages, password reset flows, and more. - -### [Dashboard Blocks](/docs/blocks/dashboard) - -Stats cards, metric displays, chart layouts for admin panels and dashboards. - -### [Form Blocks](/docs/blocks/forms) - -Contact forms, multi-step wizards, settings panels, and profile editors. - -### [Marketing Blocks](/docs/blocks/marketing) - -Pricing tables, feature grids, testimonials, CTAs, and hero sections. - -### [E-commerce Blocks](/docs/blocks/ecommerce) - -Product cards, shopping carts, checkout flows, and order summaries. - -## How to Use Blocks - -1. **Browse** the block categories to find what you need -2. **Preview** the block in action with live examples -3. **Copy** the JSON schema from the code tab -4. **Paste** into your ObjectUI application -5. **Customize** by modifying the schema to match your needs - -### Example: Using a Block - -All blocks are defined as JSON schemas. Here's how simple it is: - -```tsx -import { SchemaRenderer } from '@object-ui/react'; -import type { DeclaredNode } from '@object-ui/types'; - -// Copy this JSON from any block. `DeclaredNode` is a node of a declared type, -// so each key is checked against the type its `type` names. -const loginBlockSchema: DeclaredNode = { - type: "card", - className: "max-w-md mx-auto", - children: [ - // ... block schema here - ] -}; - -// Render it in your app -function App() { - return ; -} -``` - -## Customization - -Every block is fully customizable: - -- **Styling**: Add or modify `className` properties with Tailwind classes -- **Content**: Change text, labels, and placeholders -- **Layout**: Adjust spacing, sizing, and arrangement -- **Behavior**: Add actions, data sources, and expressions -- **Themes**: Works automatically with your theme configuration - -## Block Architecture - -All blocks follow ObjectUI best practices: - -- Built with composable components -- Use semantic HTML structure -- Follow accessibility guidelines -- Leverage Tailwind utility classes -- Support responsive breakpoints -- Include proper ARIA attributes - -## Open Source & Free Forever - -All blocks are open source under the MIT license. Use them in personal projects, commercial applications, or as inspiration for your own designs. - -## Next Steps - -- Browse [Authentication Blocks](/docs/blocks/authentication) -- Explore [Dashboard Blocks](/docs/blocks/dashboard) -- Check out [Form Blocks](/docs/blocks/forms) -- View [Marketing Blocks](/docs/blocks/marketing) -- Discover [E-commerce Blocks](/docs/blocks/ecommerce) - -## Contributing - -Have a block idea? We'd love to see it! Check out our [contribution guidelines](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md) to submit your own blocks. diff --git a/ui/content/docs/blocks/marketing.mdx b/ui/content/docs/blocks/marketing.mdx deleted file mode 100644 index cf7344e55a8..00000000000 --- a/ui/content/docs/blocks/marketing.mdx +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: "Marketing Blocks" -description: "Pricing tables, hero sections, and marketing components" ---- - -import { SchemaExample } from '@/app/components/ComponentDemo'; - -# Marketing Blocks - -Professional marketing components for landing pages and promotional content. - -## Pricing Cards - -Responsive pricing table with three tiers. - - - -## Feature Grid - -Showcase product features in a grid layout. - - - -## CTA Section - -Call-to-action section for conversions. - - - -## Testimonials - -Customer testimonials and social proof. - - - -## Usage - -Customize marketing blocks for your campaigns: - -### Update Colors - -```json -{ - "type": "card", - "className": "bg-gradient-to-r from-blue-600 to-purple-600" -} -``` - -### Customize Content - -```json -{ - "type": "text", - "content": "Your custom headline here", - "className": "text-4xl font-bold" -} -``` - -## Next Steps - -- Browse [E-commerce Blocks](/docs/blocks/ecommerce) -- Explore [Authentication Blocks](/docs/blocks/authentication) -- Learn about [Theming](/docs/guide/theming) diff --git a/ui/content/docs/blocks/meta.json b/ui/content/docs/blocks/meta.json deleted file mode 100644 index 6d9ecd7f28e..00000000000 --- a/ui/content/docs/blocks/meta.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "title": "Blocks", - "pages": [ - "authentication", - "dashboard", - "forms", - "marketing", - "ecommerce" - ] -} diff --git a/ui/content/docs/components/basic/button-group.mdx b/ui/content/docs/components/basic/button-group.mdx deleted file mode 100644 index 5dc91a4ccdc..00000000000 --- a/ui/content/docs/components/basic/button-group.mdx +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: "Button Group" -description: "Group multiple buttons together with shared styling" ---- - -The Button Group component groups multiple buttons together with consistent styling. - -## Basic Usage - - - -## Variants - - - -## Schema - -```ts -interface ButtonGroupButton { - label: string; // Button label (required) - variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'; - size?: 'default' | 'sm' | 'lg' | 'icon'; - disabled?: boolean; // Whether this button is disabled - onClick?: never; // RETIRED (objectui#6124) — author behaviour - // as a node type, e.g. an action:button node - className?: string; // Per-button CSS class -} - -interface ButtonGroupSchema { - type: 'button-group'; - buttons?: ButtonGroupButton[]; // Button definitions - - // Defaults applied to every button that does not set its own - variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'; - size?: 'default' | 'sm' | 'lg' | 'icon'; - - // States - disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope - - // Styling - className?: string; -} -``` - -## Examples - -### Toolbar Actions - - diff --git a/ui/content/docs/components/basic/div.mdx b/ui/content/docs/components/basic/div.mdx deleted file mode 100644 index 808f5f4374d..00000000000 --- a/ui/content/docs/components/basic/div.mdx +++ /dev/null @@ -1,107 +0,0 @@ ---- -title: "Div (Deprecated)" -description: "Generic container element - use Shadcn components instead" ---- - -
-

⚠️ Deprecated Component

-

- The Div component is deprecated. Please use Shadcn components instead: -

-
    -
  • For a plain wrapper: use box — the one drop-in swap (same element, your className verbatim, no layout of its own)
  • -
  • When you want their layout: use card, flex, container, stack, or grid — each injects classes of its own, and card also moves children into an extra element
  • -
-

- Move any content authored under the retired body key into children first: - objectui#6771 retired that child-list spelling, so validation refuses it by name, and neither - div nor any replacement listed above draws it. -

-
- -The Div component is a generic container element. **It is now deprecated in favor of semantic Shadcn-based components** that provide better accessibility, consistency, and design system integration. - -**Scope of this deprecation: both authoring surfaces.** A `kind:'html'` page is written -as constrained JSX text that the engine compiles (parses, never executes) into nodes, -tag name straight through. On that tier `
` is **refused**: the page fails to -compile, and the error names `box` as the replacement. `os validate` gives the same -verdict, since the published manifest does not declare `div` for the html tier, but its -refusal is a plain `forbidden-tag` error that does not name `box`. Author `` on an -html page exactly where you would author `box` in JSON. The -dev-build deprecation notice covers JSON-authored nodes, and the compile error covers -html pages. - -Styling on that tier is the blocks' own structured props (``, -``) plus a JSON `style` object — **not** Tailwind utility classes, which -produce no CSS at all when authored in page `source`. `os validate` reports that as -`page-source-className-tailwind`. See the React Pages guide, §Styling. - -## Migration Guide - -Which replacement is right depends on whether you want layout or only a wrapper. -Measured through the real renderer, on a node carrying an authored `className`: -`box` reproduces `div` exactly, while `card`, `flex`, `container`, `stack` and -`grid` each add classes of their own — and `card` additionally moves the children -inside an extra element. So a mechanical `div` → `card`/`flex`/`grid` swap changes -the page; a `div` → `box` swap does not. - -One caveat for a node still authored in the retired `body` spelling: every -option reads `children` and never `body` (objectui#6771 retired it across the -protocol, `card` included), and so does `div` itself. Such a node must move that -content into `children` as part of the swap. Until it does, validation refuses -the key by name, and the content is missing from the page under `div` and under -every replacement alike. - -### For a Plain Wrapper → Use `box` - -The mechanical swap. `box` renders `children` inside a plain block element with -your `className` passed through verbatim and nothing injected, so retyping a -`div` node to `box` is the one conversion that leaves the rendered result alone. - - - -### For Card-like Containers → Use `card` - - - -### For Flex Layouts → Use `flex` - - - -### For Grid Layouts → Use `grid` - - - -## Old Examples (Deprecated) - -Below are the old `div` examples. Please migrate to the alternatives shown above. - -## Schema - -```plaintext -// ⚠️ DEPRECATED - Use Card, Flex, Grid, or other layout components instead -interface DivSchema { - type: 'div'; - - // Content - children?: SchemaNode | SchemaNode[]; // Child components (the child key this component reads) - - // Styling - className?: string; // Tailwind CSS classes - - // Base properties - id?: string; - visible?: boolean | string | { dialect?: string; source: string }; - testId?: string; -} -``` - -## Legacy Examples (For Reference Only) - -### Old Card Layout Example - - - -### Nested Containers - - diff --git a/ui/content/docs/components/basic/icon.mdx b/ui/content/docs/components/basic/icon.mdx deleted file mode 100644 index 918956ea658..00000000000 --- a/ui/content/docs/components/basic/icon.mdx +++ /dev/null @@ -1,69 +0,0 @@ ---- -title: "Icon" -description: "Display icons from the Lucide icon library" ---- - -## Examples - - - - - - - -## Schema - -```ts -interface IconSchema { - type: 'icon'; - icon: string; // Lucide glyph name (kebab-case), REQUIRED - size?: number | 'sm' | 'md' | 'lg' | 'xl'; - color?: string; // Tailwind color class - className?: string; -} -``` - -The glyph is named by `icon`: - -```json -{ "type": "icon", "icon": "check" } -``` - -## `name` is identity, not the glyph - -`name` is the SDUI identity key every node carries, alongside `id`. It is **not** -read as a glyph name on `ui:icon`, and writing one there renders no icon: - -{/* doc-json: fragment — a deliberately refused node: it shows the retired name-as-glyph shape the paragraph below describes */} -```json -{ "type": "icon", "id": "save_icon", "name": "save_icon" } -``` - -That node is **refused** by `IconSchema` — with a message naming this rename — -and, if it reaches the renderer unvalidated, draws a dashed-square placeholder -with an accessible name saying which icon failed, rather than rendering nothing. -There is deliberately no fallback from `icon` to `name`: a key that means -"identity" sometimes and "glyph" other times, depending on whether a lucide -lookup happened to hit, is the ambiguity this contract removes. - -### Migrating stored metadata - -Metadata authored before this change names its glyph with `name`. Convert it in -bulk — once, at the source — with the converter shipped for it: - -```ts -import { migrateIconNodeKeys } from '@object-ui/types'; - -declare const storedPage: unknown; -declare function save(document: unknown): Promise; - -const { document, converted, warnings } = migrateIconNodeKeys(storedPage); -console.log(`converted ${converted} icon node(s)`); -if (warnings.length) console.warn(warnings.map((w) => w.message).join('\n')); -if (document !== storedPage) await save(document); -``` - -It walks the whole document, lifts `name` to `icon` on every icon node, and -**reports rather than guesses** for the two cases it will not touch: a node that -already declares both keys (`icon` wins; `name` stays the identity it is), and a -node that names no glyph at all. diff --git a/ui/content/docs/components/basic/image.mdx b/ui/content/docs/components/basic/image.mdx deleted file mode 100644 index e59e943efc0..00000000000 --- a/ui/content/docs/components/basic/image.mdx +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: "Image" -description: "Display images with optional fallback" ---- - -## Examples - - - - - -## Schema - -```ts -interface ImageSchema { - type: 'image'; - src: string; - alt?: string; - width?: number | string; - height?: number | string; - fallback?: string; - fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down'; -} -``` diff --git a/ui/content/docs/components/basic/meta.json b/ui/content/docs/components/basic/meta.json deleted file mode 100644 index 4268c87c668..00000000000 --- a/ui/content/docs/components/basic/meta.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "title": "Basic", - "pages": [ - "text", - "icon", - "image", - "separator", - "button-group", - "pagination", - "navigation-menu", - "sidebar", - "div", - "span" - ] -} diff --git a/ui/content/docs/components/basic/navigation-menu.mdx b/ui/content/docs/components/basic/navigation-menu.mdx deleted file mode 100644 index c5d9e8d991f..00000000000 --- a/ui/content/docs/components/basic/navigation-menu.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: "Navigation Menu" -description: "Accessible navigation menu with dropdown support" ---- - -The Navigation Menu component provides an accessible menu for site navigation. - -## Basic Usage - - - -## With Descriptions - - - -## Schema - -```ts -interface NavigationMenuItem { - label: string; - href?: string; - description?: string; - icon?: string; - items?: NavigationMenuItem[]; // Submenu items -} - -interface NavigationMenuSchema { - type: 'navigation-menu'; - items: NavigationMenuItem[]; // Menu items - className?: string; -} -``` diff --git a/ui/content/docs/components/basic/pagination.mdx b/ui/content/docs/components/basic/pagination.mdx deleted file mode 100644 index c7723ad2f99..00000000000 --- a/ui/content/docs/components/basic/pagination.mdx +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: "Pagination" -description: "Navigate through pages of content" ---- - -The Pagination component allows users to navigate through pages of data. - -## Basic Usage - - - -## On a Later Page - - - -`pagination` has no page-size or item-count keys: the page state it reads is -`currentPage` and `totalPages`. - -## Schema - -```ts -interface PaginationSchema { - type: 'pagination'; - currentPage: number; // Current page (1-based) - totalPages: number; // Total number of pages - - // Events - // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it - // by name. Author behaviour as a node type, e.g. an action:button node. - onPageChange?: (page: number) => void; - - // Styling - className?: string; -} -``` diff --git a/ui/content/docs/components/basic/separator.mdx b/ui/content/docs/components/basic/separator.mdx deleted file mode 100644 index 81f4160a067..00000000000 --- a/ui/content/docs/components/basic/separator.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: "Separator" -description: "Visual divider between content sections" ---- - -## Examples - - - - - -## Schema - -```ts -interface SeparatorSchema { - type: 'separator'; - orientation?: 'horizontal' | 'vertical'; - className?: string; -} -``` diff --git a/ui/content/docs/components/basic/sidebar.mdx b/ui/content/docs/components/basic/sidebar.mdx deleted file mode 100644 index b9c85c97b8f..00000000000 --- a/ui/content/docs/components/basic/sidebar.mdx +++ /dev/null @@ -1,70 +0,0 @@ ---- -title: "Sidebar" -description: "An app-level sidebar region that works in any host" ---- - -The `sidebar` node draws one sidebar region and renders its `children` inside it, in order. - -It works in any host. A host that already mounts a sidebar provider, such as the app shell, keeps its own: the node uses that provider rather than mounting a second one. In a host without one, the node mounts its own. - -## Basic Usage - - - -## With Groups - -A group is a heading `text` node followed by its items. A `separator` node divides two groups. - - - -## The `collapsible` key - -`collapsible` is a boolean. - -- `false` draws the sidebar in the page flow, at a fixed width. It does not follow the host's open state, so it is visible in every host. Every example on this page authors it. -- `true`, or leaving the key out, selects the collapsible form instead. That form is not drawn in the page flow: it is positioned against the edge of the viewport, at full viewport height, so it does not appear where the schema places it. Author `false` for a sidebar that does. - - - -## The `side` key - -`side` picks the edge of the viewport the collapsible form is drawn against: `left`, the default, or `right`. On the right edge the sidebar's border moves to its left side, and on a narrow viewport the sheet slides in from the right. - -```json -{ - "type": "sidebar", - "side": "right", - "children": [ - { "type": "text", "content": "Details", "className": "px-2 text-xs text-muted-foreground" } - ] -} -``` - -`side` shows on the collapsible form only, as `variant` does. With `collapsible: false` the sidebar is drawn in the page flow, where the page's layout places it, so every value draws the same column. - -## Schema - -```ts -import type { SidebarSchema } from '@object-ui/types'; - -const sidebar: SidebarSchema = { - type: 'sidebar', - collapsible: false, - className: 'gap-1 border-r p-2', - children: [ - { type: 'text', content: 'Menu', className: 'px-2 text-xs text-muted-foreground' }, - { type: 'button', label: 'Dashboard', variant: 'ghost', className: 'w-full justify-start' }, - { type: 'button', label: 'Settings', variant: 'ghost', className: 'w-full justify-start' }, - ], -}; -``` - -The renderer reads `collapsible` and `children`, and passes `side`, `variant` and `className` to the sidebar element. - -## Examples - -### With Badges - -Each item is a `flex` row holding a `text` node and a `badge` node. - - diff --git a/ui/content/docs/components/basic/span.mdx b/ui/content/docs/components/basic/span.mdx deleted file mode 100644 index 14b0d69f4e3..00000000000 --- a/ui/content/docs/components/basic/span.mdx +++ /dev/null @@ -1,79 +0,0 @@ ---- -title: "Span (Deprecated)" -description: "Inline container - use Badge or text components instead" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -
-

⚠️ Deprecated Component

-

- The Span component is deprecated. Please use Shadcn components instead: -

-
    -
  • For badges/labels: use badge component
  • -
  • For inline text emphasis: use text component with appropriate className
  • -
-
- -The Span component is an inline container. **It is now deprecated in favor of semantic Shadcn-based components** that provide better accessibility and design system integration. - -**Scope of this deprecation: JSON-authored pages.** A `kind:'html'` page is written as -constrained JSX text that the engine compiles (parses, never executes) into -nodes, tag name straight through — there, the plain inline tag is part of that tier's own -vocabulary and stays fully supported, because no other spelling of it exists for an -author to migrate to. The dev-build deprecation notice is therefore reported for -JSON-authored nodes only, and never for what the html tier compiled from its own source. - -Styling on that tier is the blocks' own structured props (``, -``) plus a JSON `style` object — **not** Tailwind utility classes, which -produce no CSS at all when authored in page `source`. `os validate` reports that as -`page-source-className-tailwind`. See the React Pages guide, §Styling. - -## Migration Guide - -Instead of using `span`, use these Shadcn alternatives: - -### For Badges → Use `badge` - - - - - - -### For Status Indicators → Use `badge` with variants - - - -### For Inline Text → Use `text` with className - - - -## Old Examples (Deprecated) - -Below are the old `span` examples. Please migrate to the alternatives shown above. - -## Schema - -```plaintext -// ⚠️ DEPRECATED - Use Badge or Text components instead -interface SpanSchema { - type: 'span'; - - // Content - value?: string; // Text content - children?: SchemaNode | SchemaNode[]; // Child components (the child key this component reads) - // Both keys are read. When both are authored, child content wins and `value` - // is ignored (the richer key renders, the scalar does not). - - // Styling - className?: string; // Tailwind CSS classes - - // Base properties - id?: string; - visible?: boolean | string | { dialect?: string; source: string }; - testId?: string; -} -``` - -## Legacy Examples (For Reference Only) diff --git a/ui/content/docs/components/basic/text.mdx b/ui/content/docs/components/basic/text.mdx deleted file mode 100644 index f9d467fb8e2..00000000000 --- a/ui/content/docs/components/basic/text.mdx +++ /dev/null @@ -1,76 +0,0 @@ ---- -title: "Text" -description: "Display text with standardized typography styles" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -The Text component displays text content with predefined typography variants. - -`variant` is the published vocabulary of `TextSchema` and the renderer maps every -value in it: `h1`-`h6` render the heading element they name, `body`, `caption` and -`overline` render an inline span. Any other spelling is refused by the validator -rather than ignored. - -## Basic Usage - - - -## Variants - - - - - - - - - - - -## Schema - -```plaintext -interface TextSchema { - type: 'text'; - content: string; // Text content to display — the one spelling - variant?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'body' | 'caption' | 'overline'; - align?: 'left' | 'center' | 'right' | 'justify'; - className?: string; // Additional CSS classes -} -``` - -> **Retired: `value`** (objectui#6951, ADR-0049 enforce-or-remove). `value` was a -> second spelling of the one content slot — the renderer read it only as the -> fallback of `content || value` — and it is no longer part of `TextSchema` on -> either face. A `text` node that authors `value` now fails validation with: -> -> > RETIRED (ADR-0049) — `value` is no longer part of TextSchema; write -> > `content`. … -> -> Rename the key to `content`; the string is unchanged. Measured before the -> retirement: 776 `content`-only `text` nodes against 25 `value`-only across -> `examples/`, `apps/`, `packages/*/examples` and `content/docs/**`. - -## Examples - -### Colored Text - -Colour is `className`, not a key of its own. `TextSchema` publishes no `color`, -and the renderer builds the element's class list from `variant`, `align` and -`className` alone — so the three tiles below author -`"className": "text-primary"`, `"text-destructive"` and -`"text-muted-foreground"` on the `text` nodes themselves. - -⚠️ This page taught a `color` key until objectui#7444. Nothing ever declared or -read it: `BaseSchema` is `.passthrough()`, so an authored `color` still renders -without error and still does nothing — it does not even reach the element, -because `color` is not on the DOM pass-through whitelist. `objectui validate` -refuses it by name. Rename the key to `className`; the Tailwind class string is -unchanged. - - - -### Text Alignment - - diff --git a/ui/content/docs/components/complex/carousel.mdx b/ui/content/docs/components/complex/carousel.mdx deleted file mode 100644 index c37e0460471..00000000000 --- a/ui/content/docs/components/complex/carousel.mdx +++ /dev/null @@ -1,60 +0,0 @@ ---- -title: "Carousel" -description: "Slideshow component for cycling through content" ---- - -The Carousel component displays content in a slideshow format with navigation controls. - -## Basic Usage - - - -## Without Arrows - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface CarouselSchema { - type: 'carousel'; - - // Configuration - orientation?: 'horizontal' | 'vertical'; // Carousel direction (default: 'horizontal') - showArrows?: boolean; // Show navigation arrows (default: true) - - // Items - items: SchemaNode[][]; // Array of slides (each slide is an array of components) - - // Options - opts?: { - loop?: boolean; // Enable infinite loop - align?: 'start' | 'center' | 'end'; // Slide alignment - }; - - // Styling - className?: string; // Container CSS classes - itemClassName?: string; // Individual slide CSS classes - - // Base properties - id?: string; - visible?: boolean | string | { dialect?: string; source: string }; - testId?: string; -} -``` - -## Examples - -### Image Gallery - - - -### Product Showcase - - - -### Testimonials - - diff --git a/ui/content/docs/components/complex/data-table.mdx b/ui/content/docs/components/complex/data-table.mdx deleted file mode 100644 index 4e8cdf163ad..00000000000 --- a/ui/content/docs/components/complex/data-table.mdx +++ /dev/null @@ -1,309 +0,0 @@ ---- -title: "Data Table" -description: "Enterprise-grade data table with sorting, filtering, pagination, and row actions" ---- - -The Data Table component provides comprehensive table functionality inspired by Airtable, including multi-column sorting, search, pagination, row selection, and export capabilities. - -## Basic Usage - - - -## With All Features - - - -## Schema - -```ts -interface TableColumn { - header: string; // Column header text - accessorKey: string; // Data property key - width?: string; // Column width (e.g., '100px', '20%') - sortable?: boolean; // Enable sorting for this column - masked?: boolean; // Withhold the raw value (see Masked columns) - chips?: boolean; // Cells draw chips: never narrower than the widest one (see Chip columns) - foreignKey?: boolean; // Raw value is a referenced record's id: the cell draws no title tooltip -} - -interface DataTableSchema { - type: 'data-table'; - - // Structure - columns: TableColumn[]; // Column definitions - data: Record[]; // Table data - caption?: string; // Table caption - - // Features - pagination?: boolean; // Enable pagination (default: true) - pageSize?: number; // Items per page (default: 10) - searchable?: boolean; // Enable search (default: true) - selectable?: boolean; // Enable row selection (default: false) - sortable?: boolean; // Enable sorting (default: true) - exportable?: boolean; // Enable CSV export (default: false) - rowActions?: boolean; // Show edit/delete buttons (default: false) - - // Advanced features - resizableColumns?: boolean; // Allow column resizing (default: true) - reorderableColumns?: boolean; // Allow column reordering (default: true) - - // Styling - className?: string; // Tailwind CSS classes on the table wrapper - cellClassName?: string; // Tailwind CSS classes on the utility cells only - // (select / row number / row actions) - - // Base properties - id?: string; - visible?: boolean | string | { dialect?: string; source: string }; - testId?: string; -} -``` - -## Cell styling - -`className` styles the table wrapper. Body cells have **two** class slots, and they -reach **disjoint** cells — neither is a superset of the other, and no cell gets both: - -- `TableColumn.cellClassName` — the **data** cells of that one column. -- `DataTableSchema.cellClassName` — the table's **utility** cells only: the leading - selection-checkbox cell (`selectable`), the row-number cell (`showRowNumbers` without - `selectable`), and the trailing row-actions cell (`rowActions`). It never reaches a - data cell. - -With `selectable` and `showRowNumbers` both on, the number and the checkbox share one -leading cell: the number by default, the checkbox on row hover, on keyboard focus, on a -touch screen and on every row while any row is selected. The header keeps its -select-all box. With row numbers off the leading cell is empty until hover. The -row-actions button shows on hover, on focus and on a selected row, and is hidden by -opacity only, so it stays reachable by keyboard. - -(The empty-state cell and the add-record row take neither.) - -Row density is therefore a **pair** of settings, not one. Per-cell padding is where -row height has to be expressed — height is a property of the cells, not of the row -element, so `rowClassName` cannot express it — so a compact table sets the same -density class on every column *and* on the schema, the second so the checkbox and -row-number cells stay the same height as the data beside them. That is exactly what -`object-grid` does for its `rowHeight` modes: - -```json -{ - "type": "data-table", - "selectable": true, - "showRowNumbers": true, - "cellClassName": "px-2 py-1 text-sm", - "columns": [ - { "header": "Name", "accessorKey": "name", "cellClassName": "px-2 py-1 text-sm" }, - { "header": "Amount", "accessorKey": "amount", "cellClassName": "px-2 py-1 text-sm text-right" } - ], - "data": [ - { "name": "Ada Lovelace", "amount": 120 }, - { "name": "Grace Hopper", "amount": 340 } - ] -} -``` - -Drop the per-column half and the data cells stay at the table primitive's default -`p-4` — the schema-level key alone does not compact a row. - -## Inline editing - -Inline editing is a **host-paired** capability of this table, not a flag a document -sets. The table only *stages* an edit; saving it is the job of callbacks that a host -supplies in code (`onRowSave`, `onCellChange`, `onBatchSave`), and a JSON document -cannot supply a function. So the keys that switch editing on — `editable`, and -`singleClickEdit` for entering edit mode on the first click instead of a -double-click — are set by the host on the `data-table` node it builds, together with -that save path. `object-grid` is that host: it sets both keys on its table and -passes its own save path, which writes through the data source. - -**Do not author `editable` or `singleClickEdit` on a `data-table` node.** Without a -host save path the edits are staged in the table and nothing persists them. To edit -records inline from a document, author an `object-grid` instead — see -[Inline Editing](/docs/plugins/plugin-grid#inline-editing) in the grid plugin. - -When a host has switched editing on, a cell enters edit mode on double-click (or on -a single click) and the table renders one of its built-in editors — text, number, -date — chosen from the column's `type`. - -"Save All" hands every pending row to `onBatchSave` at once, and reads the outcome -per row. A batch is several writes, and some can land while others are refused. A -host that knows which rows did not persist resolves with `{ failed }`, naming each -by the `rowIndex` it was handed: the table keeps and flags exactly those rows, and -clears the rest. Resolving with nothing means every row persisted. A rejection -says nothing about which rows landed, so the table keeps and flags every row. - -A pending edit belongs to its **record**, not to the position its row had: the -table keeps it under the row's `id` (or, for a row with no `id`, under the row -object itself). When the host hands the table a new read of its rows, in any -order, every edit is drawn, saved and flagged on its own record. An edit whose -record the new rows no longer include (a re-read dropped it, or it sits on -another server page) stays pending and is counted in the toolbar. "Save All" -hands it over with the row it was staged against and a negative `rowIndex` -(`-1`, `-2`, …, unique within the batch), since no position in the rows the -table holds is that record's. Every other entry's `rowIndex` is the row's index -when "Save All" is pressed. - -Cells stay editable while a row save or "Save All" is in flight. When the save -settles, it clears only what it sent: each cell whose pending value is still the -value it sent. An edit made during the save stays pending and drawn, whether it is -on another row, on another cell of the row being saved, or a new value for a cell -being saved (even the value the row loaded with, since the save writes the old -edit). The settle closes only the editor that was open when the save was pressed. - -The host can also supply `renderCellEditor`, a function the table calls first for -every cell it is about to edit; it returns a node to use, or `null` to fall through -to the built-in editor for that column. This is how `object-grid` gives a `select` -or `lookup` cell the same dedicated control the form uses, without the component -layer having to re-implement it. Its context is: - -```plaintext -renderCellEditor?: (ctx: { - column: any; - row: any; // the persisted record - pendingRow: any; // row + this row's staged, unsaved edits (#7188) - value: any; - stage: (v: any) => void; - commit: (v?: any) => void; - cancel: () => void; -}) => ReactNode; -``` - -The returned node is wrapped by the table so it inherits the exit-edit -affordances the built-in editors have: Enter commits from a single-line input, -Escape cancels, and a click outside commits. Use `stage` to record a value while -staying in edit mode, `commit` to save, and `cancel` to discard. - -The context carries the row twice, on purpose: `row` is the persisted record, and -`pendingRow` is that record with the row's staged, unsaved edits merged over it -(the same object as `row` when nothing is staged). A widget that scopes itself by -a sibling field — a `dependsOn` lookup — should read `pendingRow`, so a parent -edited in the same row re-scopes the child before anything is saved; `row` stays -the place to read what the data source last returned. - -## Keyboard navigation - -`keyboardNavigation` is set by the host in code too, beside `editable`, and is off -unless a host turns it on. `object-grid` is that host: it resolves its own -`keyboardNavigation` key (on by default when the grid renders editable) and sets -the answer on its table — see -[Keyboard navigation](/docs/plugins/plugin-grid#keyboard-navigation) in the grid -plugin (objectui#11068). - -On, the table follows the WAI-ARIA grid pattern: it is exposed as a `grid`, its -data cells take one place in the Tab order instead of one each (the cell that last -held focus), the arrow keys move focus one cell, Home / End go to the ends of the -row and Ctrl+Home / Ctrl+End to the ends of the page, and an edit ended with Enter -or Escape hands focus back to its cell. A widget a cell renders keeps its own Tab -stop. Off, every data cell is its own Tab stop, as before. - -## Chip columns - -A column with `chips: true` draws chips in its cells (a select or status field's -badges). Its minimum width is its widest drawn chip among the rows on screen, -measured once each time the data, the page or the page size changes, so a -single-word value such as "Published" is never cut to an ellipsis. The floor -raises a declared `width` and a user's resize alike; a wider `width` is kept. -Text columns have no floor: they keep their width and truncate first. -`ObjectGrid` sets the flag on select-typed columns. - -## Wide tables - -A table wider than its box shows a shadow at the right edge while columns remain -to the right, and none once it is scrolled to the end. With a right-pinned column -(the grid's Actions column) the shadow sits at that column's left edge, where the -content slides under it. The scrollbar shows while the pointer is over the table -or focus is inside it. The same affordance is exported for a page's own table as -`HorizontalOverflowBox`: - -```tsx -import { HorizontalOverflowBox, Table } from '@object-ui/components'; - - …
-
-``` - -## Masked columns - -A column with `masked: true` holds a credential. The table withholds its raw value on -every path of its own that reads it: - -- Ctrl+C / Cmd+C on one of its cells writes nothing to the clipboard. -- The cell carries no `title` tooltip. -- The table's CSV export leaves the column out. -- The column never enters inline edit, on any trigger, because every editor would - start from the raw value. -- The client-side search leaves the column out, so a term only its raw value contains - matches no row. -- Its sort is disabled: the header does not sort and the header menu offers no sort, - with client and manual sorting alike, and a client sort already set on the column - stops ordering the rows. An order by the raw value would show how it compares with - every other row's. -- Its automatic width is sized from its header, never from its values, so it does not - grow with the credential. - -Every other column copies, shows, exports, edits, searches, sorts and sizes exactly as -before. With `manualSearch` the table searches nothing itself: what the host's search -matches is up to the host. - -What the flag does **not** cover: the CSV export of a lookup column whose value is an -expanded record writes that record whole, so a credential field of the related object -is not pruned. - -The flag withholds; it does not draw. What a reader sees in the cell comes from the -producer's `cell` renderer, and the table draws a column with no `cell` as its value. -You rarely set the flag by hand. Three producers set it for every column whose field -type is drawn as a mask (`password`, `secret`, or a type registered with the mask), -from `isMaskedFieldType()` in `@object-ui/fields`: `object-grid`, the related list and -`object-data-table`. Each reads both the column's `type` and the object's field type, -so a column that shows a `secret` field as `text` keeps the flag once the object's -types are known. - -The related list and `object-data-table` fail closed: while the object definition is -still loading, or after its read failed, they set the flag on every column. - -A column whose type is not known yet is **withheld** (objectui#10657, which folded -objectui#10706). While the object's field types are loading, or after their read -failed, `object-grid`, the related list and `object-data-table` draw such a column as -the mask, never as text, and set the flag on it; it stays withheld when the read -failed. In `object-grid` and `object-data-table` that is a column with no `type` of its -own. The related list draws its cells from the object's types only, so there it is -every column without a `cell` of its own. A host that already holds the object's fields -hands them to `object-grid` as its `objectFields` prop, as the list view does, and then -there is nothing to withhold. - -`object-grid` also leaves every masked or withheld field of the grid's object out of its -own client export (CSV and JSON), draws those fields through their `cell` on its mobile -card, and refuses them as grouping keys (a masked entry is ignored with a console -warning; a withheld one groups again once the types are known). What `object-grid` does -not cover: the server-streamed export (`exportDownload`) sends the masked columns as -before and relies on the server's masking, and the client JSON export writes an -expanded lookup record whole, so a credential field of the related object is not -pruned. - -## Examples - -### Product Inventory - - - -### User Management - - - -### Sales Report - - - -## Features - -The Data Table component provides: - -- **Sorting**: Click column headers to sort (ascending/descending/none) -- **Search**: Real-time search across all columns -- **Pagination**: Navigate through large datasets -- **Selection**: Select multiple rows with checkboxes -- **Export**: Download table data as CSV -- **Row Actions**: Built-in edit and delete buttons -- **Resizable Columns**: Drag column borders to resize -- **Column Reordering**: Drag columns to reorder diff --git a/ui/content/docs/components/complex/filter-builder.mdx b/ui/content/docs/components/complex/filter-builder.mdx deleted file mode 100644 index 3c2a2797e0d..00000000000 --- a/ui/content/docs/components/complex/filter-builder.mdx +++ /dev/null @@ -1,116 +0,0 @@ ---- -title: "Filter Builder" -description: "Visual query builder for constructing complex filter conditions" ---- - -The Filter Builder component provides a visual interface for creating filter queries: a flat list of conditions combined with AND or OR, over multiple operators. - -## Basic Usage - - - -## With Initial Conditions - - - -## Schema - -```ts -interface FilterField { - value: string; // Field identifier - label: string; // Display label - type?: - | 'text' | 'number' | 'currency' | 'percent' | 'rating' - | 'date' | 'datetime' | 'time' - | 'boolean' - | 'select' | 'status' - | 'lookup' | 'master_detail' | 'user'; // Field type - options?: Array<{ value: string; label: string }>; // Static options (select-like) - // Lookup-like fields without `options` render a remote-search picker that - // queries the configured DataSource. The metadata below describes how to - // resolve and display referenced records: - referenceTo?: string; // Referenced object name (e.g. "user") - displayField?: string; // Display field on referenced object (default "name") - idField?: string; // Id field on referenced object (default "id") -} - -interface FilterCondition { - id: string; // Condition identifier - field: string; // Field to filter on - operator: string; // Comparison operator - value: any; // Filter value -} - -interface FilterGroup { - id?: string; // Group identifier (optional) - logic: 'and' | 'or'; // Logic operator (AND/OR) - conditions: FilterCondition[]; // Flat condition rows (no nested groups) -} - -interface FilterBuilderSchema { - type: 'filter-builder'; - - // Required - name: string; // Field name for form submission - fields: FilterField[]; // Available fields for filtering - - // Configuration - label?: string; // Component label - value?: FilterGroup; // Initial/controlled filter value - - // Events - // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it - // by name. Author behaviour as a node type, e.g. an action:button node. - onChange?: (filter: FilterGroup) => void; // Change handler - - // Styling - className?: string; // Tailwind CSS classes - wrapperClass?: string; // Wrapper container classes - - // Base properties - id?: string; - visible?: boolean | string | { dialect?: string; source: string }; - testId?: string; -} -``` - -## Retired: nested groups, `allowGroups` and `maxDepth` - -A filter group is one level deep. Every entry of `conditions` is one field / -operator / value row, and the group's `logic` combines all of them. The builder -never drew a nested group: it showed a sub-group as a row with blank field and -operator selects and did not show the sub-group's own conditions. Nested -sub-groups and the two keys that configured them, `allowGroups` and `maxDepth`, -are therefore retired (objectui#9306). Validation now refuses each of them by -name. Write the rows into the one group and remove the two keys. - -## Examples - -### Product Filters - - - -### User Query Builder - - - -### Advanced Query - - - -## Supported Operators - -The Filter Builder supports various comparison operators based on field type: - -- **Text fields**: equals, not equals, contains, starts with, ends with -- **Number fields**: equals, not equals, greater than, less than, between -- **Date fields**: equals, before, after, between -- **Boolean fields**: is true, is false - -## Use Cases - -- **Search interfaces**: Build advanced search forms -- **Data filtering**: Filter large datasets in tables -- **Report generation**: Create custom report criteria -- **Query builders**: Visual SQL/NoSQL query construction -- **Access control**: Define permission rules diff --git a/ui/content/docs/components/complex/filter-ui.mdx b/ui/content/docs/components/complex/filter-ui.mdx deleted file mode 100644 index 59bdc00d000..00000000000 --- a/ui/content/docs/components/complex/filter-ui.mdx +++ /dev/null @@ -1,57 +0,0 @@ ---- -title: "Filter UI" -description: "Build flexible filter panels for list and view toolbars" ---- - -The Filter UI component renders configurable filters with text, select, date, and boolean inputs. - -## JSON Configuration - -```plaintext -{ - type: 'filter-ui', - layout: 'popover', - showApply: true, - showClear: true, - filters: [ - { field: 'name', label: 'Name', type: 'text', placeholder: 'Search name' }, - { field: 'status', label: 'Status', type: 'select', options: [ - { label: 'Open', value: 'open' }, - { label: 'Closed', value: 'closed' } - ] }, - { field: 'created_at', label: 'Created', type: 'date-range' } - ] -} -``` - -## Basic Usage - - - -## Schema - -```ts -interface FilterUISchema { - type: 'filter-ui'; - filters: Array<{ - field: string; - label?: string; - type: 'text' | 'number' | 'select' | 'multi-select' | 'date' | 'date-range' | 'boolean'; - options?: Array<{ label: string; value: any }>; - placeholder?: string; - }>; - values?: Record; - onChange?: string; - showClear?: boolean; - showApply?: boolean; - layout?: 'inline' | 'popover' | 'drawer'; -} -``` - -### Retired: `filters[].operator` - -A filter entry no longer takes an `operator` (objectui#10611). The component never -read one: it picks each control from `type` and reports changes as `{ values }`, a -field-to-value record with no operator in it, and it does no matching of its own. How -each value is matched is up to the host that consumes the change. An authored -`operator` is now refused by name when the schema is validated. diff --git a/ui/content/docs/components/complex/meta.json b/ui/content/docs/components/complex/meta.json deleted file mode 100644 index 03c873905a6..00000000000 --- a/ui/content/docs/components/complex/meta.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "title": "Complex", - "pages": [ - "table", - "data-table", - "carousel", - "scroll-area", - "resizable", - "filter-builder", - "view-switcher", - "filter-ui", - "sort-ui" - ] -} diff --git a/ui/content/docs/components/complex/resizable.mdx b/ui/content/docs/components/complex/resizable.mdx deleted file mode 100644 index a524d91ee2b..00000000000 --- a/ui/content/docs/components/complex/resizable.mdx +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: "Resizable" -description: "Resizable panel groups with draggable handles" ---- - -The Resizable component creates adjustable panel layouts with draggable handles, perfect for split views and customizable interfaces. - -## Basic Usage - - - -## Vertical Layout - - - -## Three Panels - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface ResizablePanel { - defaultSize: number; // Initial size percentage - minSize?: number; // Minimum size percentage - maxSize?: number; // Maximum size percentage - content: SchemaNode | SchemaNode[]; // Panel content -} - -interface ResizableSchema { - type: 'resizable'; - - // Configuration - direction?: 'horizontal' | 'vertical'; // Panel direction (default: 'horizontal') - minHeight?: string; // Minimum container height - withHandle?: boolean; // Show handle icons (default: true) - - // Panels - panels: ResizablePanel[]; // Array of panel configurations - - // Styling - className?: string; // Tailwind CSS classes - - // Base properties - id?: string; - visible?: boolean | string | { dialect?: string; source: string }; - testId?: string; -} -``` - -## Examples - -### Code Editor Layout - - - -### Dashboard Layout - - - -### Mail Client - - diff --git a/ui/content/docs/components/complex/scroll-area.mdx b/ui/content/docs/components/complex/scroll-area.mdx deleted file mode 100644 index 82662ca9ba3..00000000000 --- a/ui/content/docs/components/complex/scroll-area.mdx +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: "Scroll Area" -description: "Customizable scrollable container with styled scrollbars" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -The Scroll Area component provides a scrollable container with styled scrollbars that match your design system. - -## Basic Usage - - - -## Horizontal Scroll - - - -## Custom Heights - - - - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface ScrollAreaSchema { - type: 'scroll-area'; - - // Dimensions - height?: string | number; // Container height ('200px', '50vh', or 200 = px) - width?: string | number; // Container width ('100%', '50vw', or 300 = px) - - // Configuration - orientation?: 'vertical' | 'horizontal' | 'both'; // Scroll direction (default: 'vertical') - - // Content - children?: SchemaNode | SchemaNode[]; // Scrollable content (the only content channel) - - // Styling - className?: string; // Tailwind CSS classes - - // Base properties - id?: string; - visible?: boolean | string | { dialect?: string; source: string }; - testId?: string; -} -``` - -The scrollable body is authored under `children`, and only under `children`: -the renderer reads `renderChildren(schema.children)` and nothing else, and the -registration's `defaultProps` ships `children` too. A scroll area that puts its -body under `content` is not refused anywhere — `content` is not a member of -`ScrollAreaSchema`, but the zod twin extends a `.passthrough()` base, so it -validates, survives parsing, and reaches the DOM as a stray attribute on an -otherwise empty scroll box. - -`height` and `width` take a number as well as a CSS length; a bare number is -applied as pixels (`height: 200` renders `height: 200px`). Neither has a -render-time default — omitting one leaves the container unsized in that axis. - -## Examples - -### Message List - - - -### File List - - - -### Code Block - - diff --git a/ui/content/docs/components/complex/sort-ui.mdx b/ui/content/docs/components/complex/sort-ui.mdx deleted file mode 100644 index 240b03397b4..00000000000 --- a/ui/content/docs/components/complex/sort-ui.mdx +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: "Sort UI" -description: "Configure sorting with dropdowns or button toggles" ---- - -The Sort UI component exposes single or multi-field sorting controls. - -## JSON Configuration - -```plaintext -{ - type: 'sort-ui', - variant: 'dropdown', - multiple: true, - fields: [ - { field: 'name', label: 'Name' }, - { field: 'created_at', label: 'Created At' } - ], - sort: [{ field: 'name', direction: 'asc' }] -} -``` - -## Basic Usage - - - -## Schema - -```ts -interface SortUISchema { - type: 'sort-ui'; - fields: Array<{ - field: string; - label?: string; - }>; - sort?: Array<{ - field: string; - direction: 'asc' | 'desc'; - }>; - onChange?: string; - multiple?: boolean; - variant?: 'dropdown' | 'buttons'; -} -``` diff --git a/ui/content/docs/components/complex/table.mdx b/ui/content/docs/components/complex/table.mdx deleted file mode 100644 index 1f79e7853ed..00000000000 --- a/ui/content/docs/components/complex/table.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: "Table" -description: "Display data in tabular format" ---- - -## Examples - - - -## Schema - -```ts -interface TableSchema { - type: 'table'; - columns: Array<{ header: string; accessorKey: string }>; - data: Record[]; -} -``` diff --git a/ui/content/docs/components/complex/view-switcher.mdx b/ui/content/docs/components/complex/view-switcher.mdx deleted file mode 100644 index eb6d0d063b7..00000000000 --- a/ui/content/docs/components/complex/view-switcher.mdx +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: "View Switcher" -description: "Switch between multiple view configurations in one container" ---- - -The View Switcher component renders a selector and the active view schema in one place. - -## JSON Configuration - -```plaintext -{ - type: 'view-switcher', - variant: 'tabs', - position: 'top', - defaultView: 'grid', - views: [ - { - type: 'grid', - label: 'Grid', - schema: { type: 'text', content: 'Grid content' } - }, - { - type: 'kanban', - label: 'Kanban', - schema: { type: 'text', content: 'Kanban content' } - } - ] -} -``` - -## Basic Usage - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface ViewSwitcherSchema { - type: 'view-switcher'; - views: Array<{ - type: 'list' | 'detail' | 'grid' | 'kanban' | 'calendar' | 'timeline' | 'map'; - label?: string; - icon?: string; - schema?: SchemaNode; - }>; - defaultView?: string; - activeView?: string; - variant?: 'tabs' | 'buttons' | 'dropdown'; - position?: 'top' | 'bottom' | 'left' | 'right'; - onViewChange?: string; - persistPreference?: boolean; - storageKey?: string; - allowCreateView?: boolean; // Show "+" button to create views - viewActions?: Array<{ // Per-view action icons - type: 'share' | 'settings' | 'duplicate' | 'delete'; - icon?: string; - }>; -} -``` diff --git a/ui/content/docs/components/data-display/alert.mdx b/ui/content/docs/components/data-display/alert.mdx deleted file mode 100644 index 26022ce87db..00000000000 --- a/ui/content/docs/components/data-display/alert.mdx +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: "Alert" -description: "Display important messages and notifications" ---- - -## Examples - - - - - -## Schema - -```ts -interface AlertSchema { - type: 'alert'; - title?: string; - description?: string; - variant?: 'default' | 'destructive'; - icon?: string; - className?: string; -} -``` diff --git a/ui/content/docs/components/data-display/avatar.mdx b/ui/content/docs/components/data-display/avatar.mdx deleted file mode 100644 index 8b7ea02cf1f..00000000000 --- a/ui/content/docs/components/data-display/avatar.mdx +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: "Avatar" -description: "User profile image or fallback initials" ---- - -## Examples - - - - - -## Schema - -```ts -interface AvatarSchema { - type: 'avatar'; - src?: string; - alt?: string; - fallback?: string; - size?: 'sm' | 'md' | 'lg'; - className?: string; -} -``` diff --git a/ui/content/docs/components/data-display/badge.mdx b/ui/content/docs/components/data-display/badge.mdx deleted file mode 100644 index b6e2545a3d6..00000000000 --- a/ui/content/docs/components/data-display/badge.mdx +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: "Badge" -description: "Small status or label indicator" ---- - -## Examples - - - -## Schema - -```ts -interface BadgeSchema { - type: 'badge'; - label: string; - variant?: 'default' | 'secondary' | 'outline' | 'destructive'; - className?: string; -} -``` diff --git a/ui/content/docs/components/data-display/breadcrumb.mdx b/ui/content/docs/components/data-display/breadcrumb.mdx deleted file mode 100644 index 30d95c01544..00000000000 --- a/ui/content/docs/components/data-display/breadcrumb.mdx +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: "Breadcrumb" -description: "Display the current location within a navigational hierarchy" ---- - -The Breadcrumb component shows the current page's location within the site hierarchy. - -## Basic Usage - - - -## With Icons - - - -## Separator - -An item trail is joined by `separator`, a plain string. Leave it unauthored and -the trail separates with the declared default, `/`: - - - -Any string is accepted, including an empty one (`""`) for no visible separator -at all. - -## Collapsing a long trail - -`maxItems` bounds how many crumbs are **rendered**. When the trail is longer, -the first crumb and the last `maxItems - 1` survive and an ellipsis marks what -was elided — so the current page, the crumb the trail exists to name, is never -the one dropped: - - - -A trail already within `maxItems` is rendered whole, and a `maxItems` that -cannot mean a count (below `1`) is ignored rather than emptying the trail. - -## Icons - -An item's `icon` is a **kebab-case Lucide icon name**, resolved against lucide's -runtime `icons` record — the same surface `ui:button` and the `action:*` family -resolve against. A name that is not a live key of that record (an unknown, or a -retired spelling such as `layout`) renders **no glyph**, never a fallback glyph -and never the literal name as text. The glyph is drawn for every crumb: the -final one (rendered as the current page) as well as the linked ones before it. - -## Schema - -```ts -interface BreadcrumbItem { - label: string; - href?: string; - icon?: string; // kebab-case Lucide icon name (e.g. "panels-top-left") -} - -interface BreadcrumbSchema { - type: 'breadcrumb'; - items: BreadcrumbItem[]; // Breadcrumb items - separator?: string; // Separator between crumbs (default "/") - maxItems?: number; // Maximum crumbs to display before collapsing - className?: string; -} -``` diff --git a/ui/content/docs/components/data-display/kbd.mdx b/ui/content/docs/components/data-display/kbd.mdx deleted file mode 100644 index d9ef2c1e9d2..00000000000 --- a/ui/content/docs/components/data-display/kbd.mdx +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: "Kbd" -description: "Display keyboard shortcuts and hotkeys" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -The Kbd component displays keyboard shortcuts in a styled format. - -## Basic Usage - - - -## Multiple Key Combinations - - - - - - - -## Schema - -```ts -interface KbdSchema { - type: 'kbd'; - keys: string[]; // Keyboard keys - className?: string; -} -``` - -## Examples - -### In Documentation - - diff --git a/ui/content/docs/components/data-display/list.mdx b/ui/content/docs/components/data-display/list.mdx deleted file mode 100644 index 214e9a7db54..00000000000 --- a/ui/content/docs/components/data-display/list.mdx +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: "List" -description: "Display a list of items" ---- - -## Examples - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface ListSchema { - type: 'list'; - title?: string; // heading drawn above the list - bind?: string; // path to an array in the host scope, read first - items?: SchemaNode[]; // drawn when `bind` resolves no array - ordered?: boolean; - className?: string; - wrapperClass?: string; -} -``` - -A `list` needs at least one of `bind` and `items`. The renderer reads `bind` through -`useDataScope` first and falls back to `items` when the bound value is not an array, so -a node may carry both. A bind-only list draws one entry per element of the bound array: - -```json -{ - "type": "list", - "bind": "customerNames" -} -``` - -A `list` with neither key is refused at the node by `safeValidateSchema` and the strict -face, with `params.code` set to `LIST_ENTRIES_REQUIRED`. Until objectui#11405 `items` was -required, so the bind-only list above was refused at `items`. The TypeScript face declares -`items` optional and cannot express the at-least-one rule; the validator enforces it. - - - `wrapperClass` is now typed. The renderer has read it all along — it lands on the - wrapper element around the list — but it went undeclared until objectui#7722, - surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's `.passthrough()`. - Declared as `string` on both faces, a **non-string** `wrapperClass` that used to - parse green is now refused by name — on `ListSchema` and through - `safeValidateSchema` alike. Pass the Tailwind class string; a number or an object - gets a parse error where it used to get silence. - diff --git a/ui/content/docs/components/data-display/meta.json b/ui/content/docs/components/data-display/meta.json deleted file mode 100644 index 64822d678ce..00000000000 --- a/ui/content/docs/components/data-display/meta.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "title": "Data Display", - "pages": [ - "badge", - "avatar", - "alert", - "list", - "breadcrumb", - "kbd", - "statistic", - "tree-view" - ] -} diff --git a/ui/content/docs/components/data-display/statistic.mdx b/ui/content/docs/components/data-display/statistic.mdx deleted file mode 100644 index 5606d46867c..00000000000 --- a/ui/content/docs/components/data-display/statistic.mdx +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: "Statistic" -description: "Display key metrics and statistical values with trend indicators" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -The Statistic component displays numerical data and key metrics with optional labels, descriptions, and trend indicators. - -## Basic Usage - - - -## With Trend Indicators - - - - - - - -## With Descriptions - - - -## Schema - -```ts -interface StatisticSchema { - type: 'statistic'; - - // Content - label?: string; // Label/title of the statistic - value: string | number; // Main value to display - description?: string; // Additional description text - icon?: string; // Optional icon name - - // Trend indicator - trend?: 'up' | 'down' | 'neutral'; // Trend direction - - // Styling - className?: string; // Tailwind CSS classes - - // Base properties - id?: string; - visible?: boolean | string | { dialect?: string; source: string }; - testId?: string; -} -``` - -## Examples - -### Dashboard Metrics - - - -### Sales Dashboard - - - -### Simple Stats Row - - diff --git a/ui/content/docs/components/data-display/tree-view.mdx b/ui/content/docs/components/data-display/tree-view.mdx deleted file mode 100644 index b969e337376..00000000000 --- a/ui/content/docs/components/data-display/tree-view.mdx +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: "Tree View" -description: "Hierarchical tree structure for displaying nested data" ---- - -The Tree View component displays hierarchical data in an expandable tree structure, perfect for file explorers, organizational charts, and nested navigation. - -## Basic Usage - - - -## Nested Structure - - - -## Schema - -```ts -interface TreeNode { - id: string; // Unique node identifier - label: string; // Node label text - icon?: string; // Icon name ('folder' or 'file') - children?: TreeNode[]; // Child nodes - data?: any; // Additional node data -} - -interface TreeViewSchema { - type: 'tree-view'; - - // Data — inline nodes, or bind the tree with `bind` (read first) - nodes?: TreeNode[]; // Tree nodes (the one inline spelling) - title?: string; // Tree title - - // Selection - defaultExpandedIds?: string[]; // Default expanded node IDs - defaultSelectedIds?: string[]; // Default selected node IDs - expandedIds?: string[]; // Controlled expanded node IDs - selectedIds?: string[]; // Controlled selected node IDs - multiSelect?: boolean; // Enable multi-selection (default: false) - - // Appearance - showLines?: boolean; // Show connecting lines (default: true) - - // Events - // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it - // by name. Author behaviour as a node type, e.g. an action:button node. - onNodeClick?: (node: TreeNode) => void; // Node click handler - - // Styling - className?: string; // Tailwind CSS classes - - // Base properties - id?: string; - visible?: boolean | string | { dialect?: string; source: string }; - testId?: string; -} -``` - -> **Retired: `data`** (objectui#6951, ADR-0049 enforce-or-remove). `data` was a -> second spelling of the tree's one inline-nodes slot — the renderer read it only -> as the last limb of `bind || nodes || data` — and it is no longer part of -> `TreeViewSchema` on either face. A `tree-view` node that authors `data` now -> fails validation with: -> -> > RETIRED (ADR-0049) — `data` is no longer part of TreeViewSchema; write -> > `nodes` (or bind the tree with `bind`). … -> -> Rename the key to `nodes`; the array is unchanged. `bind` is unchanged and is -> still read first, so a `bind`-only tree-view stays a legal document, and -> `nodes` stays optional (no presence rule was added). `TreeNode.data` — the -> per-node payload — is a different member and is untouched. - -## Examples - -### Organization Chart - - - -### Navigation Menu - - diff --git a/ui/content/docs/components/disclosure/accordion.mdx b/ui/content/docs/components/disclosure/accordion.mdx deleted file mode 100644 index 195902d250a..00000000000 --- a/ui/content/docs/components/disclosure/accordion.mdx +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: "Accordion" -description: "Expandable content sections" ---- - -## Examples - -The second item sets `disabled: true`, which the renderer forwards to that item -only — the rest of the accordion stays interactive. - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface AccordionItem { - value: string; // Unique item identifier - title: string; // Item title/trigger - content: SchemaNode | SchemaNode[]; // Item content - disabled?: boolean; // Disables this item only -} - -interface AccordionSchema { - type: 'accordion'; - items: AccordionItem[]; - accordionType?: 'single' | 'multiple'; // Selection mode; default 'single' - collapsible?: boolean; // Whether items can be collapsed; default true - defaultValue?: string | string[]; // Default expanded item value(s) - value?: string | string[]; // Controlled expanded item value(s) - // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it - // by name. Author behaviour as a node type, e.g. an action:button node. - onValueChange?: (value: string | string[]) => void; - variant?: 'default' | 'bordered' | 'separated'; // default 'default' -} -``` diff --git a/ui/content/docs/components/disclosure/collapsible.mdx b/ui/content/docs/components/disclosure/collapsible.mdx deleted file mode 100644 index 06a2d4a7e63..00000000000 --- a/ui/content/docs/components/disclosure/collapsible.mdx +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: "Collapsible" -description: "Toggle visibility of content" ---- - -## Examples - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface CollapsibleSchema { - type: 'collapsible'; - trigger: SchemaNode | SchemaNode[]; - content: SchemaNode | SchemaNode[]; - defaultOpen?: boolean; -} -``` - -The body is authored under `content`, not `children`: the renderer reads -`schema.content` inside `CollapsibleContent`, with no `children` fallback of any -kind, and the registration's `defaultProps` ships `content` too. A collapsible -that puts its body under `children` is refused by the zod twin — `content` is -required — and paints an empty body wherever nothing validates it. diff --git a/ui/content/docs/components/disclosure/meta.json b/ui/content/docs/components/disclosure/meta.json deleted file mode 100644 index 0c021b56ddd..00000000000 --- a/ui/content/docs/components/disclosure/meta.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "title": "Disclosure", - "pages": [ - "accordion", - "collapsible", - "toggle-group" - ] -} diff --git a/ui/content/docs/components/disclosure/toggle-group.mdx b/ui/content/docs/components/disclosure/toggle-group.mdx deleted file mode 100644 index 6d42d5cdf64..00000000000 --- a/ui/content/docs/components/disclosure/toggle-group.mdx +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: "Toggle Group" -description: "A group of toggle buttons where one or more can be selected" ---- - -The Toggle Group component allows users to toggle between multiple options. - -## Basic Usage - - - -## Multiple Selection - - - -## With Labels - -The third item sets `disabled: true`, which the renderer forwards to that toggle -only — the rest of the group stays interactive. - - - -## Schema - -```ts -interface ToggleGroupItem { - value: string; - label: string; - disabled?: boolean; // Disables this item only -} - -interface ToggleGroupSchema { - type: 'toggle-group'; - selectionType?: 'single' | 'multiple'; // Selection mode - items: ToggleGroupItem[]; // Toggle items - value?: string | string[]; // Selected value(s) - variant?: 'default' | 'outline'; - size?: 'sm' | 'default' | 'lg'; - - // Events - // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it - // by name. Author behaviour as a node type, e.g. an action:button node. - onValueChange?: (value: string | string[]) => void; - - // States - disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope - - // Styling - className?: string; -} -``` diff --git a/ui/content/docs/components/feedback/empty.mdx b/ui/content/docs/components/feedback/empty.mdx deleted file mode 100644 index 535e841d2ec..00000000000 --- a/ui/content/docs/components/feedback/empty.mdx +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: "Empty" -description: "Empty state placeholder with optional action" ---- - -The Empty component displays a placeholder when there's no content to show. - -## Basic Usage - - - -## With Icon - - - -## With Action - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface EmptySchema { - type: 'empty'; - icon?: string; // Lucide icon name - title?: string; // Main message - description?: string; // Supporting text - action?: SchemaNode; // Call to action button (any node; a bare string renders as text) - className?: string; -} -``` - -## Examples - -### Search Results - - - -### Empty List - - diff --git a/ui/content/docs/components/feedback/loading.mdx b/ui/content/docs/components/feedback/loading.mdx deleted file mode 100644 index 8f7ca2984a7..00000000000 --- a/ui/content/docs/components/feedback/loading.mdx +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: "Loading" -description: "Loading spinner to indicate processing" ---- - -## Examples - - - - - -## Schema - -```ts -interface LoadingSchema { - type: 'loading'; - label?: string; // Text drawn under the spinner - size?: 'sm' | 'md' | 'lg'; - className?: string; -} -``` - - - The message under the spinner is `label`, the key `LoadingSchema` declares. - Until objectui#11347 the renderer read an undeclared `text` instead and left - `label` inert. `text` is not a key of this node: write `label`. - diff --git a/ui/content/docs/components/feedback/meta.json b/ui/content/docs/components/feedback/meta.json deleted file mode 100644 index 7145611d432..00000000000 --- a/ui/content/docs/components/feedback/meta.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "title": "Feedback", - "pages": [ - "loading", - "progress", - "skeleton", - "spinner", - "empty", - "toast", - "sonner", - "toaster" - ] -} diff --git a/ui/content/docs/components/feedback/progress.mdx b/ui/content/docs/components/feedback/progress.mdx deleted file mode 100644 index 9a5486ee93f..00000000000 --- a/ui/content/docs/components/feedback/progress.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: "Progress" -description: "Progress bar to show completion status" ---- - -## Examples - - - - - -## Schema - -```ts -interface ProgressSchema { - type: 'progress'; - value: number; // 0-100 - className?: string; -} -``` diff --git a/ui/content/docs/components/feedback/skeleton.mdx b/ui/content/docs/components/feedback/skeleton.mdx deleted file mode 100644 index 3e1c298e67b..00000000000 --- a/ui/content/docs/components/feedback/skeleton.mdx +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: "Skeleton" -description: "Placeholder for loading content" ---- - -## Examples - - - -## Schema - -```ts -interface SkeletonSchema { - type: 'skeleton'; - width?: string | number; - height?: string | number; - className?: string; -} -``` diff --git a/ui/content/docs/components/feedback/sonner.mdx b/ui/content/docs/components/feedback/sonner.mdx deleted file mode 100644 index 1a692b78159..00000000000 --- a/ui/content/docs/components/feedback/sonner.mdx +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: "Sonner" -description: "Toast notifications using Sonner library" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -The Sonner component provides beautiful toast notifications with a rich API. - -## Basic Usage - -A `sonner` node renders its own trigger button. Clicking the button raises the toast the -node declares, so the whole demo is one schema node with no handler to wire up. -`buttonLabel` and `buttonVariant` style that trigger; `message`, `description` and -`variant` describe the toast it raises. - - - -## Toast Types - - - - - - - - -## With Description - -`description` adds a secondary line under the message. - - - -## Schema - -```ts -interface SonnerSchema { - type: 'sonner'; - message?: string; // Toast message - title?: string; // Alias for message - description?: string; // Additional description - variant?: 'default' | 'success' | 'error' | 'warning' | 'info'; - - // Trigger button - buttonLabel?: string; // Trigger button text (default: 'Show Toast') - buttonVariant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'; -} -``` - -## Examples - -### Custom Trigger Button - - diff --git a/ui/content/docs/components/feedback/spinner.mdx b/ui/content/docs/components/feedback/spinner.mdx deleted file mode 100644 index 58f00113e5d..00000000000 --- a/ui/content/docs/components/feedback/spinner.mdx +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: "Spinner" -description: "Loading spinner indicator" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -The Spinner component displays a loading indicator. - -## Basic Usage - - - -## Sizes - - - - - - - -## Schema - -```ts -interface SpinnerSchema { - type: 'spinner'; - size?: 'sm' | 'md' | 'lg'; - className?: string; -} -``` - -## Examples - -### In Button - - - -### Centered - - diff --git a/ui/content/docs/components/feedback/toast.mdx b/ui/content/docs/components/feedback/toast.mdx deleted file mode 100644 index a79b8e9ce63..00000000000 --- a/ui/content/docs/components/feedback/toast.mdx +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: "Toast" -description: "Brief notification messages that appear temporarily" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -The Toast component displays brief notifications to users that appear temporarily and then disappear. - -## Basic Usage - -A `toast` node renders its own trigger button. Clicking the button raises the toast the -node declares — the title, description, variant and `durationMs` are all read off the node -itself, so the whole demo is one schema node with no handler to wire up. - - - -## Variants - -`variant` selects the toast style. `ToastSchema` declares exactly five members — -`default`, `success`, `warning`, `error` and `info`; the four below plus `success`, -which the Success Message example uses. - - - - - - - - -## Schema - -```ts -interface ToastSchema { - type: 'toast'; - title?: string; // Toast title - description?: string; // Toast message - variant?: 'default' | 'success' | 'warning' | 'error' | 'info'; - - // Timing - durationMs?: number; // Auto-dismiss time in ms -} -``` - -## Examples - -### Success Message - - - -### Error Message - - diff --git a/ui/content/docs/components/feedback/toaster.mdx b/ui/content/docs/components/feedback/toaster.mdx deleted file mode 100644 index 577c086a8c2..00000000000 --- a/ui/content/docs/components/feedback/toaster.mdx +++ /dev/null @@ -1,68 +0,0 @@ ---- -title: "Toaster" -description: "Container for displaying toast notifications" ---- - -The Toaster component provides a container for toast notifications. It should be placed once at the root level of your application to enable toast functionality. - -## Basic Usage - -The Toaster component is typically added to your app's layout and doesn't require configuration: - - - -## Schema - -```ts -interface ToasterSchema { - type: 'toaster'; - - // Configuration - position?: 'top-left' | 'top-center' | 'top-right' | - 'bottom-left' | 'bottom-center' | 'bottom-right'; // Toast position - limit?: number; // Maximum number of toasts (default: 5) - - // Styling - className?: string; // Tailwind CSS classes - - // Base properties - id?: string; - visible?: boolean | string | { dialect?: string; source: string }; - testId?: string; -} -``` - -## Examples - -### In App Layout - -```plaintext -{ - type: 'div', - className: 'min-h-screen', - children: [ - // Your app content - { type: 'page', pageType: 'app', title: 'Dashboard', children: [...] }, - - // Toaster at root level - { type: 'toaster' } - ] -} -``` - -### Usage with Toast Actions - -The Toaster component works in conjunction with Toast components to display notifications. Once the Toaster is mounted, you can trigger toasts programmatically or via Toast components: - - - -### Configuration - - - -## Notes - -- Place the Toaster component once in your app's root layout -- ObjectUI ships a single toaster implementation, backed by Sonner -- Toast notifications are triggered separately using toast functions or Toast components -- The Toaster manages the positioning and lifecycle of all toasts diff --git a/ui/content/docs/components/form/button.mdx b/ui/content/docs/components/form/button.mdx deleted file mode 100644 index e07539bf7c8..00000000000 --- a/ui/content/docs/components/form/button.mdx +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: "Button" -description: "Trigger actions and events with customizable button styles" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -The Button component triggers actions when clicked. It supports multiple variants, sizes, and states. - -## Basic Usage - - - -## Variants - - - - - - - - - - -## Sizes - - - - - - -## With Icons - - - - - - -## States - - - - - - -## Schema - -```ts -interface ButtonSchema { - type: 'button'; - label: string; // Button text - variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; - size?: 'sm' | 'default' | 'lg' | 'icon'; - - // Icons - icon?: string; // Lucide icon name - iconPosition?: 'left' | 'right'; // Icon placement - - // States - disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope - loading?: boolean; - - // Actions - // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it - // by name. Author behaviour as a node type, e.g. an action:button node. - onClick?: () => void | Promise; // Click handler - - // Styling - className?: string; // Tailwind classes, e.g. "w-full" for a full-width button -} -``` - -## Examples - -### Action Buttons - - - -### Full Width - - diff --git a/ui/content/docs/components/form/calendar.mdx b/ui/content/docs/components/form/calendar.mdx deleted file mode 100644 index c15f0e9d688..00000000000 --- a/ui/content/docs/components/form/calendar.mdx +++ /dev/null @@ -1,89 +0,0 @@ ---- -title: "Calendar" -description: "Date selection component with single, multiple, and range modes" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -The Calendar component allows users to select dates in various modes including single date, multiple dates, or date ranges. - -## Basic Usage - - - -## Selection Modes - - - - - - -## Multiple Selection - - - -## Selected Date - -Each day in `value` and `defaultValue` is an ISO 8601 date string in JSON (`"2026-09-15"` or `"2026-09-15T09:00:00Z"`); a TypeScript caller may also pass a `Date`. A date-only string selects the day it names in every time zone. - -The shape of the selection follows `mode`: - -| `mode` | `value` / `defaultValue` | -|---|---| -| `single` (default) | one day | -| `multiple` | a list of days | -| `range` | `{ "from": …, "to": … }` — `to` is optional | - -```json -{ "type": "ui:calendar", "mode": "single", "defaultValue": "2026-09-15" } -``` - -```json -{ "type": "ui:calendar", "mode": "multiple", "defaultValue": ["2026-09-15", "2026-09-17"] } -``` - -```json -{ "type": "ui:calendar", "mode": "range", "defaultValue": { "from": "2026-09-15", "to": "2026-09-19" } } -``` - -Schema validation refuses a selection whose shape does not fit its mode — a single day under `mode: "multiple"`, for example — instead of letting it reach the renderer. - -The calendar opens on the month of its first selected day: the day itself in `single` mode, the first valid listed day in `multiple` mode (an entry that is not a date is skipped), and `from` in `range` mode. With no selection it opens on the current month. It reads that month once, when it first renders: a `value` that changes afterwards does not move the month it shows. - -## Schema - -```ts -type Day = Date | string; // one calendar day: an ISO 8601 date string in JSON, or a Date - -interface CalendarSchema { - type: 'calendar'; - - // Selection - mode?: 'single' | 'multiple' | 'range'; // Selection mode (default: 'single') - defaultValue?: Day | Day[] | { from: Day; to?: Day }; // Default selection, shaped by mode (Day = ISO 8601 string in JSON, or Date) - value?: Day | Day[] | { from: Day; to?: Day }; // Controlled selection, shaped by mode - - // Constraints - minDate?: Date | string; // Minimum selectable date - maxDate?: Date | string; // Maximum selectable date - disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope - - // Styling - className?: string; // Tailwind CSS classes - - // Base properties - id?: string; - visible?: boolean | string | { dialect?: string; source: string }; - testId?: string; -} -``` - -## Examples - -### In a Form - - - -### With Custom Styling - - diff --git a/ui/content/docs/components/form/checkbox.mdx b/ui/content/docs/components/form/checkbox.mdx deleted file mode 100644 index f7386adae7d..00000000000 --- a/ui/content/docs/components/form/checkbox.mdx +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: "Checkbox" -description: "Toggle selection with checkboxes" ---- - -## Examples - - - - - -## Schema - -```ts -interface CheckboxSchema { - type: 'checkbox'; - name?: string; - label?: string; - defaultChecked?: boolean; - checked?: boolean; - required?: boolean; - disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope - description?: string; - error?: string; - className?: string; - wrapperClass?: string; -} -``` diff --git a/ui/content/docs/components/form/combobox.mdx b/ui/content/docs/components/form/combobox.mdx deleted file mode 100644 index 26aa86bafbf..00000000000 --- a/ui/content/docs/components/form/combobox.mdx +++ /dev/null @@ -1,84 +0,0 @@ ---- -title: "Combobox" -description: "Searchable dropdown for selecting from a list of options" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -The Combobox component combines a text input with a dropdown list, allowing users to search and select from options. - -## Basic Usage - - - -## With Search - - - -## States - - - - - - -## Schema - -```ts -interface ComboboxOption { - value: string; - label: string; - disabled?: boolean; // Option renders dimmed and cannot be selected -} - -interface ComboboxSchema { - type: 'combobox'; - options?: ComboboxOption[]; // Available options (absent: an empty list) - value?: string; // Selected value - name?: string; // Form-serialization key, placed on the trigger button - - // Text - placeholder?: string; // Button placeholder - description?: string; // Help text below the control, announced via aria-describedby - - // States - disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope - - // Styling - className?: string; -} -``` - - - There is no `defaultValue` on a combobox — authoring it is refused by name, - on both the TypeScript face and `safeValidateSchema`. Write `value`. - - A combobox node shows the option named by `value` and its selection does not - change, so "the value it starts at" and "the value it shows" are one question; - the key was retired under ADR-0049 rather than turned into a second spelling of - `value`. A `select` node does take `defaultValue`, because it hands the control - a change handler and so has a selection that can move away from its default — - and unlike `combobox`, `select` is also a built-in **form field** type, where - the form supplies the default itself. - - -## Help Text - -```json -{ - "type": "combobox", - "name": "country", - "description": "Where the account is billed.", - "placeholder": "Select country...", - "options": [ - { "value": "us", "label": "United States" }, - { "value": "de", "label": "Germany" } - ] -} -``` - -## Examples - -### Dynamic Options - - diff --git a/ui/content/docs/components/form/command.mdx b/ui/content/docs/components/form/command.mdx deleted file mode 100644 index 690418b5193..00000000000 --- a/ui/content/docs/components/form/command.mdx +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: "Command" -description: "Fast command menu with search and keyboard navigation" ---- - -The Command component provides a fast, searchable command menu with keyboard navigation. - -## Basic Usage - - - -## Icons - -An item's `icon` is a **kebab-case Lucide icon name**, resolved against lucide's -runtime `icons` record — the same surface `ui:button` and the `action:*` family -resolve against. A name that is not a live key of that record (an unknown, or a -retired spelling such as `smile`) renders **no glyph**, never a fallback glyph -and never the literal name as text. - -## Schema - -```ts -interface CommandItem { - value: string; - label: string; - icon?: string; // kebab-case Lucide icon name (e.g. "face-slightly-smiling") -} - -interface CommandGroup { - heading?: string; - items: CommandItem[]; -} - -interface CommandSchema { - type: 'command'; - placeholder?: string; // Search placeholder - groups?: CommandGroup[]; // Command groups (absent: only the empty text) - emptyText?: string; // Text when no results - - // Styling - className?: string; -} -``` - -## Examples - -### File Command Palette - -A single-group palette of file commands, with its own search placeholder. - - diff --git a/ui/content/docs/components/form/date-picker.mdx b/ui/content/docs/components/form/date-picker.mdx deleted file mode 100644 index 7e8bb7d6874..00000000000 --- a/ui/content/docs/components/form/date-picker.mdx +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: "Date Picker" -description: "Input for selecting dates with a calendar popup" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -The Date Picker component allows users to select a date from a calendar interface. - -## Basic Usage - - - -## With Default Date - - - -The calendar popup opens on the month of the selected date, so a date in another month or year is in view when the popup opens. With no date it opens on the current month. - -## States - - - - - - -## Schema - -```ts -interface DatePickerSchema { - type: 'date-picker'; - value?: string | Date; // Selected date (ISO string or Date) - placeholder?: string; // Placeholder text - - // Events - // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it - // by name. Author behaviour as a node type, e.g. an action:button node. - onChange?: (date: Date | undefined) => void; - - // States - disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope - - // Styling - className?: string; - wrapperClass?: string; -} -``` - - - `wrapperClass` is now typed. The renderer has read it all along — it lands on the - wrapper element around the date picker and its label — but it went undeclared until - objectui#7722, surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's - `.passthrough()`. Declared as `string` on both faces, a **non-string** - `wrapperClass` that used to parse green is now refused by name — on - `DatePickerSchema` and through `safeValidateSchema` alike. Pass the Tailwind class - string; a number or an object gets a parse error where it used to get silence. - - -## Examples - -### In a Form - - - -### Date Range - - diff --git a/ui/content/docs/components/form/file-upload.mdx b/ui/content/docs/components/form/file-upload.mdx deleted file mode 100644 index c82455fd190..00000000000 --- a/ui/content/docs/components/form/file-upload.mdx +++ /dev/null @@ -1,74 +0,0 @@ ---- -title: "File Upload" -description: "Modern file upload component with drag-and-drop support" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -The File Upload component provides a futuristic interface for uploading files with visual feedback, progress indication, and multiple file support. - -## Basic Usage - - - -## Single vs Multiple - - - - - - -## File Type Restrictions - - - -## Schema - -```text -interface FileUploadSchema { - type: 'file-upload'; - - // Required - id: string; // Field identifier - - // Configuration - name?: string; // Field name for form submission - label?: string; // Label text - buttonText?: string; // Upload button text - - // File constraints - accept?: string; // Accepted file types (e.g., 'image/*', '.pdf,.doc') - multiple?: boolean; // Allow multiple files (default: false) - maxSize?: number; // Maximum file size in bytes - maxFiles?: number; // Maximum number of files (for multiple) - - // States - disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope - - // Help text - description?: string; // Help text or description - error?: string; // Error message - - // Styling - className?: string; // Tailwind CSS classes - wrapperClass?: string; // Wrapper container classes - - // Base properties - visible?: boolean | string | { dialect?: string; source: string }; - testId?: string; -} -``` - -## Examples - -### Document Upload - - - -### Profile Picture - - - -### Full Example - - diff --git a/ui/content/docs/components/form/form.mdx b/ui/content/docs/components/form/form.mdx deleted file mode 100644 index 02109791035..00000000000 --- a/ui/content/docs/components/form/form.mdx +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: "Form" -description: "Form container with validation and submission handling" ---- - -The Form component provides a complete form solution with validation and submission handling. - -## Basic Usage - - - -## With Validation - - - -## Schema - -```ts -interface FormField { - name: string; - label?: string; - type: 'input' | 'textarea' | 'select' | 'checkbox' | 'radio-group' | 'date-picker'; - placeholder?: string; - required?: boolean; - disabled?: boolean; - - // Validation - minLength?: number; - maxLength?: number; - min?: number; - max?: number; - step?: number; // a number, never 'any'; without one, a number input steps by 10^-scale, or 'any' with no scale - pattern?: string; - - // Options (for select, radio-group) - options?: Array<{ value: string; label: string }>; - - // Styling: the classes of the control this field draws, joined with its own; never the field row - className?: string; -} - -interface FormSchema { - type: 'form'; - fields: FormField[]; // Form fields - defaultValues?: Record; // Initial values, keyed by field name (a field has no default key of its own) - submitLabel?: string; // Submit button text (default 'Submit') - - // Events - // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it - // by name. Author behaviour as a node type, e.g. an action:button node. - onSubmit?: (data: Record) => void | Promise; - - // Layout - columns?: number; // Grid layout - layout?: 'vertical' | 'horizontal'; // Label above its field (default), or beside it from `sm` up (stacked below `sm`) - - // Styling - className?: string; -} -``` - -## Examples - -### Multi-column Form - - diff --git a/ui/content/docs/components/form/input-otp.mdx b/ui/content/docs/components/form/input-otp.mdx deleted file mode 100644 index fb15261ecc2..00000000000 --- a/ui/content/docs/components/form/input-otp.mdx +++ /dev/null @@ -1,58 +0,0 @@ ---- -title: "Input OTP" -description: "One-time password input with auto-focus" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -The Input OTP component provides a secure way to enter one-time passwords or verification codes. - -## Basic Usage - - - -## With Separator - - - -## Different Lengths - - - - - - -## Schema - -```ts -interface InputOTPSchema { - type: 'input-otp'; - length?: number; // Number of digits (default 6) - value?: string; // Current value - separator?: boolean; // Show visual separator - - // Events - // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it - // by name. Author behaviour as a node type, e.g. an action:button node. - onChange?: (value: string) => void; - - // States - disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope - - // Styling - className?: string; -} -``` - - - `length` is the slot count, and the renderer now reads it. Until objectui#11347 - it read an undeclared `maxLength` instead and ignored `length`, so the 4- and - 8-digit examples above drew six slots. `maxLength` is not a key of this node: - write `length`. - - -## Examples - -### In a Form - - diff --git a/ui/content/docs/components/form/input.mdx b/ui/content/docs/components/form/input.mdx deleted file mode 100644 index ffb675c8705..00000000000 --- a/ui/content/docs/components/form/input.mdx +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: "Input" -description: "Basic text input field for user data entry" ---- - -## Examples - - - - - - - -## Schema - -```ts -interface InputSchema { - type: 'input'; - name?: string; - label?: string; - inputType?: 'text' | 'password' | 'email' | 'number' | 'tel' | 'url'; - placeholder?: string; - defaultValue?: string | number; - required?: boolean; - disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope - readonly?: boolean; -} -``` diff --git a/ui/content/docs/components/form/label.mdx b/ui/content/docs/components/form/label.mdx deleted file mode 100644 index 33e02ff4cb2..00000000000 --- a/ui/content/docs/components/form/label.mdx +++ /dev/null @@ -1,25 +0,0 @@ ---- -title: "Label" -description: "Text label for form elements" ---- - -The Label component renders accessible labels for form fields. - -## Basic Usage - - - -## With a Password Input - - - -## Schema - -```ts -interface LabelSchema { - type: 'label'; - text: string; // Label text - htmlFor?: string; // Associated input ID - className?: string; -} -``` diff --git a/ui/content/docs/components/form/meta.json b/ui/content/docs/components/form/meta.json deleted file mode 100644 index 52b243d2cf7..00000000000 --- a/ui/content/docs/components/form/meta.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "title": "Form", - "pages": [ - "button", - "input", - "textarea", - "select", - "combobox", - "checkbox", - "radio-group", - "switch", - "slider", - "label", - "date-picker", - "input-otp", - "command", - "form", - "calendar", - "file-upload", - "toggle" - ] -} diff --git a/ui/content/docs/components/form/radio-group.mdx b/ui/content/docs/components/form/radio-group.mdx deleted file mode 100644 index e545ee6fefa..00000000000 --- a/ui/content/docs/components/form/radio-group.mdx +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: "Radio Group" -description: "A set of checkable buttons where only one can be selected at a time" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -The Radio Group component allows users to select one option from a set of mutually exclusive options. - -## Basic Usage - - - -## With Default Value - - - -## Layout Options - - - - - - -## States - - - - - - -## Schema - -```ts -interface RadioOption { - value: string; - label: string; - disabled?: boolean; -} - -interface RadioGroupSchema { - type: 'radio-group'; - options: RadioOption[]; // Available options - value?: string; // Selected value - - // Layout - orientation?: 'horizontal' | 'vertical'; - - // States - disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope - - // Styling - className?: string; -} -``` - -## Examples - -### In a Form - - - -### With Descriptions - - diff --git a/ui/content/docs/components/form/select.mdx b/ui/content/docs/components/form/select.mdx deleted file mode 100644 index 366d6520628..00000000000 --- a/ui/content/docs/components/form/select.mdx +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: "Select" -description: "Dropdown selection from a list of options" ---- - -## Examples - - - - - -## Schema - -```ts -interface SelectSchema { - type: 'select'; - name?: string; - label?: string; - options: { label: string; value: string | number }[]; - placeholder?: string; - required?: boolean; - disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope - wrapperClass?: string; -} -``` - - - `wrapperClass` is now typed. The renderer has read it all along — it lands on the - wrapper element around the select and its label — but it went undeclared until - objectui#7722, surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's - `.passthrough()`. Declared as `string` on both faces, a **non-string** - `wrapperClass` that used to parse green is now refused by name — on `SelectSchema` - and through `safeValidateSchema` alike. Pass the Tailwind class string; a number or - an object gets a parse error where it used to get silence. - diff --git a/ui/content/docs/components/form/slider.mdx b/ui/content/docs/components/form/slider.mdx deleted file mode 100644 index a1ce2a8256c..00000000000 --- a/ui/content/docs/components/form/slider.mdx +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: "Slider" -description: "Select a value from a range" ---- - -## Examples - - - -## Schema - -```ts -interface SliderSchema { - type: 'slider'; - name?: string; - label?: string; - min?: number; - max?: number; - step?: number; - defaultValue?: number | number[]; -} -``` diff --git a/ui/content/docs/components/form/switch.mdx b/ui/content/docs/components/form/switch.mdx deleted file mode 100644 index ba30663104b..00000000000 --- a/ui/content/docs/components/form/switch.mdx +++ /dev/null @@ -1,33 +0,0 @@ ---- -title: "Switch" -description: "Toggle switch for binary options" ---- - -## Examples - - - - - -## Schema - -```ts -interface SwitchSchema { - type: 'switch'; - name?: string; - label?: string; - defaultChecked?: boolean; - disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope - wrapperClass?: string; -} -``` - - - `wrapperClass` is now typed. The renderer has read it all along — it lands on the - wrapper element around the switch and its label — but it went undeclared until - objectui#7722, surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's - `.passthrough()`. Declared as `string` on both faces, a **non-string** - `wrapperClass` that used to parse green is now refused by name — on `SwitchSchema` - and through `safeValidateSchema` alike. Pass the Tailwind class string; a number or - an object gets a parse error where it used to get silence. - diff --git a/ui/content/docs/components/form/textarea.mdx b/ui/content/docs/components/form/textarea.mdx deleted file mode 100644 index 48ff61457f3..00000000000 --- a/ui/content/docs/components/form/textarea.mdx +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: "Textarea" -description: "Multi-line text input for longer content" ---- - -## Examples - - - - - -## Schema - -```ts -interface TextareaSchema { - type: 'textarea'; - name?: string; - label?: string; - placeholder?: string; - rows?: number; - required?: boolean; - disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope - wrapperClass?: string; -} -``` - - - `wrapperClass` is now typed. The renderer has read it all along — it lands on the - wrapper element around the textarea and its label — but it went undeclared until - objectui#7722, surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's - `.passthrough()`. Declared as `string` on both faces, a **non-string** - `wrapperClass` that used to parse green is now refused by name — on `TextareaSchema` - and through `safeValidateSchema` alike. Pass the Tailwind class string; a number or - an object gets a parse error where it used to get silence. - diff --git a/ui/content/docs/components/form/toggle.mdx b/ui/content/docs/components/form/toggle.mdx deleted file mode 100644 index 9ffaa0afe8a..00000000000 --- a/ui/content/docs/components/form/toggle.mdx +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: "Toggle" -description: "Two-state button for toggling options on and off" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -The Toggle component is a button that can be toggled between on and off states, useful for settings and preferences. - -## Basic Usage - - - -## Variants - - - - - - -## Sizes - - - -## States - - - - - - -## Schema - -(Schema documentation temporarily disabled) - - -## Examples - -### With Icons - - - -### In a Settings Panel - - - -### Toolbar Toggles - - diff --git a/ui/content/docs/components/index.md b/ui/content/docs/components/index.md deleted file mode 100644 index 72fc56aa788..00000000000 --- a/ui/content/docs/components/index.md +++ /dev/null @@ -1,186 +0,0 @@ ---- -title: "Component Gallery" -description: "Explore all ObjectUI components organized by category" ---- - -# Component Gallery - -ObjectUI provides a comprehensive set of components built on React, Tailwind CSS, and Shadcn UI. All components are defined through JSON schemas and rendered with pixel-perfect quality. - -## Quick Navigation - -Browse components by category to find what you need: - -### [Basic Components](/docs/components/basic/text) -Essential building blocks: Text, Icon, Image, Separator, HTML - -### [Form Components](/docs/components/form/button) -Interactive inputs: Button, Input, Select, Checkbox, Switch, Textarea, Slider - -### [Layout Components](/docs/components/layout/container) -Structure your UI: Container, Card, Grid, Flex, Stack, Tabs - -### [Data Display](/docs/components/data-display/badge) -Show information: Badge, Avatar, Alert, List - -### [Feedback Components](/docs/components/feedback/loading) -User feedback: Loading, Progress, Skeleton - -### [Overlay Components](/docs/components/overlay/dialog) -Floating elements: Dialog, Drawer, Tooltip, Popover - -### [Disclosure Components](/docs/components/disclosure/accordion) -Show/hide content: Accordion, Collapsible - -### [Complex Components](/docs/components/complex/table) -Advanced patterns: Table (with sorting, filtering, pagination) - -## Component Categories - -### Basic Components - -The foundation of your UI. These are simple, single-purpose components: - -- **[Text](/docs/components/basic/text)** - Display text with typography control -- **[Icon](/docs/components/basic/icon)** - Render icons from Lucide React -- **[Image](/docs/components/basic/image)** - Display images with lazy loading -- **[Separator](/docs/components/basic/separator)** - Visual divider between content - -### Form Components - -Interactive elements for user input: - -- **[Button](/docs/components/form/button)** - Trigger actions with multiple variants -- **[Input](/docs/components/form/input)** - Text input with validation -- **[Select](/docs/components/form/select)** - Dropdown selection -- **[Checkbox](/docs/components/form/checkbox)** - Boolean selection -- **[Switch](/docs/components/form/switch)** - Toggle switch -- **[Textarea](/docs/components/form/textarea)** - Multi-line text input -- **[Slider](/docs/components/form/slider)** - Numeric range selection - -### Layout Components - -Structure and organize your interface: - -- **[Container](/docs/components/layout/container)** - Responsive container with max-width -- **[Card](/docs/components/layout/card)** - Content card with header and footer -- **[Grid](/docs/components/layout/grid)** - CSS Grid layout -- **[Flex](/docs/components/layout/flex)** - Flexbox layout -- **[Stack](/docs/components/layout/stack)** - Vertical or horizontal stack -- **[Tabs](/docs/components/layout/tabs)** - Tabbed interface - -### Data Display - -Present data to users: - -- **[Badge](/docs/components/data-display/badge)** - Small status indicators -- **[Avatar](/docs/components/data-display/avatar)** - User profile images -- **[Alert](/docs/components/data-display/alert)** - Contextual messages -- **[List](/docs/components/data-display/list)** - Ordered or unordered lists - -### Feedback Components - -Provide visual feedback: - -- **[Loading](/docs/components/feedback/loading)** - Loading spinner -- **[Progress](/docs/components/feedback/progress)** - Progress bar -- **[Skeleton](/docs/components/feedback/skeleton)** - Loading placeholder - -### Overlay Components - -Floating UI elements: - -- **[Dialog](/docs/components/overlay/dialog)** - Modal dialog -- **[Drawer](/docs/components/overlay/drawer)** - Slide-out drawer -- **[Tooltip](/docs/components/overlay/tooltip)** - Hover tooltips -- **[Popover](/docs/components/overlay/popover)** - Floating popover - -### Disclosure Components - -Expandable content: - -- **[Accordion](/docs/components/disclosure/accordion)** - Expandable sections -- **[Collapsible](/docs/components/disclosure/collapsible)** - Toggle content visibility - -### Complex Components - -Advanced, feature-rich components: - -- **[Table](/docs/components/complex/table)** - Data table with sorting, filtering, and pagination - -## Usage Pattern - -All ObjectUI components follow the same schema-based pattern: - -```json -{ - "type": "component-name", - "className": "tailwind-classes", - "componentSpecificProperty": "value" -} -``` - -### Example: Button - -```json -{ - "type": "button", - "label": "Click Me", - "variant": "default", - "size": "default", - "className": "mt-4" -} -``` - -### Example: Card with Form - -```json -{ - "type": "card", - "title": "User Profile", - "className": "max-w-md", - "children": { - "type": "form", - "fields": [ - { - "type": "input", - "name": "name", - "label": "Full Name" - }, - { - "type": "input", - "name": "email", - "label": "Email", - "inputType": "email" - } - ] - } -} -``` - -## Features - -All ObjectUI components share these characteristics: - -- ✅ **Schema-Driven** - Define with JSON, not code -- ✅ **Tailwind CSS** - Use utility classes directly in schemas -- ✅ **Accessible** - WCAG 2.1 AA compliant -- ✅ **Responsive** - Mobile-first design -- ✅ **Themeable** - Light/dark mode support -- ✅ **Type-Safe** - Full TypeScript support -- ✅ **Performant** - Lazy-loaded and tree-shakable - -## Next Steps - -- **[Quick Start Guide](/docs/guide/quick-start)** - Build your first ObjectUI app -- **[Schema Rendering](/docs/guide/schema-rendering)** - Learn how the engine works -- **[Component Registry](/docs/guide/component-registry)** - Register custom components -- **[Expressions](/docs/guide/expressions)** - Dynamic values with expressions - -## Need Help? - -Can't find what you're looking for? Check out: - -- [Architecture](/docs/guide/architecture) - Core concepts and package boundaries -- [API Reference](/docs/api) - Schema documentation and protocol specs -- [GitHub](https://github.com/objectstack-ai/objectui) - Report issues or contribute diff --git a/ui/content/docs/components/layout/aspect-ratio.mdx b/ui/content/docs/components/layout/aspect-ratio.mdx deleted file mode 100644 index 516bad5e588..00000000000 --- a/ui/content/docs/components/layout/aspect-ratio.mdx +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: "Aspect Ratio" -description: "Maintain a consistent width to height ratio" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -The Aspect Ratio component maintains a consistent width to height ratio for content. - -## Basic Usage - - - -## Different Ratios - - - - - - - -## Schema - -The box holds its content one of two ways, and the renderer reads exactly one of them: -set `image` for a photo, which is drawn to fill the box; otherwise the box renders -`children`. - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface AspectRatioSchema { - type: 'aspect-ratio'; - ratio?: number; // Width / Height ratio (default: 16 / 9) - image?: string; // Image URL, drawn to fill the box - alt?: string; // Alt text for `image` - children?: SchemaNode | SchemaNode[]; // Content, when no `image` is set - className?: string; -} -``` - -## Examples - -### Video Container - - diff --git a/ui/content/docs/components/layout/box.mdx b/ui/content/docs/components/layout/box.mdx deleted file mode 100644 index d2f8239f95f..00000000000 --- a/ui/content/docs/components/layout/box.mdx +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: "Box" -description: "Neutral block container — authored classes pass through verbatim, nothing is injected" ---- - -The Box component is the **neutral block container**: it renders its `children` -inside a single block element, emits the `className` you author **verbatim**, -and injects **zero classes of its own**. - -Reach for `box` when you want a styled wrapper or a plain grouping element and -the other layout components would add layout you did not ask for: - -- `container` injects width, centering and responsive padding -- `flex` / `stack` inject a flex display mode and gaps -- `grid` injects a grid display mode and columns -- `card` injects border, background and shadow, and wraps content in a card body - -If you *want* those semantics, use those components — they are the right tool -for flex rows, grids and cards. `box` is for the case where the box itself -should contribute nothing but your own classes. - -`box` replaces the deprecated `div` component on the JSON authoring surface. - -## Examples - -### Styled wrapper - - - -### Pure grouping - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface BoxSchema { - type: 'box'; - - // Content — `children` is the ONLY content channel - children?: SchemaNode | SchemaNode[]; - - // Styling — passed through verbatim, never merged with injected classes - className?: string; - - // Base properties - id?: string; - visible?: boolean | string | { dialect?: string; source: string }; -} -``` - -## Migrating from `div` - -`box` is a drop-in replacement for a JSON-authored `div`, with one deliberate -difference: **`box` reads `children` only** — as, since objectui#6771 retired -the `body` spelling, every renderer now does. The old `div` renderer fell back -to `body` when `children` was absent; a `div` node still authored with `body` -must move that content into `children`, whether or not it becomes a `box`: - -```json -{ - "type": "box", - "className": "flex-1 p-4", - "children": [ - { "type": "text", "content": "Main content area" } - ] -} -``` - -A `kind:'html'` page takes the same swap: `
` is refused there at compile time, -and the refusal names `box`. Author `` instead. diff --git a/ui/content/docs/components/layout/card.mdx b/ui/content/docs/components/layout/card.mdx deleted file mode 100644 index f7b66cd6f49..00000000000 --- a/ui/content/docs/components/layout/card.mdx +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: "Card" -description: "Container with header, content, and footer sections" ---- - -## Examples - - - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface CardSchema { - type: 'card'; - title?: string; - description?: string; - children?: SchemaNode[]; - header?: SchemaNode[]; - footer?: SchemaNode[]; - className?: string; -} -``` diff --git a/ui/content/docs/components/layout/container.mdx b/ui/content/docs/components/layout/container.mdx deleted file mode 100644 index d274127242a..00000000000 --- a/ui/content/docs/components/layout/container.mdx +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: "Container" -description: "Centered content container with max-width" ---- - -## Examples - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface ContainerSchema { - type: 'container'; - children: SchemaNode[]; - // Max width constraint. `false` means NO maximum width, and it is rendered as - // an explicit `max-w-none` — it cancels a max-width inherited from elsewhere - // (the typography plugin's `prose`, for instance), which simply omitting the - // class would not do. - maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' - | '7xl' | 'full' | 'screen' | false; // default: 'xl' - centered?: boolean; // default: true - padding?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | 12 | 16; // default: 4 - className?: string; -} -``` - -`padding` is a step on the container's spacing scale, and `0` means none. The twelve -steps are the ones the renderer maps to a padding class, so they are the only values -validation accepts: `"padding": 9` or `"padding": 20` is refused with the set named -(objectui#11424). Such a number used to pass validation and then render with no padding -at all, not even the default. diff --git a/ui/content/docs/components/layout/flex.mdx b/ui/content/docs/components/layout/flex.mdx deleted file mode 100644 index ab9feb60c57..00000000000 --- a/ui/content/docs/components/layout/flex.mdx +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: "Flex" -description: "Flexible box layout container" ---- - -## Examples - - - -## Schema - -A `flex` node takes its props — the child list included — in its `properties` bag, the -spelling `@objectstack/spec`'s page component declares. `objectui validate` refuses a `flex` -prop written on the node itself, by name, and points at its bag member -(`properties.direction`, `properties.children`, …). `className` and the other base props stay -on the node. - -```json -{ - "type": "flex", - "className": "p-4", - "properties": { - "direction": "col", - "gap": 4, - "children": [ - { "type": "text", "content": "First" }, - { "type": "text", "content": "Second" } - ] - } -} -``` - -The bag's members are `FlexLayoutProps` from `@object-ui/types`: - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface FlexNode { - type: 'flex'; - className?: string; - properties?: { - direction?: 'row' | 'col' | 'row-reverse' | 'col-reverse'; - gap?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; // default: 2 - align?: 'start' | 'end' | 'center' | 'baseline' | 'stretch'; - justify?: 'start' | 'end' | 'center' | 'between' | 'around' | 'evenly'; - wrap?: boolean; - children?: SchemaNode[]; - }; -} -``` - -Nothing changes at render time: `SchemaRenderer` hoists every `properties` key onto the node -before the `flex` renderer reads it, so a stored node that still writes these props flat keeps -rendering. - -`gap` is a step on the flex spacing scale, 0 to 8, and `0` means none. Those are the steps the -renderer maps to a gap class, so they are the only values validation accepts: -`"properties": { "gap": 9 }` is refused with the set named (objectui#11474). Such a number -used to pass validation and then render with no gap at all, not even the default. diff --git a/ui/content/docs/components/layout/grid.mdx b/ui/content/docs/components/layout/grid.mdx deleted file mode 100644 index d549c5b2085..00000000000 --- a/ui/content/docs/components/layout/grid.mdx +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: "Grid" -description: "Responsive grid layout container" ---- - -## Examples - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -type ColumnCount = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; - -interface GridSchema { - type: 'grid'; - columns?: ColumnCount | Partial>; // default: 2 - gap?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12; // default: 4 - children: SchemaNode[]; - className?: string; -} -``` - -`columns` is a column count from 1 to 12, or an object of such counts keyed by breakpoint -(`{ "xs": 1, "md": 2, "lg": 4 }`). Those twelve counts are the ones the renderer maps to a column -class, at every breakpoint, so they are the only values validation accepts: `"columns": 13`, -`"columns": 0` or `"columns": { "md": 13 }` is refused with the set named (objectui#11491). Such a -count used to draw no column class where it was authored: `13` lost its `md` count, and `0` drew -two columns. A bare count above 1 is one column on a phone and that count from `md` up; an object -keeps full control of every breakpoint. - -The object is the only way to set a count per breakpoint. The flat `smColumns`, `mdColumns`, -`lgColumns` and `xlColumns` keys are retired and refused by name (objectui#11505): write the count -as that breakpoint's member, so `"columns": 1, "mdColumns": 2` becomes `"columns": { "xs": 1, "md": 2 }`. - -`gap` is a step on the grid's spacing scale, and `0` means none. The ten steps are the ones -the renderer maps to a gap class, so they are the only values validation accepts: `"gap": 9` -or `"gap": 16` is refused with the set named (objectui#11474). For such a number the renderer -used to build a gap class at runtime that no compiled stylesheet defines, so the grid rendered -with no gap at all. diff --git a/ui/content/docs/components/layout/meta.json b/ui/content/docs/components/layout/meta.json deleted file mode 100644 index 008a4480bb6..00000000000 --- a/ui/content/docs/components/layout/meta.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "title": "Layout", - "pages": [ - "box", - "container", - "card", - "grid", - "flex", - "stack", - "tabs", - "aspect-ratio", - "page", - "semantic" - ] -} diff --git a/ui/content/docs/components/layout/page.mdx b/ui/content/docs/components/layout/page.mdx deleted file mode 100644 index 35b94ce246b..00000000000 --- a/ui/content/docs/components/layout/page.mdx +++ /dev/null @@ -1,90 +0,0 @@ ---- -title: "Page" -description: "Full-page container with title, description, and content sections" ---- - -The Page component provides a standardized full-page layout with built-in support for titles, descriptions, and organized content sections. - -## Basic Usage - - - -## With Title and Description - - - -`pageType` decides whether the page draws its own `title` and `description`. `app`, `list`, -`utility` and `home` draw both, unless a titled `page:header` block in the page takes over the -heading. A page with no `pageType` is a `record` page, which draws neither: its heading is a -[`page:header`](/docs/layout/page-header) block. So both examples above name `"pageType": "app"`. -The [layout guide's Content Width](/docs/guide/layout#content-width) section covers the width -each type sets. - -When a titled `page:header` takes over the heading, it also draws the page's `description`, -under its title and subtitle, so the description never sits above the title. - -## Region Block Grid - -A page's `regions` lay their blocks out on a 12-column grid. A block's width comes from its -kind, not from a key on the block: a metric tile (`element:number`, `object-metric`, -`statistic`) spans 3 columns, a chart (`chart`, `object-chart`) spans 6, and every other block -spans all 12. The spans fold by screen width: - -| Screen width | Metric tile | Chart | Other blocks | -|---|---|---|---| -| 1280px and wider | 3 | 6 | 12 | -| 768px to 1279px | 6 | 12 | 12 | -| under 768px | 12 | 12 | 12 | - -So four metric tiles share one row on a wide screen and take two rows on a 1024px one. A -`sidebar` or `aside` region beside `main` is a narrow column, so its blocks always span 12. -On the `dashboard` template each region is a cell of the page's region grid and lays its -own blocks out the same way. A page's `children` (no `regions`) stack one block per row. - -A `page:card` or `page:section` lays its children on the same grid. The container spans 12 -in its region, and its children take their own kind's span, so a card holding four metric -tiles shows them in one row on a wide screen. Studio's page designer shows each block's -span on its card, and on the child cards of a `page:card` or `page:section`. - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface PageSchema { - type: 'page'; - - // Header - title?: string; // Page title; not drawn on a 'record' page - icon?: string; // Page icon (Lucide icon name) - description?: string; // Page description; not drawn on a 'record' page - - // Page kind. Default 'record': its heading is a `page:header` block. - pageType?: 'record' | 'home' | 'app' | 'utility' | 'list'; - - // Content - children?: SchemaNode | SchemaNode[]; // Main content — one node or a list - - // Styling - className?: string; // Tailwind CSS classes - - // Base properties - id?: string; - visible?: boolean | string | { dialect?: string; source: string }; - testId?: string; -} -``` - -## Examples - -### Dashboard Page - - - -### Settings Page - - - -### Content Page - - diff --git a/ui/content/docs/components/layout/semantic.mdx b/ui/content/docs/components/layout/semantic.mdx deleted file mode 100644 index 999c4a2be1a..00000000000 --- a/ui/content/docs/components/layout/semantic.mdx +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: "Semantic" -description: "Semantic HTML5 elements for structured content (header, nav, main, aside, section, article, footer)" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -Semantic components provide HTML5 semantic elements that give meaning to the structure of web content, improving accessibility and SEO. - -## Available Elements - -ObjectUI provides semantic wrappers for these HTML5 elements: -- `header` - Page or section header -- `nav` - Navigation section -- `main` - Main content area -- `aside` - Sidebar or complementary content -- `section` - Thematic grouping of content -- `article` - Self-contained content -- `footer` - Page or section footer - -## Basic Usage - - - -## Header - - - -## Navigation - - - -## Section and Article - - - - - - -## Aside and Footer - - - - - - -## Schema - -```plaintext -// All semantic elements share the same schema structure -interface SemanticSchema { - type: 'header' | 'nav' | 'main' | 'aside' | 'section' | 'article' | 'footer'; - - // Content - children?: SchemaNode | SchemaNode[]; // Child components — the only child-list key - - // Styling - className?: string; // Tailwind CSS classes - - // Base properties - id?: string; - visible?: boolean | string; - testId?: string; -} -``` - -`children` is the one child-list key: the renderer takes its child list from `schema.children` only. The `body` spelling was retired by objectui#6771 — schema validation refuses it with a pointer to `children`, and a `body` list renders nothing. - -## Examples - -### Page Layout - - - -### Blog Post - - diff --git a/ui/content/docs/components/layout/stack.mdx b/ui/content/docs/components/layout/stack.mdx deleted file mode 100644 index c96400d8788..00000000000 --- a/ui/content/docs/components/layout/stack.mdx +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: "Stack" -description: "Vertical stack layout for organizing content" ---- - -## Examples - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface StackSchema { - type: 'stack'; - gap?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10; // default: 2 - children: SchemaNode[]; - className?: string; -} -``` - -`gap` is a step on the stack's spacing scale, and `0` means none. The nine steps are the -ones the renderer maps to a gap class, so they are the only values validation accepts: -`"gap": 7` or `"gap": 9` is refused with the set named (objectui#11474). Such a number used -to pass validation and then render with no gap at all, not even the default. A `flex` maps a -different set: it has `7` and no `10`. diff --git a/ui/content/docs/components/layout/tabs.mdx b/ui/content/docs/components/layout/tabs.mdx deleted file mode 100644 index 926d935ee6e..00000000000 --- a/ui/content/docs/components/layout/tabs.mdx +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: "Tabs" -description: "Organize content into switchable tabs" ---- - -## Examples - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface TabsSchema { - type: 'tabs'; - items: Array<{ - label: string; - content: SchemaNode[]; - }>; - defaultValue?: number; -} -``` diff --git a/ui/content/docs/components/meta.json b/ui/content/docs/components/meta.json deleted file mode 100644 index 6daa0959b2d..00000000000 --- a/ui/content/docs/components/meta.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "title": "Components", - "pages": [ - "basic", - "form", - "layout", - "data-display", - "feedback", - "overlay", - "disclosure", - "navigation", - "complex" - ] -} diff --git a/ui/content/docs/components/navigation/header-bar.mdx b/ui/content/docs/components/navigation/header-bar.mdx deleted file mode 100644 index 6c0714e8a0b..00000000000 --- a/ui/content/docs/components/navigation/header-bar.mdx +++ /dev/null @@ -1,164 +0,0 @@ ---- -title: "Header Bar" -description: "Application header with sidebar trigger and breadcrumb navigation" ---- - -The Header Bar component provides a standard application header with sidebar trigger button and breadcrumb navigation. - -## Basic Usage - - - -## With Multiple Breadcrumbs - - - -## Crumb Icons - -A crumb's `icon` is a **kebab-case Lucide icon name**, resolved against lucide's -runtime `icons` record — the same surface `ui:breadcrumb`, `ui:button` and the -`action:*` family resolve against, reached through the same shared resolver. A -name that is not a live key of that record (an unknown, or a retired spelling -such as `layout`) renders **no glyph**, never a fallback glyph and never the -literal name as text. - - - -`crumbs` and a `ui:breadcrumb`'s `items` are the **same** declared item shape, -so an icon authored on either draws the same glyph. - -## Styling - -`className` is merged onto the header's root `header` element, after the -header's own classes, through `cn()` (`tailwind-merge`). A utility you write that -conflicts with one of the defaults replaces it; any other class is added. - -```json -{ - "type": "header-bar", - "className": "h-20 sm:h-20 bg-muted/40", - "crumbs": [ - { "label": "Home", "href": "#" }, - { "label": "Reports" } - ] -} -``` - -A conflict is decided per variant. The default height is `h-14`, and `sm:h-16` -from the `sm` breakpoint up, so `h-20` alone replaces only the first: from `sm` -up the header is still `h-16`. Write `sm:h-20` too, as above, for one height at -every width. - -Without `className`, the header renders its default classes unchanged. - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface BreadcrumbItem { - label: string; // Breadcrumb text - href?: string; // Link URL (optional for last item) - icon?: string; // kebab-case Lucide icon name (e.g. "panels-top-left") - siblings?: Array<{ label: string; href: string }>; // Quick-switch dropdown -} - -interface HeaderBarSchema { - type: 'header-bar'; - - // Breadcrumbs - crumbs?: BreadcrumbItem[]; // Breadcrumb items - - // Search - search?: { - enabled: boolean; // Draws the search input when true - placeholder?: string; // Input placeholder text - shortcut?: string; // Hint in the input from `sm` up, e.g. "⌘K" (text only) - }; - - // Right side, after the search input - actions?: SchemaNode[]; // Nodes rendered in order - rightContent?: SchemaNode; // One node, rendered after `actions` - - // Styling - className?: string; // Tailwind classes, merged onto the root header element - - // Base properties - id?: string; - visible?: boolean | string | { dialect?: string; source: string }; - testId?: string; -} -``` - -### Retired keys - -`title`, `logo`, `nav`, `left`, `center`, `right`, `sticky` and `height` are retired -on this node (objectui#10387, alongside `variant` from objectui#10286). The renderer -never read any of them, so an authored value drew the same header as leaving it out. -They now fail validation by name, and the TypeScript declaration types each one -`never`. Delete the key: - -- `title` — name the current page with the last entry of `crumbs`. -- `logo` — put brand content in `rightContent` or `actions`. -- `nav` — put links in `crumbs`, or use a `navigation-menu` / `sidebar` node. -- `left` / `center` — put custom content in `rightContent` or `actions`. -- `right` — use `actions` (a node list) or `rightContent` (one node). -- `sticky` — make the parent layout sticky; the header itself never was. -- `height` — set the height with `className` instead (see [Styling](#styling)). By - default it is `h-14`, and `sm:h-16` from the `sm` breakpoint up. - -## Examples - -### Application Header - - - -### Settings Navigation - - - -### Admin Panel - - - -## Usage Notes - -- The Header Bar component includes a sidebar trigger button that works with the Sidebar component -- The last breadcrumb item is typically rendered as plain text (current page) -- A crumb may carry an `icon` (see [Crumb Icons](#crumb-icons)) and `siblings`, which turns it into a quick-switch dropdown -- All other breadcrumb items should have an `href` to enable navigation -- The header is `h-14` (56px) tall, and `h-16` (64px) from the `sm` breakpoint up; `className` overrides either (see [Styling](#styling)) -- Includes a vertical separator between the sidebar trigger and breadcrumbs - -## Integration - -This component is designed to work with the sidebar layout pattern: - -```plaintext -{ - type: 'div', - children: [ - { type: 'sidebar', /* sidebar config */ }, - { - type: 'div', - className: 'flex-1', - children: [ - { - type: 'header-bar', - crumbs: [ - { label: 'Home', href: '#' }, - { label: 'Current' } - ] - }, - { - type: 'main', - className: 'p-6', - children: [ - // Main content - ] - } - ] - } - ] -} -``` diff --git a/ui/content/docs/components/navigation/meta.json b/ui/content/docs/components/navigation/meta.json deleted file mode 100644 index 9117a04b922..00000000000 --- a/ui/content/docs/components/navigation/meta.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "title": "Navigation", - "pages": [ - "header-bar" - ] -} diff --git a/ui/content/docs/components/overlay/alert-dialog.mdx b/ui/content/docs/components/overlay/alert-dialog.mdx deleted file mode 100644 index 76f6be71a60..00000000000 --- a/ui/content/docs/components/overlay/alert-dialog.mdx +++ /dev/null @@ -1,57 +0,0 @@ ---- -title: "Alert Dialog" -description: "A modal dialog that interrupts the user with important content and expects a response" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -The Alert Dialog component is used to interrupt the user with important content and require a confirmation before proceeding. - -## Basic Usage - - - -## Variants - - - - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface AlertDialogSchema { - type: 'alert-dialog'; - title?: string; // Dialog title - description?: string; // Dialog description - - // Trigger - trigger?: SchemaNode | SchemaNode[]; // Component that triggers the dialog - - // Body and footer - content?: SchemaNode | SchemaNode[]; // Rendered between the header and the footer - cancelText?: string; // Cancel button label; no cancel button when omitted - actionText?: string; // Confirm button label; no confirm button when omitted - actionVariant?: 'default' | 'destructive'; // Confirm button variant; red confirm when 'destructive' - - // Open state - defaultOpen?: boolean; // Initial state when uncontrolled (default: false) - open?: boolean; // Controlled open state - - // Styling - className?: string; -} -``` - -The footer is driven by the two label keys: the cancel button renders only when `cancelText` is set and the confirm button only when `actionText` is set — neither has a renderer default (the designer palette seeds `Cancel` / `Continue`). The confirm button's click handler, `onAction`, is a runtime slot a React host supplies through the TypeScript interface; it has no JSON spelling, and the validator refuses it by name. - -The confirm button's look is `actionVariant`. Omit it and the button stays the default primary; write `'destructive'` for the red confirm a delete dialog wants. Those are the two values it takes — not the six a `button` node takes — because the renderer applies the variant as a class override on top of the primitive's built-in styling, and the remaining upstream variants set no background and/or no text colour, so the default's would show through. The cancel button is always `outline` and has no variant key. (The older spelling `confirmVariant` is retired and refused by name — it never reached the DOM.) - -## Examples - -### With Custom Actions - - diff --git a/ui/content/docs/components/overlay/context-menu.mdx b/ui/content/docs/components/overlay/context-menu.mdx deleted file mode 100644 index f2f1a5d7c00..00000000000 --- a/ui/content/docs/components/overlay/context-menu.mdx +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: "Context Menu" -description: "Display a menu on right-click or long-press" ---- - -The Context Menu component displays a menu when right-clicking on an element. - -## Basic Usage - - - -## Icons - -An item's `icon` is a **kebab-case Lucide icon name**, resolved against lucide's -runtime `icons` record — the same surface `ui:button`, `ui:dropdown-menu` and the -`action:*` family resolve against. It is read on **both** arms: a leaf item and a -submenu trigger. A name that is not a live key of that record (an unknown or a -retired spelling such as `edit`) renders **no glyph**, never a fallback glyph and -never the literal name as text. - -## Dividers - -An item is either a command or a divider between groups of commands — never -both, and a divider never carries a `label` (objectui#6523). Author a divider -as `{ "separator": true }`; the retired `{ "type": "separator" }` spelling is -refused at parse time rather than silently ignored. - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface ContextMenuCommandItem { - label: string; - icon?: string; // kebab-case Lucide icon name (e.g. "trash") - shortcut?: string; // e.g. "Ctrl+C" — a single string, not an array - disabled?: boolean; - // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it - // by name. Author behaviour as a node type, e.g. an action:button node. - onClick?: () => void; // Click handler - children?: ContextMenuItem[]; // Submenu items — drawn as a nested submenu -} - -interface ContextMenuDividerItem { - separator: true; // renders as a divider — no label -} - -type ContextMenuItem = ContextMenuCommandItem | ContextMenuDividerItem; - -interface ContextMenuSchema { - type: 'context-menu'; - trigger?: SchemaNode | SchemaNode[]; // Trigger element — a placeholder renders when absent - items: ContextMenuItem[]; // Menu items - triggerClassName?: string; // Classes for the right-clickable area - contentClassName?: string; // Classes for the menu panel - modal?: boolean; // Forwarded to the Radix root - className?: string; -} -``` - -The item's `onClick` is a runtime slot a React host supplies through the -TypeScript `MenuItem` interface; it has no JSON spelling, and the validator -refuses it by name on the item. The menu schema itself declares no authorable -event slot either, so there is no menu-level `onSelect`. diff --git a/ui/content/docs/components/overlay/dialog.mdx b/ui/content/docs/components/overlay/dialog.mdx deleted file mode 100644 index 742584d207a..00000000000 --- a/ui/content/docs/components/overlay/dialog.mdx +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: "Dialog" -description: "Modal dialog overlay for focused interactions" ---- - -## Examples - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface DialogSchema { - type: 'dialog'; - trigger: SchemaNode | SchemaNode[]; - title?: string; - description?: string; - content?: SchemaNode | SchemaNode[]; - footer?: SchemaNode[]; -} -``` diff --git a/ui/content/docs/components/overlay/drawer.mdx b/ui/content/docs/components/overlay/drawer.mdx deleted file mode 100644 index 3666efd54e0..00000000000 --- a/ui/content/docs/components/overlay/drawer.mdx +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: "Drawer" -description: "Sliding panel from edge of screen" ---- - -## Examples - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface DrawerSchema { - type: 'drawer'; - trigger: SchemaNode | SchemaNode[]; - title?: string; - content?: SchemaNode | SchemaNode[]; - side?: 'left' | 'right' | 'top' | 'bottom'; -} -``` diff --git a/ui/content/docs/components/overlay/dropdown-menu.mdx b/ui/content/docs/components/overlay/dropdown-menu.mdx deleted file mode 100644 index f6a19f9c0fd..00000000000 --- a/ui/content/docs/components/overlay/dropdown-menu.mdx +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: "Dropdown Menu" -description: "Display a menu of actions or options triggered by a button" ---- - -The Dropdown Menu component displays a list of actions or options when triggered. - -## Basic Usage - - - -## With Icons - - - -## Icons - -An item's `icon` is a **kebab-case Lucide icon name**, resolved against lucide's -runtime `icons` record — the same surface `ui:button` and the `action:*` family -resolve against. A name that is not a live key of that record (an unknown or a -retired spelling such as `edit`) renders **no glyph**, never a fallback glyph -and never the literal name as text. - -## Dividers - -An item is either a command or a divider between groups of commands — never -both, and a divider never carries a `label` (objectui#6523). Author a divider -as `{ "separator": true }`; the retired `{ "type": "separator" }` spelling is -refused at parse time rather than silently ignored. - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface DropdownMenuCommandItem { - label: string; - icon?: string; // kebab-case Lucide icon name (e.g. "square-pen") - shortcut?: string; // e.g. "Ctrl+S" — a single string, not an array - disabled?: boolean; - // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it - // by name. Author behaviour as a node type, e.g. an action:button node. - onClick?: () => void; // Click handler - children?: DropdownMenuItem[]; // Submenu items — drawn as a nested submenu -} - -interface DropdownMenuDividerItem { - separator: true; // renders as a divider — no label -} - -type DropdownMenuItem = DropdownMenuCommandItem | DropdownMenuDividerItem; - -interface DropdownMenuSchema { - type: 'dropdown-menu'; - trigger: SchemaNode | SchemaNode[]; // Trigger component - items: DropdownMenuItem[]; // Menu items - - // Styling - className?: string; -} -``` - -The item's `onClick` is a runtime slot a React host supplies through the -TypeScript `MenuItem` interface; it has no JSON spelling, and the validator -refuses it by name on the item. The menu schema itself declares no authorable -event slot either, so there is no menu-level `onSelect`. diff --git a/ui/content/docs/components/overlay/hover-card.mdx b/ui/content/docs/components/overlay/hover-card.mdx deleted file mode 100644 index de233eba031..00000000000 --- a/ui/content/docs/components/overlay/hover-card.mdx +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: "Hover Card" -description: "Display rich content in a popup when hovering over a trigger" ---- - -The Hover Card component displays rich content when hovering over an element. - -## Basic Usage - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface HoverCardSchema { - type: 'hover-card'; - trigger: SchemaNode | SchemaNode[]; // Trigger element - content: SchemaNode | SchemaNode[]; // Card content - side?: 'top' | 'right' | 'bottom' | 'left'; - align?: 'start' | 'center' | 'end'; - openDelayMs?: number; // Delay before opening (ms) - closeDelayMs?: number; // Delay before closing (ms) - className?: string; -} -``` diff --git a/ui/content/docs/components/overlay/menubar.mdx b/ui/content/docs/components/overlay/menubar.mdx deleted file mode 100644 index fdba0db18d3..00000000000 --- a/ui/content/docs/components/overlay/menubar.mdx +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: "Menubar" -description: "Horizontal menu bar with dropdown menus" ---- - -The Menubar component provides a horizontal menu bar similar to desktop applications. - -## Basic Usage - - - -## Icons - -An item's `icon` is a **kebab-case Lucide icon name**, resolved against lucide's -runtime `icons` record — the same surface `ui:dropdown-menu`, `ui:context-menu` -and the `action:*` family resolve against. It is read on **every** arm that draws -an item: a top-level item, a submenu trigger, and an item inside a submenu, at any -depth. A name that is not a live key of that record (an unknown or a retired -spelling such as `edit`) renders **no glyph**, never a fallback glyph and never -the literal name as text. - -## Dividers - -An item is either a command or a divider between groups of commands — never -both, and a divider never carries a `label` (objectui#6523). Author a divider -as `{ "separator": true }`; the retired `{ "type": "separator" }` spelling is -refused at parse time rather than silently ignored. - -## Schema - -```ts -interface MenubarCommandItem { - label: string; - icon?: string; - shortcut?: string; // e.g. "Ctrl+T" — a single string, not an array - disabled?: boolean; - // RUNTIME SLOT (objectui#6124): a React host supplies it; JSON refuses it - // by name. Author behaviour as a node type, e.g. an action:button node. - onClick?: () => void; // Click handler - children?: MenubarItem[]; // Submenu items — drawn as a nested submenu -} - -interface MenubarDividerItem { - separator: true; // renders as a divider — no label -} - -type MenubarItem = MenubarCommandItem | MenubarDividerItem; - -interface MenubarMenu { - label: string; - items: MenubarItem[]; -} - -interface MenubarSchema { - type: 'menubar'; - menus?: MenubarMenu[]; // Menu definitions - className?: string; -} -``` - -The item's `onClick` is a runtime slot a React host supplies through the -TypeScript `MenuItem` interface; it has no JSON spelling, and the validator -refuses it by name on the item. The menu schema itself declares no authorable -event slot either, so there is no menu-level `onSelect`. diff --git a/ui/content/docs/components/overlay/meta.json b/ui/content/docs/components/overlay/meta.json deleted file mode 100644 index 54855a1bfaf..00000000000 --- a/ui/content/docs/components/overlay/meta.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "title": "Overlay", - "pages": [ - "dialog", - "drawer", - "tooltip", - "popover", - "alert-dialog", - "sheet", - "hover-card", - "context-menu", - "dropdown-menu", - "menubar" - ] -} diff --git a/ui/content/docs/components/overlay/popover.mdx b/ui/content/docs/components/overlay/popover.mdx deleted file mode 100644 index f80317be436..00000000000 --- a/ui/content/docs/components/overlay/popover.mdx +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: "Popover" -description: "Floating content panel" ---- - -## Examples - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface PopoverSchema { - type: 'popover'; - trigger: SchemaNode | SchemaNode[]; - content?: SchemaNode | SchemaNode[]; - side?: 'top' | 'right' | 'bottom' | 'left'; -} -``` diff --git a/ui/content/docs/components/overlay/sheet.mdx b/ui/content/docs/components/overlay/sheet.mdx deleted file mode 100644 index 46c40e0c0a8..00000000000 --- a/ui/content/docs/components/overlay/sheet.mdx +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: "Sheet" -description: "Slide-in panel from the edge of the screen" ---- - -import { DemoGrid } from '@/app/components/ComponentDemo'; - -The Sheet component displays content in a panel that slides in from the edge of the screen. - -## Basic Usage - - - -## Sides - - - - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface SheetSchema { - type: 'sheet'; - trigger?: SchemaNode | SchemaNode[]; // Trigger component - title?: string; // Sheet title - description?: string; // Sheet description - content?: SchemaNode | SchemaNode[]; // Sheet content - side?: 'left' | 'right' | 'top' | 'bottom'; - className?: string; -} -``` diff --git a/ui/content/docs/components/overlay/tooltip.mdx b/ui/content/docs/components/overlay/tooltip.mdx deleted file mode 100644 index 505783f9d34..00000000000 --- a/ui/content/docs/components/overlay/tooltip.mdx +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: "Tooltip" -description: "Contextual information on hover" ---- - -## Examples - - - -## Schema - -```ts -import type { SchemaNode } from '@object-ui/types'; - -interface TooltipSchema { - type: 'tooltip'; - trigger?: SchemaNode | SchemaNode[]; // Element the tooltip attaches to - content?: string; // Tooltip text — read first - children?: SchemaNode | SchemaNode[]; // Rich content — the fallback half - side?: 'top' | 'right' | 'bottom' | 'left'; - align?: 'start' | 'center' | 'end'; - delayDurationMs?: number; // Delay before showing (ms) -} -``` - -The renderer reads `schema.trigger` for the trigger and -`schema.content || children` for the body. `content` is text only: a node or -a list of nodes there is refused by validation, because the renderer places -`content` directly in the tooltip body and a node there fails to render -(objectui#10295). Author rich content under `children` instead. When both are -authored, a non-empty `content` wins and `children` is not rendered. - -The trigger is authored under `trigger`, not `children` (objectui#6939). A -tooltip that puts its trigger under `children` validates, but paints an empty -trigger: `children` is the tooltip's rich-content slot, not its trigger. diff --git a/ui/content/docs/core/app-schema.mdx b/ui/content/docs/core/app-schema.mdx deleted file mode 100644 index 486fc1e1296..00000000000 --- a/ui/content/docs/core/app-schema.mdx +++ /dev/null @@ -1,317 +0,0 @@ ---- -title: "Application Schema (AppComponentSchema)" -description: "Configure your entire application with navigation, branding, and global settings" ---- - -import { SchemaExample } from '@/app/components/ComponentDemo'; - -# Application Schema - -The `AppComponentSchema` defines the top-level configuration for your entire ObjectUI application, including navigation menus, branding, layout strategies, and app-level actions. - -## Overview - -AppComponentSchema provides a declarative way to configure: -- **Navigation menus** - Hierarchical menu structures with icons and badges -- **Branding** - Logo, title, favicon -- **Layout strategies** - Sidebar, header, or empty layout -- **App-level actions** - `navigation` items of type `action` that run a declared action - -## Interactive Examples - -### Navigation Menu Preview - - - -### Application Header Bar - - - -## Basic Usage - -```ts -import type { AppComponentSchema } from '@object-ui/types'; - -const app: AppComponentSchema = { - type: 'app', - name: 'my-crm', - title: 'My CRM Application', - branding: { logo: '/logo.svg', favicon: '/favicon.ico' }, - layout: 'sidebar', - - menu: [ - { - type: 'item', - label: 'Dashboard', - icon: 'layout-dashboard', - path: '/dashboard' - } - ] -}; -``` - -## Properties - -The tables in this section describe `AppComponentSchema`, declared by -`@object-ui/types` (`packages/types/src/app.ts`). They group its properties by -topic; the declaration remains the complete list. - -### Basic Configuration - -| Property | Type | Description | -|----------|------|-------------| -| `type` | `'app'` | Component type identifier (required) | -| `name` | `string` | Application system identifier | -| `title` | `string` | Display title shown in browser | -| `description` | `string` | Application description | -| `icon` | `string` | Lucide icon name | -| `branding.logo` | `string` | Logo image URL (or a `data:` URI) | -| `branding.favicon` | `string` | Favicon URL | - -The logo has one spelling, `branding.logo`. A top-level `logo` is refused by the validator and -names `branding` as the fix (objectui#10827): it was a second spelling that `@objectstack/spec` -never declared. For an icon name, use `icon`. The favicon follows the same rule: it is -`branding.favicon`, and a top-level `favicon` is refused the same way (objectui#10842). - -### Layout Configuration - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `layout` | `'sidebar' \| 'header' \| 'empty'` | `'sidebar'` | Global layout strategy | - -**Layout Options:** -- **`sidebar`** - Standard admin layout with left sidebar navigation -- **`header`** - Top navigation bar only -- **`empty`** - No layout, pages handle their own structure - -### Navigation Menu - -The `menu` property accepts an array of `AppMenuItem` objects: - -```ts -interface AppMenuItem { - type?: 'item' | 'group' | 'separator'; - label?: string; - icon?: string; // Lucide icon name - path?: string; // Route path - href?: string; // External link - children?: AppMenuItem[]; // Submenu items - badge?: string | number; - hidden?: boolean | string; // Visibility condition -} -``` - -#### Menu Types - -**Item** - Single navigation link -```json -{ - "type": "item", - "label": "Dashboard", - "icon": "layout-dashboard", - "path": "/dashboard", - "badge": "New" -} -``` - -**Group** - Collapsible menu group -```json -{ - "type": "group", - "label": "Sales", - "icon": "dollar-sign", - "children": [ - { "type": "item", "label": "Leads", "path": "/leads" }, - { "type": "item", "label": "Deals", "path": "/deals" } - ] -} -``` - -**Separator** - Visual separator -```json -{ - "type": "separator" -} -``` - -### Global Actions - -App-level actions — a "Quick Create" or "Log a Call" entry that is not tied to -one record — are `navigation` items of type `action`. The item names an action -declared in your metadata by `actionDef.actionName`; it does not carry the -action's definition. This is the same item shape `@objectstack/spec` accepts in -an app's `navigation`, so the document validates on both sides: - -```ts -import type { AppComponentSchema } from '@object-ui/types'; - -const app: AppComponentSchema = { - type: 'app', - name: 'acme_crm', - title: 'Acme CRM', - navigation: [ - { - id: 'quick_create', - type: 'action', - label: 'Quick Create', - icon: 'zap', - actionDef: { actionName: 'quick_create' } - } - ] -}; -``` - -The console renders the item in the app's sidebar. On click it looks up the -action named in `actionDef.actionName` among the declared `action` metadata and -runs it through the same action runner as a toolbar button, so confirmations, -parameter dialogs and permission checks apply. `actionDef.params` passes -arguments to that action. An item that names no declared action shows an error -when clicked instead of doing nothing. The standalone runner renders only the -legacy `menu` and does not render `navigation` items. - - -The free-form `actions` array (`AppAction`: header buttons and a user avatar -menu) is retired, and an app that authors `actions` fails validation with a -message pointing here (objectui#7469). `@objectstack/spec`'s `AppSchema` never -declared the key, so the platform rejected those apps too. The signed-in user's -menu belongs to the host shell, not to app metadata. - - -## Complete Example - -```ts -import type { AppComponentSchema } from '@object-ui/types'; - -const crm: AppComponentSchema = { - type: 'app', - name: 'acme-crm', - title: 'Acme CRM', - description: 'Customer Relationship Management System', - branding: { logo: '/acme-logo.svg', favicon: '/favicon.ico' }, - layout: 'sidebar', - - menu: [ - { - type: 'item', - label: 'Dashboard', - icon: 'layout-dashboard', - path: '/dashboard' - }, - { - type: 'separator' - }, - { - type: 'group', - label: 'Sales', - icon: 'dollar-sign', - children: [ - { - type: 'item', - label: 'Leads', - icon: 'users', - path: '/leads', - badge: 12 - }, - { - type: 'item', - label: 'Opportunities', - icon: 'target', - path: '/opportunities' - }, - { - type: 'item', - label: 'Quotes', - icon: 'file-text', - path: '/quotes' - } - ] - }, - { - type: 'group', - label: 'Marketing', - icon: 'megaphone', - children: [ - { - type: 'item', - label: 'Campaigns', - path: '/campaigns' - }, - { - type: 'item', - label: 'Email Templates', - path: '/templates' - } - ] - }, - { - type: 'separator' - }, - { - type: 'item', - label: 'Settings', - icon: 'settings', - path: '/settings', - hidden: '${user.role !== "admin"}' - } - ] -}; -``` - -## Runtime Validation - -Use Zod validation to ensure your app configuration is correct: - -```ts -import { AppComponentSchema } from '@object-ui/types/zod'; - -const myAppConfig = { - type: 'app', - name: 'my-crm', - title: 'My CRM Application', -}; - -const result = AppComponentSchema.safeParse(myAppConfig); - -if (result.success) { - console.log('Valid app configuration'); -} else { - console.error('Validation errors:', result.error); -} -``` - -## Conditional Navigation - -Use expression syntax to show/hide menu items based on user permissions: - -```json -{ - "type": "item", - "label": "Admin Panel", - "path": "/admin", - "hidden": "${user.role !== 'admin'}" -} -``` - -## Use Cases - -AppComponentSchema is ideal for: - -- **Multi-page applications** - Configure complex application structures with multiple routes -- **Admin dashboards** - Create comprehensive admin panels with role-based navigation -- **CRM systems** - Build customer relationship management interfaces -- **Internal tools** - Develop enterprise internal tools with centralized navigation -- **SaaS products** - Configure multi-tenant applications with customizable layouts - -## Best Practices - -1. **Use semantic icons** - Choose Lucide icons that clearly represent the section -2. **Group related items** - Use menu groups to organize navigation -3. **Limit top-level items** - Keep the main menu concise (5-7 items) -4. **Add badges for notifications** - Show counts or status indicators -5. **Hide admin features** - Use conditional visibility for role-based access - -## Related - -- [Layout Schema](/docs/guide/layout) - Page layout configuration -- [Navigation Components](/docs/components/basic/navigation-menu) - Navigation UI components -- [Theme Schema](/docs/core/theme-schema) - Application theming diff --git a/ui/content/docs/core/enhanced-actions.mdx b/ui/content/docs/core/enhanced-actions.mdx deleted file mode 100644 index c2de567049b..00000000000 --- a/ui/content/docs/core/enhanced-actions.mdx +++ /dev/null @@ -1,530 +0,0 @@ ---- -title: "Enhanced Actions" -description: "Advanced action system with AJAX calls, chaining, conditions, and tracking" ---- - -import { SchemaExample } from '@/app/components/ComponentDemo'; - -# Enhanced Actions - -ObjectUI's action system has been significantly enhanced to support complex workflows, including AJAX API calls, confirmation dialogs, action chaining, conditional execution, and comprehensive tracking. - -## Overview - -The enhanced `ActionSchema` provides: -- **New action types**: `ajax`, `confirm`, `dialog` -- **Action chaining**: Execute multiple actions sequentially or in parallel -- **Conditional execution**: a `condition` predicate gates whether an action runs -- **Notices**: `successMessage` / `errorMessage` strings -- **Tracking**: Event logging and analytics -- **Retry logic**: Automatic retry with configurable backoff - -## Interactive Examples - -### Action Buttons - - - -### Confirmation Dialog Pattern - - - -### Action Toolbar - - - -## Action Types - -### Ajax Actions - -Execute API calls with full request configuration: - -```ts -import type { ActionSchema } from '@object-ui/types'; - -const ajaxAction: ActionSchema = { - type: 'action', - label: 'Load Data', - actionType: 'ajax', - api: '/api/users', - method: 'GET', - headers: { - 'Authorization': 'Bearer token' - }, - data: { - filter: 'active' - }, - successMessage: 'Data loaded successfully', - errorMessage: 'Failed to load data' -}; -``` - -**Ajax Properties:** -- `api` - API endpoint URL -- `method` - HTTP method (GET, POST, PUT, DELETE, PATCH) -- `data` - Request body/payload -- `headers` - Custom HTTP headers -- `timeoutMs` - Request timeout in milliseconds (renamed from `timeout`) -- `retry` - Retry configuration (`maxAttempts`, `delayMs`) - -### Confirm Actions - -Show confirmation dialog before executing: - -```ts -import type { ActionSchema } from '@object-ui/types'; - -const confirmAction: ActionSchema = { - type: 'action', - label: 'Delete Record', - actionType: 'confirm', - confirmText: 'Are you sure you want to delete this record?', - api: '/api/records/123', - method: 'DELETE' -}; -``` - -**Confirm Properties:** -- `confirmText` - Confirmation message shown in the dialog. This is the one - confirm spelling — it is what the translation bundle addresses - (`{ns}.objects.{obj}._actions.{name}.confirmText`), matching - `@objectstack/spec`'s action surface. A structured `confirm` object - (`{ title, message, ... }`) was retired in objectui#4314: it had no - translation key, so its dialogs could never be localized. - -### Dialog Actions - -Open a modal or dialog: - -```ts -import type { ActionSchema } from '@object-ui/types'; - -const dialogAction: ActionSchema = { - type: 'action', - label: 'Edit Details', - actionType: 'dialog', - dialog: { - title: 'Edit Record', - size: 'lg', - content: { - type: 'form', - fields: [ - { name: 'name', type: 'input', label: 'Name' }, - { name: 'email', type: 'input', label: 'Email' } - ] - }, - actions: [ - { - type: 'action', - label: 'Save', - actionType: 'ajax', - api: '/api/records/123', - method: 'PUT' - } - ] - } -}; -``` - -## Action Params (input collection) - -An action may declare `params` (spec `ActionParamSchema`) to collect user input -in a dialog before it runs. Each param renders through the **same field-widget -renderer the object form uses**, so a param of *any* form-supported field type — -`select`, `lookup`, `date`, `file`, `image`, `richtext`, `color`, `address`, … — -gets its real widget, not a text box (ADR-0059): - -```json -{ - "name": "approve", - "label": "Approve", - "params": [ - { "name": "comment", "type": "textarea", "label": "Comment", "required": true }, - { "name": "attachments", "type": "file", "multiple": true, "accept": ["application/pdf"] }, - { "name": "assignee", "field": "owner_id" }, - { "name": "notify", "type": "boolean", "label": "Notify the requester", "defaultValue": true } - ] -} -``` - -- **Inline params** declare `name` + `type` (any spec `FieldType`), plus widget - config: `options`, `multiple`, `accept`, `maxSize`, `placeholder`, - `helpText`, `defaultValue`. -- **Field-backed params** declare `field` (+ optional `objectOverride`) and - inherit label, type, options, lookup picker config, `multiple`, `accept`, - and `maxSize` from the object's field definition; inline properties override. - The same resolution runs when the action is named in a list's `bulkActions` - and launched from the selection bar, so a field-backed lookup param gets the - record picker there too. On the selection bar, the field's picker target, - display field and `dependsOn` reach the picker; its other picker settings - (`lookupFilters`, `lookupColumns`, …) do not yet. -- `required` blocks submit while the value is empty; `visible` (a CEL - predicate over `features` / `current_user` / `data`) hides a param - entirely — e.g. gate a param on an opt-in server capability. There is no - `app` root: objectui#8155 removed it, because neither ADR-0068 nor - `@objectstack/formula`'s `SCOPE_ROOTS` declares one. -- Values are passed through to the action exactly as the widget emits them - (`number` → number, `date` → `YYYY-MM-DD`, lookup → record id(s), `file` → - the uploaded `sys_file` id(s); arrays when `multiple`). An upload whose adapter - returns no file id is refused in the widget, so no inline file object is sent. - -File/image params upload through the ambient `UploadProvider`; lookup/user -params query through the surrounding `SchemaRendererContext` data source — no -extra wiring per action. While a file/image upload is in flight the dialog's -**Confirm** button is disabled (labelled "Uploading…"), so a param can't be -submitted before its uploaded fileId is ready. - -## Action Chaining - -Execute multiple actions in sequence or parallel: - -```ts -import type { ActionSchema } from '@object-ui/types'; - -const chainedAction: ActionSchema = { - type: 'action', - label: 'Process Order', - actionType: 'ajax', - api: '/api/orders/process', - method: 'POST', - - chain: [ - { - type: 'action', - label: 'Send Email', - actionType: 'ajax', - api: '/api/emails/send', - method: 'POST' - }, - { - type: 'action', - label: 'Update Inventory', - actionType: 'ajax', - api: '/api/inventory/update', - method: 'PUT' - }, - { - type: 'action', - label: 'Log Event', - actionType: 'ajax', - api: '/api/events/log', - method: 'POST' - } - ], - - chainMode: 'sequential' // or 'parallel' -}; -``` - -**Chain Modes:** -- `sequential` - Execute actions one after another (default) -- `parallel` - Execute all actions simultaneously - -## Conditional Execution - -`condition` is a **gate**, not a branch: the action executes only while the -predicate holds. - -```ts -import type { ActionSchema } from '@object-ui/types'; - -const gatedAction: ActionSchema = { - type: 'action', - label: 'Require Manager Approval', - actionType: 'confirm', - confirmText: 'Amount exceeds $1000. Manager approval needed.', - - // Offered only for amounts over 1000 - condition: '${data.amount > 1000}' -}; -``` - -**How the gate is written** - four spellings, all read by the same evaluator: - -| Spelling | Example | -|---|---| -| Boolean | `condition: false` | -| Bare CEL predicate | `condition: 'data.amount > 1000'` | -| `${...}` template | `condition: '${data.amount > 1000}'` | -| Normalized envelope | `condition: { dialect: 'cel', source: 'data.amount > 1000' }` | - -**How the gate is read:** -- Declared and **false** - the action does not execute; the runner reports - `Action condition not met`. -- Declared and **true** - the action executes normally. -- **Not declared** (key absent, or an empty predicate) - the action executes. - -The envelope is what `objectstack build` emits for a compiled predicate, so -authored metadata and built metadata both parse. This is the same predicate -vocabulary `visible` and `disabled` carry. - -### Branching: one action per branch - -There is no `then` / `else` - a branch is expressed as **separate actions with -mutually exclusive conditions**. Each action is gated on its own, so exactly one -of them runs: - -```ts -import type { ActionSchema } from '@object-ui/types'; - -const approvalActions: ActionSchema[] = [ - { - type: 'action', - label: 'Require Manager Approval', - actionType: 'confirm', - confirmText: 'Amount exceeds $1000. Manager approval needed.', - condition: '${data.amount > 1000}' - }, - { - type: 'action', - label: 'Auto Approve', - actionType: 'ajax', - api: '/api/approve', - method: 'POST', - condition: '${data.amount <= 1000}' - } -]; -``` - -Sibling actions - a toolbar's `actions`, a table's `rowActions` / `batchActions`, -or an event's action list - are each dispatched through their own gate, which is -what makes this an either/or. - -A `chain` step also carries its own `condition`, but a **sequential** chain stops -at the first step whose condition does not hold (a blocked step reports -`Action condition not met`, and sequential chaining stops on the first failure). -So use a step `condition` to gate an **optional follow-up**, and sibling actions -for an either/or. - -> **Retired (objectui#3917):** `condition` used to be documented here as -> `{ expression, then, else }`. Nothing ever read `expression`, `then` or -> `else`. The runner has always read this key as the predicate above, and an -> object without a `source` reads as "no gate declared", so an action written -> that way ran **unconditionally**, with no error at author time and no -> diagnostic at runtime. The shape is now refused by the zod schema -> (`CRUDActionSchema` in `@object-ui/types/zod`) instead of being accepted and -> ignored. - -## Post-success behaviour - -This legacy `ActionSchema` has no callback slot. The Phase-2 pair it used to -declare — `onSuccess` / `onFailure` carrying an `ActionCallback` object -(`{ type: 'toast' | 'message' | 'redirect' | 'reload' | 'custom' | 'ajax' | 'dialog', message, url, api, … }`) -— was RETIRED (objectui#7068): nothing ever read it (the runner never consumed -the shape), and `@objectstack/spec`'s `ActionSchema` refuses it at publish. Both -keys are `never` on the TypeScript face and named refusals on the Zod mirror, so an -authored callback fails at the authoring site with the migration in the message. - -What to write instead: - -- **A notice** — `successMessage` / `errorMessage`, plain strings (the runner - surfaces `successMessage` as a toast after a successful action). An action - whose handler can succeed in more than one way can also carry the spec's - `outcomeMessages` (on `UIActionSchema`, for `type: 'api'` and `type: 'script'`): - one message per snake_case `outcome` the handler returns in its answer. The - success toast shows the `outcomeMessages` entry the answer's `outcome` names, - else `successMessage` (objectui#11344). Both may read the handler's answer as - `result`. An `outcomeMessages` entry writes it as `${result.*}` — - `'Environment ${result.name} archived'` — the same scope `onSuccess.navigate` - reads. `successMessage` writes it as a `{{ }}` hole — - `'Environment {{ result.name }} updated'` — and can also name the record the - action ran against as `{{ record.* }}`; a `${…}` token in it is shown as - written (objectui#13476). A `message` in the server's answer is not shown - unless the copy asks for it (`{{ result.message }}` in `successMessage`). - Without either, the runner falls back to a generic "Action completed - successfully". - That fallback is text the runner writes itself, and so are the "Action - failed" error toast for an error with no readable message, a parallel - chain's "One or more parallel actions failed", and the label it hands your - toast handler for an undoable success toast's Undo button - (`options.undo.label`). Those are the strings it translates; an author's - `outcomeMessages` / `successMessage` / `errorMessage` never go through the - runner's translator. On a named action, `@object-ui/react`'s `useActionTextLocalizer` - resolves `successMessage` and each `outcomeMessages` entry (its bundle entry, - else the inline locale map) before the action runs. - `` and `useActionRunner` install the session's translator on - their runner, and a runner you build yourself takes one through - `runner.setTranslator(t)`. -- **Post-success navigation** — the spec's `onSuccess` block, `{ navigate, openIn }`, - declared on `UIActionSchema` and forwarded to the runner (objectui#5934). It is - a spec key, not a member of this legacy type, so it is not shown in a fence here. -- **Follow-up work** — `chain` (see [Action Chaining](#action-chaining)): declared - actions, not callbacks. - -## Action Tracking - -Track actions for analytics: - -```ts -import type { ActionSchema } from '@object-ui/types'; - -const trackedAction: ActionSchema = { - type: 'action', - label: 'Download Report', - actionType: 'ajax', - api: '/api/reports/download', - - tracking: { - enabled: true, - event: 'report_downloaded', - metadata: { - reportType: 'sales', - format: 'pdf', - dateRange: '2024-01' - } - } -}; -``` - -## Retry Logic - -Automatically retry failed requests: - -```ts -import type { ActionSchema } from '@object-ui/types'; - -const retryAction: ActionSchema = { - type: 'action', - label: 'Submit', - actionType: 'ajax', - api: '/api/submit', - method: 'POST', - - timeoutMs: 30000, // 30 seconds - - retry: { - maxAttempts: 3, - delayMs: 1000 // 1 second between retries - } -}; -``` - -## Complete Example - -A comprehensive action combining multiple features: - -```ts -import type { ActionSchema } from '@object-ui/types'; - -const complexAction: ActionSchema = { - type: 'action', - label: 'Process Order', - icon: 'shopping-cart', - variant: 'default', - - // Confirm before processing - actionType: 'confirm', - confirmText: 'Process this order for {{ record.customer_name }}?', - - // Execution gate - this action is the premium path; the standard path is a - // sibling action carrying the opposite predicate - condition: '${data.totalAmount > 1000}', - - // Action chain - chain: [ - { - type: 'action', - label: 'Send Confirmation Email', - actionType: 'ajax', - api: '/api/emails/order-confirmation', - method: 'POST', - data: { - orderId: '${data.id}', - customerEmail: '${data.customerEmail}' - } - }, - { - type: 'action', - label: 'Update Inventory', - actionType: 'ajax', - api: '/api/inventory/update', - method: 'PUT', - data: { - items: '${data.items}' - } - }, - { - type: 'action', - label: 'Create Invoice', - actionType: 'ajax', - api: '/api/invoices/create', - method: 'POST' - } - ], - chainMode: 'sequential', - - // Notices - successMessage: 'Order processed successfully!', - errorMessage: 'Unable to process order. Please try again.', - - // Tracking - tracking: { - enabled: true, - event: 'order_processed', - metadata: { - source: 'web_app', - amount: '${data.totalAmount}' - } - }, - - // Retry - timeoutMs: 60000, - retry: { - maxAttempts: 3, - delayMs: 2000 - }, - - // Post-action behavior - reload: true, - close: true, - redirect: '/orders/success' -}; -``` - -## Runtime Validation - -```ts -import { CRUDActionSchema } from '@object-ui/types/zod'; - -const myAction = { - type: 'action', - label: 'Delete Record', - actionType: 'confirm', - confirmText: 'Are you sure?', -}; - -const result = CRUDActionSchema.safeParse(myAction); - -if (result.success) { - console.log('Valid action configuration'); -} else { - console.error('Validation errors:', result.error); -} -``` - -## Use Cases - -Enhanced Actions are ideal for: - -- **API integration** - Connect to backend services and external APIs -- **Multi-step processes** - Execute complex workflows with multiple stages -- **Form submissions** - Handle form data with validation and chained follow-up actions -- **Confirmation dialogs** - Add safety checks for critical operations -- **Event tracking** - Monitor user interactions for analytics -- **Batch operations** - Process multiple items in sequence or parallel - -## Best Practices - -1. **Use confirm for destructive actions** - Always confirm delete, archive, etc. -2. **Provide clear feedback** - Set `successMessage` / `errorMessage` so users learn what happened -3. **Chain related operations** - Group logically related API calls -4. **Track important events** - Enable tracking for business-critical actions -5. **Set appropriate timeouts** - Don't let users wait indefinitely -6. **Retry transient failures** - Use retry for network-related errors -7. **Keep chains short** - Long chains can be hard to debug - -## Related - -- [Building a CRUD App](/docs/guide/building-crud-app) - CRUD operations with actions -- [Form](/docs/components/form/form) - Form submission actions -- [Data Source](/docs/guide/data-source) - API integration diff --git a/ui/content/docs/core/meta.json b/ui/content/docs/core/meta.json deleted file mode 100644 index 3a3685ffc67..00000000000 --- a/ui/content/docs/core/meta.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "title": "Core", - "pages": [ - "schema-renderer", - "app-schema", - "theme-schema", - "enhanced-actions", - "report-schema" - ] -} diff --git a/ui/content/docs/core/report-schema.mdx b/ui/content/docs/core/report-schema.mdx deleted file mode 100644 index 5a28e71da15..00000000000 --- a/ui/content/docs/core/report-schema.mdx +++ /dev/null @@ -1,494 +0,0 @@ ---- -title: "Report Schema (ReportComponentSchema)" -description: "Enterprise reports with aggregation, export, and scheduling" ---- - -import { SchemaExample } from '@/app/components/ComponentDemo'; - -# Report Schema - -The `ReportComponentSchema` enables creating comprehensive data reports with field aggregation, multiple export formats, and automated scheduling. - -## Overview - -ReportComponentSchema provides: -- **Field aggregation** - Sum, average, count, min, max, distinct -- **Export formats** - PDF, Excel, CSV, JSON, HTML -- **Scheduled reports** - Daily, weekly, monthly, quarterly, yearly -- **Email distribution** - Automatic report delivery -- **Interactive builder** - Report configuration UI -- **Data filtering** - Complex filter criteria - -## Interactive Examples - -### Sales Report Header - - - -### Report Data Table - - - -### Schedule Configuration Preview - - - -## Basic Usage - -```ts -import type { ReportComponentSchema } from '@object-ui/types'; - -const salesReport: ReportComponentSchema = { - type: 'report', - title: 'Monthly Sales Report', - description: 'Sales performance analysis', - - fields: [ - { - name: 'total_sales', - label: 'Total Sales', - type: 'number', - aggregation: 'sum', - format: 'currency' - }, - { - name: 'order_count', - label: 'Orders', - type: 'number', - aggregation: 'count' - } - ], - - filters: [ - { - field: 'date', - operator: 'between', - values: ['2024-01-01', '2024-01-31'] - } - ], - - showExportButtons: true -}; -``` - -## Properties - -The tables in this section describe `ReportComponentSchema`, declared by -`@object-ui/types` (`packages/types/src/reports.ts`) and imported by name in the -example above. They group its properties by topic; the declaration remains the -complete list. - -### Basic Configuration - -| Property | Type | Description | -|----------|------|-------------| -| `type` | `'report'` | Component type identifier (required) | -| `title` | `string` | Report title | -| `description` | `string` | Report description | - -> **Retired (objectui#6121):** `ReportComponentSchema.dataSource` and -> `ReportBuilderSchema.dataSources` used to be documented and declared here. -> Both were annotated with `DataSource`, the runtime **adapter** interface -> (`find(resource, params)`), which no JSON document can author — and no -> renderer ever read either key off a schema: the report renderers take their -> adapter as a React prop or from the renderer context. Both keys are now -> `never` on the TypeScript face and are refused **by name** by the published -> validator, so an authored value fails loudly instead of being accepted and -> ignored. A report binds its data through the semantic-layer `dataset` form -> (ADR-0021); a legacy presentation report receives already-fetched rows under -> `data`. - -### Report Fields - -```ts -interface ReportField { - name: string; // Field name - label?: string; // Display label - - // Drives type-aware cell rendering. Author-provided only — the renderer - // reads `type` straight off this field and does not infer it from any - // bound object (ReportComponentSchema has no `objectName`). - type?: - | 'string' | 'text' | 'number' | 'date' | 'datetime' | 'time' | 'boolean' - | 'select' | 'multi_select' | 'status' - | 'lookup' | 'reference' | 'master_detail' - | 'email' | 'url' | 'phone' | 'currency' | 'percent' - | 'image' | 'file' | 'user' - | 'richtext' | 'html' | 'markdown' | 'json' | 'tags'; - - // Used when type is select / multi_select / status. - options?: Array<{ value: string | number; label: string; color?: string }>; - - // Used when type is lookup / reference / master_detail. Enables - // deep-links to the related record's detail page. - referenceTo?: string; - - aggregation?: 'sum' | 'avg' | 'min' | 'max' | 'count' | 'distinct'; - format?: string; // Display format - showInSummary?: boolean; // Show in summary section - sortOrder?: number; // Sort order - - // Legacy opt-in: render a plain string cell as a Badge. - // For most cases prefer `type: 'select'` + `options`. - renderAs?: 'badge' | 'text'; - colorMap?: Record; // value → CSS class -} -``` - -#### Type-aware rendering - -The runtime maps `field.type` to a cell renderer from -`@object-ui/fields`'s `getCellRenderer` registry. Examples: `select` -becomes a coloured `Badge`, `lookup` becomes a deep link to the -related record, `boolean` becomes ✓/✗, `email`/`url`/`phone` become -`mailto:`/external/`tel:` links, `image` becomes a thumbnail. Any -unknown type falls back to plain text. - -There is no automatic hydration from a bound object — `ReportComponentSchema` -has no `objectName` property, and nothing in `packages/plugin-report` -resolves one. `ReportViewer`'s `renderCellValue` reads `field.type` directly -off each `ReportField` entry and passes it (plus `field.options` / -`field.referenceTo`) straight into `getCellRenderer`; `field.label || field.name` -is what renders the column header. Declare `type` (and `options` / -`referenceTo` where relevant) on every field that needs type-aware -rendering — a field with no `type` falls back to plain text. - -### Aggregation Types - -- **`sum`** - Total of all values -- **`avg`** - Average value -- **`min`** - Minimum value -- **`max`** - Maximum value -- **`count`** - Count of records -- **`distinct`** - Count of unique values - -### Filters - -```ts -interface ReportFilter { - field: string; - operator: 'equals' | 'not_equals' | 'contains' | 'greater_than' | - 'less_than' | 'between' | 'in' | 'not_in'; - value?: any; - values?: any[]; // For 'between' and 'in' operators -} -``` - -### Group By - -```ts -interface ReportGroupBy { - field: string; - label?: string; - sort?: 'asc' | 'desc'; -} -``` - -## Report Sections - -Define report structure with sections: - -```ts -import type { ChartSchema, ReportField, SchemaNode } from '@object-ui/types'; - -interface ReportSection { - type: 'header' | 'summary' | 'chart' | 'table' | 'text' | 'page-break'; - title?: string; - content?: SchemaNode | SchemaNode[]; - chart?: ChartSchema; // For type='chart' - columns?: ReportField[]; // For type='table' - text?: string; // For type='text' - visible?: boolean | string; // Visibility condition -} -``` - -## Export Configuration - -```ts -interface ReportExportConfig { - format: 'pdf' | 'excel' | 'csv' | 'json' | 'html'; - filename?: string; - includeHeaders?: boolean; - orientation?: 'portrait' | 'landscape'; // PDF only - pageSize?: 'A4' | 'A3' | 'Letter' | 'Legal'; // PDF only - options?: Record; -} -``` - -## Scheduling - -```ts -import type { ReportExportFormat } from '@object-ui/types'; - -interface ReportScheduleConfig { - enabled?: boolean; - frequency?: 'once' | 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly'; - dayOfWeek?: number; // For weekly (0-6) - dayOfMonth?: number; // For monthly (1-31) - time?: string; // HH:mm format - timezone?: string; - recipients?: string[]; // Email addresses - subject?: string; - body?: string; - formats?: ReportExportFormat[]; // Formats to attach -} -``` - -## Complete Example - -```ts -import type { ReportComponentSchema } from '@object-ui/types'; - -const comprehensiveReport: ReportComponentSchema = { - type: 'report', - title: 'Quarterly Sales Analysis', - description: 'Comprehensive sales performance analysis by region and product', - - // Report fields - fields: [ - { - name: 'region', - label: 'Region', - type: 'string' - }, - { - name: 'product', - label: 'Product', - type: 'string' - }, - { - name: 'revenue', - label: 'Revenue', - type: 'number', - aggregation: 'sum', - format: 'currency', - showInSummary: true - }, - { - name: 'units_sold', - label: 'Units Sold', - type: 'number', - aggregation: 'sum', - showInSummary: true - }, - { - name: 'avg_price', - label: 'Average Price', - type: 'number', - aggregation: 'avg', - format: 'currency' - } - ], - - // Filters - filters: [ - { - field: 'date', - operator: 'between', - values: ['2024-01-01', '2024-03-31'] - }, - { - field: 'status', - operator: 'equals', - value: 'completed' - } - ], - - // Grouping - groupBy: [ - { - field: 'region', - label: 'Region', - sort: 'asc' - }, - { - field: 'product', - label: 'Product', - sort: 'desc' - } - ], - - // Report sections - sections: [ - { - type: 'header', - title: 'Executive Summary' - }, - { - type: 'summary', - title: 'Key Metrics' - }, - { - type: 'chart', - title: 'Revenue Trend', - chart: { - type: 'chart', - chartType: 'line', - xAxisKey: 'month', - data: [ - { month: 'January', Revenue: 120000 }, - { month: 'February', Revenue: 145000 }, - { month: 'March', Revenue: 132000 } - ], - series: [ - { - name: 'Revenue', - type: 'line' - } - ] - } - }, - { - type: 'table', - title: 'Detailed Breakdown', - columns: [ - { name: 'region', label: 'Region' }, - { name: 'product', label: 'Product' }, - { name: 'revenue', label: 'Revenue', aggregation: 'sum' }, - { name: 'units_sold', label: 'Units', aggregation: 'sum' } - ] - }, - { - type: 'page-break' - }, - { - type: 'text', - text: 'Report generated on ${new Date().toLocaleDateString()}' - } - ], - - // Schedule configuration - schedule: { - enabled: true, - frequency: 'monthly', - dayOfMonth: 1, - time: '09:00', - timezone: 'America/New_York', - recipients: [ - 'sales-team@company.com', - 'management@company.com' - ], - subject: 'Monthly Sales Report - ${date}', - body: 'Please find attached the monthly sales report.', - formats: ['pdf', 'excel'] - }, - - // Export configuration - defaultExportFormat: 'pdf', - exportConfigs: { - pdf: { - format: 'pdf', - filename: 'sales-report-${date}.pdf', - orientation: 'landscape', - pageSize: 'A4', - includeHeaders: true - }, - excel: { - format: 'excel', - filename: 'sales-report-${date}.xlsx', - includeHeaders: true - }, - csv: { - format: 'csv', - filename: 'sales-data-${date}.csv', - includeHeaders: true - } - }, - - // UI options - showExportButtons: true, - showPrintButton: true, - showScheduleButton: true, - refreshIntervalSeconds: 300 // Auto-refresh every 5 minutes -}; -``` - -## Report Builder - -Use `ReportBuilderSchema` for interactive report creation: - -```ts -import type { ReportBuilderSchema } from '@object-ui/types'; - -const builder: ReportBuilderSchema = { - type: 'report-builder', - - report: { - type: 'report', - title: 'Untitled Report' - }, - - availableFields: [ - { name: 'revenue', label: 'Revenue', type: 'number' }, - { name: 'units', label: 'Units Sold', type: 'number' } - ], - - showPreview: true -}; -``` - -## Report Viewer - -Use `ReportViewerSchema` to display generated reports: - -```ts -import type { ReportComponentSchema, ReportViewerSchema } from '@object-ui/types'; - -// The report defined under "Basic Usage" above, and the rows a run produced. -declare const salesReport: ReportComponentSchema; -declare const reportData: Array>; - -const viewer: ReportViewerSchema = { - type: 'report-viewer', - report: salesReport, - data: reportData, - showToolbar: true, - allowExport: true, - allowPrint: true, - loading: false -}; -``` - -## Runtime Validation - -```ts -import { ReportComponentSchema } from '@object-ui/types/zod'; - -// The report configuration to validate. -declare const myReport: unknown; - -const result = ReportComponentSchema.safeParse(myReport); - -if (result.success) { - console.log('Valid report configuration'); -} else { - console.error('Validation errors:', result.error); -} -``` - -## Use Cases - -ReportComponentSchema is perfect for: - -- **Analytics dashboards** - Display key business metrics and KPIs -- **Business intelligence** - Generate insights from operational data -- **Automated reporting** - Schedule regular reports for stakeholders -- **Data exports** - Provide data in multiple formats (PDF, Excel, CSV) -- **Compliance reporting** - Generate audit trails and regulatory reports -- **Executive summaries** - Create high-level overviews for management - -## Best Practices - -1. **Use meaningful aggregations** - Choose aggregation types that make sense for the data -2. **Limit field count** - Too many fields make reports hard to read -3. **Group logically** - Group by dimensions that provide insights -4. **Test exports** - Verify all export formats render correctly -5. **Set reasonable schedules** - Don't over-email recipients -6. **Include filters** - Allow users to customize date ranges -7. **Add summary sections** - Provide key metrics at the top - -## Related - -- [Charts Plugin](/docs/plugins/plugin-charts) - Data visualization -- [Data Table](/docs/components/complex/data-table) - Tabular data display -- [Data Source](/docs/guide/data-source) - Data integration diff --git a/ui/content/docs/core/schema-renderer.mdx b/ui/content/docs/core/schema-renderer.mdx deleted file mode 100644 index cb840b0b8f2..00000000000 --- a/ui/content/docs/core/schema-renderer.mdx +++ /dev/null @@ -1,188 +0,0 @@ ---- -title: "SchemaRenderer" -description: "Core component for rendering JSON schemas into React components" ---- - -The SchemaRenderer is the heart of ObjectUI. It takes a JSON schema and dynamically renders the appropriate React components based on the schema's `type` field. - -## How It Works - -The SchemaRenderer uses the Component Registry to look up the appropriate component for each schema type and renders it with the provided props. - -```tsx -import { SchemaRenderer } from '@object-ui/react'; - - -``` - -## Basic Example - - - -## Nested Schemas - -The SchemaRenderer automatically handles nested schemas: - - - -## Schema Structure - -```ts -// abridged: `BaseSchema` in @object-ui/types carries the full member list -interface BaseSchema { - type: string; // Component type (e.g., 'card', 'button', 'text') - id?: string; // Unique identifier - className?: string; // Tailwind CSS classes - children?: BaseSchema | BaseSchema[]; // Child components -} -``` - -Every node type extends `BaseSchema` with its own keys, and none of them -carries an index signature (objectui#8347): in a typed literal, a key the -node's type does not declare, a misspelling included, is a compile error. -A child in a slot is checked against its own `type`'s declaration. - -## Error Handling - -When an unknown component type is encountered, SchemaRenderer displays a helpful error message: - - - -## Component Registry - -The SchemaRenderer uses the Component Registry to resolve component types: - -```tsx -import { ComponentRegistry } from '@object-ui/core'; -import { SchemaRenderer } from '@object-ui/react'; -import type { BaseSchema } from '@object-ui/types'; - -function MyWidgetComponent() { - return
My widget
; -} - -// Register a custom component -ComponentRegistry.register('my-widget', MyWidgetComponent); - -// Declare its node type. A node slot and the `schema` prop take the node types -// `@object-ui/types` declares, plus each type an application adds to -// `CustomNodeRegistry`, so the node below is checked against `MyWidgetSchema`. -interface MyWidgetSchema extends BaseSchema { - type: 'my-widget'; - customProp?: string; -} - -declare module '@object-ui/types' { - interface CustomNodeRegistry { - 'my-widget': MyWidgetSchema; - } -} - -// Now you can use it in schemas - -``` - -## Usage in Applications - -### Simple Rendering - -```tsx -import { SchemaRenderer } from '@object-ui/react'; -import type { PageNodeSchema } from '@object-ui/types'; - -function App() { - const schema: PageNodeSchema = { - type: 'page', - pageType: 'app', - title: 'Dashboard', - children: [ - { type: 'text', content: 'Welcome to your dashboard' } - ] - }; - - return ; -} -``` - -### Dynamic Schemas - -```tsx -import { SchemaRenderer } from '@object-ui/react'; -import { useState, useEffect } from 'react'; - -function DynamicPage() { - const [schema, setSchema] = useState(null); - - useEffect(() => { - // Fetch schema from API - fetch('/api/page-schema') - .then(res => res.json()) - .then(setSchema); - }, []); - - if (!schema) return
Loading...
; - - return ; -} -``` - -### With Props - -You can pass additional props to the rendered component: - -```plaintext - console.log('Clicked!')} - className="extra-classes" -/> -``` - -## Data Attributes - -SchemaRenderer automatically adds data attributes for debugging: - -- `data-obj-id`: The schema's id field -- `data-obj-type`: The schema's type field - -These can be used for debugging, testing, or styling: - -```css -[data-obj-type="card"] { - /* Target all card components */ -} -``` - -## Advanced Features - -### String Rendering - -If a schema is just a string, it's rendered as text: - -```plaintext - -// Renders: Hello World -``` - -### Null/Undefined Handling - -Null or undefined schemas render nothing: - -```plaintext - -// Renders: null -``` - -## Related - -- [Component Registry](/docs/guide/component-registry) - Register custom components -- [Schema Rendering Guide](/docs/guide/schema-rendering) - Deep dive into schema rendering diff --git a/ui/content/docs/core/theme-schema.mdx b/ui/content/docs/core/theme-schema.mdx deleted file mode 100644 index 32651f4cf70..00000000000 --- a/ui/content/docs/core/theme-schema.mdx +++ /dev/null @@ -1,440 +0,0 @@ ---- -title: "Theme Schema (Theme)" -description: "Theming with the Theme document — color palettes, light/dark/auto modes, and CSS variables" ---- - -import { SchemaExample } from '@/app/components/ComponentDemo'; - -# Theme Schema - -ObjectUI theming is driven by a **theme document** — a JSON object typed as `Theme` from -`@object-ui/types`. A theme is **not a component**: there is no `type: 'theme'` node to declare on -a page (the component wrapper this page documented until objectui#5489 was never implemented by any -renderer — declaring one produced an "Unknown component type" panel, never a theme manager). -Instead, the document is handed to `ThemeProvider`, which turns it into CSS custom properties and -applies them to the DOM. - -The theme system has three parts: - -- **`Theme`** (`@object-ui/types`) — the authoring document: colors, typography, border radii, - shadows, custom variables, inheritance. `@object-ui/types` owns this vocabulary: the spec - retired its theme module, and the shapes moved here (objectui#5716). -- **ThemeEngine** (`@object-ui/core`) — pure functions that convert a `Theme` into a CSS - custom-property map (`generateThemeVars`), resolve inheritance (`resolveThemeInheritance`) and - resolve the effective mode (`resolveMode`). -- **`ThemeProvider` / `useTheme`** (`@object-ui/react`) — the React context that injects the - variables, toggles the `light` / `dark` class, and optionally persists the user's choice. - -## Interactive Examples - -### Color Palette Preview - - - -### Theme-Aware Components - - - -## Basic Usage - -```ts -import type { Theme } from '@object-ui/types'; - -const professional: Theme = { - name: 'professional', - label: 'Professional', - mode: 'auto', - colors: { - primary: '#3b82f6', - background: '#ffffff', - text: '#0f172a', - }, -}; -``` - -`name`, `label` and `colors` are required; `colors.primary` is the only required color. Everything -else is optional — an absent `mode` is treated as `'auto'`. - -## Applying a Theme - -`ThemeProvider` wraps a subtree, registers the available themes, resolves inheritance and mode, -generates the CSS variables and injects them (on `document.documentElement` by default): - -```tsx -import type { ReactNode } from 'react'; -import type { Theme } from '@object-ui/types'; -import { ThemeProvider } from '@object-ui/react'; - -const corporate: Theme = { - name: 'corporate', - label: 'Corporate', - colors: { primary: '#2563eb' }, -}; - -export function App({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} -``` - -Inside the provider, `useTheme()` exposes the resolved state and the switching actions: - -```tsx -import { useTheme } from '@object-ui/react'; - -export function ThemeControls() { - const { resolvedMode, setMode, setTheme, themes } = useTheme(); - return ( -
- - {themes.map((t) => ( - - ))} -
- ); -} -``` - -`useTheme()` throws outside a provider; `useOptionalTheme()` returns `null` instead. - -### ThemeProvider Props - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `themes` | `Theme[]` | `[]` | Available theme documents | -| `defaultTheme` | `string` | first theme's `name` | Initially active theme | -| `defaultMode` | `ThemeMode` | `'auto'` | Initial mode | -| `persist` | `boolean` | `false` | Persist theme + mode to `localStorage` | -| `storageKey` | `string` | `'objectui-theme'` | `localStorage` key prefix (stored as `-name` / `-mode`) | -| `target` | `HTMLElement \| null` | `document.documentElement` | Element receiving the CSS variables and mode class | - -Persistence is a **provider** concern — there is no `persistPreference` or `storageKey` key on the -theme document itself. - -## Theme Properties - -| Property | Type | Required | Description | -|----------|------|----------|-------------| -| `name` | `string` | yes | Unique theme identifier | -| `label` | `string` | yes | Human-readable display name | -| `description` | `string` | no | Optional description | -| `mode` | `'light' \| 'dark' \| 'auto'` | no | Display mode; absence means `'auto'` | -| `colors` | `ColorPalette` | yes | Color palette — the only required token group | -| `typography` | `{ fontFamily?: { base?: string } }` | no | Only `fontFamily.base` is live (see [Typography](#typography)) | -| `borderRadius` | scale object | no | Rounded-corner scale (see [Border Radius](#border-radius)) | -| `shadows` | scale object | no | Box-shadow scale (see [Shadows](#shadows)) | -| `customVars` | `Record` | no | Emitted verbatim as `--: ` | -| `extends` | `string` | no | Name of a theme to inherit from | - -## Theme Modes - -`ThemeMode` is `'light' | 'dark' | 'auto'` — **there is no `'system'` member**; the OS-following -mode is spelled `'auto'`. The vocabulary is also exported as a runtime tuple: - -```ts -import { THEME_MODES, type ThemeMode } from '@object-ui/types'; - -const mode: ThemeMode = 'auto'; -console.log(mode, THEME_MODES); // auto ['auto', 'light', 'dark'] -``` - -With `'auto'`, `ThemeProvider` resolves the effective mode from `prefers-color-scheme` and -re-resolves live when the OS preference changes. The resolved mode is applied as a `light` / -`dark` class on the target element, so Tailwind `dark:` variants respond to it. - -A theme document carries a **single `colors` map**, not per-mode palettes: the same variables are -injected in both modes. For a palette that differs between light and dark, author two theme -documents — typically a dark variant that `extends` the light one (see -[Theme Inheritance](#theme-inheritance)) — and switch between them with `setTheme`. - -## Color Palette - -`colors.primary` is required; every other key is optional. Keys are emitted as the Shadcn CSS -variables ObjectUI components already consume: - -| Key | Required | CSS variable | -|-----|----------|--------------| -| `primary` | yes | `--primary` | -| `secondary` | | `--secondary` | -| `accent` | | `--accent` | -| `success` | | `--success` | -| `warning` | | `--warning` | -| `error` | | `--destructive` | -| `info` | | `--info` | -| `background` | | `--background` | -| `surface` | | `--card` | -| `text` | | `--foreground` | -| `textSecondary` | | `--muted-foreground` | -| `border` | | `--border` | -| `disabled` | | `--muted` | -| `primaryLight` | | `--primary-light` | -| `primaryDark` | | `--primary-dark` | -| `secondaryLight` | | `--secondary-light` | -| `secondaryDark` | | `--secondary-dark` | - -Hex values (`#3b82f6`) are converted to the `H S% L%` channel format Shadcn variables expect; any -other CSS color syntax (`rgb(...)`, `hsl(...)`, `oklch(...)`) passes through unchanged. - -```ts -import type { ColorPalette } from '@object-ui/types'; - -const colors: ColorPalette = { - primary: '#3b82f6', - secondary: '#64748b', - accent: '#8b5cf6', - error: '#ef4444', - background: '#ffffff', - surface: '#f8fafc', - text: '#0f172a', - textSecondary: '#64748b', - border: '#e2e8f0', -}; -``` - -## Typography - -`typography.fontFamily.base` is the only live typography key — it is emitted as `--font-sans`: - -```ts -import type { Theme } from '@object-ui/types'; - -const branded: Theme = { - name: 'branded', - label: 'Branded', - colors: { primary: '#3b82f6' }, - typography: { - fontFamily: { base: 'Inter, system-ui, sans-serif' }, - }, - customVars: { - 'font-size-base': '16px', - 'line-height-base': '1.5', - }, -}; -``` - -The former typography scales (`fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`, -`fontFamily.heading`, `fontFamily.mono`) were retired upstream (objectstack#5021): a -theme declaring them is **refused, not accepted-and-stripped**. `customVars` -is the declared replacement — an entry is emitted verbatim, so -`customVars: { 'font-size-lg': '1.125rem' }` puts the same `--font-size-lg` on the document that -the retired scale used to. The retired keys are typed `never`, which makes the refusal a -compile-time error: - -```ts -import type { Theme } from '@object-ui/types'; - -const legacy: Theme = { - name: 'legacy', - label: 'Legacy', - colors: { primary: '#3b82f6' }, - typography: { - // @ts-expect-error -- `fontSize` was retired (objectstack#5021); author `customVars` instead - fontSize: 16, - }, -}; -``` - -## Border Radius - -The key is `borderRadius` (not `radius`), and the middle step is `base` (not `default`): - -```ts -import type { Theme } from '@object-ui/types'; - -const rounded: Theme = { - name: 'rounded', - label: 'Rounded', - colors: { primary: '#3b82f6' }, - borderRadius: { - sm: '0.25rem', - base: '0.5rem', - md: '0.75rem', - lg: '1rem', - xl: '1.5rem', - }, -}; -``` - -| Key | CSS variable | -|-----|--------------| -| `none` | `--radius-none` | -| `sm` | `--radius-sm` | -| `base` | `--radius` | -| `md` | `--radius-md` | -| `lg` | `--radius-lg` | -| `xl` | `--radius-xl` | -| `2xl` | `--radius-2xl` | -| `full` | `--radius-full` | - -## Shadows - -The `shadows` scale has the same shape, with `inner` in place of `full`: - -| Key | CSS variable | -|-----|--------------| -| `none` | `--shadow-none` | -| `sm` | `--shadow-sm` | -| `base` | `--shadow` | -| `md` | `--shadow-md` | -| `lg` | `--shadow-lg` | -| `xl` | `--shadow-xl` | -| `2xl` | `--shadow-2xl` | -| `inner` | `--shadow-inner` | - -## Custom Variables - -`customVars` entries are emitted verbatim onto the target element as `--: ` (a leading -`--` is added when the key does not carry one). This is the declared door for any token the schema -does not model — z-index steps, animation durations, layout dimensions: - -```ts -import type { Theme } from '@object-ui/types'; - -const dashboard: Theme = { - name: 'dashboard', - label: 'Dashboard', - colors: { primary: '#3b82f6' }, - customVars: { - 'header-height': '4rem', - 'sidebar-width': '16rem', - '--z-modal': '1400', - }, -}; -``` - -## Theme Inheritance - -A theme can extend another by `name`. On resolution the chain is merged deep for `colors`, -`typography`, `borderRadius`, `shadows` and `customVars` — the child overrides key by key and -inherits the rest. Cycles are detected and stop the walk. - -```ts -import type { Theme } from '@object-ui/types'; - -const acmeLight: Theme = { - name: 'acme-light', - label: 'Acme', - colors: { primary: '#3b82f6', background: '#ffffff', text: '#0f172a' }, - borderRadius: { base: '0.5rem' }, -}; - -const acmeDark: Theme = { - name: 'acme-dark', - label: 'Acme Dark', - extends: 'acme-light', - colors: { primary: '#60a5fa', background: '#0f172a', text: '#f1f5f9' }, -}; -``` - -Register both on the provider: `acme-dark` resolves against `acme-light`, inheriting the -`borderRadius` scale while its `colors` override key by key. - -The engine functions are exported for direct use: - -```ts -import { generateThemeVars, resolveMode } from '@object-ui/core'; -import type { Theme } from '@object-ui/types'; - -const probe: Theme = { - name: 'probe', - label: 'Probe', - colors: { primary: '#3b82f6' }, -}; - -const vars = generateThemeVars(probe); // { '--primary': '217 91% 60%' } -const effective = resolveMode('auto'); // 'light' | 'dark', from prefers-color-scheme -console.log(vars, effective); -``` - -## Validation - -Theme documents are validated at the **type level**: retired keys are `never`-typed tombstones, so -an invalid document fails to compile rather than being silently stripped at runtime. There is no -runtime validator for theme documents — the zod schemas `@objectstack/spec` used to publish -(`ThemeDefinitionSchema` and its token sub-schemas) were retired with its theme module, and -`@object-ui/types/zod` does not export a replacement. - -For the runtime check that matters to theming — accessibility — the engine ships WCAG helpers: - -```ts -import { contrastRatio, meetsContrastLevel } from '@object-ui/core'; - -const ratio = contrastRatio('#0f172a', '#ffffff'); // ≈ 14.9 -const readable = meetsContrastLevel('#0f172a', '#ffffff', 'AA'); // true -console.log(ratio, readable); -``` - -## Complete Theme Example - -```ts -import type { Theme } from '@object-ui/types'; - -const professional: Theme = { - name: 'professional', - label: 'Professional', - description: 'Default corporate look', - mode: 'auto', - - colors: { - primary: '#3b82f6', - secondary: '#64748b', - accent: '#8b5cf6', - success: '#10b981', - warning: '#f59e0b', - error: '#ef4444', - info: '#0ea5e9', - background: '#ffffff', - surface: '#f8fafc', - text: '#0f172a', - textSecondary: '#64748b', - border: '#e2e8f0', - }, - - typography: { - fontFamily: { base: 'Inter, system-ui, sans-serif' }, - }, - - borderRadius: { - sm: '0.25rem', - base: '0.5rem', - md: '0.75rem', - lg: '1rem', - }, - - shadows: { - sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)', - base: '0 1px 3px 0 rgb(0 0 0 / 0.1)', - lg: '0 10px 15px -3px rgb(0 0 0 / 0.1)', - }, - - customVars: { - 'header-height': '4rem', - 'sidebar-width': '16rem', - }, -}; -``` - -## Best Practices - -1. **Use the semantic keys** — map brand colors onto `primary` / `accent` / `error` rather than - inventing custom variables for tokens the palette already models. -2. **Test both modes** — an `'auto'` theme renders under both the `light` and `dark` classes. -3. **Maintain contrast** — check WCAG pairs with `meetsContrastLevel` from `@object-ui/core`. -4. **Default to `'auto'`** — respect the OS preference; it is the provider's default mode. -5. **Persist on the provider** — user preference is `ThemeProvider`'s `persist` / `storageKey`, - not a key on the theme document. -6. **Share tokens with `extends`** — author variants as small overrides of a base theme. - -## Related - -- [App Schema](/docs/core/app-schema) - Application configuration -- [Schema Overview](/docs/guide/schema-overview) - Where theming sits among the schema families -- [CSS Variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) - MDN documentation -- [Tailwind Theming](https://tailwindcss.com/docs/theme) - Tailwind CSS theming guide diff --git a/ui/content/docs/fields/auto-number.mdx b/ui/content/docs/fields/auto-number.mdx deleted file mode 100644 index 72fab47e162..00000000000 --- a/ui/content/docs/fields/auto-number.mdx +++ /dev/null @@ -1,158 +0,0 @@ ---- -title: "AutoNumber Field" -description: "Read-only auto-generated sequence number" ---- - -The AutoNumber Field component displays auto-generated sequence numbers. This is a read-only field where the value is automatically generated by the backend when records are created. - -## Basic Usage - - - -## Custom Format - - - -## Date-Based Format - - - -## Field Schema - -An auto-number field is authored as `AutoNumberFieldMetadata` (`@object-ui/types`), -which is the source of truth for the key set: it extends `BaseFieldMetadata` with the -record-number pattern, `autonumberFormat`. The type value is `autonumber`, the spelling -`@objectstack/spec`'s `FieldType` declares (`auto_number` is refused at `type`). - -```ts -import type { AutoNumberFieldMetadata } from '@object-ui/types'; - -const invoiceNumber: AutoNumberFieldMetadata = { - type: 'autonumber', - name: 'invoice_number', - label: 'Invoice Number', - inlineHelpText: 'Assigned by the platform when the record is created.', - readonly: true, - autonumberFormat: 'INV-{0000}', -}; -``` - -`autonumberFormat` is the spelling `@objectstack/spec` prefers on a new field. -`format` is the older shorthand for the same pattern, and the spec still accepts -it: `format: 'INV-{0000}'` mints the same numbers. When a field sets both, -`autonumberFormat` wins and `format` is ignored. - -The generated value, and the `className` a host supplies, are **not** metadata keys — -they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Format Templates - -Common format patterns: - -### Simple Sequential - -```plaintext -autonumberFormat: '{0000}' // 0001, 0002, 0003... -autonumberFormat: '{00000}' // 00001, 00002, 00003... -``` - -### With Prefix - -```plaintext -autonumberFormat: 'ORD-{0000}' // ORD-0001, ORD-0002... -autonumberFormat: 'INV-{00000}' // INV-00001, INV-00002... -autonumberFormat: 'CUST-{000}' // CUST-001, CUST-002... -``` - -### Date-Based - -```plaintext -autonumberFormat: '{YYYY}-{0000}' // 2024-0001, 2024-0002... -autonumberFormat: '{YY}{MM}-{000}' // 2403-001, 2403-002... -autonumberFormat: 'ORD-{YYYYMMDD}-{00}' // ORD-20240315-01... -``` - -### Mixed Format - -```plaintext -autonumberFormat: 'PO-{YYYY}-{MM}-{0000}' // PO-2024-03-0001 -``` - -## Format Placeholders - -These are the tokens `parseAutonumberFormat` (`@objectstack/spec/data`) reads: - -- `{0}`, `{00}`, `{000}`, etc. - Sequential number, zero-padded to at least that many digits (one per format) -- `{YYYY}` - Four-digit year (2024) -- `{YY}` - Two-digit year (24) -- `{MM}` - Two-digit month (03) -- `{DD}` - Two-digit day (15) -- `{YYYYMMDD}` - Year, month and day together (20240315) -- `{field_name}` - The value of another field on the same record - -The date tokens are exact and case-sensitive, and there are no others: no -quarter, week or time token. Any other name in braces is a `{field_name}` -token, so `{Q}` reads a field called `Q`, not the quarter. A referenced field -must exist on the object and be set before the record is created: -`objectstack compile` refuses a format that names a field the object does not -have, and an insert whose referenced field is empty fails rather than mint a -number. - -## Backend Implementation - -ObjectUI does not render the format. An ObjectStack backend mints each value -when the record is inserted, with the renderer `@objectstack/spec/data` -exports: `parseAutonumberFormat` reads the format into tokens and -`renderAutonumber` renders them. The query engine and the SQL driver share -that renderer, so the placeholders above are the whole grammar. A backend of -your own should call the same two functions rather than re-implement the -token grammar. - -## Sequence Management - -Allocating the next number is the backend's job, not the renderer's. ObjectUI -never generates a value: `AutoNumberField` displays whatever the saved record -already carries, and renders a muted placeholder dash while the field is still -empty. What ObjectUI owns is the metadata the backend reads — how each value -is formatted. There is no starting-value key: `@objectstack/spec`'s `FieldSchema` -refuses `starting_number` by name, so where a sequence starts is the backend's -counter, not the field's metadata: - -```ts -import type { AutoNumberFieldMetadata } from '@object-ui/types'; - -const orderNumber: AutoNumberFieldMetadata = { - type: 'autonumber', - name: 'order_number', - label: 'Order Number', - autonumberFormat: 'ORD-{YYYY}-{0000}', -}; -``` - -On the backend, keep one counter per object, field and counter scope, and -increment it in the same transaction that inserts the record, so two concurrent -inserts cannot read the same current value. The scope is whatever the format -renders before the `{0000}` slot, and `renderAutonumber` returns it with the -value: `'ORD-{YYYY}-{0000}'` counts per year and restarts each January, while -`'ORD-{0000}'` keeps one count. - -Because the number is assigned at insert time, it does not exist while the -record is still being drafted: a create form shows the field empty, and the -value appears once the saved record comes back. - -## Use Cases - -- **Order Management**: Order numbers, PO numbers -- **Invoicing**: Invoice IDs, receipt numbers -- **Ticketing**: Support ticket IDs, case numbers -- **Customer Management**: Customer IDs, account numbers -- **Inventory**: SKU numbers, serial numbers -- **Document Management**: Document IDs, revision numbers - -## Best Practices - -1. **Choose appropriate padding**: Use enough digits for expected volume -2. **Include year for long-running systems**: Helps with archival and partitioning -3. **Use meaningful prefixes**: Makes numbers self-documenting -4. **Don't expose internal IDs**: Use auto-numbers for user-facing identifiers -5. **Consider reset policies**: Decide if/when sequences reset (yearly, monthly, etc.) diff --git a/ui/content/docs/fields/boolean.mdx b/ui/content/docs/fields/boolean.mdx deleted file mode 100644 index 357b3a0170b..00000000000 --- a/ui/content/docs/fields/boolean.mdx +++ /dev/null @@ -1,87 +0,0 @@ ---- -title: "Boolean Field" -description: "Switch/checkbox input for true/false values" ---- - -The Boolean Field component provides a switch or checkbox input for collecting true/false boolean values. - -## Basic Usage - - - -## With Description - - - -## Default Value - - - -## Field Schema - -A boolean field is authored as `BooleanFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set. It adds nothing of its own to -`BaseFieldMetadata` beyond the discriminant — a checkbox needs no extra configuration. - -```ts -import type { BooleanFieldMetadata } from '@object-ui/types'; - -const isActive: BooleanFieldMetadata = { - type: 'boolean', - name: 'is_active', - label: 'Active', - description: 'Inactive records stay searchable but are excluded from lists.', - required: false, - defaultValue: true, -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Read-only Display - -In a read-only row (a `readonly` field in a generated form, or a detail body), the widget draws -a real `true` as "Yes" and a real `false` as "No". **Only a real boolean is a value of a boolean -field.** `@objectstack/spec` declares the runtime value of `boolean` / `toggle` as a bare JS -boolean on the wire; the SQL `0`/`1` repair and the `'true'` / `'false'` spellings belong to -the producer boundaries (driver read-coercion, CSV import), never to the widget. So `null`, -`undefined`, `[]`, `{}`, `0`, `1`, `''`, `'true'` and `'false'` all render the shared -`EmptyValue` affordance — an em-dash with the accessible name "No value", the same affordance -every other field widget draws for an absent value — because the record holds no boolean -there. The widget keeps no second truth table: a `'false'` string that reaches it is a producer -that skipped its coercion, and the place to fix that is the data source. - -## Use Cases - -- **Feature Toggles**: Enable/disable features -- **Preferences**: User settings and preferences -- **Permissions**: Access control flags -- **Status**: Active/inactive, published/draft states -- **Agreements**: Terms acceptance, consent flags - -## Cell Renderer - -In tables/grids, `BooleanCellRenderer` draws a real boolean as a disabled checkbox — checked -for `true`, unchecked for `false` — named by the field's label. One field-name convention -changes the shape: a completion field (`completed`, `is_completed`, `done`, `is_done`) draws a -green "Completed" or an outlined "Not completed" indicator. Every other name draws the -checkbox, so `false` is a neutral unchecked box whatever the field is called: `active`, -`enabled` and `verified` read the same as any other boolean. - -**Only a real boolean is a value of a boolean column.** `@objectstack/spec` declares the -runtime value of `boolean` / `toggle` as a bare JS boolean on the wire; the SQL `0`/`1` repair -and the `'true'` / `'false'` spellings belong to the producer boundaries (driver read-coercion, -CSV import). So `null`, `undefined`, `[]`, `{}`, `0`, `1`, `''`, `'true'` and `'false'` all -render the shared `EmptyValue` affordance — an em-dash with the accessible name "No value" — -because the record holds no boolean there. A `'false'` string that reaches this renderer is a -producer that skipped its coercion: fix it at the data source; the renderer keeps no second -truth table. - -## Styling - -The boolean field uses the Switch component from Shadcn UI, providing: -- Smooth animations -- Accessible keyboard navigation -- Focus states -- Disabled states diff --git a/ui/content/docs/fields/currency.mdx b/ui/content/docs/fields/currency.mdx deleted file mode 100644 index 022f5307790..00000000000 --- a/ui/content/docs/fields/currency.mdx +++ /dev/null @@ -1,94 +0,0 @@ ---- -title: "Currency Field" -description: "Formatted currency input with locale support" ---- - -The Currency Field component provides a formatted currency input with proper locale formatting and currency symbol display. - -## Basic Usage - - - -## Different Currencies - - - -## Field Schema - -A currency field is authored as `CurrencyFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set: it extends `BaseFieldMetadata` with the currency -declaration, the total-digit `precision` and the two numeric bounds. - -```ts -import type { CurrencyFieldMetadata } from '@object-ui/types'; - -const amount: CurrencyFieldMetadata = { - type: 'currency', - name: 'amount', - label: 'Amount', - placeholder: '0.00', - required: true, - currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }, - min: 0, - max: 1000000, -}; -``` - -**The currency is `currencyConfig`**, `@objectstack/spec`'s own declaration. A field that -always shows one currency declares `{ currencyMode: 'fixed', defaultCurrency }`; a field -with no `currencyConfig`, or with `currencyMode: 'dynamic'`, shows the tenant's default -currency. A field-level `currency` key is refused by the spec by name, and -`CurrencyFieldMetadata` refuses it at compile time. - -**Decimal places come from the currency, not from a field key.** An amount shows its -currency's own ISO 4217 minor unit — two decimals for USD or CNY, none for JPY, three -for KWD — in the read-only display, in the input's `step` and in the rounding applied -when the input loses focus. `precision` is the total digit count of the stored decimal -(a DECIMAL(18,2) amount declares `precision: 18`), so it never sets how many decimals are -shown. With no currency resolved at all, the amount shows two decimals. - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## What the browser rewrites before this field sees it - -This widget renders a native `type="number"` input, so the browser decides what -the box accepts. Two things can happen and **only one of them is announced**: - -- **Announced.** Text the browser cannot read at all — `1e`, a lone `-`, a lone - `.` — leaves the box visibly showing what was typed while its value reads - empty. The field is marked `aria-invalid` and draws - *"Not saved: the text in this box is not a number."* -- ⚠️ **Not announced.** Entries the browser silently **truncates**: pasting - ``1.2.3`` stores ``1.23``, and `0x10` stores ``10``. No warning is - possible here — the browser discards the extra characters as they arrive, so - nothing reaches ObjectUI to check. - -⛔ **"No warning" therefore does not mean "the value is right."** Full -explanation and the reasoning: [What a number field silently -rewrites](/docs/guide/fields#what-a-number-field-silently-rewrites). - -## Supported Currencies - -- **USD**: US Dollar ($) -- **EUR**: Euro (€) -- **GBP**: British Pound (£) -- **JPY**: Japanese Yen (¥) -- And all other ISO 4217 currency codes - -## Cell Renderer - -In tables/grids, currency values are formatted with the appropriate symbol: - -```ts -import { CurrencyCellRenderer } from '@object-ui/fields'; - -// Renders: $1,234.56 or €1.234,56 based on locale -``` - -## Use Cases - -- **Pricing**: Product prices, service costs -- **Financial Data**: Revenue, expenses, budgets -- **Transactions**: Payment amounts, invoice totals -- **Salaries**: Compensation amounts diff --git a/ui/content/docs/fields/date.mdx b/ui/content/docs/fields/date.mdx deleted file mode 100644 index 221cbb2ed07..00000000000 --- a/ui/content/docs/fields/date.mdx +++ /dev/null @@ -1,116 +0,0 @@ ---- -title: "Date Field" -description: "Date picker input for date and datetime values" ---- - -The Date Field component provides a date picker for selecting dates and optionally times. - -## Basic Usage - - - -## With Default Value - - - -## Field Schema - -A date field is authored as `DateFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with a display format -and the due/deadline flag. - -```ts -import type { DateFieldMetadata } from '@object-ui/types'; - -const closeDate: DateFieldMetadata = { - type: 'date', - name: 'close_date', - label: 'Close Date', - placeholder: 'Pick a date', - required: true, - format: 'yyyy-MM-dd', - dueLike: true, -}; -``` - -`dueLike` declares the date a deadline, gating the relative "Overdue Nd" wording and -the red styling. It is `@objectstack/spec`'s `FieldSchema.dueLike`, and the spec -accepts it on `date` and `datetime` fields only. `datetime` is its own type with its -own metadata (`DateTimeFieldMetadata`) and honours `dueLike` identically; see -[DateTime Field](/docs/fields/datetime). Nothing is inferred from the field's name: -without the key, a past `due_date` reads as any past date does. - -`settledWhen` says when the deadline is settled for one record: a CEL predicate over -the record's own columns, read as `record`. While it holds, no overdue wording or -colour applies to that record. It is `FieldSchema.settledWhen`, and the spec accepts -it only beside `dueLike: true`: - -```ts -import type { DateFieldMetadata } from '@object-ui/types'; - -const dueDate: DateFieldMetadata = { - type: 'date', - name: 'due_date', - dueLike: true, - settledWhen: "record.status == 'done'", -}; -``` - -Every view that draws the date beside its record — the grid, the record page, related -lists, kanban and gallery cards, the compact list and the tree — evaluates -`settledWhen` against that record, so a done task's past due date reads neutral while -an open one's reads overdue. A predicate that fails to evaluate leaves the deadline -unsettled. - -⚠️ The predicate reads the record as the view fetched it. A grid, a list view and a -related list add the fields `settledWhen` reads to their own query, so the field the -predicate reads need not be a column. A field the viewer may not read is never -fetched, so a deadline whose predicate reads one stays unsettled for that viewer. An -approval's record summary judges the deadline against the snapshot it shows. - -There is no date-range key. `@objectstack/spec`'s `FieldSchema` declares no date bound -(its `min` / `max` are numbers) and refuses `min_date` / `max_date` by name, and no -widget ever read them, so `DateFieldMetadata` refuses both at compile time. - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Date Formats - -The date field supports various display formats: - -- **Short**: 12/31/2024 -- **Medium**: Dec 31, 2024 (default) -- **Long**: December 31, 2024 -- **Full**: Monday, December 31, 2024 - -## Use Cases - -- **Birthdates**: User birth dates -- **Deadlines**: Task or project deadlines -- **Events**: Event start/end dates -- **Appointments**: Meeting or appointment scheduling -- **Releases**: Product or content release dates - -## Cell Renderer - -In tables/grids, dates are formatted consistently: - -```ts -import { DateCellRenderer } from '@object-ui/fields'; - -// Renders: Dec 31, 2024 -// Or: Dec 31, 2024 2:30 PM (for datetime) -``` - -## DateTime Variant - -For collecting both date and time: - -```plaintext -{ - type: 'datetime', - name: 'appointment', - label: 'Appointment Time' -} -``` diff --git a/ui/content/docs/fields/datetime.mdx b/ui/content/docs/fields/datetime.mdx deleted file mode 100644 index 1c4b8c1616c..00000000000 --- a/ui/content/docs/fields/datetime.mdx +++ /dev/null @@ -1,128 +0,0 @@ ---- -title: "DateTime Field" -description: "Date and time input with combined picker" ---- - -The DateTime Field component provides a combined date and time input for collecting both date and time information in a single field. - -## Basic Usage - - - -## With Default Value - - - -## Required Field - - - -## Read-Only - - - -## Field Schema - -A datetime field is authored as `DateTimeFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set: it extends `BaseFieldMetadata` with a display -format and the due/deadline flag. As on the [Date Field](/docs/fields/date), there is -no range key: the spec refuses `min_date` / `max_date` by name and nothing read them. - -```ts -import type { DateTimeFieldMetadata } from '@object-ui/types'; - -const startsAt: DateTimeFieldMetadata = { - type: 'datetime', - name: 'starts_at', - label: 'Starts At', - placeholder: 'Pick a date and time', - required: true, - format: 'yyyy-MM-dd HH:mm', - dueLike: true, -}; -``` - -## Overdue Affordance - -`dueLike` declares the datetime a deadline. It is `@objectstack/spec`'s -`FieldSchema.dueLike`, the same key, with the same meaning, that the -[Date Field](/docs/fields/date) uses, and the spec accepts it on `date` and -`datetime` fields only. A past `starts_at` or `created_at` is not a missed -deadline, so only a field that says so gets the affordance: - -- the relative face reads **"Overdue Nd"** instead of the neutral "N days ago"; -- the cell is styled red, whichever display face it is painting. - -An absent key, or `false`, means "not a deadline", with nothing inferred from -the field's name: an undeclared `due_at` in the past reads as any past datetime -does. - -`settledWhen` says when the deadline is settled for one record. It is -`FieldSchema.settledWhen`: a CEL predicate over the record's own columns, read as -`record`, written as a bare string or as the spec's expression envelope. While it -holds, no overdue wording or colour applies to that record, and the spec accepts it -only beside `dueLike: true`: - -```ts -import type { DateTimeFieldMetadata } from '@object-ui/types'; - -const respondBy: DateTimeFieldMetadata = { - type: 'datetime', - name: 'respond_by', - dueLike: true, - settledWhen: { dialect: 'cel', source: "record.status == 'closed'" }, -}; -``` - -Every view that draws the datetime beside its record evaluates `settledWhen` -against that record (see [Date Field](/docs/fields/date)), and a settled deadline -reads the neutral phrase with no red. A predicate that fails to evaluate leaves -the deadline unsettled. - -The affordance is **day-granular**. It compares calendar days, so a datetime a -few hours past its deadline still reads "Today" and is not red — the same answer -the date field gives for a deadline falling today. The shortest overdue phrase -is "Overdue 2d". - -The two halves do not switch on and off together. The red styling starts the day -after the deadline; the "Overdue Nd" wording starts a day later still, and then -stops once the date is far enough in the past that the relative face falls back -to an absolute date. The red does not stop with it — a long-overdue deadline -reads as a plain past date, in red. - -The same key is authorable on a **detail-view field entry**, where it means -exactly this. See [DetailViewSchema](/docs/api/schema-reference#detailviewschema). - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Date Format - -The datetime field stores values in ISO 8601 format: `YYYY-MM-DDTHH:mm` - -Example: `2024-03-15T14:30` represents March 15, 2024 at 2:30 PM - -## Cell Renderer - -When used in data tables or grids: - -```ts -import { DateTimeCellRenderer } from '@object-ui/fields'; - -// Renders: Mar 15, 2024, 02:30 PM -``` - -## Time Zone - -A datetime is an instant, and it renders in the workspace's time zone: the -`timezone` that `GET /api/v1/auth/me/localization` answers, which the console -hands to every date face through `LocalizationProvider`. Without one it renders -in the viewer's own zone. A date-only value is a calendar day and shows the -same day in every zone. - -## Use Cases - -- **Event Scheduling**: Meeting times, appointments -- **Timestamps**: Order placed, task deadline -- **Booking Systems**: Reservation date and time -- **Notifications**: Scheduled notification time diff --git a/ui/content/docs/fields/email.mdx b/ui/content/docs/fields/email.mdx deleted file mode 100644 index 2a7c7d6b981..00000000000 --- a/ui/content/docs/fields/email.mdx +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: "Email Field" -description: "Email input with validation" ---- - -The Email Field component provides a text input with built-in email validation and formatting. - -## Basic Usage - - - -## Required Email - - - -## Field Schema - -An email field is authored as `EmailFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with a single length -bound, `@objectstack/spec`'s own `FieldSchema.maxLength`, typed by reference. -Address-shape validation is the widget's, not a metadata key. - -```ts -import type { EmailFieldMetadata } from '@object-ui/types'; - -const contactEmail: EmailFieldMetadata = { - type: 'email', - name: 'contact_email', - label: 'Email Address', - placeholder: 'name@example.com', - required: true, - maxLength: 254, -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Validation - -The email field automatically validates: -- Proper email format (user@domain.com) -- Presence of @ symbol -- Valid domain structure -- No spaces or invalid characters - -## Cell Renderer - -In tables/grids, email addresses are clickable links: - -```ts -import { EmailCellRenderer } from '@object-ui/fields'; - -// Renders as: user@example.com -// Clicking opens default email client -``` - -## Use Cases - -- **User Registration**: Account email addresses -- **Contact Forms**: Customer contact information -- **Support**: Support ticket emails -- **Notifications**: Email notification addresses -- **Team Members**: Team member email addresses - -## Features - -- **Autocomplete**: Browser email autocomplete -- **Validation**: Real-time email format validation -- **Clickable Links**: Email links in read-only mode -- **Accessible**: Proper ARIA labels and keyboard navigation diff --git a/ui/content/docs/fields/file.mdx b/ui/content/docs/fields/file.mdx deleted file mode 100644 index 1990174dc17..00000000000 --- a/ui/content/docs/fields/file.mdx +++ /dev/null @@ -1,121 +0,0 @@ ---- -title: "File Field" -description: "File upload with multiple file support and metadata display" ---- - -The File Field component provides a file upload interface with support for multiple files, file type filtering, and file metadata display. - -## Basic Usage - - - -## Multiple Files - - - -## File Type Restrictions - - - -## Field Schema - -A file field is authored as `FileFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with `multiple`, `accept` -and `maxSize`. `maxSize` is the largest file the widget accepts, in bytes, and it is the -spec's `FieldSchema.maxSize`: a larger pick is refused before any upload starts. There is -no key for a file count, because the spec declares none and no widget enforces one. - -The field's stored **value** is the bare `sys_file` id the upload adapter minted — a -`string`, or `string[]` when `multiple` — the reference form the platform's stored -contract admits. An upload whose adapter surfaces no id is refused in the widget with -"did not complete" and nothing is submitted (objectui#7699); mount an `UploadProvider` -whose adapter registers the file with the platform (`createObjectStackUploadAdapter`). -`UploadedFileMetadata`, exported from the same package, is the **legacy inline shape** a -record written before file-as-reference may still hold: the widgets still render it, -and never write a new one. - -**Restricted files.** When the reader may read the record but not a file's `sys_file` -metadata, the platform sends that file as `{ id, metadataRefused: true }` -(`FileRefusedValueSchema` in `@objectstack/spec/data`). `readFileValue` flags it as -`metadataRefused: true` on the `FileValueView`, and every file, image and avatar renderer -shows a lock with a translated "Restricted file" label (`fields.file.restricted`) instead of a -name or a thumbnail. The open link still points to the id-derived download endpoint, which -decides access itself. An editable widget that re-submits the field's values sends such a -file back as its bare id. - -```ts -import type { FileFieldMetadata, UploadedFileMetadata } from '@object-ui/types'; - -const attachments: FileFieldMetadata = { - type: 'file', - name: 'attachments', - label: 'Attachments', - inlineHelpText: 'PDF or Word, up to 10 MB each.', - multiple: true, - accept: ['application/pdf', 'application/msword'], - maxSize: 10 * 1024 * 1024, -}; - -// The LEGACY inline shape a pre-reference record may still hold as its VALUE — -// rendered by the widgets, never written by them (a new upload stores the id). -const legacyStoredFile: UploadedFileMetadata = { - name: 'contract.pdf', - original_name: 'Contract (signed).pdf', - size: 248_310, - mime_type: 'application/pdf', - url: 'https://files.example.com/contract.pdf', -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Accepted File Types - -Common MIME type examples: - -```plaintext -// Documents -accept: ['application/pdf', 'application/msword'] - -// Images -accept: ['image/png', 'image/jpeg', 'image/gif'] - -// Archives -accept: ['application/zip', 'application/x-rar'] - -// Spreadsheets -accept: ['application/vnd.ms-excel', 'text/csv'] - -// Wildcards -accept: ['image/*'] // All images -``` - -## Features - -- **File List Display**: Shows uploaded files with names and sizes -- **Remove Files**: Individual file removal with X button -- **Size Display**: File sizes shown in KB/MB -- **Upload Button**: Clear upload interface -- **Multiple Selection**: Support for batch file uploads - -## Cell Renderer - -In tables/grids, displays file count: - -```ts -import { FileCellRenderer } from '@object-ui/fields'; - -// Renders: "3 files" or "document.pdf" -``` - -## Integration Notes - -The widget uploads through the ambient `UploadProvider` from `@object-ui/providers` and stores the `sys_file` id its adapter returns in `meta.fileId`; `createObjectStackUploadAdapter` (ObjectStack's presigned flow) returns one. With no provider mounted, `useUpload()` falls back to an object-URL adapter that persists nothing and returns no id, so every pick is refused with "did not complete" and the field is not changed. - -## Use Cases - -- **Document Management**: PDF uploads, contracts -- **File Attachments**: Email attachments, support tickets -- **Media Libraries**: Asset uploads, resources -- **Data Import**: CSV files, batch uploads diff --git a/ui/content/docs/fields/formula.mdx b/ui/content/docs/fields/formula.mdx deleted file mode 100644 index 2b8c025f8d2..00000000000 --- a/ui/content/docs/fields/formula.mdx +++ /dev/null @@ -1,117 +0,0 @@ ---- -title: "Formula Field" -description: "Read-only computed field with automatic calculation" ---- - -The Formula Field component displays computed values calculated from other fields. This is a read-only field where the value is automatically calculated by the backend. - -## Basic Usage - - - -## Text Formula - - - -## Date Formula - - - -## Field Schema - -A formula field is authored as `FormulaFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set: it extends `BaseFieldMetadata` with the -expression and its declared return type. Both are `@objectstack/spec`'s own -`FieldSchema` members, typed by reference: - -- `expression` — the formula, written in CEL against the record - (`record.quantity * record.unit_price`), or the spec's expression envelope - (`{ "dialect": "cel", "source": "…" }`); -- `returnType` — a closed union of `'number' | 'text' | 'boolean' | 'date'`. - -```ts -import type { FormulaFieldMetadata } from '@object-ui/types'; - -const totalPrice: FormulaFieldMetadata = { - type: 'formula', - name: 'total_price', - label: 'Total Price', - readonly: true, - expression: 'record.quantity * record.unit_price', - returnType: 'number', -}; -``` - -There is no `formula` key. `FieldSchema` refuses it by name and points at -`expression`, and `FormulaFieldMetadata` does not declare it. - -The computed value, and the `className` a host supplies, are **not** metadata keys — -they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Return Types - -The formula field draws its value by `returnType`. The form and the table cell -read the same rule, so one stored value reads the same in both: - -- **number**: Formatted as a number field formats it, in the viewer's display - locale and at the width a `scale` on the field sets (`200000` reads `200,000` - in en-US) -- **boolean**: The UI language's Yes / No word; a value that is not a JS boolean - reads as empty -- **date**: The date field's read-only face (`2020-07-04` reads `Jul 4, 2020` in - en-US), in the table cell too: it does not use the date cell's relative style -- **text**: The value as text, in a monospace font - -With no `returnType`, a JS number is drawn as a number and any other value as -text. Nothing is inferred from the expression. - -## Formula Examples - -Common formula patterns, each the value of `expression` (CEL, with the record's -fields under `record.`): - -```plaintext -// Arithmetic -expression: 'record.price * record.quantity' -expression: '(record.subtotal - record.discount) * record.tax_rate' - -// Text concatenation -expression: 'record.first_name + " " + record.last_name' -expression: 'record.city + ", " + record.state + " " + record.zip' - -// Conditional -expression: 'record.age >= 18 ? "Adult" : "Minor"' -expression: 'record.status == "closed" ? record.completed_at : null' - -// Date calculations -expression: 'addDays(record.created_at, 7)' -expression: 'daysBetween(record.start_date, record.end_date)' -``` - -## Cell Renderer - -In tables/grids, `FormulaCellRenderer` draws the value by the same rule as the -form (see Return Types above). Only text is drawn in a monospace font. A null or -empty value renders an em-dash. - -```ts -import { FormulaCellRenderer } from '@object-ui/fields'; - -// with no returnType -// renders the number cell's text: 200,000 (en-US) -``` - -## Backend Implementation - -Formula fields are computed on the backend: the platform evaluates the field's -`expression` against the record and returns the result as the field's value. -The widget never evaluates anything; it formats the value it is given by -`returnType`. - -## Use Cases - -- **Calculations**: Totals, subtotals, tax amounts -- **Aggregations**: Sum of related records -- **Concatenations**: Full names, addresses -- **Derived Values**: Age from birthdate, days until deadline -- **Conditional Logic**: Status based on other fields diff --git a/ui/content/docs/fields/grid.mdx b/ui/content/docs/fields/grid.mdx deleted file mode 100644 index 098c0ebf5e2..00000000000 --- a/ui/content/docs/fields/grid.mdx +++ /dev/null @@ -1,370 +0,0 @@ ---- -title: "Grid Field" -description: "Sub-table field for inline tabular data" ---- - -The Grid Field component provides an inline table for managing related records or tabular data within a parent record. It displays data in rows and columns with optional editing capabilities. - -## Basic Usage - - - -## With Data - - - -## Read-Only - - - -## Field-Level Keys - - - -## Field Schema - -A grid field is authored as `GridFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with the column list, -the row-count and row-action limits, the footer total, the Add button's label and the -row field that keeps the rows' order. -Each column is `@objectstack/spec`'s inline grid column (`InlineGridColumn`, the -element of the spec's `inlineColumns` list on a `master_detail` field), typed by -reference, so the columns are checked by the same compiler that checks the field, -and `objectui validate` refuses a column key the spec does not declare. - -```ts -import type { GridFieldMetadata } from '@object-ui/types'; - -const lineItems: GridFieldMetadata = { - type: 'grid', - name: 'line_items', - label: 'Line Items', - columns: [ - { name: 'product', label: 'Product', type: 'lookup', required: true, width: 240 }, - { name: 'quantity', label: 'Qty', type: 'number', width: 80 }, - { name: 'unit_price', label: 'Unit Price', type: 'currency', width: 120 }, - { name: 'amount', label: 'Amount', type: 'currency', computed: true, expr: 'quantity * unit_price' }, - ], - minRows: 1, - maxRows: 50, - allowAdd: true, - allowDelete: true, - allowReorder: false, - totalField: 'amount', - addLabel: 'Add line', - sortField: 'position', -}; -``` - -The field-level keys, each read under exactly this one spelling: - -- `minRows` / `maxRows` — the row-count limits. -- `allowAdd` / `allowDelete` — whether rows can be added or removed. Each row's - duplicate action follows `allowAdd`, since a duplicate is an add. -- `allowReorder` — set `false` to remove the drag handles; rows can be reordered - by dragging otherwise. -- `totalField` — the `name` of the **child** column summed into the footer total. - It carries the spec's `amountField`. The spec's own `totalField` on a - master-detail subform is a different thing with the same name: the parent field - the save writes the sum to. No total shows when it is unset. -- `addLabel` — the Add button's label. -- `sortField` — the name of a field on each **row** that the grid stamps with the - row's index (0, 1, 2, …) whenever a row is added, removed, duplicated or moved, - so the order a drag-reorder leaves is saved with the rows. Editing a cell moves - no row and leaves every row's position as it was. It is not one of the columns: - a column of that name would have its typed value overwritten. Rows carry no - position when it is unset. When - a master-detail form derives a detail's grid from the child object (it does - unless the detail names its relationship field and types every column), it sets - this to the child's first field named `position`, `sort_order`, `sequence`, - `line_no`, `line_number` or `sort`; there is no master-detail key to override - that pick. - -The same keys are accepted on a `form` field entry of `type: 'grid'`, as the -Field-Level Keys example above writes them. `FormField` declares each one by -reference to `GridFieldMetadata`, and its zod mirror judges each one by that type, -so the strict authoring face accepts them there and refuses a value of the wrong -type. Only the `grid` widget reads them. - -A read-only or disabled grid offers no add, delete, duplicate or reorder, whatever -these keys say. The line-number column always shows. There is no `reorderable`, -`amount_field`, `amountField`, `allow_duplicate` or `show_line_numbers` key: none -is read, and `GridFieldMetadata` refuses each. - -A column's `width` is a **number** of pixels. There is no per-column `editable` -key (whether cells can be edited follows the field's own read-only state) and no -per-column `defaultValue`: a new row starts with every cell empty. - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -### Retired snake_case keys - -Until objectui#11610 these eight keys were spelled in snake_case: `min_rows`, -`max_rows`, `allow_add`, `allow_delete`, `allow_reorder`, `total_field`, -`add_label` and `sort_field`. Each is now refused by name, with the camelCase key -to write instead, and its value carries over unchanged: - -- `GridFieldMetadata` and `FormField` declare each as a `never` member, so - TypeScript refuses it at the authoring site. -- `objectui validate` refuses it on a `form` field entry with - ``Did you mean `min_rows` → `minRows`?`` (and so on for each key). -- The `grid` widget draws an alert naming the retired keys and their replacements - instead of the grid, and logs the same text with `console.error`. It does not - quietly draw a grid that ignores them. - -## Column Types - -A column's `type` is one of the spec's nine cell controls: `text`, `number`, -`currency`, `date`, `datetime`, `time`, `select`, `lookup` and `file`. Any other -value is refused. - -```plaintext -columns: [ - { name: 'name', label: 'Name', type: 'text', required: true }, - { name: 'quantity', label: 'Qty', type: 'number' }, - { name: 'price', label: 'Price', type: 'currency' }, - { name: 'date', label: 'Date', type: 'date' }, - { name: 'status', label: 'Status', type: 'select', options: [...] }, - { name: 'receipt', label: 'Receipt', type: 'file', accept: ['image/*', '.pdf'] } -] -``` - -A `currency` column has no currency key of its own: its cells are shown in the -tenant's default currency. - -### File / image columns - -A `file` column renders a real upload control inside the cell — a compact -"Upload" button that opens the native file picker, with uploaded files shown -as removable chips (image files show a thumbnail). This covers the common -"attach a receipt per expense line" pattern without opening the per-row form. - -- `accept?: string[]` — restrict the picker (e.g. `['image/*', '.pdf']`). -- `multiple?: boolean` — allow several files per cell. -- Uploads go through the configured `UploadProvider` adapter, exactly like the - full-size file field. - -When grid columns are auto-derived from a child object's schema (master-detail -subforms), `file` / `image` / `avatar` fields map to `file` columns -automatically — image-flavoured fields default to `accept: ['image/*']`. - -Shown as text (read-only, or in the per-row form's list), a file cell reads the -file's name, or a count of its files when it holds several. The count and the -word used for a file with no name come from the session language's pack -(`detail.fileCount`, `fields.file.fileFallback`): an English session reads -`2 files` and `File`, and every other language reads its own pack's words, in -the plural form that language takes for the count. - -### Boolean columns from a child object - -When columns are auto-derived, a `boolean` or `toggle` child field becomes a -`select` column whose options store `'true'` and `'false'`. Their labels are the -session language's words for yes and no (`common.yes` / `common.no`), `Yes` / -`No` in an English session. Only the label is translated; the stored value is -the same in every language. - -Because file/image/avatar now render in-grid, a child object with a *single* -such field keeps the inline **grid** form factor by default (the smart -`inlineEdit` heuristic no longer forces a per-row form for one attachment -column). Only a truly form-only field (textarea / rich text / JSON / location) -or **several** rich fields tips the default to the per-row form; an explicit -`inlineEdit: 'grid' | 'form'` always wins. - -## Data Format - -Grid data is stored as an array of objects: - -```plaintext -const gridValue = [ - { product: 'Item 1', quantity: 2, price: 29.99 }, - { product: 'Item 2', quantity: 1, price: 49.99 }, - { product: 'Item 3', quantity: 5, price: 9.99 } -]; -``` - -## Common Patterns - -### Invoice Line Items - -```plaintext -{ - type: 'grid', - name: 'line_items', - label: 'Line Items', - columns: [ - { name: 'description', label: 'Description', type: 'text', required: true }, - { name: 'quantity', label: 'Quantity', type: 'number', required: true }, - { name: 'unit_price', label: 'Unit Price', type: 'currency', required: true }, - { name: 'amount', label: 'Amount', type: 'currency', computed: true, expr: 'quantity * unit_price' } - ] -} -``` - -### Order Details - -```plaintext -{ - type: 'grid', - name: 'order_details', - label: 'Order Details', - columns: [ - { name: 'sku', label: 'SKU', type: 'text' }, - { name: 'product', label: 'Product', type: 'lookup', reference: 'products' }, - { name: 'quantity', label: 'Qty', type: 'number' }, - { name: 'price', label: 'Price', type: 'currency' }, - { name: 'discount', label: 'Discount', type: 'number' }, - { name: 'total', label: 'Total', type: 'currency' } - ] -} -``` - -### Task Checklist - -```plaintext -{ - type: 'grid', - name: 'tasks', - label: 'Tasks', - columns: [ - { name: 'task', label: 'Task', type: 'text', required: true }, - { name: 'assigned_to', label: 'Assigned To', type: 'lookup', reference: 'sys_user' }, - { name: 'due_date', label: 'Due Date', type: 'date' }, - { name: 'completed', label: 'Done', type: 'select', options: [{ label: 'Yes', value: 'true' }, { label: 'No', value: 'false' }] } - ] -} -``` - -## Features - -- **Table Display**: Clean tabular layout, every row shown -- **Type-Specific Cells**: Each editable cell is the control its column's `type` names; - read-only mode formats `date`, `datetime`, `time`, `file` and `lookup` cells for display -- **Read-Only Mode**: Full table view without editing -- **Responsive**: Scrollable for many columns - -## Cell Renderer - -In tables/grids, a `grid` value is shown as a compact placeholder, not as a -nested table. It has no named renderer export of its own — the component is -resolved by field type, which is the supported path for every type: - -```ts -import { getCellRenderer } from '@object-ui/fields'; - -const GridCell = getCellRenderer('grid'); - -// renders: [Grid] -``` - -An empty value (`null`, `undefined`, `''` or `[]`) renders the shared "No value" -affordance instead, so an empty cell is distinguishable from a populated one. - -## Full Grid Functionality - -For advanced grid features, use the grid plugin (`@object-ui/plugin-grid`): - -```plaintext -// Basic inline grid (simple display) -{ type: 'grid', name: 'items', columns: [...] } - -// Advanced grid with full features (requires plugin) -// The block's props go in its `properties` bag (the spec's -// `ComponentPropsMap['object-grid']` row) — a `props` envelope is never read -{ - type: 'object-grid', // the registered type name — there is no `plugin:grid` - bind: 'items', // node-level: resolves the array from the surrounding data scope - properties: { - columns: [ // spec `ListColumn[]` — each entry is keyed by `field` - { field: 'product', label: 'Product' }, - { field: 'quantity', label: 'Quantity', sortable: false } - ], - editable: true, // Plugin feature: inline cell editing - pagination: { pageSize: 20 } // Plugin feature: pagination - } -} -``` - -**Note**: `editable` and `pagination` are read by `@object-ui/plugin-grid` (after `SchemaRenderer` hoists the bag onto the node), not by the basic grid field. Sorting and search are **not** node keys: column sorting is on by default and is turned off per column (`{ field, sortable: false }`), and there is no node-level `filterable` — a grid that fetches its own rows declares its query with `objectName` plus `filter` / `sort`. - -## Use Cases - -- **Invoice/Order Line Items**: Product lines, services -- **Expense Reports**: Expense entries, receipts -- **Time Tracking**: Time entries, work logs -- **Inventory**: Stock items, materials -- **Checklists**: Task lists, requirements -- **Schedules**: Appointments, bookings -- **Configurations**: Settings lists, parameters - -## Backend Storage - -Grid data is typically stored as JSON: - -```plaintext -// Database column type: JSONB (PostgreSQL) -interface OrderRecord { - id: string; - customer_id: string; - line_items: Array<{ - product: string; - quantity: number; - price: number; - }>; -} - -// Store in database -const order = { - customer_id: 'CUST-123', - line_items: [ - { product: 'Widget A', quantity: 2, price: 29.99 }, - { product: 'Widget B', quantity: 1, price: 49.99 } - ] -}; - -await db.insert('orders', order); -``` - -## Validation - -Example validation for grid data: - -```plaintext -// InlineGridColumn: the spec's grid column type (@objectstack/spec/data) -const validateGridData = (data: any[], columns: InlineGridColumn[]) => { - const errors: string[] = []; - - data.forEach((row, index) => { - columns.forEach(col => { - // Check required columns - if (col.required && !row[col.name]) { - errors.push(`Row ${index + 1}: ${col.label} is required`); - } - - // Validate by type - if (col.type === 'number' && isNaN(row[col.name])) { - errors.push(`Row ${index + 1}: ${col.label} must be a number`); - } - }); - }); - - return errors; -}; -``` - -## Integration with Advanced Grid - -The `grid` field and `@object-ui/plugin-grid`'s `object-grid` node do different jobs. -The `grid` field edits an array stored on the parent record, through the field-level -keys above. `object-grid` lists records (fetched for its `objectName`, or handed to -it) and acts on them as records. What each one reads: - -| Feature | `grid` field (`@object-ui/fields`) | `object-grid` (`@object-ui/plugin-grid`) | -| --- | --- | --- | -| Inline editing | Every cell but a computed one is its column's control, unless the field is read-only or disabled | `editable: true` (off by default), only where the current user may edit the object | -| Add and remove rows | `allowAdd` and `allowDelete`, each on unless `false` | An add-record row with `operations.create`, and a row's Delete with `operations.delete` and the host's `onDelete`, each only where the current user may create or delete the object's records | -| Sorting and filtering | None | Column-header sorting (a column opts out with `sortable: false`), the query's `filter` and `sort`, and search over `searchableFields` | -| Drag-and-drop reordering | Rows, with `allowReorder` (on unless `false`); `sortField` keeps the order on save | Columns only, with `reorderableColumns: true`; rows are not reordered | -| Export | None | `exportOptions` | -| Computed columns | A column with `computed: true` and an arithmetic `expr` over the row's other cells | None: a formula field's value is shown as the server computed it | -| Totals | One footer total, the sum of the `totalField` column | A footer summary per column (`columns[].summary`), and aggregates on group headers (`aggregations`) | diff --git a/ui/content/docs/fields/image.mdx b/ui/content/docs/fields/image.mdx deleted file mode 100644 index 20baab54906..00000000000 --- a/ui/content/docs/fields/image.mdx +++ /dev/null @@ -1,98 +0,0 @@ ---- -title: "Image Field" -description: "Image upload with thumbnail preview and multiple image support" ---- - -The Image Field component provides an image upload interface with thumbnail previews, multiple image support, and visual file management. - -## Basic Usage - - - -## Multiple Images - - - -## Field Schema - -An image field is authored as `ImageFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with `multiple`, `accept` -and `maxSize`, the spec's `FieldSchema.maxSize` in bytes. There is no key for a file count -or for pixel dimensions, because the spec declares none and no widget enforces one. A stored -image is the bare `sys_file` id the upload adapter minted, the same value shape the file -field stores: see the -[File Field](/docs/fields/file) for the refusal when no id comes back, and for -`UploadedFileMetadata`, the legacy inline shape an older record may still hold. -An image the reader may not open (`{ id, metadataRefused: true }`) renders as a restricted -lock tile, never a thumbnail; see **Restricted files** on the [File Field](/docs/fields/file). - -```ts -import type { ImageFieldMetadata } from '@object-ui/types'; - -const productPhotos: ImageFieldMetadata = { - type: 'image', - name: 'product_photos', - label: 'Product Photos', - multiple: true, - accept: ['image/png', 'image/jpeg', 'image/webp'], - maxSize: 5 * 1024 * 1024, -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Accepted Image Types - -By default, accepts all common image formats: - -```plaintext -// Default: 'image/*' -// Specific formats: -accept: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'] -``` - -## Features - -- **Thumbnail Grid**: Images displayed in 4-column grid -- **Preview**: Visual thumbnails for uploaded images -- **Hover to Remove**: Delete button appears on hover -- **Batch Upload**: Multiple image selection -- **Size Restrictions**: Configurable file size limits - -## Cell Renderer - -In tables/grids, displays image thumbnails: - -```ts -import { ImageCellRenderer } from '@object-ui/fields'; - -// Shows up to 3 thumbnails plus count if more -``` - -## Image Optimization - -For best results, cap the upload size and accept web-friendly formats: - -```plaintext -{ - type: 'image', - name: 'product_images', - label: 'Product Photos', - multiple: true, - maxSize: 5242880, // 5 MB - accept: ['image/jpeg', 'image/png', 'image/webp'] -} -``` - -## Integration Notes - -Uploads go through the ambient `UploadProvider`, exactly as for the [File Field](/docs/fields/file): the stored value is the `sys_file` id the adapter returns, and a pick whose adapter returns none is refused with "did not complete". - -## Use Cases - -- **User Profiles**: Avatar/profile pictures -- **Product Catalogs**: Product images, SKU photos -- **Photo Galleries**: Image collections, portfolios -- **Content Management**: Blog post images, media assets -- **Real Estate**: Property photos, listings diff --git a/ui/content/docs/fields/location.mdx b/ui/content/docs/fields/location.mdx deleted file mode 100644 index 909528f3f4a..00000000000 --- a/ui/content/docs/fields/location.mdx +++ /dev/null @@ -1,152 +0,0 @@ ---- -title: "Location Field" -description: "Geographic coordinates input for latitude and longitude" ---- - -The Location Field component provides an input for geographic coordinates, storing latitude and longitude as a structured object. - -## Basic Usage - - - -## With Default Value - - - -## Read-Only - - - -## Field Schema - -A location field is authored as `LocationFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set: it adds no key of its own to `BaseFieldMetadata`. -There is no zoom key: `@objectstack/spec`'s `FieldSchema` refuses `default_zoom` by -name and the widget never read it, so `LocationFieldMetadata` refuses it at compile time. - -```ts -import type { LocationFieldMetadata } from '@object-ui/types'; - -const officeLocation: LocationFieldMetadata = { - type: 'location', - name: 'office_location', - label: 'Office Location', - placeholder: 'latitude, longitude', - required: false, -}; -``` - -The coordinates themselves are the field's **value**, not metadata: the widget stores -an object carrying a `lat` and an `lng` and displays it as a comma-separated pair. -That value shape is exported as `LocationValue` by `@objectstack/spec/data` — -`{ lat, lng, altitude?, accuracy? }` — and it is what -`valueSchemaFor({ type: 'location' })` validates a stored location against. - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Data Format - -The location field stores coordinates as an object: - -```plaintext -{ - lat: 37.7749, - lng: -122.4194 -} -``` - -`altitude` and `accuracy` are optional numbers on the same object. - -**Note**: the stored keys are `lat` and `lng`. The older `{ latitude, longitude }` -spelling is deprecated and is **rejected** by `valueSchemaFor({ type: 'location' })` -with `invalid_type` at `[lat]` and `[lng]` — do not author it. - -Input format: `latitude, longitude` — a user still types a comma-separated -latitude-then-longitude pair; only the stored key names are `lat` / `lng`. -- Example: `37.7749, -122.4194` - -## Coordinate Ranges - -- **Latitude** (`lat`): -90 to 90 (negative = South, positive = North) -- **Longitude** (`lng`): -180 to 180 (negative = West, positive = East) - -Examples: -- New York: `40.7128, -74.0060` -- Tokyo: `35.6762, 139.6503` -- Sydney: `-33.8688, 151.2093` -- London: `51.5074, -0.1278` - -## Use Cases - -- **Store Locator**: Retail locations, branch offices -- **Delivery Zones**: Service areas, delivery points -- **Event Venues**: Conference locations, meeting points -- **Asset Tracking**: Equipment locations, vehicle tracking -- **Real Estate**: Property coordinates, land parcels - -## Integration with Maps - -For full map functionality, consider integrating with map services. The example -below has two halves: the **input** that captures a coordinate pair, and the **map -node** that plots what it stored. - -**1. The input.** `LocationField` is the widget behind the `location` field type. -Render it directly when you build the form yourself: - -```tsx -import { useState } from 'react'; -import { LocationField } from '@object-ui/fields'; -import type { LocationFieldMetadata } from '@object-ui/types'; - -const field: LocationFieldMetadata = { - type: 'location', - name: 'location', - label: 'Store location', -}; - -export function StoreLocationInput() { - const [value, setValue] = useState<{ lat: number; lng: number } | null>(null); - return ; -} -``` - -**2. The map over the same field.** With the map plugin installed, an `object-map` -node reads that field off every record. It is a metadata node, not a component -call — its keys sit in the node's `properties` bag, the spelling -`@objectstack/spec` declares, which the renderer hoists onto the node; a key -written flat on the node is refused by `objectui validate` (objectui#10859): - -```jsonc -{ - "type": "object-map", // the registered type name — there is no `plugin:map` - "properties": { - "objectName": "store", // the records to plot - "map": { // the declared config input; markers are derived from the data - "locationField": "location", // this page's field, read as { lat, lng } - "titleField": "name", // field used as the marker label - "zoom": 12, // initial zoom level - // Initial centre as [latitude, longitude]. Used only when no record - // matches — with records the view fits to their bounds instead. - "center": [37.7749, -122.4194] - } - } -} -``` - -**Note**: the map plots the records it fetches, so it takes an `objectName` (or inline rows under `staticData`, or a `data: { provider: 'value', items }` configuration — a bare array under `data` is not a record source) rather than a `bind` path, and it derives its markers from those records — a `markers` key on the node is not read. Every marker setting lives under the declared `map` input. - -## Validation - -The field validates coordinate ranges: - -```plaintext -// Valid coordinates -{ lat: 37.7749, lng: -122.4194 } ✓ - -// Invalid - latitude out of range -{ lat: 95, lng: -122.4194 } ✗ - -// Invalid - longitude out of range -{ lat: 37.7749, lng: 200 } ✗ -``` diff --git a/ui/content/docs/fields/lookup.mdx b/ui/content/docs/fields/lookup.mdx deleted file mode 100644 index 6269cfac54e..00000000000 --- a/ui/content/docs/fields/lookup.mdx +++ /dev/null @@ -1,245 +0,0 @@ ---- -title: "Lookup Field" -description: "Reference field for linking to other objects/records" ---- - -The Lookup Field component provides a reference field for creating relationships between objects and records. It supports dynamic data loading from a DataSource with debounced search, loading/error/empty states, keyboard navigation, and optional quick-create entry. - -## Basic Usage - - - -## Multiple References - - - -## Field Schema - -A lookup field is authored as `LookupFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with the reference -target, the display and description fields, an optional static option list, and the -Record Picker's column and paging configuration. Static options are `SelectOptionMetadata`, -and picker columns are `LookupColumnDef` — both exported, both checked here. - -```ts -import type { LookupFieldMetadata } from '@object-ui/types'; - -const accountId: LookupFieldMetadata = { - type: 'lookup', - name: 'account_id', - label: 'Account', - placeholder: 'Search accounts…', - required: true, - reference: 'accounts', - reference_field: 'name', - descriptionField: 'industry', - multiple: false, - searchable: true, - allowCreate: true, - // Record Picker dialog (Enterprise): columns accept a field name or a descriptor. - lookupColumns: ['name', { field: 'industry', label: 'Industry', width: '160px' }], - lookupPageSize: 10, - lookupFilters: [{ field: 'active', operator: 'eq', value: true }], -}; -``` - -The target object is `reference` — the spelling `@objectstack/spec`'s `FieldSchema` -declares, and the only one the lookup and user widgets and their read cells read. -[objectui#11070](https://github.com/objectstack-ai/objectui/issues/11070) retired -objectui's snake_case twin `reference_to`: the spec refuses it by name, and nothing -reads it. A stored object definition that still spells it is folded onto `reference` -when it is served through the ObjectStack adapter; a definition handed to the widget -any other way names no target — rename the key to `reference`. - -The quick-create switch and the Record Picker's configuration are `allowCreate`, -`lookupColumns` and `lookupPageSize`, the spellings `FieldSchema` declares and the only -ones the widget reads. -[objectui#12157](https://github.com/objectstack-ai/objectui/issues/12157) retired -objectui's snake_case twins `allow_create`, `lookup_columns` and `lookup_page_size`, -with no alias: the spec refuses each of them, and nothing reads them. A stored object -definition that still spells one is folded onto the declared key when it is served -through the ObjectStack adapter or the console's metadata store; a definition handed -to the widget any other way loses the setting — rename the key. - -The record field a pick commits as the value, `id` unless told otherwise, is picker -configuration rather than object metadata. `FieldSchema` refuses an `idField` by -name, so a lookup field definition cannot carry it, and `LookupFieldMetadata` does -not declare it. A host sets it where it builds the picker: on a form entry -(`FormField.idField`), on an action parameter, or on a `master_detail` inline grid -column. A reference stored by machine name sets it to `name`. - -When no data source is available the field falls back to a static option list: - -```ts -import type { LookupFieldMetadata } from '@object-ui/types'; - -const priority: LookupFieldMetadata = { - type: 'lookup', - name: 'priority', - label: 'Priority', - reference: 'priorities', - options: [ - { label: 'High', value: 'high' }, - { label: 'Normal', value: 'normal' }, - ], -}; -``` - -A static option may also carry a `description` — secondary text the picker's -typeahead searches alongside the label. Both faces declare it: -`SelectOptionMetadata` in `@object-ui/types`, and `@objectstack/spec` as -`SelectOptionSchema.description`, by the option-A ruling on -[objectui#6140](https://github.com/objectstack-ai/objectui/issues/6140) / -[objectui#6153](https://github.com/objectstack-ai/objectui/issues/6153) — so an -option carrying it is accepted in an authored object document. The spec's option -is still strict: a key it does not declare, such as `icon` (which -`SelectOptionMetadata` carries only for objectui's own renderers), is refused -**by name** (`unrecognized_keys`), and since `FieldSchema` routes a field's -`options` through that schema, it fails the **whole field**. -`select-option-spec-extension-7014.test.ts` (`@object-ui/types`) re-derives both -verdicts against the installed spec. The -`dataSource` a host injects and the `onCreateNew` callback it passes are widget props, -not metadata. - -A **dependent lookup** scopes its candidates by a sibling field's value, declared -with `dependsOn` — the same `BaseFieldMetadata` member the select widgets gate on -([objectui#6153](https://github.com/objectstack-ai/objectui/issues/6153)), in the -spec's field-level shape: an array of controlling field names, or `{ field, param }` -entries when the remote filter parameter differs from the local field name. While -any controlling value is empty the trigger is gated ("Select account first"); once -set, every candidate query — the typeahead popover, the Record Picker and the -people picker — carries the chain as a hard `$filter` no user input can override. - -```ts -import type { LookupFieldMetadata } from '@object-ui/types'; - -const contact: LookupFieldMetadata = { - type: 'lookup', - name: 'contact', - label: 'Contact', - reference: 'contacts', - // Filter `contacts` by `account_id` equal to the form's current `account`. - dependsOn: [{ field: 'account', param: 'account_id' }], -}; -``` - -`dependsOn` is the only spelling this widget reads. -[objectui#7357](https://github.com/objectstack-ai/objectui/issues/7357) retired -objectui's snake_case twin `depends_on`: it was never a `@objectstack/spec` key -(the object contract refuses it by name, so a document carrying it is rejected -at publish), and the widget no longer reads it. Metadata that still spells it -that way does not gate and does not scope the picker — rename it to `dependsOn`. - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Dynamic Data Source - -When a `DataSource` is available (via `SchemaRendererContext`, explicit prop, or field config), the Lookup popup **automatically** fetches records from the referenced object: - -```plaintext -// Automatic — DataSource from SchemaRendererContext -// (works out-of-the-box in ObjectForm, DrawerForm, etc.) -{ - type: 'lookup', - name: 'customer', - label: 'Customer', - reference: 'customers', - reference_field: 'name', // Display field (default: 'name') - descriptionField: 'industry', // Optional secondary field -} -``` - -The popup will: -1. Fetch records via `dataSource.find(reference, { $top: 50 })` on open -2. Send `$search` queries with 300ms debounce as the user types -3. Show loading spinner, error state with retry, and empty state -4. Display "Showing X of Y" when more records exist than the page size -5. Show a **"Show All Results"** button (inside the popover) to open the full Record Picker dialog when total exceeds page size - -## Browse All Button - -Every Lookup field with a `dataSource` always renders a **"Browse All"** button (table icon) next to the quick-select trigger. This button opens the full **RecordPickerDialog** directly, regardless of dataset size — ensuring enterprise features like multi-column tables, sort/filter bar, and cell renderers are always discoverable. - -- Always visible when `dataSource` is configured -- Opens the Record Picker dialog without needing to open the popover first -- Keyboard accessible and screen-reader friendly (`aria-label="Browse all records"`) - -## Record Picker Dialog (Enterprise) - -The full **RecordPickerDialog** can be opened in two ways: -1. **"Browse All" button** (table icon) — always visible next to the quick-select trigger -2. **"Show All Results"** link inside the popover — shown when total records exceed the page size - -```plaintext -// Configure the Record Picker with lookupColumns -{ - type: 'lookup', - name: 'order', - label: 'Order', - reference: 'orders', - reference_field: 'order_number', - descriptionField: 'customer_name', - lookupColumns: [ - { field: 'order_number', label: 'Order #' }, - { field: 'customer_name', label: 'Customer' }, - { field: 'total_amount', label: 'Amount' }, - { field: 'status', label: 'Status' }, - ], - lookupPageSize: 15, -} -``` - -The Record Picker dialog provides: -- **Multi-column table** with configurable columns via `lookupColumns` -- **Search** with debounced server-side querying -- **Column sorting** via clickable headers (sends `$orderby` to DataSource) -- **Pagination** with page-by-page navigation -- **Keyboard navigation** — Arrow keys to move between rows, Enter/Space to select -- **Single/Multi-select** with visual check indicators and confirmation flow -- **Responsive layout** — Mobile-friendly width (95vw on small screens) -- **Loading, error, and empty states** -- Auto-inferred columns from `reference_field` when `lookupColumns` is not set - -## Lookup vs Master-Detail - -- **Lookup**: Standard reference field, can be deleted independently -- **Master-Detail**: Parent-child relationship, deleting parent deletes children - -## Cell Renderer - -In tables/grids, lookup values display the referenced record name: - -```ts -import { LookupCellRenderer } from '@object-ui/fields'; - -// Single value: Display name/label -// Multiple values: Multiple chips/badges -``` - -## Use Cases - -- **Assignments**: Assign tasks to users -- **Relationships**: Link related records -- **Categories**: Reference to category objects -- **Parent Records**: Master-detail relationships -- **Team Members**: Multi-user references - -## Features - -- **Two-Level Interaction**: Popover typeahead (Level 1) + full Record Picker dialog (Level 2) -- **Record Picker Dialog**: Enterprise-grade table with multi-column, pagination, search, sorting -- **Inline Popover**: Level 1 opens as anchored dropdown (non-modal) for fast typeahead -- **Column Sorting**: Clickable column headers with `$orderby` server-side sort -- **Dynamic DataSource Loading**: Automatically fetches records from referenced objects -- **Search**: Debounced type-ahead search with `$search` parameter -- **Multi-Select**: Support for multiple references with confirmation flow -- **Keyboard Navigation**: Arrow keys to navigate rows, Enter to select in both levels -- **Responsive**: Mobile-friendly width, adapts to screen size -- **Loading/Error/Empty States**: Friendly feedback for all states -- **Secondary Field Display**: Show description/subtitle per option -- **Quick-Create Entry**: Optional "Create new" button when no results -- **Configurable Columns**: `lookupColumns` for multi-column picker display -- **Base Filters**: `lookupFilters` to restrict selectable records -- **Pagination**: Page-by-page navigation in Record Picker dialog -- **Backward Compatible**: Falls back to static options when no DataSource diff --git a/ui/content/docs/fields/meta.json b/ui/content/docs/fields/meta.json deleted file mode 100644 index 9625fd439ec..00000000000 --- a/ui/content/docs/fields/meta.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "title": "Fields", - "pages": [ - "text", - "textarea", - "number", - "currency", - "percent", - "boolean", - "select", - "date", - "datetime", - "time", - "email", - "phone", - "url", - "password", - "file", - "image", - "location", - "lookup", - "formula", - "summary", - "auto-number", - "user", - "rich-text", - "object", - "vector", - "grid", - "widget-props" - ] -} diff --git a/ui/content/docs/fields/number.mdx b/ui/content/docs/fields/number.mdx deleted file mode 100644 index 787b9032af5..00000000000 --- a/ui/content/docs/fields/number.mdx +++ /dev/null @@ -1,97 +0,0 @@ ---- -title: "Number Field" -description: "Numeric input field for integer and decimal numbers" ---- - -The Number Field component provides a numeric input for collecting integer or decimal numbers, with an optional number of decimal places (`scale`). - -## Basic Usage - - - -## With Decimal Places - - - -## With Min/Max - - - -## Field Schema - -A number field is authored as `NumberFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with the numeric -bounds, the stored precision and scale, the stepper increment, and the display -hint `useGrouping` (see [Cell Renderer](#cell-renderer)). - -```ts -import type { NumberFieldMetadata } from '@object-ui/types'; - -const quantity: NumberFieldMetadata = { - type: 'number', - name: 'quantity', - label: 'Quantity', - placeholder: '0', - required: true, - min: 0, - max: 9999, - precision: 10, - scale: 0, - step: 1, - useGrouping: true, -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## What the browser rewrites before this field sees it - -This widget renders a native `type="number"` input, so the browser decides what -the box accepts. Two things can happen and **only one of them is announced**: - -- **Announced.** Text the browser cannot read at all — `1e`, a lone `-`, a lone - `.` — leaves the box visibly showing what was typed while its value reads - empty. The field is marked `aria-invalid` and draws - *"Not saved: the text in this box is not a number."* -- ⚠️ **Not announced.** Entries the browser silently **truncates**: pasting - ``1.2.3`` stores ``1.23``, and `0x10` stores ``10``. No warning is - possible here — the browser discards the extra characters as they arrive, so - nothing reaches ObjectUI to check. - -⛔ **"No warning" therefore does not mean "the value is right."** Full -explanation and the reasoning: [What a number field silently -rewrites](/docs/guide/fields#what-a-number-field-silently-rewrites). - -## Use Cases - -- **Quantities**: Order quantities, stock levels -- **Measurements**: Dimensions, weights, distances -- **Ratings**: Numeric ratings or scores -- **Ages**: User age or duration values - -## Cell Renderer - -In tables/grids, numbers are displayed with tabular formatting: - -```ts -import { NumberCellRenderer } from '@object-ui/fields'; - -// Renders in a tabular-nums font, to the field's `scale`: -// a stored 1234.5 on a field declaring `scale: 2` renders: 1,234.50 -// the same value on a field with no `scale` renders: 1,234.5 -``` - -Whether the value gets thousands separators is the field's `useGrouping`: - -| `useGrouping` | `scale` | Thousands separators | -|---|---|---| -| `false` | any | no: `12345.5` | -| `true` | any | yes: a scale-0 count reads `2,026` | -| not set | `0` | no: read as a year or another ordinal, `2026` | -| not set | anything else, or not set | yes: `12,345.5` | - -`useGrouping` is the `@objectstack/spec` field key of the same name. An authored -`true` always groups, even in a locale that leaves four-digit numbers alone by -default (es-ES renders `1.234`). With the key unset, the grouped rows follow the -locale's own convention. diff --git a/ui/content/docs/fields/object.mdx b/ui/content/docs/fields/object.mdx deleted file mode 100644 index a2ba42ac9d3..00000000000 --- a/ui/content/docs/fields/object.mdx +++ /dev/null @@ -1,220 +0,0 @@ ---- -title: "Object Field" -description: "JSON object editor for structured data" ---- - -The Object Field component provides a JSON editor for storing and editing structured data objects. It validates JSON syntax and provides a textarea interface for complex data. - -It is the form editor for the free-form JSON field types: `object`, `composite`, `record` and `json` all resolve to it. A `json` value need not be an object — an array, a number, a string, a boolean or `null` is shown as its JSON text and parsed back as itself. (`code` is a different field: it stays a raw-text editor and saves its text.) - -## Basic Usage - - - -## Structured Configuration - - - -## Nested Data - - - -## Read-Only - - - -## Field Schema - -A JSON field is authored as `ObjectFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set. Its type value is `json`, the `@objectstack/spec` -`FieldType` for a free-form JSON value: `object` is not a spec field type, and -`FieldSchema` refuses it at `type`. The type adds no key of its own to -`BaseFieldMetadata`; the shape the value must have is declared on the object instead -(see [Schema Validation](#schema-validation)). - -```ts -import type { ObjectFieldMetadata } from '@object-ui/types'; - -const settings: ObjectFieldMetadata = { - type: 'json', - name: 'settings', - label: 'Settings', - placeholder: '{ }', - inlineHelpText: 'Stored as JSON.', -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## JSON Validation - -The field validates JSON syntax in real-time: - -- **Valid JSON**: Updates the value immediately -- **Invalid JSON**: Refused. The text stays in the box so it can be corrected, the box says "Invalid JSON" and is marked invalid, and the value is not updated. Inside a form the box is also invalid for the browser's constraint validation, so the form will not submit until the text parses — the last valid value is never sent in its place. -- **Empty Input**: Sets value to `null` - -## Data Format - -The object field stores data as parsed JSON: - -```plaintext -// Input (string) -'{"name": "John", "age": 30}' - -// Stored (object) -{ name: "John", age: 30 } - -// Display (formatted) -{ - "name": "John", - "age": 30 -} -``` - -## Common Patterns - -### Configuration Objects - -```plaintext -{ - type: 'json', - name: 'api_config', - label: 'API Configuration', - value: { - endpoint: 'https://api.example.com', - timeout: 5000, - retries: 3, - headers: { - 'Content-Type': 'application/json' - } - } -} -``` - -### Metadata - -```plaintext -{ - type: 'json', - name: 'custom_metadata', - label: 'Custom Metadata', - value: { - tags: ['important', 'urgent'], - priority: 'high', - department: 'engineering' - } -} -``` - -### Preferences - -```plaintext -{ - type: 'json', - name: 'user_preferences', - label: 'Preferences', - value: { - theme: 'dark', - language: 'en', - notifications: { - email: true, - push: false, - sms: false - } - } -} -``` - -## Cell Renderer - -In tables/grids, a `json` value is rendered by the JSON cell renderer — -`getCellRenderer('json')` resolves to it, as `getCellRenderer('object')` does: - -```ts -import { JsonCellRenderer } from '@object-ui/fields'; - -// renders single-line JSON, -// truncated to the column width with the full text in the cell's `title`. -// A null or empty value renders an em-dash instead. -``` - -`composite` and `record` values are not drawn as JSON. Their cell — in a grid and -on the record page alike — is one line of labelled sub-values, with the full text -in the `title`: - -```plaintext -// composite { width: 10, height: 20 } -Width 10 · Height 20 - -// record { primary: { name: "A", score: 9 }, backup: { name: "B", score: 7 } } -Primary (Name A · Score 9) · Backup (Name B · Score 7) -``` - -A label is the humanized key: the field metadata declares no sub-fields to take a -label from. A number sub-value is formatted as the number cell formats it, a boolean -reads as Yes / No in the reader's language, a string reads as itself, and an object or -an array nested inside a sub-value stays compact JSON. -An empty value, `[]`, `{}` and a non-object value read as the JSON cell reads them. - -## Schema Validation - -The shape a JSON value must have is declared on the **object**, not on the field. -`@objectstack/spec` has a validation rule for exactly this, `type: 'json_schema'`, which -names the field and carries a JSON Schema document. Like every object validation rule it -runs on the server's write path, on the writes its `events` name. `FieldSchema` has no -field-level `schema` key: it refuses one -by name, and `ObjectFieldMetadata` refuses it at compile time. - -```plaintext -// on the object definition -validations: [ - { - type: 'json_schema', - name: 'product_specs_shape', - field: 'product_specs', - message: 'Product specifications are not in the expected shape.', - schema: { - type: 'object', - properties: { - weight: { type: 'number' }, - dimensions: { - type: 'object', - properties: { - width: { type: 'number' }, - height: { type: 'number' }, - depth: { type: 'number' } - } - }, - materials: { type: 'array' } - } - } - } -] -``` - -## Use Cases - -- **Configuration**: Application settings, API configurations -- **Metadata**: Custom properties, tags, attributes -- **API Responses**: Storing API response data -- **Preferences**: User preferences, feature flags -- **Structured Data**: Any complex structured information -- **JSON Storage**: Raw JSON data storage - -## Best Practices - -1. **Use for Flexible Data**: When schema changes frequently -2. **Validate on Backend**: Always validate structure server-side -3. **Consider Alternatives**: Use typed fields when structure is fixed -4. **Document Schema**: Provide clear documentation for expected structure -5. **Size Limits**: Set reasonable size limits for JSON data - -## Backend Validation - -ObjectUI does not check the shape of a JSON value. `ObjectField` checks JSON **syntax** -only — it accepts any value `JSON.parse` accepts, and refuses a draft it cannot parse — -so nothing on the client rejects a well-formed object whose shape is wrong. Shape -validation belongs on the server, and the object's `json_schema` rule above is how it is -declared there. diff --git a/ui/content/docs/fields/password.mdx b/ui/content/docs/fields/password.mdx deleted file mode 100644 index 249f8c01566..00000000000 --- a/ui/content/docs/fields/password.mdx +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: "Password Field" -description: "Secure password input with visibility toggle" ---- - -The Password Field component provides a secure text input for passwords with a toggle button to show/hide the password text. - -## Basic Usage - - - -## With Validation - - - -## Confirm Password - - - -## Field Schema - -A password field is authored as `PasswordFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set: it extends `BaseFieldMetadata` with the two length -bounds, `minLength` and `maxLength` — `@objectstack/spec`'s own `FieldSchema` members, -typed by reference. The reveal toggle and the masked read-only rendering are the widget's. - -```ts -import type { PasswordFieldMetadata } from '@object-ui/types'; - -const password: PasswordFieldMetadata = { - type: 'password', - name: 'password', - label: 'Password', - placeholder: 'Enter a password', - required: true, - minLength: 12, - maxLength: 128, -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Features - -- **Masked Input**: Password is hidden by default with bullet points (•) -- **Toggle Visibility**: Eye icon button to show/hide password -- **Secure Display**: Read-only mode shows •••••••• for a stored value, never the value itself; an empty value shows the shared "No value" affordance instead -- **Validation**: Built-in support for length requirements - -## Cell Renderer - -In tables/grids, a stored `password` or `secret` value renders as a fixed mask -(`••••••`), never as the value. An empty value (`null`, `undefined`, `''` or `[]`) -renders the shared "No value" affordance instead, so a credential that was never -set is distinguishable from one that is. - -## Security Best Practices - -When using password fields: - -1. **Never log passwords**: Don't log form values containing passwords -2. **Use HTTPS**: Always transmit over secure connections -3. **Validation**: Enforce minimum length and complexity requirements -4. **Storage**: Never store passwords in plain text on the backend -5. **Auto-complete**: Consider disabling auto-complete for sensitive passwords - -## Use Cases - -- **User Registration**: New account password -- **Login Forms**: User authentication -- **Password Change**: Updating existing passwords -- **Security Settings**: API keys, tokens (when masked input is appropriate) diff --git a/ui/content/docs/fields/percent.mdx b/ui/content/docs/fields/percent.mdx deleted file mode 100644 index 0a26cad56fa..00000000000 --- a/ui/content/docs/fields/percent.mdx +++ /dev/null @@ -1,115 +0,0 @@ ---- -title: "Percent Field" -description: "Percentage input with automatic conversion and formatting" ---- - -The Percent Field component provides a percentage input that automatically converts between the stored value and the displayed percentage (0-100). How a value is stored is declared by the field: a fraction (0-1) by default, or whole percentage points when the field declares a `max` above 1. - -## Basic Usage - - - -## With Decimal Places - - - -## Required Field - - - -## Read-Only - - - -## Field Schema - -A percent field is authored as `PercentFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set: it extends `BaseFieldMetadata` with the decimal -places (`scale`), the total-digit `precision` and the two bounds. - -```ts -import type { PercentFieldMetadata } from '@object-ui/types'; - -const discountRate: PercentFieldMetadata = { - type: 'percent', - name: 'discount_rate', - label: 'Discount Rate', - placeholder: '0%', - scale: 2, - min: 0, - max: 1, -}; -``` - -**Decimal places come from `scale`, not from `precision`.** `scale` is how many -decimals the percent shows: the input's step, the read-only display and the table cell -all follow it, so a stored `0.855` on the field above shows `85.50%`. `precision` is the -total digit count of the stored decimal, so it never sets how many decimals are shown. -Leave `scale` out and the field shows the platform's default width for a percent, which -is whole percents: the same `0.855` shows `86%`. - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## What the browser rewrites before this field sees it - -This widget renders a native `type="number"` input, so the browser decides what -the box accepts. Two things can happen and **only one of them is announced**: - -- **Announced.** Text the browser cannot read at all — `1e`, a lone `-`, a lone - `.` — leaves the box visibly showing what was typed while its value reads - empty. The field is marked `aria-invalid` and draws - *"Not saved: the text in this box is not a number."* -- ⚠️ **Not announced.** Entries the browser silently **truncates**: pasting - ``1.2.3`` stores ``0.0123``, and `0x10` stores ``0.1``. No warning is - possible here — the browser discards the extra characters as they arrive, so - nothing reaches ObjectUI to check. - -⛔ **"No warning" therefore does not mean "the value is right."** Full -explanation and the reasoning: [What a number field silently -rewrites](/docs/guide/fields#what-a-number-field-silently-rewrites). - -## Value Conversion - -The percent field handles automatic conversion, at the storage the field -declares. `@objectstack/spec` states the rule as `percentScaleOf`: a percent -field stores a **fraction** unless it declares a `max` above 1, in which case -it stores **whole percentage points**. - -- **Fraction (the default, no `max` or `max: 1`)**: the stored value is a - decimal between 0 and 1 (e.g., `0.75`), displayed as `75%`. A stored `1` is - `100%`. -- **Whole points (`max: 100`, or any `max` above 1)**: the stored value is - already the percentage (e.g., `75`), displayed as `75%`. A stored `1` is - `1%`. - -Every face reads the same declaration — the input, its read-only text, the -table cell, the grid footer, the record header chip, the gantt tooltip, the -gallery card and the dashboard tile — so one stored value shows one percentage -everywhere. None of them guesses the storage from the value's size. - -Example, for a field with no `max`: -```plaintext -// Stored in database: 0.85 -// Displayed to user: 85% -// User enters: 90 -// Stored as: 0.90 -``` - -## Cell Renderer - -In tables/grids, values are formatted with percentage symbol, to the field's `scale`: - -```ts -import { PercentCellRenderer } from '@object-ui/fields'; - -// A stored 0.855 on a field declaring `scale: 2` renders: 85.50% -// The same value on a field with no `scale` renders: 86% -``` - -## Use Cases - -- **Progress Tracking**: Task completion, project progress -- **Analytics**: Conversion rates, success metrics -- **Financial Data**: Interest rates, discount percentages -- **Statistics**: Performance scores, efficiency ratings diff --git a/ui/content/docs/fields/phone.mdx b/ui/content/docs/fields/phone.mdx deleted file mode 100644 index 69062700c43..00000000000 --- a/ui/content/docs/fields/phone.mdx +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: "Phone Field" -description: "Phone number input with formatting" ---- - -The Phone Field component provides a text input optimized for phone number entry with proper formatting. - -## Basic Usage - - - -## Required Phone - - - -## Field Schema - -A phone field is authored as `PhoneFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with a display format. - -```ts -import type { PhoneFieldMetadata } from '@object-ui/types'; - -const mobile: PhoneFieldMetadata = { - type: 'phone', - name: 'mobile', - label: 'Mobile', - placeholder: '(555) 000-0000', - required: false, - format: '(###) ###-####', -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Cell Renderer - -In tables/grids, phone numbers are clickable tel: links: - -```ts -import { PhoneCellRenderer } from '@object-ui/fields'; - -// Renders as: +1 (555) 123-4567 -// Clicking initiates a phone call on mobile devices -``` - -## Use Cases - -- **Contact Information**: Customer phone numbers -- **Support**: Support line numbers -- **Emergency Contacts**: Emergency contact numbers -- **Delivery**: Delivery contact numbers -- **Appointments**: Callback numbers - -## Features - -- **Mobile Optimized**: Opens phone dialer on mobile -- **Clickable Links**: tel: links in read-only mode -- **International Support**: Supports international formats -- **Accessible**: Proper input type for mobile keyboards diff --git a/ui/content/docs/fields/rich-text.mdx b/ui/content/docs/fields/rich-text.mdx deleted file mode 100644 index 95a90f7a20e..00000000000 --- a/ui/content/docs/fields/rich-text.mdx +++ /dev/null @@ -1,158 +0,0 @@ ---- -title: "Rich Text Field" -description: "Rich text editor for formatted content" ---- - -The Rich Text Field component edits formatted text content with markdown or HTML support — a plain-textarea editor today, with the stored markup rendered formatted on every read surface. - -## Basic Usage - - - -## HTML Editor - - - -## Field Schema - -`markdown`, `html` and `richtext` are three field types served by one widget, and each -has its own exported metadata type — `MarkdownFieldMetadata`, `HtmlFieldMetadata` and -`RichtextFieldMetadata` (`@object-ui/types`). All three extend `BaseFieldMetadata` and -add a length bound and an inline-editor height; there is no combined "rich text" -metadata type. - -```ts -import type { - HtmlFieldMetadata, - MarkdownFieldMetadata, - RichtextFieldMetadata, -} from '@object-ui/types'; - -const releaseNotes: MarkdownFieldMetadata = { - type: 'markdown', - name: 'release_notes', - label: 'Release Notes', - placeholder: 'Write the notes…', - maxLength: 20000, - // Inline editor height in text rows (positive integer; default 8). The - // fullscreen dialog sizes itself and ignores it. - rows: 12, -}; - -const emailBody: HtmlFieldMetadata = { - type: 'html', - name: 'email_body', - label: 'Email Body', - maxLength: 50000, -}; - -const articleBody: RichtextFieldMetadata = { - type: 'richtext', - name: 'article_body', - label: 'Article Body', - rows: 10, - maxLength: 50000, -}; -``` - -`richtext` gained its exported type in -[objectui#7083](https://github.com/objectstack-ai/objectui/issues/7083); until then it -was the one key of the three with no member to annotate against, so its metadata could -only be written through a cast to one of its siblings. It stores **HTML** — it reads -through the same display pipeline as `html`, not the markdown one -([objectui#5452](https://github.com/objectstack-ai/objectui/issues/5452)) — and that -pipeline is the only thing `type` changes between the three. - -`maxLength` is `@objectstack/spec`'s own `FieldSchema.maxLength`, typed by reference: a -bound on the stored text. The widget reads it — the editor's native stop, a character -counter and its `aria-describedby` description, inline and in the fullscreen dialog -([objectui#8438](https://github.com/objectstack-ai/objectui/issues/8438)) — and the form's -validation rules enforce it again when the form is submitted, whatever the field's type. -The snake_case `max_length` is retired: the spec refuses it by name and nothing reads it -([objectui#11070](https://github.com/objectstack-ai/objectui/issues/11070)). - -`rows` sizes the inline editor, in text rows — declared on all three types (and on -`@objectstack/spec`'s `FieldSchema` for the multiline editor types), matching the -`textarea` field's key of the same name ([objectui#6140](https://github.com/objectstack-ai/objectui/issues/6140)). -The editor is a plain textarea today — there is no formatting toolbar, no preview pane -and no pixel height to configure, so none of the three metadata types declares one: -`toolbar`, `preview`, `minHeight` and `maxHeight` are **not** metadata keys, and writing -them does nothing. - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Supported Formats - -### Markdown -- **Headings**: `# H1`, `## H2`, `### H3` -- **Bold/Italic**: `**bold**`, `*italic*` -- **Lists**: Ordered and unordered -- **Links**: `[text](url)` -- **Images**: `![alt](url)` -- **Code**: Inline and blocks -- **Tables**: Markdown tables - -### HTML -- Headings, paragraphs, emphasis, lists, quotes, code, tables, links and images -- Sanitized on display against an allow-list (`rehype-sanitize`, GitHub-style): - links keep `http`, `https`, `mailto` and relative URLs, and images keep `http`, - `https` and relative URLs -- Inline `style`, event-handler attributes, embedded frames, objects and SVG or - MathML content are not rendered; an element outside the allow-list shows its text - without its tag -- The `markdown` and `html` / `richtext` display pipelines share the same allow-list - -## Cell Renderer - -In tables/grids, rich text is shown as plain text: - -```ts -import { TextCellRenderer } from '@object-ui/fields'; - -// Strips formatting, shows plain text only -// Long content is truncated -``` - -## Use Cases - -- **Blog Posts**: Article content, blog posts -- **Documentation**: Technical documentation -- **Descriptions**: Product or feature descriptions -- **Comments**: Rich formatted comments -- **Emails**: Email templates, rich email content -- **Pages**: CMS page content - -## Features - -- **Markdown Support**: For lightweight formatting -- **HTML Support**: For advanced formatting -- **Formatted read surfaces**: Grids, detail pages and the readonly form branch - render the stored markup formatted (sanitized for HTML) -- **Fullscreen editing**: An expand affordance when the form opts in - (`mobile.fullscreenLongText`) - -The editing surface itself is a plain textarea with no header row above it; guidance -for the person typing comes from the field's own `placeholder` and help text. A -formatting toolbar, an edit-time preview pane and WYSIWYG editing are **not yet -implemented** (whatever the editor grows into, both the inline and fullscreen -surfaces gain it at once, since they share one editor component). - -## Editor Modes - -The rich text field can operate in different modes: - -1. **Markdown Mode** (`type: 'markdown'`) - - Lightweight markup - - Easy to write and read - - Great for documentation - -2. **HTML Mode** (`type: 'html'`) - - Full HTML editing - - Advanced formatting - - Embedded content support - -3. **Rich Text Mode** (`type: 'richtext'`) - - Stores HTML, and reads through the same display pipeline as `html` - - Editable under `RichtextFieldMetadata` - - Same plain-textarea editing surface as the other two diff --git a/ui/content/docs/fields/select.mdx b/ui/content/docs/fields/select.mdx deleted file mode 100644 index 2fc92e50141..00000000000 --- a/ui/content/docs/fields/select.mdx +++ /dev/null @@ -1,202 +0,0 @@ ---- -title: "Select Field" -description: "Dropdown selection field with single or multiple options" ---- - -The Select Field component provides a dropdown for selecting one or more options from a predefined list. - -## Basic Usage - - - -## With Colors - - - -## Multiple Selection - - - -## Cascading & Role-Gated Options - -An option can carry a `visibleWhen` CEL predicate — it is offered only when the -predicate is TRUE. The predicate is evaluated against the **live record** plus -**`current_user`**, the same engine and binding environment as a field-level -`visibleWhen`. This single mechanism covers two needs: - -- **Cascading / dependent options** — narrow a child list by a parent field - (country → province → city). -- **Role / context gating** — offer an option only to certain users. - -Declare the sibling field(s) a select reacts to with `dependsOn`. While any is -empty the control is **gated** (a "Select country first" hint) instead of -showing an unfiltered list; once the parent changes, the list re-evaluates and -any now-invalid selection is **cleared automatically** (no stale "China + -California" pair). The clear is saved like any other edit: a single select (or -radio) is emptied to `null`, which a save writes as "clear the stored value", and -a multiple select keeps only the values still offered. - -### Cascading (country → province) - - - -```json -{ - "type": "form", - "fields": [ - { "name": "country", "label": "Country", "type": "select", "options": [ - { "label": "China", "value": "cn" }, - { "label": "United States", "value": "us" } - ]}, - { "name": "province", "label": "Province", "type": "select", "dependsOn": "country", "options": [ - { "label": "Zhejiang", "value": "zj", "visibleWhen": "record.country == 'cn'" }, - { "label": "Guangdong", "value": "gd", "visibleWhen": "record.country == 'cn'" }, - { "label": "California", "value": "ca", "visibleWhen": "record.country == 'us'" }, - { "label": "Texas", "value": "tx", "visibleWhen": "record.country == 'us'" } - ]} - ] -} -``` - -Chain a third level (`city`, `dependsOn: "province"`) the same way — the gate and -cascade-clear propagate down the chain. - -### Role-gated option - - - -```json -{ "name": "visibility", "type": "select", "options": [ - { "label": "Private (only me)", "value": "private" }, - { "label": "My team", "value": "team" }, - { "label": "Whole organization", "value": "org" }, - { "label": "Public — external", "value": "public", "visibleWhen": "'admin' in current_user.positions" } -]} -``` - -> **Security — hiding is UX, not authorization.** A `visibleWhen` on an option -> only removes it from the dropdown on the client; a determined caller can still -> submit the value. When an option is gated for **access-control** reasons the -> **server must also reject** writes of that value (the rule-validator evaluates -> the picked value's `visibleWhen`). Use option `visibleWhen` for convenience and -> cascades freely; for real authorization, pair it with server-side enforcement. - -### When to use options vs. a lookup - -`visibleWhen` options are for **small, static dictionaries** (category → -subcategory, a handful of provinces). When the data is large, changes over time, -or is shared across forms (real country/province/city tables, org units, product -catalogs), model each level as a **`lookup`** with `dependsOn` instead — the -candidate query is filtered server-side and paginated. See -[Lookup Field](/docs/fields/lookup). - -## Field Schema - -A select field is authored as `SelectFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with the option list -and the multiple/searchable switches. Each option is a `SelectOptionMetadata`, so the -options are checked by the same compiler that checks the field. - -```ts -import type { SelectFieldMetadata } from '@object-ui/types'; - -const status: SelectFieldMetadata = { - type: 'select', - name: 'status', - label: 'Status', - placeholder: 'Select a status', - required: true, - multiple: false, - searchable: true, - options: [ - { label: 'Draft', value: 'draft', color: 'gray' }, - { label: 'Active', value: 'active', color: 'blue' }, - // Offered only when the predicate is true, evaluated against the live record. - { label: 'Archived', value: 'archived', color: 'red', visibleWhen: "current_user.is_admin" }, - { label: 'Locked', value: 'locked', disabled: true }, - ], -}; -``` - -### A blank `label` falls back to the `value` - -`label` is **required** on an option and an empty string is a legal value for it — -`@objectstack/spec`'s `SelectOptionSchema` accepts `{ "value": "low", "label": "" }` -and refuses an option with no `label` key at all. An option authored that way (the -field designer writes it whenever you fill only the value box) is therefore a document -the platform stores and serves, and every option widget — `select`, `multiselect`, -`radio`, `checkboxes` — **displays its `value`** for it rather than an empty row, on -both the editable and the read-only path -(`20b507aff`). A label that -is only whitespace is treated the same way. - -This is a display fallback, not a repair: the stored option keeps its empty `label`. -Author a real label whenever the value is not something you want a person to read — -`low` is a tolerable row, `acct_st_2` is not. - -Cascading option lists are driven by a sibling field's value, declared with -`dependsOn` — a `BaseFieldMetadata` member every field type inherits -([objectui#6153](https://github.com/objectstack-ai/objectui/issues/6153)), in the -shape `@objectstack/spec` declares at field level: an **array** of controlling -field names, or `{ field, param }` entries when the remote parameter name differs -from the local field name. While any controlling value is empty the widget is -gated; once it is set, each option's `visibleWhen` decides whether it is offered, -and a selection the parent no longer offers is cleared. - -```ts -import type { SelectFieldMetadata } from '@object-ui/types'; - -const province: SelectFieldMetadata = { - type: 'select', - name: 'province', - label: 'Province', - dependsOn: ['country'], - options: [ - { label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" }, - { label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" }, - ], -}; -``` - -A bare parent name (`dependsOn: "country"`, as in the form schema above) is the -**form-level** shape, `FormField.dependsOn`; on field metadata the spec accepts -only the array, and `SelectFieldMetadata` refuses the string for the same reason. -The metadata key wins over the `dependsOn` widget prop a host may pass. -[objectui#7357](https://github.com/objectstack-ai/objectui/issues/7357) retired -objectui's snake_case twin `depends_on`, which was never a `@objectstack/spec` -key; `dependsOn` is the only spelling any widget reads. Metadata that still -spells it the old way has no effect — rename it. - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Available Colors - -- `gray` - Default neutral color -- `red` - For errors, urgent items -- `orange` - For warnings, high priority -- `yellow` - For pending, attention needed -- `green` - For success, completed -- `blue` - For info, in progress -- `indigo` - For special items -- `purple` - For creative, design -- `pink` - For featured items - -## Cell Renderer - -In tables/grids, select values are displayed as colored badges: - -```ts -import { SelectCellRenderer } from '@object-ui/fields'; - -// Single value: Colored badge -// Multiple values: Multiple badges in a row -``` - -## Use Cases - -- **Status Fields**: Order status, task status -- **Categories**: Product categories, content types -- **Tags**: Multi-tag selection -- **Priorities**: Task or ticket priorities -- **Roles**: User roles or permissions diff --git a/ui/content/docs/fields/summary.mdx b/ui/content/docs/fields/summary.mdx deleted file mode 100644 index a4a00dd5d6c..00000000000 --- a/ui/content/docs/fields/summary.mdx +++ /dev/null @@ -1,180 +0,0 @@ ---- -title: "Summary Field" -description: "Read-only aggregation field for rollup calculations" ---- - -The Summary Field component displays aggregated values from related records. This is a read-only field where the value is automatically calculated by the backend through rollup aggregations. - -## Count Summary - - - -## Sum Summary - - - -## Average Summary - - - -## Field Schema - -A summary field is authored as `SummaryFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set: it extends `BaseFieldMetadata` with the roll-up -definition `summaryOperations`. `summaryOperations` is -`@objectstack/spec`'s own `FieldSchema.summaryOperations`, typed by reference: the -child `object`, the child `field` to aggregate, the aggregation `function` — a closed -union of `'count' | 'sum' | 'min' | 'max' | 'avg'` — and optionally the child's -`relationshipField` and a `filter` restricting which child rows are aggregated. - -```ts -import type { SummaryFieldMetadata } from '@object-ui/types'; - -const totalRevenue: SummaryFieldMetadata = { - type: 'summary', - name: 'total_revenue', - label: 'Total Revenue', - readonly: true, - summaryOperations: { - object: 'opportunities', - field: 'amount', - function: 'sum', - filter: { stage: 'closed_won' }, - }, -}; -``` - -The computed value, and the `className` a host supplies, are **not** metadata keys — -they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Aggregation Functions - -The function decides what the backend computes, not how the value is drawn. The -form and the table cell read the same rule for every function, so one stored -value reads the same in both: - -- **a number**: Formatted as a number field formats it, in the viewer's display - locale (`15750.5` reads `15,750.5` in en-US). With no `scale` on the field it - keeps its own precision, so a `count` reads as a whole number (`12000` reads - `12,000`); a `scale` on the field sets a fixed width in both places (`scale: 2` - reads `15,750.50`) -- **anything else**: The value as text; an empty value reads as empty - -The five functions: - -- **count**: Count of related records -- **sum**: Sum of numeric field values -- **avg**: Average of numeric field values -- **min**: Minimum value in the field -- **max**: Maximum value in the field - -## Examples - -### Count Related Records - -```plaintext -{ - type: 'summary', - name: 'task_count', - label: 'Open Tasks', - summaryOperations: { - object: 'tasks', - field: 'id', - function: 'count' - } -} -``` - -### Sum Amounts - -```plaintext -{ - type: 'summary', - name: 'total_sales', - label: 'Total Sales', - summaryOperations: { - object: 'invoices', - field: 'amount', - function: 'sum' - } -} -``` - -### Average Value - -```plaintext -{ - type: 'summary', - name: 'avg_response_time', - label: 'Avg Response Time', - summaryOperations: { - object: 'support_tickets', - field: 'response_time_hours', - function: 'avg' - } -} -``` - -### Min/Max Values - -```plaintext -{ - type: 'summary', - name: 'highest_bid', - label: 'Highest Bid', - summaryOperations: { - object: 'bids', - field: 'amount', - function: 'max' - } -} -``` - -## Cell Renderer - -In tables/grids, a `summary` value is rendered by the formula cell renderer — -`getCellRenderer('summary')` resolves to this same component: - -```ts -import { FormulaCellRenderer } from '@object-ui/fields'; - -// renders the computed -// value as a number, through the number cell: 15,750.5 (en-US), not monospace -// A null or empty value renders an em-dash instead. -``` - -## Backend Implementation - -Summary fields are calculated through database aggregations: - -```plaintext -// Example backend aggregation -const calculateSummary = async (summaryOperations: SummaryOperations, parentId: string) => { - const { object, field, function: fn } = summaryOperations; - - switch (fn) { - case 'count': - return db.count(object, { parent_id: parentId }); - - case 'sum': - return db.sum(object, field, { parent_id: parentId }); - - case 'avg': - return db.avg(object, field, { parent_id: parentId }); - - case 'min': - return db.min(object, field, { parent_id: parentId }); - - case 'max': - return db.max(object, field, { parent_id: parentId }); - } -}; -``` - -## Use Cases - -- **Order Management**: Total order value, order count -- **CRM**: Number of contacts, total deal value -- **Project Management**: Task count, total hours -- **Analytics**: Average ratings, min/max values -- **Financial**: Sum of expenses, average transaction size diff --git a/ui/content/docs/fields/text.mdx b/ui/content/docs/fields/text.mdx deleted file mode 100644 index 9f80b763387..00000000000 --- a/ui/content/docs/fields/text.mdx +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: "Text Field" -description: "Single-line text input field for basic text entry" ---- - -The Text Field component provides a single-line text input for collecting basic text data from users. It's the most commonly used field type for names, titles, and short text entries. - -## Basic Usage - - - -## With Placeholder - - - -## Required Field - - - -## Read-Only - - - -## Field Schema - -A text field is authored as `TextFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with the -text-specific validation keys. The length bounds are `@objectstack/spec`'s own -`FieldSchema.minLength` / `maxLength`, typed by reference; the snake_case -`min_length` / `max_length` are retired, refused by the spec by name and read by -nothing. - -```ts -import type { TextFieldMetadata } from '@object-ui/types'; - -const productName: TextFieldMetadata = { - type: 'text', - name: 'product_name', - label: 'Product Name', - placeholder: 'Enter a product name', - inlineHelpText: 'Shown on the storefront and in search results.', - required: true, - minLength: 2, - maxLength: 120, -}; -``` - -## Format Validation - -A regex check is declared on the **object**, not on the field. `@objectstack/spec` -has a validation rule for it: an entry of the object definition's `validations[]` -whose `type` is `'format'`. For the field above it carries: - -- `name`: the rule's own snake_case name, e.g. `'product_name_characters'`; -- `field`: the field it checks, `'product_name'`; -- `regex`: `'^[A-Za-z0-9 -]+$'`. It is not anchored for you, so write `^` and `$` - to match the whole value; -- `message`: `'Letters, digits, spaces and hyphens only.'`, the reason given when - the value is refused. - -The server runs the rule on every write that touches the field and refuses a -non-empty value the `regex` does not match, with the rule's `message`. `FieldSchema` -has no field-level `pattern` or `pattern_message`: it refuses both by name, and -`TextFieldMetadata` refuses them at compile time. - -The form does not pre-check this rule in the browser. A value that breaks it is -refused when the record is saved, and the rule's `message` is the reason given. - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Use Cases - -- **User Names**: Collecting first names, last names, or usernames -- **Titles**: Post titles, document names, or product names -- **Short Text**: Any short text input that doesn't require multiple lines -- **IDs**: Custom identifiers or reference numbers - -## Cell Renderer - -When used in data tables or grids, the text field is rendered as simple truncated text: - -```ts -import { TextCellRenderer } from '@object-ui/fields'; - -// Automatically used for type: 'text' in grids/tables -// Displays value or '-' if empty -``` diff --git a/ui/content/docs/fields/textarea.mdx b/ui/content/docs/fields/textarea.mdx deleted file mode 100644 index 510af499de1..00000000000 --- a/ui/content/docs/fields/textarea.mdx +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: "TextArea Field" -description: "Multi-line text input for longer content" ---- - -The TextArea Field component provides a multi-line text input for collecting longer text content from users, such as descriptions, comments, or notes. - -## Basic Usage - - - -## Custom Rows - - - -## Required Field - - - -## Field Schema - -A textarea field is authored as `TextareaFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set: it extends `BaseFieldMetadata` with the length -bounds and the editor's visible row count. The length bounds are `@objectstack/spec`'s -own `FieldSchema.minLength` / `maxLength`, typed by reference; `maxLength` also sets the -editor's native stop and its `{n}/{max}` character counter. - -```ts -import type { TextareaFieldMetadata } from '@object-ui/types'; - -const description: TextareaFieldMetadata = { - type: 'textarea', - name: 'description', - label: 'Description', - placeholder: 'Describe this record…', - rows: 6, - required: false, - minLength: 0, - maxLength: 2000, -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Use Cases - -- **Descriptions**: Product descriptions, bio information -- **Comments**: User feedback, review comments -- **Notes**: Meeting notes, task descriptions -- **Messages**: Contact form messages, support tickets - -## Cell Renderer - -When displayed in tables or grids, long text is truncated: - -```ts -import { TextCellRenderer } from '@object-ui/fields'; - -// Same renderer as text field - truncates long content -``` diff --git a/ui/content/docs/fields/time.mdx b/ui/content/docs/fields/time.mdx deleted file mode 100644 index 1bed6a2847a..00000000000 --- a/ui/content/docs/fields/time.mdx +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: "Time Field" -description: "Time-only input field for hour and minute selection" ---- - -The Time Field component provides a time-only input for collecting hour and minute information without a date component. - -## Basic Usage - - - -## With Default Value - - - -## Required Field - - - -## Read-Only - - - -## Field Schema - -A time field is authored as `TimeFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with a display format. - -```ts -import type { TimeFieldMetadata } from '@object-ui/types'; - -const startTime: TimeFieldMetadata = { - type: 'time', - name: 'start_time', - label: 'Start Time', - placeholder: 'HH:mm', - required: true, - format: 'HH:mm', -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Time Format - -The time field uses 24-hour format: `HH:mm` - -Examples: -- `09:00` - 9:00 AM -- `14:30` - 2:30 PM -- `23:45` - 11:45 PM - -## Use Cases - -- **Operating Hours**: Business hours, shift times -- **Scheduling**: Daily task times, recurring events -- **Alarms**: Reminder times, notification schedules -- **Time Tracking**: Work start/end times diff --git a/ui/content/docs/fields/url.mdx b/ui/content/docs/fields/url.mdx deleted file mode 100644 index 1a9a6d7fc5a..00000000000 --- a/ui/content/docs/fields/url.mdx +++ /dev/null @@ -1,70 +0,0 @@ ---- -title: "URL Field" -description: "URL input with validation and link rendering" ---- - -The URL Field component provides a text input with URL validation and clickable link rendering. - -## Basic Usage - - - -## Required URL - - - -## Field Schema - -A URL field is authored as `UrlFieldMetadata` (`@object-ui/types`), which is the source -of truth for the key set: it extends `BaseFieldMetadata` with a single length bound, -`@objectstack/spec`'s own `FieldSchema.maxLength`, typed by reference. - -```ts -import type { UrlFieldMetadata } from '@object-ui/types'; - -const website: UrlFieldMetadata = { - type: 'url', - name: 'website', - label: 'Website', - placeholder: 'https://example.com', - required: false, - maxLength: 2048, -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Validation - -The URL field automatically validates: -- Proper URL protocol (http:// or https://) -- Valid domain structure -- No invalid characters - -## Cell Renderer - -In tables/grids, URLs are clickable external links: - -```ts -import { UrlCellRenderer } from '@object-ui/fields'; - -// Renders as: https://example.com -// Opens in new tab with security attributes -``` - -## Use Cases - -- **Websites**: Company or personal websites -- **Social Media**: Social media profile links -- **Documentation**: External documentation links -- **Resources**: Reference materials, articles -- **Portfolios**: Portfolio or project links - -## Features - -- **Link Preview**: Clickable links in read-only mode -- **New Tab**: External links open in new tab -- **Security**: Proper rel="noopener noreferrer" attributes -- **Validation**: Real-time URL format validation -- **Accessible**: Proper ARIA labels for screen readers diff --git a/ui/content/docs/fields/user.mdx b/ui/content/docs/fields/user.mdx deleted file mode 100644 index b7373506512..00000000000 --- a/ui/content/docs/fields/user.mdx +++ /dev/null @@ -1,169 +0,0 @@ ---- -title: "User Field" -description: "User and owner selector with avatar display" ---- - -The User Field component provides a user selector for assigning users or owners to records. It displays user avatars and supports both single and multiple user selection. - -## Basic Usage - - - -## Multiple Users - - - -## Read-Only Record Owner - -A record-owner field is a plain `user` field whose NAME carries the ownership -meaning — there is no separate owner type. Marking it `readonly` is what makes -it display-only. - - - -## Field Schema - -A user field is authored as `UserFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with the avatar field -and the candidate filters. `user` is a lookup specialised to the `sys_user` system -object, so its filters are `LookupFilterDef`. - -```ts -import type { UserFieldMetadata } from '@object-ui/types'; - -const owner: UserFieldMetadata = { - type: 'user', - name: 'owner_id', - label: 'Owner', - required: true, - multiple: false, - avatar_field: 'image', - lookupFilters: [{ field: 'banned', operator: 'ne', value: true }], -}; -``` - -The field stores the selected user's id (or an array of ids when `multiple` is set), -not a user object; the picker resolves names and avatars from `sys_user` itself. - -The picker is the search-first people picker, with each candidate's business unit and -email beside its name. Both are picker configuration rather than object metadata: -`FieldSchema` refuses `picker` and `subtitle` by name, so a user field definition -cannot carry them, and `UserFieldMetadata` does not declare them. A form entry may set -them (`FormField`); `picker: 'default'` opens the classic record-picker dialog instead: - -```ts -import type { FormField } from '@object-ui/types'; - -const reviewer: FormField = { - name: 'reviewer_id', - type: 'user', - label: 'Reviewer', - picker: 'search', - subtitle: ['primary_business_unit_id.name', 'email'], -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## User vs Owner - -Both are the same field **type**. What differs is the field's name and whether -it is writable: - -- **Assignment field**: a selectable `user` field for assignees, team members, etc. -- **Owner field**: a `user` field named `owner`, typically `readonly` and - defaulted to the record creator. - -There is no `owner` field type. It existed as a synonym until objectui#4814 -retired it (it resolved to the very same widget, and it was never a member of -`@objectstack/spec`'s `FieldType`). Authoring `type: 'owner'` now renders a -visible refusal naming this migration rather than silently falling back to a -text input. Write `{ type: 'user', name: 'owner' }` instead. - -## Display Features - -- **Avatar Badges**: User avatars with initials if no image -- **Color Coding**: Consistent colors for user avatars -- **Multiple Display**: Shows up to 3 users, then "+N more" -- **Hover Info**: User details on avatar hover - -## Cell Renderer - -In tables/grids, displays avatar with name: - -```ts -import { UserCellRenderer } from '@object-ui/fields'; - -// Single user: Avatar + name -// Multiple users: Overlapping avatars + count -``` - -## How it works - -`user` fields are a **lookup specialized to the framework's `sys_user` -object** — there is no custom user API to wire up. The `UserField` widget -delegates to the shared lookup picker with the reference fixed to `sys_user`, -reusing the same debounced search, record-picker dialog and id resolution as any -lookup field: - -- The field stores the selected user's **id** (a foreign key to `sys_user`); - `multiple: true` stores an array of ids. -- A `dataSource` (provided by `SchemaRenderer` / the app shell) supplies the - candidate search — the picker queries `sys_user` by name/email as you type. -- On read, `$expand` resolves the id(s) to the full user record so cells and - read-only views show the name/avatar (via `UserCellRenderer`). - -No custom user-management integration is required when a `dataSource` is present. - -## Permission Patterns - -Common permission configurations: - -```plaintext -// Record owner only — a `user` field whose NAME carries the ownership meaning -{ - type: 'user', - name: 'owner', - label: 'Owner', - readonly: true, - defaultValue: 'current_user' -} - -// Assignable user -{ - type: 'user', - name: 'assigned_to', - label: 'Assigned To', - required: true -} - -// Team members -{ - type: 'user', - name: 'collaborators', - label: 'Collaborators', - multiple: true -} -``` - -## Use Cases - -- **Task Management**: Task assignee, reviewer -- **CRM**: Account owner, opportunity owner -- **Support**: Ticket assignee, support agent -- **Project Management**: Project members, task owners -- **Approval Workflows**: Approvers, reviewers -- **Collaboration**: Document collaborators, editors - -## Backend behavior - -The platform handles the common cases natively — you generally don't write -backend code for user fields: - -- **Owner stamping** — declare `defaultValue: 'current_user'` and the acting - user's id is filled in at insert time (no create hook required). -- **Display resolution** — request `expand=` and the stored id(s) resolve - to the full `sys_user` record (name / avatar) for rendering. -- **Ownership & permissions** — record ownership and row-level security continue - to use the platform's `owner_id` convention and security rules. diff --git a/ui/content/docs/fields/vector.mdx b/ui/content/docs/fields/vector.mdx deleted file mode 100644 index ba2c75ac896..00000000000 --- a/ui/content/docs/fields/vector.mdx +++ /dev/null @@ -1,225 +0,0 @@ ---- -title: "Vector Field" -description: "Vector embeddings viewer for AI/ML data" ---- - -The Vector Field component displays vector embeddings used in AI/ML applications. This is a read-only field that shows a preview of the embedding values and dimensionality. - -## Basic Usage - - - -## Different Dimensions - - - -## Field Schema - -A vector field is authored as `VectorFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with the embedding -dimensionality, `dimensions`, the one vector key `@objectstack/spec`'s `FieldSchema` -declares. There is no normalisation key: the spec refuses `normalize` by name and the -widget never read it, so `VectorFieldMetadata` refuses it at compile time. - -```ts -import type { VectorFieldMetadata } from '@object-ui/types'; - -const embedding: VectorFieldMetadata = { - type: 'vector', - name: 'embedding', - label: 'Embedding', - readonly: true, - dimensions: 1536, -}; -``` - -The computed value, and the `className` a host supplies, are **not** metadata keys — -they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Display Format - -Vectors are displayed with a preview: - -``` -[0.1234, -0.5678, 0.9012...] (768D) -``` - -Shows: -- First 3 values (formatted to 4 decimal places) -- Total dimensionality in parentheses - -## Common Embedding Dimensions - -Different AI models use different embedding sizes: - -- **OpenAI text-embedding-3-small**: 512, 1536, or 3072 dimensions (configurable) -- **OpenAI text-embedding-3-large**: 256, 1024, or 3072 dimensions (configurable) -- **OpenAI text-embedding-ada-002**: 1536 dimensions (fixed) -- **Sentence Transformers (BERT)**: 768 dimensions -- **OpenAI CLIP**: 512 dimensions -- **Word2Vec**: 100-300 dimensions -- **Custom Models**: Variable dimensions - -## Data Format - -Vectors are stored as arrays of floating-point numbers: - -```plaintext -// Example embedding -const embedding: number[] = [ - 0.1234, -0.5678, 0.9012, 0.3456, -0.7890, - // ... 763 more values for 768D embedding -]; -``` - -## Cell Renderer - -In tables/grids, a `vector` value is shown as a compact placeholder, not as its -components. It has no named renderer export of its own — the component is -resolved by field type, which is the supported path for every type: - -```ts -import { getCellRenderer } from '@object-ui/fields'; - -const VectorCell = getCellRenderer('vector'); - -// renders: [Vector] -``` - -An empty value (`null`, `undefined`, `''` or `[]`) renders the shared "No value" -affordance instead, so an empty cell is distinguishable from a populated one. - -## Vector Operations - -Common operations with vector fields (performed on backend): - -### Similarity Search - -```plaintext -// Find similar vectors using cosine similarity -const findSimilar = async (queryVector: number[], limit: number = 10) => { - return await db.raw(` - SELECT - id, - 1 - (embedding <=> $1) as similarity - FROM documents - ORDER BY embedding <=> $1 - LIMIT $2 - `, [queryVector, limit]); -}; -``` - -### Distance Metrics - -- **Cosine Similarity**: Measures angle between vectors -- **Euclidean Distance**: Straight-line distance -- **Dot Product**: Inner product of vectors - -## Use Cases - -- **Semantic Search**: Find similar documents, products, or content -- **Recommendation Systems**: Product recommendations, content suggestions -- **Text Analysis**: Document clustering, topic modeling -- **Image Search**: Similar image finding, visual search -- **Anomaly Detection**: Identify outliers in data -- **AI Applications**: Any machine learning feature vectors - -## Backend Implementation - -Example vector storage and search: - -```plaintext -// PostgreSQL with pgvector extension -import { Client } from 'pg'; - -// Create table with vector column -const createTable = async () => { - await db.raw(` - CREATE TABLE IF NOT EXISTS documents ( - id SERIAL PRIMARY KEY, - content TEXT, - embedding vector(768) - ); - - CREATE INDEX ON documents - USING ivfflat (embedding vector_cosine_ops); - `); -}; - -// Generate embedding (using OpenAI) -const generateEmbedding = async (text: string): Promise => { - const response = await openai.embeddings.create({ - model: 'text-embedding-3-small', - input: text - }); - return response.data[0].embedding; -}; - -// Store document with embedding -const storeDocument = async (content: string) => { - const embedding = await generateEmbedding(content); - return db.insert('documents', { - content, - embedding - }); -}; - -// Search similar documents -const searchSimilar = async (query: string, limit = 10) => { - const queryEmbedding = await generateEmbedding(query); - return db.raw(` - SELECT - id, - content, - 1 - (embedding <=> $1::vector) as similarity - FROM documents - ORDER BY embedding <=> $1::vector - LIMIT $2 - `, [queryEmbedding, limit]); -}; -``` - -## Database Support - -Vector storage is supported by: - -- **PostgreSQL**: pgvector extension -- **Pinecone**: Specialized vector database -- **Weaviate**: Vector search engine -- **Milvus**: Open-source vector database -- **Qdrant**: Vector similarity search engine -- **Chroma**: AI-native embedding database - -## Performance Considerations - -1. **Indexing**: Use approximate nearest neighbor (ANN) indices -2. **Quantization**: Consider dimension reduction for storage -3. **Batch Operations**: Generate embeddings in batches -4. **Caching**: Cache frequently used embeddings -5. **Async Generation**: Generate embeddings asynchronously - -## Example: Semantic Search - -```plaintext -// 1. Generate embedding for user query -const query = "Find documentation about authentication"; -const queryEmbedding = await generateEmbedding(query); - -// 2. Search for similar documents -const results = await db.raw(` - SELECT - id, - title, - content, - 1 - (embedding <=> $1::vector) as similarity - FROM documents - WHERE 1 - (embedding <=> $1::vector) > 0.7 - ORDER BY embedding <=> $1::vector - LIMIT 10 -`, [queryEmbedding]); - -// 3. Display results with similarity scores -results.forEach(doc => { - console.log(`${doc.title} (${(doc.similarity * 100).toFixed(1)}% match)`); -}); -``` diff --git a/ui/content/docs/fields/widget-props.mdx b/ui/content/docs/fields/widget-props.mdx deleted file mode 100644 index dfc53a37ee0..00000000000 --- a/ui/content/docs/fields/widget-props.mdx +++ /dev/null @@ -1,119 +0,0 @@ ---- -title: "Field Widget Props" -description: "What a field widget receives at runtime, and why those keys are not field metadata" ---- - -Every field reference page in this section documents one thing: the **metadata you -author** for that field type. This page documents the other half — the props a field -**widget** receives when it renders. - -The two are different shapes with different producers, and confusing them is the most -common authoring mistake this section can cause. A key like the live value, the -host-supplied `className`, or the form's `disabled` state belongs to the widget at -runtime; writing it on an object's field definition publishes a key -`@objectstack/spec`'s strict schemas reject. - -## The type is the source of truth - -`FieldWidgetComponentProps` is exported from `@object-ui/fields`. It is a **closed** -type — there is no `[key: string]: any` in it (objectui#3221), so a misspelled prop is -a compile error rather than a permanent `undefined`. That is what makes it usable as a -reference: the compiler answers "is this a real prop", and this page does not have to. - -A widget implements it by taking it as its props type: - -```tsx -import { toDomProps, type FieldWidgetComponentProps } from '@object-ui/fields'; - -/** A custom single-line widget, registered for a field type of your own. */ -export function SlugWidget(props: FieldWidgetComponentProps) { - const { value, onChange, field, readonly, disabled, className, error } = props; - - return ( - onChange(event.target.value)} - placeholder={field.placeholder} - readOnly={readonly} - disabled={disabled} - // The widget drives the a11y state; the message TEXT stays with the form - // renderer, and the required MARKER is drawn by its label. - aria-invalid={Boolean(error)} - aria-required={Boolean(field.required)} - /> - ); -} -``` - -Read the full member list from the type — in your editor, or from -`packages/fields/src/widgets/types.ts`, where every key carries a doc comment naming -its producer and its consumer. This page deliberately does not copy that list: a -hand-maintained restatement of a declared surface is exactly the drift these pages are -being fixed for. - -## What the categories are - -The type is assembled from five groups, and knowing which group a key is in tells you -who supplies it: - -1. **The controlled-input contract.** The current value, the callback that changes it, - the field's metadata carrier, and the display-state flags every widget interprets. - Every widget in the package implements this group; the rest are optional. -2. **Host plumbing.** What a rendering host forwards to widgets that need more than a - value — a data source for widgets that query records, live sibling-field values for - cascading and dependent options, resolved labels and hints for the copy those gates - render, a compact mode for grid cells, and record-selection callbacks for pickers. - A widget that needs none of it destructures none of it. -3. **DOM pass-through.** The identity, focus and event keys that may legitimately land - on the element a widget renders — the field's `id`, the `aria-describedby` the form - control minted, and so on. `toDomProps` is this group's runtime executor, bound to - the declaration in both directions by compile-time assertions, so a key cannot be - declared here and silently never delivered. Every other registered widget is held - to the same rule with the SDUI contract's own key set — the plugin-widget contract - is stated once, in [What Reaches the DOM](/docs/guide/plugin-development#what-reaches-the-dom). -4. **The ARIA attribute family**, intersected in whole from React's `AriaAttributes`. - This group is the bulk of the member count, which is why the count is not a useful - thing to quote. -5. **`data-*` attributes**, open by design and expressed as a template-literal key so - `keyof` stays finite — an undeclared prop still fails. - -## Metadata and props are two shapes, not one - -The authored metadata arrives at the widget under a single carrier -(objectui#3233 converged it at the producers; there is no second key to check). The -live value never lives on metadata, and the metadata never lives on the DOM: - -```ts -import type { FieldWidgetComponentProps } from '@object-ui/fields'; -import type { FieldMetadata, TextFieldMetadata } from '@object-ui/types'; - -// What you AUTHOR: object metadata, validated at publish. -const slug: TextFieldMetadata = { - type: 'text', - name: 'slug', - label: 'Slug', - maxLength: 80, -}; - -// What the widget RECEIVES at runtime. -declare const props: FieldWidgetComponentProps; - -const carrier: FieldMetadata = props.field; // the authored metadata, unchanged -const live: string = props.value; // never a metadata key - -export { slug, carrier, live }; -``` - -Assigning `slug` into `props.field` type-checks; the reverse — writing `props.value` -into `slug` — does not, and that asymmetry is the whole distinction. - -## Where each half is documented - -- **Metadata keys** — the `Field Schema` section of each field page in this section, - as a literal annotated with that field type's exported `*FieldMetadata`. -- **Runtime props** — this page, and the type it names. diff --git a/ui/content/docs/guide/agent-skills.md b/ui/content/docs/guide/agent-skills.md deleted file mode 100644 index 96cbe301816..00000000000 --- a/ui/content/docs/guide/agent-skills.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -title: "Agent Skills" -description: "Install the ObjectUI Agent Skill into Claude Code, Cursor, Codex, GitHub Copilot, Windsurf, Gemini, Cline, and other agents with a single command." ---- - -# Agent Skills - -ObjectUI ships an official **Agent Skill** — a structured bundle of rules, guides, and evals that teaches AI coding agents how to build pages, plugins, and integrations the *right* way (schema-first, no-touch zones respected, English-only, expression-aware). - -The skill is published on [skills.sh](https://skills.sh/objectstack-ai/objectui) and lives in [`skills/objectui/`](https://github.com/objectstack-ai/objectstack/tree/main/skills/objectui) inside this repository. - -[![skills.sh](https://skills.sh/b/objectstack-ai/objectui)](https://skills.sh/objectstack-ai/objectui) - -## Install - -One command, in your project root: - -```bash -npx skills add objectstack-ai/objectui -``` - -The `skills` CLI auto-detects which AI agent you're using and writes the skill to the location that agent reads from — for example: - -| Agent | Install location | -| --- | --- | -| Claude Code | `.claude/skills/objectui/` | -| GitHub Copilot | `.github/copilot/skills/objectui/` | -| Cursor | `.cursor/skills/objectui/` | -| Codex | `.codex/skills/objectui/` | -| Windsurf, Gemini, Cline, Goose, Kilo, Droid, Trae, … | per-agent path | - -See [skills.sh/docs](https://skills.sh/docs) for the full, up-to-date list of supported agents. - -## What you get - -Once installed the skill activates automatically whenever you describe ObjectUI work in chat. Nothing to import, nothing to configure. - -The skill is structured as a single entry point plus deep-dive guides: - -``` -skills/objectui/ -├── SKILL.md # Entry point — core principles, tech stack, scope -├── rules/ # Non-negotiable global constraints -│ ├── protocol.md -│ ├── styling.md -│ └── composition.md -├── guides/ # Domain-specific deep dives, loaded on demand -│ ├── architecture.md -│ ├── app-composition.md -│ ├── page-builder.md -│ ├── plugin-development.md -│ ├── schema-expressions.md -│ ├── data-integration.md -│ ├── project-setup.md -│ ├── testing.md -│ ├── i18n.md -│ ├── mobile.md -│ └── auth-permissions.md -└── evals/ # Machine-checkable prompts (one per guide) -``` - -Two files that once shipped in this bundle — `rules/no-touch-zones.md` and -`guides/console-development.md` — moved into the repo-internal -[`.claude/skills/objectui-contributor/`](https://github.com/objectstack-ai/objectui/tree/main/.claude/skills/objectui-contributor) -skill instead. Both talk about paths that only exist inside this monorepo — `apps/console/` -and `packages/components/src/ui/` — which a customer install of the published skill never -has, so they stayed behind as contributor-only guidance rather than shipping to every -consumer. - -When the agent picks up a task it: - -1. Reads `SKILL.md` for core principles, scope boundaries, and the package map. -2. Loads the relevant `rules/*.md` so non-negotiables (Shadcn purity, expression syntax, layout composition) are respected. -3. Pulls the matching `guides/*.md` for the task at hand. - -## What the skill enforces - -The skill bakes the ObjectUI worldview into every answer your agent gives you: - -- **Schema-first**: page output is JSON for ``, not bespoke React. -- **Shadcn-native aesthetics**: components stay in Tailwind + `cn()` + `cva`; no inline styles, no CSS-in-JS. -- **Protocol agnostic**: data goes through the `DataSource` interface, not raw `fetch`/`axios`. -- **Expression-aware**: `visible`, `hidden`, `disabled` use `${data.*}` / `${props.*}` templates, never raw JS. -- **No-touch zones**: upstream Shadcn primitives in `packages/components/src/ui/**` are never edited — extensions live in `custom/` wrappers. -- **English-only**: all generated component text, labels, comments, and docs are English. -- **Scope discipline**: the skill explicitly defers backend/data-modelling questions to the `objectstack-*` skills, so it stays focused on the UI engine. - -Each rule has a machine-checkable eval under `evals/`, so regressions in agent behaviour are caught the same way regressions in code are. - -## When *not* to use it - -If your question is purely about data modelling, kernel plugins, ObjectQL queries, CEL formulas, or server-side automation, prefer the matching `objectstack-*` skill. The ObjectUI skill's frontmatter explicitly defers those topics so multiple skills can coexist cleanly in your agent. - -## Updating - -To pull the latest version of the skill after we ship improvements: - -```bash -npx skills add objectstack-ai/objectui -``` - -The CLI is idempotent — running it again refreshes the local copy in place. - -## Contributing - -The skill source lives in [`skills/objectui/`](https://github.com/objectstack-ai/objectstack/tree/main/skills/objectui). PRs are welcome — see [`skills/objectui/README.md`](https://github.com/objectstack-ai/objectstack/blob/main/skills/objectui/README.md) for the maintenance rules (guide ↔ eval naming, English-only, eval assertion format). diff --git a/ui/content/docs/guide/architecture-overview.md b/ui/content/docs/guide/architecture-overview.md deleted file mode 100644 index bcaaf1c44e2..00000000000 --- a/ui/content/docs/guide/architecture-overview.md +++ /dev/null @@ -1,342 +0,0 @@ ---- -title: Architecture Overview -description: Deep dive into the ObjectUI 3-layer architecture, data flow, plugin system, state management, and action system. ---- - -# Architecture Overview - -ObjectUI is a **Server-Driven UI (SDUI) engine** that transforms JSON schemas into fully interactive React interfaces built on Shadcn/Tailwind. This document covers the internal architecture, data flow, and extension points. - -## The 3-Layer Architecture - -ObjectUI enforces a strict separation across three layers. Each layer has clear constraints on what it may import and what it may contain. - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ Layer 1: @objectstack/spec (The Protocol) │ -│ Pure TypeScript type definitions — 12 export modules │ -│ ❌ No runtime code. No React. No dependencies. │ -└──────────────────────────────┬──────────────────────────────────────┘ - │ imports (never redefines) -┌──────────────────────────────▼──────────────────────────────────────┐ -│ Layer 2: @object-ui/types (The Bridge) │ -│ Re-exports spec types + ObjectUI-specific schemas │ -│ ❌ No runtime code. Zero runtime dependencies. │ -└──────────────────────────────┬──────────────────────────────────────┘ - │ consumed by -┌──────────────────────────────▼──────────────────────────────────────┐ -│ Layer 3: Implementations (The Runtime) │ -│ core, react, components (91+), fields (35+), plugins, etc. │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -### Layer 1 — `@objectstack/spec` (The Protocol) - -The upstream JSON specification for all ObjectStack products. ObjectUI **imports** these types but never redefines them. Located externally and consumed as `@objectstack/spec`; the range each package installs is declared in that package's own `package.json`, under `dependencies`. - -### Layer 2 — `@object-ui/types` (The Bridge) - -Lives in `packages/types/`. Re-exports spec types and adds ObjectUI-specific schemas (component schemas, widget props, field props). Has **zero** runtime dependencies — only type-level imports of `@objectstack/spec` and `zod` for validation schemas. Marked `sideEffects: false`. - -### Layer 3 — Implementations (The Runtime) - -All packages that ship runnable code: `core`, `react`, `components`, `fields`, `layout`, `i18n`, `auth`, `permissions`, and every `plugin-*` package. - -## Package Dependency Graph - -``` -@objectstack/spec - │ - ▼ -@object-ui/types ◄─────────────────────────────────────┐ - │ │ - ▼ │ -@object-ui/core │ - │ (registry, evaluator, actions, validation) │ - ▼ │ -@object-ui/react ──────► @object-ui/i18n │ - │ (SchemaRenderer, hooks, contexts) │ - ├──────────────────────────────────────┐ │ - ▼ ▼ │ -@object-ui/components @object-ui/fields │ - │ (91+ Shadcn atoms) (35+ inputs) │ - ▼ ▼ │ -@object-ui/layout @object-ui/plugin-* │ - (AppShell, Header, (grid, kanban, charts, │ - Sidebar, routing) dashboard, form, etc.) │ - │ │ - └─────────────────┘ - (all packages import types) -``` - -**Strict rules:** - -| Package | May Import | Must NOT Import | -|---------|-----------|-----------------| -| `types` | `@objectstack/spec` | Any runtime package | -| `core` | `types`, `lodash`, `zod` | React, any UI library | -| `react` | `core`, `types`, `i18n` | Plugin packages directly | -| `components` | `types` (for props) | `core`, `react` | -| `fields` | `types` (for `FieldWidgetProps`) | `core` business logic | -| `plugin-*` | Any package | Other plugins | - -## Data Flow: Schema → Screen - -The rendering pipeline transforms a JSON schema into a live React component tree: - -``` - JSON Schema (from API or static file) - │ - ▼ - ┌─────────────┐ ┌──────────────────┐ - │ SchemaRenderer │───►│ ExpressionEvaluator │ - │ (packages/ │ │ Resolves ${...} │ - │ react/src/) │ │ expressions │ - └──────┬────────┘ └──────────────────┘ - │ - ▼ - ┌─────────────────┐ - │ ComponentRegistry │ ← registry.resolve(schema.type) - │ (packages/core/ │ - │ src/registry/) │ - └──────┬──────────┘ - │ - ▼ - ┌──────────────────┐ - │ React Component │ ← Wrapped in ErrorBoundary - │ (Button, Grid, │ with ARIA attributes - │ Kanban, etc.) │ - └──────────────────┘ -``` - -1. **SchemaRenderer** (`packages/react/src/SchemaRenderer.tsx`) receives a JSON schema object. -2. It evaluates dynamic expressions via `ExpressionEvaluator` (`packages/core/src/evaluator/`). -3. It looks up the component type in the `ComponentRegistry` (`packages/core/src/registry/Registry.ts`). -4. The matched component renders with schema props, wrapped in a per-component `ErrorBoundary` with ARIA accessibility attributes (`aria-label`, `aria-describedby`, `role`). - -## The Plugin System - -Plugins are self-contained packages that register heavy or complex views (grids, kanbans, charts). They follow a consistent pattern defined in `packages/core/src/registry/PluginSystem.ts`. - -### Plugin Lifecycle - -``` - import 'plugin-kanban' - │ - ▼ - PluginSystem.load(plugin) - │ ├─ Check dependencies - │ ├─ Prevent duplicate loading - │ └─ Call plugin.register(scope) - ▼ - PluginScope.registerComponent(type, Component, meta) - │ └─ Auto-prefixes with plugin namespace - ▼ - ComponentRegistry.register('object-kanban', ObjectKanbanRenderer, { - namespace: 'plugin-kanban', - category: 'view' - }) -``` - -### Lazy Loading - -Plugins use `React.lazy()` wrapped by `LazyPluginLoader` (`packages/react/src/LazyPluginLoader.tsx`): - -- **Retry logic**: 2 retries with 1-second delay by default -- **Custom fallbacks**: Loading skeleton + error boundary -- **Tree-shaking**: Plugins are code-split from the initial bundle - -### Plugin Registration Pattern - -Every plugin follows the same structure (see `packages/plugin-kanban/`, `packages/plugin-grid/`): - - - -```typescript -// 1. Lazy-load the heavy implementation -const LazyKanban = React.lazy(() => import('./KanbanImpl')); - -// 2. Define a thin wrapper with Suspense -const KanbanRenderer: React.FC = ({ schema }) => ( - }> - - -); - -// 3. Register in the global registry -ComponentRegistry.register('object-kanban', KanbanRenderer, { - namespace: 'plugin-kanban', - label: 'Object Kanban', - category: 'view', - inputs: [/* schema config */] -}); -``` - -Scaffold a new plugin with `npx @object-ui/create-plugin`. - -## State Management - -ObjectUI uses **React Context** for all state management, with contexts scoped to specific concerns: - -### Core Contexts (`packages/react/src/context/`) - -| Context | Purpose | -|---------|---------| -| `SchemaRendererContext` | Data scope + debug mode for the rendering tree | -| `ActionContext` | Action runner instance for handling user interactions | -| `ThemeContext` | Theme tokens and dark/light mode | -| `NotificationContext` | Toast and notification system | -| `DndContext` | Drag-and-drop state for sortable views | - -### Domain Contexts (separate packages) - -| Context | Package | Purpose | -|---------|---------|---------| -| `AuthContext` | `packages/auth/` | Authentication state | -| `PermissionContext` | `packages/permissions/` | RBAC/ABAC permissions | -| `I18nProvider` | `packages/i18n/` | Locale and translations | - -Contexts are composed at the application root and consumed by plugins and components via hooks (e.g., `useActionRunner`, `useExpression`, `useViewData`). - -## Expression Evaluation - -The expression engine lives in `packages/core/src/evaluator/` and powers dynamic schemas: - -### Components - -- **`ExpressionEvaluator.ts`** — Main engine: parses and evaluates `${...}` template expressions. -- **`ExpressionContext.ts`** — Variable scope: provides `data`, `user`, `params` to expressions. -- **`ExpressionCache.ts`** — Caches parsed expressions for repeated evaluations. -- **`FormulaFunctions.ts`** — Built-in functions available inside expressions. - -### Expression Types - -```json -{ - "visible": "${data.role === 'admin'}", - "label": "Hello, ${data.user.name}!", - "disabled": "${data.status !== 'draft'}", - "className": "${data.priority === 'high' ? 'text-red-500' : 'text-gray-500'}" -} -``` - -Expressions support: -- **String interpolation**: `"Welcome, ${data.name}"` -- **Conditionals**: `"${data.age > 18}"` -- **Ternary operators**: `"${data.active ? 'Yes' : 'No'}"` -- **Variable references**: `data.*`, `user.*`, `params.*` - -## Action System - -The action system (`packages/core/src/actions/`) handles user interactions defined in schemas. - -### ActionRunner (`ActionRunner.ts`) - -Executes action schemas and returns directives: - -``` - User Click → Schema Action - │ - ▼ - ActionRunner.execute(action, context) - │ ├─ Check confirmation dialog - │ ├─ Evaluate conditions - │ └─ Execute by action type - ▼ - ActionResult - ├─ reload: boolean - ├─ redirect: string - ├─ modal: SchemaObject - └─ toast: { message, type } -``` - -### Supported Action Types - -| Type | Description | -|------|-------------| -| `script` | Dispatch a named/registered script action — an `action.body` always executes **server-side** | -| `url` | Navigate to a URL | -| `api` | Make an API request (AJAX) | -| `modal` | Open a modal with a nested schema | -| `flow` | Start a server-side flow by name (`target`). In the console, a click on a declared `type: 'flow'` action goes through the action endpoint, `POST /api/v1/actions/{object}/{action}`, so every server-side gate the action declares applies; a flow start that names no declared action (an inline page button, a dashboard header action) goes through `POST /api/v1/automation/{flow}/trigger`. A run that pauses at a `screen` node opens the flow runner. Whether the run pauses or goes straight through, it closes on one success toast: the action's `successMessage`, else the flow's own `successMessage`, else a default sentence. A run that ends refused or fails shows no success toast | - -### Server Action Dispatch (`serverActionHandler.ts`) - -`ActionSchema.body` (the spec's preferred binding for script actions) executes -server-side via `POST /api/v1/actions/{object}/{action}` — the client -dispatches, it never interprets a body (a browser cannot enforce the L2 -sandbox's capabilities/timeout/memory contract, and L1 is formula-engine CEL, -a different dialect from the `${...}` evaluator). - -`createServerActionHandler` is the core factory every consumer uses to build -that dispatch. It owns the protocol (name-based action identity, record-id -resolution, re-entrancy guard, the `/actions` response-envelope rule) and -injects the three things core has no opinion about: - - - -```typescript -import { createServerActionHandler } from '@object-ui/core'; - -const script = createServerActionHandler({ - fetch: myAuthenticatedFetch, // auth is yours - baseUrl: 'https://api.example.com', // origin is yours ('' = same-origin) - resolveObject: () => currentObject, // fallback object scope is yours - onRefresh: () => notifyDataChanged(), // data invalidation is yours -}); - -// Registered handlers beat the built-in executors: - -``` - -The console builds on the same factory (`@object-ui/app-shell`'s -`createConsoleServerActionHandler` adds the popup pre-open dance and the -`redirectUrl` convention on top). - -### TransactionManager (`TransactionManager.ts`) - -Wraps multi-step actions in transactions for consistency, supporting rollback on failure. - -## Key Directories Reference - -``` -packages/ -├── types/src/ # Layer 2 — Pure type definitions -├── core/src/ -│ ├── evaluator/ # Expression engine -│ ├── registry/ # Component & plugin registries -│ ├── actions/ # ActionRunner, TransactionManager -│ ├── validation/ # Schema validation engine -│ ├── adapters/ # Data source adapters (API, Value) -│ ├── data-scope/ # ViewDataProvider, element data sources -│ ├── query/ # Query AST (filtering/sorting) -│ ├── theme/ # ThemeEngine -│ └── builder/ # Schema builder utilities -├── react/src/ -│ ├── SchemaRenderer.tsx -│ ├── LazyPluginLoader.tsx -│ ├── context/ # All React contexts -│ └── hooks/ # 20+ hooks (useExpression, useActionRunner, etc.) -├── components/ # 91+ Shadcn UI atoms -├── fields/ # 35+ field renderers -├── layout/ # AppShell, Header, Sidebar -├── i18n/ # Internationalization -├── auth/ # AuthContext + providers -├── permissions/ # RBAC/ABAC -└── plugin-*/ # 20 plugin packages - ├── plugin-grid/ - ├── plugin-kanban/ - ├── plugin-charts/ - ├── plugin-dashboard/ - ├── plugin-form/ - └── ... (15 more) -``` - -## Further Reading - -- [Schema Overview](/docs/guide/schema-overview) — JSON schema structure -- [Schema Rendering](/docs/guide/schema-rendering) — How schemas become UI -- [Component Registry](/docs/guide/component-registry) — Registering components -- [Plugin Development](/docs/guide/plugin-development) — Building plugins -- [Expressions](/docs/guide/expressions) — Expression syntax reference -- [Theming](/docs/guide/theming) — Theme configuration diff --git a/ui/content/docs/guide/architecture.md b/ui/content/docs/guide/architecture.md deleted file mode 100644 index 470a391a35f..00000000000 --- a/ui/content/docs/guide/architecture.md +++ /dev/null @@ -1,476 +0,0 @@ ---- -title: "Architecture Overview" -description: "Understanding ObjectUI's architecture, design principles, and how the pieces fit together" ---- - -# Architecture Overview - -ObjectUI is a universal, server-driven UI (SDUI) engine built on React, Tailwind CSS, and Shadcn UI. This guide explains the core architecture and how all the pieces work together. - -## Core Philosophy - -ObjectUI follows three fundamental principles: - -1. **JSON-First**: Every UI element is described as JSON metadata, not hardcoded React components -2. **Backend Agnostic**: Works with any backend system (ObjectStack, custom APIs, etc.) -3. **Component Library Quality**: Combines low-code speed with Shadcn/Tailwind design quality - -## Architecture Layers - -``` -┌─────────────────────────────────────────────┐ -│ JSON Schema (Protocol) │ ← Backend sends this -├─────────────────────────────────────────────┤ -│ @object-ui/react (Renderer) │ ← Interprets schema -├─────────────────────────────────────────────┤ -│ Component Registry + Field Registry │ ← Lookup system -├─────────────────────────────────────────────┤ -│ @object-ui/components (UI Primitives) │ ← Buttons, Cards, etc. -│ @object-ui/fields (Form Inputs) │ ← Text, Date, Select -│ @object-ui/layout (Page Structure) │ ← AppShell, Sidebar -│ @object-ui/plugin-* (Advanced Widgets) │ ← Grid, Charts, Kanban -├─────────────────────────────────────────────┤ -│ Shadcn UI + Radix UI (Primitives) │ ← Accessible components -│ Tailwind CSS (Styling) │ ← Utility-first CSS -└─────────────────────────────────────────────┘ -``` - -## Package Structure - -ObjectUI is organized as a PNPM monorepo with clear separation of concerns: - -### Core Packages - -#### `@object-ui/types` -- **Role**: The Protocol -- **Contains**: Pure TypeScript interfaces for JSON schemas -- **Constraint**: ZERO dependencies, no React code -- **Example**: `BaseSchema`, `ActionSchema`, `FieldSchema` - -#### `@object-ui/core` -- **Role**: The Engine -- **Contains**: Schema validation, expression evaluation, registries -- **Constraint**: No UI library dependencies, logic only -- **Features**: - - Expression engine (`visible: "${record.age > 18}"`) - - Schema registry and validation - - Event system - -#### `@object-ui/react` -- **Role**: The Runtime -- **Contains**: `SchemaRenderer` and React integration -- **Purpose**: Transforms JSON schemas into live React components - -### UI Packages - -#### `@object-ui/components` -- **Role**: The Atoms -- **Contains**: Shadcn primitives (Button, Badge, Card, Dialog, etc.) -- **Constraint**: Pure UI, no business logic -- **Style**: Tailwind CSS with `class-variance-authority` - -#### `@object-ui/fields` -- **Role**: The Inputs -- **Contains**: Standard field renderers (Text, Number, Select, Date, etc.) -- **Implements**: `FieldWidgetProps` interface -- **Purpose**: Reusable form inputs with consistent API - -#### `@object-ui/layout` -- **Role**: The Shell -- **Contains**: Page structure components (AppShell, Page, Sidebar, Header) -- **Purpose**: Routing-aware composition and app scaffolding - -### Plugin Packages - -Each plugin provides specialized, complex widgets: - -- `@object-ui/plugin-grid` - Data tables with ObjectStack integration -- `@object-ui/plugin-kanban` - Kanban board view -- `@object-ui/plugin-charts` - Recharts-based visualizations -- `@object-ui/plugin-calendar` - Calendar and event views -- `@object-ui/plugin-map` - Map visualization -- `@object-ui/plugin-form` - Advanced forms -- `@object-ui/plugin-editor` - Code editor (Monaco) -- `@object-ui/plugin-markdown` - Markdown renderer -- `@object-ui/plugin-gantt` - Gantt chart timeline -- `@object-ui/plugin-timeline` - Event timeline -- `@object-ui/plugin-dashboard` - Dashboard layouts -- `@object-ui/plugin-chatbot` - Chat interface - -**Important**: Heavy dependencies (like Monaco, Recharts) are only allowed in plugin packages to keep the core bundle small. - -### Utility Packages - -Development tools and integration utilities: - -- `@object-ui/cli` - Command-line tool for building apps from schemas -- `@object-ui/create-plugin` - Interactive plugin scaffolder -- `@object-ui/runner` - Universal runtime for testing and demos -- `@object-ui/data-objectstack` - ObjectStack data backend adapter -- `vscode-extension` - VS Code extension for schema development - -[Learn more about utilities →](/docs/utilities) - -## How Schema Rendering Works - -### 1. JSON Schema Input - -A backend system sends a JSON schema: - -```json -{ - "type": "card", - "title": "Welcome", - "children": { - "type": "text", - "content": "Hello, ${user.name}!" - } -} -``` - -### 2. SchemaRenderer Processing - -The `SchemaRenderer` component: - -1. Receives the schema, and reads the expression scope off the context above it -2. Evaluates expressions (`${user.name}`) -3. Looks up the component type in the registry -4. Recursively renders child schemas -5. Handles events and state updates - -The scope does **not** arrive as a prop. `SchemaRenderer` declares exactly one prop, `schema`, -and forwards everything else it is handed to the component the schema names — so a `data={…}` -written on the element is neither read nor refused. The host publishes its values with -`PredicateScopeProvider`, and every key it publishes becomes a root the expressions can read: - -```tsx -import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react' -import type { DeclaredNode } from '@object-ui/types' - -// The schema from step 1, as the object the renderer receives. -const schema: DeclaredNode = { - type: 'card', - title: 'Welcome', - children: { - type: 'text', - content: 'Hello, ${user.name}!', - }, -} - -function App() { - const scope = { user: { name: 'Alice' } } - - return ( - - - - ) -} -``` - -An app built on `@object-ui/app-shell` does not mount this provider itself: the shell's -`ExpressionProvider` already feeds the same channel with the signed-in `user` (also readable as -`current_user`) and `features`. On top of what the host published, the renderer supplies -`record` — the row a record surface is bound to — and `page`, the page-local variables. - -⛔ `SchemaRendererProvider`'s `dataSource` is **not** an expression root. It carries the host's -`DataSource` *adapter*, the object data renderers call `find()` on; the two are different -channels on purpose. - -### 3. Component Registry Lookup - -The registry maps type strings to React components: - - -```typescript -// During app initialization -ComponentRegistry.register('card', CardComponent) -ComponentRegistry.register('text', TextComponent) - -// At runtime -const Component = ComponentRegistry.get('card') // → CardComponent -``` - -### 4. React Component Rendering - -The registered component renders with evaluated props: - - -```tsx - - - -``` - -## The Registry Pattern - -ObjectUI uses two registry systems for extensibility: - -### Component Registry - -Maps schema types to React components: - - -```tsx -import { ComponentRegistry } from '@object-ui/core' - -// Register a component -ComponentRegistry.register('my-widget', MyWidgetComponent, { - label: 'My Widget', - category: 'Custom', - icon: 'box', - inputs: [ - { name: 'title', type: 'string' } - ] -}) -``` - -### Field Registry - -Maps field types to input components: - - -```tsx -import { registerFieldRenderer } from '@object-ui/fields' - -// Register a field renderer -registerFieldRenderer('rating', RatingFieldComponent) -``` - -This allows: -- ✅ Overriding standard components -- ✅ Adding custom field types -- ✅ Plugin system for complex widgets -- ✅ Keeping bundles small (lazy loading) - -## Expression System - -ObjectUI includes a powerful expression engine for dynamic UIs: - -### String Interpolation - -```json -{ - "type": "text", - "content": "Welcome, ${user.firstName} ${user.lastName}!" -} -``` - -### Conditional Rendering - -```json -{ - "type": "button", - "label": "Submit", - "visible": "${current_user.role === 'admin'}", - "disabled": "${record.status === 'locked'}" -} -``` - -`current_user` is the signed-in user the host's `ExpressionProvider` publishes; `record` is the -row a record surface is bound to, and is the only spelling a row field has — the bare shorthand -(`status`) and the wrong-layer `data.status` were both retired on runtime record surfaces -(objectui#5330 phase 2). A head name outside the scope is not refused: the predicate is -unevaluable, this surface fails soft, and the node is shown on every row. - -A button's text key is `label`, and `text` is not a `ButtonSchema` key at all. Only -`objectui validate` refuses the misspelling, by name: the rendering face is -`.passthrough()` — `BaseSchema` KEEPS the unknown key — and the renderer, which reads -`schema.label`, never looks at it. -Measured on the node above with `text`: the button renders with an empty `textContent`, -so it appears on screen as a blank rectangle with no text. - -### Data Transformations - -```json -{ - "type": "statistic", - "label": "Orders", - "value": "${orders.length}", - "description": "${orders.length > 10 ? 'Above target' : 'On track'}" -} -``` - -`statistic` rather than `badge`: an expression is evaluated only on a key the node's own -type carries, and `expressionBindableTextKeysFor` gives `statistic` the rows `label`, -`value` and `description` while giving `badge` none. A badge's text is its `label`, and it -has to arrive already resolved. - -See the [Expressions Guide](/docs/guide/expressions) for complete details. - -## Data Flow - -``` -Backend API - ↓ -JSON Schema + Data - ↓ -SchemaRenderer (evaluates expressions) - ↓ -Component Registry (maps types) - ↓ -React Components (render UI) - ↓ -User Interactions (events) - ↓ -Event Handlers (update data) - ↓ -Re-render (React state updates) -``` - -## Styling System - -ObjectUI uses **Tailwind CSS** exclusively for styling: - -### Class-Variance-Authority (CVA) - -All component variants use `cva` for type-safe variants: - - -```tsx -import { cva } from 'class-variance-authority' - -const buttonVariants = cva( - 'inline-flex items-center justify-center rounded-md', - { - variants: { - variant: { - default: 'bg-primary text-primary-foreground', - destructive: 'bg-destructive text-destructive-foreground', - }, - size: { - default: 'h-10 px-4 py-2', - sm: 'h-9 px-3', - lg: 'h-11 px-8', - } - } - } -) -``` - -### Class Merging - -Use `cn()` helper (tailwind-merge + clsx) for class overrides: - - -```tsx -import { cn } from '@/lib/utils' - - - - -// The grid re-resolves `view` whenever `activeView` changes. - -``` - -Selecting **Active** re-queries with the `active` view's `filter` -(`status != Done`) and `sort` (`priority asc`) applied — you never assemble -`$filter` / `$orderby` by hand. A view name your backend does not publish is -reported, not silently ignored: swap `activeView` for a name outside -`listViews` and the grid renders a configuration-error panel in place of the -table, the same way an unresolved `objectName` does (Step 5). A page that -instead fell back to the object's full, unfiltered scope would look like it -worked while returning every record regardless of which button was pressed — -so this block does not offer that fallback. - -**Search needs no binding at all.** `object-grid` renders its own search box -in the toolbar — on by default — and typing there drives -`DataSource.find()`'s `$search` parameter directly; there is no separate -query-param key to author. Add `searchableFields: ['title', 'description']` -to the grid's `properties` bag to narrow which fields the server matches; leave -it out and the server decides. - -## Step 8: Add a Detail View - -Create a detail page that renders a single record with all its fields: - -```tsx -import { SchemaRenderer } from '@object-ui/react'; - -function TaskDetail({ taskId, onBack }: { taskId: string; onBack: () => void }) { - return ( -
- - -
- ); -} -``` - -Render `TaskDetail` inside the same `SchemaRendererProvider` as the grid — it -reads the injected data source from context, exactly as `object-grid` and -`object-form` do. - -Two keys differ from the form above, and both matter: - -- **`resourceId`, not `recordId`.** `detail-view` sources the record id from - `resourceId`; `recordId` is `object-form`'s spelling. The two blocks are not - interchangeable here. -- **No `data`.** On `detail-view`, `data` means *"here is the record already, - do not fetch"* — so handing it anything (including the object's metadata) - makes the block skip `findOne` entirely and render that value as if it were - the record. Omit it and the block loads the record for itself. - -Use this component in your main app with simple routing state, or integrate with a router like React Router or TanStack Router for URL-based navigation. - -## Deployment Considerations - -**Environment config** — Keep your API URL configurable: - - -```ts -const dataSource = new RestDataSource( - import.meta.env.VITE_API_URL || 'http://localhost:3000/api' -); -``` - -**Performance** — Use server-side pagination via `$top`/`$skip` query params. ObjectUI plugins support lazy loading via `LazyPluginLoader` from `@object-ui/react`. Set `cache: { enabled: true, ttl: 300 }` on your schema for client-side caching. - -**Production build** — Run `pnpm build` and deploy the `dist/` folder to any static host (Vercel, Netlify, Cloudflare Pages). - -**Authentication** — Extend `RestDataSource` to inject auth headers: - - -```ts -class AuthenticatedDataSource extends RestDataSource { - constructor(baseUrl: string, private getToken: () => string) { - super(baseUrl); - } - // Override fetch calls to include: Authorization: `Bearer ${this.getToken()}` -} -``` - -## Next Steps - -- Explore the [Schema Overview](/docs/guide/schema-overview) for advanced schema features -- Add a Kanban board view using `@object-ui/plugin-kanban` (see the [Kanban Plugin](/docs/plugins/plugin-kanban) reference for runnable board schemas) -- Connect to a production backend with the [Data Connectivity](/docs/guide/data-source) guide -- Build multi-object apps with relationships using `lookup` / `master_detail` fields (see the [Lookup Field](/docs/fields/lookup) reference) diff --git a/ui/content/docs/guide/component-registry.md b/ui/content/docs/guide/component-registry.md deleted file mode 100644 index 639bfec04eb..00000000000 --- a/ui/content/docs/guide/component-registry.md +++ /dev/null @@ -1,465 +0,0 @@ ---- -title: "Component Registry" ---- - -The Component Registry is Object UI's system for mapping schema types to React components. Understanding the registry is key to extending Object UI with custom components. - -## Overview - -The registry acts as a lookup table that the `SchemaRenderer` uses to determine which React component to render for each schema type: - -``` -Schema Type → Component Registry → React Component -``` - -## Getting the Registry - -`ComponentRegistry` is a process-level singleton exported by `@object-ui/core`. -Import it directly — there is no accessor function and nothing to construct: - -```tsx -import { ComponentRegistry } from '@object-ui/core' -``` - -## Registering Components - -### Using Default Components - -The easiest way to get started is to register all default components: - -```tsx -import { initializeComponents } from '@object-ui/components' -// Side-effect import: loading the package runs its own field registration. -import '@object-ui/fields' - -// Call once at app initialization -initializeComponents() -``` - -Loading each package registers what it owns — the components and the field -widgets both land in the one `ComponentRegistry`; `initializeComponents()` exists -so a bundler cannot tree-shake the side-effect import away. The individual -renderers are not exported for hand-registration: registration is what loading -the package does. - -This registers all built-in components like: -- Forms: `input`, `textarea`, `select`, `checkbox`, etc. -- Data: `table`, `list`, `card`, `tree-view`, etc. -- Layout: `page`, `grid`, `flex`, `container`, etc. -- Feedback: `alert`, `dialog`, `toast`, etc. - -### Registering Custom Components - -Create and register your own components: - -```tsx -import { ComponentRegistry } from '@object-ui/core' -import type { BaseSchema } from '@object-ui/types' - -interface MyComponentSchema extends BaseSchema { - type: 'my-component' - title: string - content: string -} - -function MyComponent(props: MyComponentSchema) { - return ( -
-

{props.title}

-

{props.content}

-
- ) -} - -ComponentRegistry.register('my-component', MyComponent) -``` - -Now you can use it in schemas: - -```json -{ - "type": "my-component", - "title": "Hello", - "content": "This is my custom component!" -} -``` - -## Component Interface - -All registered components receive the schema as props: - - -```tsx -interface ComponentProps { - // The complete schema object - schema: T - - // Data context (optional) - data?: Record - - // Event handlers (optional) - onAction?: (action: any, context: any) => void - onChange?: (value: any) => void - onSubmit?: (data: any) => void -} - -function MyRenderer(props: ComponentProps) { - const { schema, data, onChange } = props - - return ( -
- {/* Your component implementation */} -
- ) -} -``` - -## Advanced Registration - -### With Metadata - -Register components with additional metadata: - - -```tsx -ComponentRegistry.register('my-component', MyComponent, { - label: 'My Custom Component', - category: 'Custom', - icon: 'component-icon', - inputs: [ - { name: 'title', type: 'string' }, - { name: 'content', type: 'string' } - ] -}) -``` - -This metadata is used by the Visual Designer to provide better editing experience. - -`inputs` is also the authoring contract the SDUI parser validates against. A -component that renders `schema.children` declares the slot as -`{ name: 'children', type: 'slot' }`; a child list authored under a component -whose `inputs` carry no such entry draws the `not-a-container` diagnostic. -`isContainer` is a separate fact — *layout* containment, which keeps a block out -of a react page's JSX scope — and does not stand in for the slot. - -### Lazy Loading - -Register components that load on demand: - - -```tsx -// The loader runs the first time a schema asks for `heavy-component`. -ComponentRegistry.registerLazy('heavy-component', () => import('./HeavyComponent')) -``` - -### Overriding Built-in Components - -Override default components with your own: - - -```tsx -import { ComponentRegistry } from '@object-ui/core' -import { initializeComponents } from '@object-ui/components' -import '@object-ui/fields' - -// Register defaults first -initializeComponents() - -// Override specific component -ComponentRegistry.register('button', MyCustomButton) -``` - -## Component Categories - -Default components are organized by category: - -### Form Components - -- `input` -- `textarea` -- `select` -- `checkbox` -- `radio` -- `switch` -- `slider` -- `date-picker` -- `time-picker` -- `file-upload` -- `color-picker` - -### Data Display - -- `table` -- `list` -- `card` -- `tree-view` -- `timeline` -- `calendar` -- `kanban` - -### Layout - -- `page` -- `container` -- `grid` -- `flex` -- `tabs` -- `accordion` -- `divider` -- `spacer` - -### Feedback - -- `alert` -- `toast` -- `dialog` -- `drawer` -- `popover` -- `tooltip` -- `progress` -- `skeleton` -- `spinner` - -### Navigation - -- `menu` -- `breadcrumb` -- `pagination` -- `steps` - -### Other - -- `button` -- `link` -- `text` -- `icon` -- `image` -- `video` -- `badge` -- `avatar` - -## Checking Registered Components - -### Get All Registered Types - -```tsx -import { ComponentRegistry } from '@object-ui/core' - -const types = ComponentRegistry.getAllTypes() -console.log(types) // ['input', 'button', 'form', ...] -``` - -### Check if Type is Registered - -```tsx -import { ComponentRegistry } from '@object-ui/core' - -if (ComponentRegistry.has('my-component')) { - console.log('Component is registered') -} -``` - -### Get Component Metadata - -```tsx -import { ComponentRegistry } from '@object-ui/core' - -const metadata = ComponentRegistry.getMeta('input') -console.log(metadata) -// { -// label: 'Input', -// category: 'Form', -// icon: 'input-icon', -// ... -// } -``` - -## Best Practices - -### 1. Register Once at App Initialization - -```tsx -// main.tsx or App.tsx -import { initializeComponents } from '@object-ui/components' -import '@object-ui/fields' - -initializeComponents() - -function App() { - // Your app code -} -``` - -### 2. Use TypeScript for Custom Components - -```tsx -import type { BaseSchema } from '@object-ui/types' - -interface CustomSchema extends BaseSchema { - type: 'custom' - customProp: string -} - -function CustomComponent(props: { schema: CustomSchema }) { - // TypeScript ensures type safety -} -``` - -### 3. Follow Naming Conventions - -Use kebab-case for component types: -- ✅ `my-component`, `custom-button`, `data-table` -- ❌ `MyComponent`, `customButton`, `DataTable` - -### 4. Provide Meaningful Metadata - - -```tsx -ComponentRegistry.register('rating', RatingComponent, { - label: 'Star Rating', - category: 'Form', - icon: 'star', - labelling: 'group' -}) -``` - -### 5. Handle Missing Props Gracefully - - -```tsx -function MyComponent(props: ComponentProps) { - const { schema } = props - const title = schema.title || 'Default Title' - const content = schema.content || '' - - return ( -
-

{title}

-

{content}

-
- ) -} -``` - -## Creating Plugin Packages - -Group related components into plugin packages: - - -```tsx -// @my-org/objectui-plugin-charts -import { ComponentRegistry } from '@object-ui/core' -import { BarChart } from './BarChart' -import { LineChart } from './LineChart' -import { PieChart } from './PieChart' - -export function registerChartComponents() { - ComponentRegistry.register('bar-chart', BarChart) - ComponentRegistry.register('line-chart', LineChart) - ComponentRegistry.register('pie-chart', PieChart) -} -``` - -Usage: - - -```tsx -import { initializeComponents } from '@object-ui/components' -import '@object-ui/fields' -import { registerChartComponents } from '@my-org/objectui-plugin-charts' - -initializeComponents() -registerChartComponents() -``` - -## Example: Custom Form Component - -Here's a complete example of a custom form component: - -```tsx -import { forwardRef, useState } from 'react' -import { ComponentRegistry } from '@object-ui/core' -import type { BaseSchema } from '@object-ui/types' -import { cn } from '@object-ui/components' - -interface RatingSchema extends BaseSchema { - type: 'rating' - name: string - label?: string - maxStars?: number - required?: boolean - disabled?: boolean - onChange?: (value: number) => void -} - -const RatingComponent = forwardRef( - ({ schema }, ref) => { - const [value, setValue] = useState(0) - const maxStars = schema.maxStars || 5 - - const handleClick = (rating: number) => { - if (schema.disabled) return - setValue(rating) - schema.onChange?.(rating) - } - - return ( -
- {schema.label && ( - - )} -
- {Array.from({ length: maxStars }).map((_, index) => ( - - ))} -
-
- ) - } -) - -RatingComponent.displayName = 'Rating' - -// Register the component -ComponentRegistry.register('rating', RatingComponent, { - label: 'Star Rating', - category: 'Form', - labelling: 'group', - inputs: [ - { name: 'name', type: 'string', required: true }, - { name: 'label', type: 'string' }, - { name: 'maxStars', type: 'number', description: 'Defaults to 5 — the renderer\'s own fallback' }, - { name: 'required', type: 'boolean' }, - { name: 'disabled', type: 'boolean' } - ] -}) - -export { RatingComponent } -``` - -## Next Steps - -- [Expression System](./expressions.md) - Learn about dynamic expressions -- [Schema Rendering](./schema-rendering.md) - Understand the rendering engine -- [Custom Plugin Development](/docs/guide/plugin-development) - Deep dive into component creation - -## Related Documentation - -- [`@object-ui/core` README](https://github.com/objectstack-ai/objectui/tree/main/packages/core) - Component registry API -- [`@object-ui/react` README](https://github.com/objectstack-ai/objectui/tree/main/packages/react) - React integration -- [Schema Type Reference](/docs/api/schema-reference) - Component metadata reference diff --git a/ui/content/docs/guide/console.md b/ui/content/docs/guide/console.md deleted file mode 100644 index 04b102dae9f..00000000000 --- a/ui/content/docs/guide/console.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -title: Console App -description: Getting started with the ObjectStack Console — the reference SDUI application for ObjectUI. ---- - -# ObjectStack Console - -The **Console** is the reference application for [ObjectUI](/docs/guide). It renders a full-featured admin interface from JSON metadata — objects, views, dashboards, and actions — with zero custom pages required. - -## Quick Start - -```bash -# From the repository root -pnpm run setup # builds @objectstack/* at .objectstack-sha, then installs -pnpm dev # starts the console dev server (Vite) -``` - -The console opens at **http://localhost:5180** (the port is fixed in `apps/console/vite.config.ts`). There is no bundled mock backend — `apps/console/.env.development` ships `VITE_SERVER_URL` **empty** (same origin), and the Vite dev server proxies `/api/*` to `http://localhost:3000` by default, so an ObjectStack server has to be listening there. See [Running with a Real Backend](#running-with-a-real-backend) to point dev at a different one. - -## Key Features - -| Feature | Description | -|---------|-------------| -| **Multi-App Switcher** | Switch between the apps discovered from the connected server. | -| **Dynamic Navigation** | Sidebar renders from the app's `navigation` tree (objects, groups, URLs, pages). | -| **Object Views** | List / Grid / Kanban / Calendar — backed by `@object-ui/plugin-view`. | -| **CRUD Dialogs** | Create & edit records via schema-driven forms. | -| **Expression Visibility** | Show/hide navigation items with a CEL `visible` predicate over `current_user` and `features`, e.g. `visible: "'admin' in current_user.positions"`. A `visible` that reads `data` needs the entry to declare a `dataSource`. | -| **Branding** | Per-app colors, favicons, and logos via `AppShell` branding. | -| **Command Palette** | `⌘+K` opens a searchable command bar for quick navigation. | -| **Search in Studio** | On the Studio landing (`/studio`) the header's search, or `⌘+K`, opens the command palette over the Studio rather than an app: its packages, their objects and their flows, matched by label or machine name. A package opens its Data pillar, an object opens in its package's Data pillar, a flow in its package's Automations pillar, and a flow that belongs to no package in `/studio/~org/automations`. App entries, record search and the full-search page are not offered there, since no app is open. | -| **Studio Access** | Studio needs the `studio.access` capability. A caller without it who opens any Studio URL stays on that URL and sees one sentence saying Studio access is needed, in their language, with a **Back to home** link to the declared landing (the launcher where none is declared). The Studio builder is not loaded for them. | -| **Studio Package Scope** | Metadata counts, diagnostics and the metadata lists follow the selected package. The Studio app has no home page of its own: its root opens the first navigation entry that leads to a page, by the same rule as every app. | -| **Package-less Flows in Studio** | Flows that belong to no package — such as a clone of a packaged flow, made from Setup › Packaged automation — are listed and edited at `/studio/~org/automations`, reached from the Studio home ("Not in a package") or the package switcher. They open editable, edits save as drafts, and Publish promotes those drafts. | -| **Studio Automations Rail** | The Automations pillar's flow rail has a search box that matches a flow's label or its machine name, case-insensitively. A long flow name wraps instead of being cut off. | -| **Create App in Studio** | In a package that has no app yet, the Studio header's **Create app** saves the new app as a draft and then opens the **Interfaces** pillar, where that app is designed. Leaving a pillar that holds an unsent edit asks first. | -| **Read-only Packages in Studio** | The Studio home lists code and installed packages under "Installed (read-only · browsable)". Clicking a card opens the package for browsing (`/studio/:packageId/data`). The card's **Customize with an overlay** link opens the package's metadata directory (`/apps/setup/metadata?package=:packageId`). Whether an item can take an environment overlay is decided per metadata type (`allowEnvironmentOverlay`), not per package, and the directory marks each type. Unless the runtime reports `features.marketplace: false`, the section also links to the marketplace (`/apps/setup/system/marketplace`), the same route as Home's "Start with a template". | -| **Edit app (Studio)** | Workspace admins get an **Edit app** button in the top bar inside a running app (the pencil alone on narrow screens), and an **Edit APP in Studio** row at the end of the app switcher, both opening the app's owning package on the Studio design surface. On an interface route — a dashboard, page, or report — they deep-link straight to that surface's design page in the Interfaces pillar (`/studio/:packageId/interfaces?surface=:`, e.g. `surface=page:showcase_crm_workbench`); elsewhere (objects, the app root) they open the package's Data tab (`/studio/:packageId/data`). These interfaces are authored in Studio — there is no in-page edit panel. | -| **Studio Copilot Drafts** | When the AI copilot saves a change to a live app as a draft, its card offers **Review changes and publish** and **Undo**. Review opens Studio's **Changes** drawer for the package: in the Studio copilot it opens in place (same pillar, same selection), and from the full-page chat it opens the package's Studio with the drawer open. The drawer's link is `/studio/:packageId/:tab?changes=review`; Studio opens the drawer to review the pending drafts and drops the parameter. Undo discards the package's pending drafts (`POST /api/v1/packages/:id/discard-drafts`) and asks first when other drafts are pending. The top bar's **Changes** count follows the copilot as its tools answer, including a change the environment publishes immediately. The Studio copilot draws no floating pending-changes bar over its composer: there the top bar's **Changes** and **Publish** are the way to review and publish. The full-page chat and the console dock show that bar while the package has pending drafts. | -| **Dashboard Refresh** | A dashboard page shows a **Refresh All** button at the right of its page header, and a dashboard that sets `refreshIntervalSeconds` (Studio's auto-refresh field) re-reads its widgets' data every that many seconds. Widgets re-read in place, so they are not remounted, and a dataset-bound single-value (KPI) tile re-reads too. `0` or no value means no automatic refresh. | -| **Refused Dashboards and List Views** | The server lists only the dashboards and list views a user may open, so one gated by `requiredPermissions` is not in their lists. When such a dashboard or list view is opened by its URL, the console asks the server about it by name. If the server refuses it with `403 PERMISSION_DENIED`, the page says "You don't have access" and shows the server's sentence, which names the item. The page does not say "not found", and the list page does not switch to the default view. For a name that does not exist, the dashboard page still says it was not found and the list page still opens the default view. An object's own list view that the server sends whole to a user who may edit the object is listed and opens. | -| **Setup Positions** | Setup → Positions lists, for each position, how many people hold it in the active organization and which permission sets it grants. Both columns come from the server (`GET /meta/position?include=grants`), which computes them where it enforces them. The column lists only the permission sets the position's definition names; a set that merely shares the position's name is not granted and not listed. A holder count the server did not serve (no active organization, or no read on position assignments) shows "—", not 0. On every Setup metadata list, a click anywhere on a row opens that item. New Position asks for the permission sets with the label and name, so a position is created and bound in one save. On a position's page, Add holder offers the members of the active organization only (its `sys_member` rows), and Remove asks first, naming the person. | -| **App Creation Wizard** | 4-step wizard (Basic Info → Objects → Navigation → Branding) to create or edit apps. | -| **Record Approvals Tab** | A record with approval requests grows an Approvals tab on its detail page (peer of Details/Related, with a request-count badge) — current step, decision progress, resolved "waiting on" approvers, the merged decision timeline, and a submitter remind button — visible to every viewer who can read the record, not just approvers. | -| **Approval Request Pages** | A request opens on its record page, read through the approvals routes rather than the data API: the request with its decision tally and the `viewer` block its decision actions gate on, and its Timeline from the request's actions route. Those routes serve a request to each party to it, so an approver who is not an administrator sees the full page — fields, decision actions and Timeline — although the data API stays closed to them on both approval objects. | -| **Error Boundary** | Graceful error handling with a retry button. | - -### Object design (Studio Data tab) - -Selecting an object in Studio's **Data** pillar (`/studio/:packageId/data`) opens -two tabs over that object, **Records · Form**, and an **Advanced** menu that holds -**Validations · Hooks · Actions · API · Settings** (objectui#5813). The -**Advanced** trigger keeps its own name whichever of those panels is open: the -open panel is the checked item inside the menu, and the trigger shows as -selected while one of its panels is open. Each of Validations, Hooks and Actions -is a no-code **config panel driven by the corresponding metadata**, and each -supports **adding** new entries — no code round-trip required: - -| Advanced item | Edits | Panel | -|-----|-------|-------| -| **Validations** | the object's inline `validations[]` (spec `ValidationRuleSchema`) | Master-detail covering **every** rule type — `script`, `cross_field`, `state_machine`, `format`, `json_schema`, `conditional`. The **New** menu opens on common rules in plain words (*End date on or after start date*, *Number can't be negative*, *Reject the save when…*); each writes a working rule, or waits for the condition the author gives. Every rule type is under **Advanced**. A new rule whose type carries a condition (`script`, `cross_field`, `conditional`) is saved only once it has one; a rule's type can still be switched in place. CEL predicates reuse the shared `ConditionBuilder`, fed the object's draft fields. | -| **Hooks** | the separate `hook` metadata type targeting this object | Master-detail whose editor is the platform `SchemaForm` **driven by the live `hook` JSONSchema from `/meta/types`**, so its fields and enums always match the running server's contract. | -| **Actions** | the object's inline `actions[]` (spec `ActionSchema`) | Master-detail using the type-aware `ActionDefaultInspector`; anything not curated falls through to a **"More fields"** form fed the live `action` JSONSchema, so no spec property is un-editable. | - -Validations and Actions persist with the object's own **Save draft**; Hooks (a -distinct metadata type) save per-hook. Nothing goes live until the package is -published from the top-bar **Publish** flow. - -#### Why a configured action can be missing from the app - -An action that is saved, published and correctly placed can still appear on no -surface at all. There are two reasons, and the Actions panel states both — the -running app deliberately states neither, because an end user should not be told -about capabilities they do not have. - -* **No placement.** `locations` is empty, so the action surfaces nowhere. - A selection-only action is legitimate here: it is placed by a list view's - `bulkActions` / `bulkActionDefs` instead. -* **The capability gate.** `action.requiredPermissions` (ADR-0066 D4) is - enforced with a 403 on the platform action route and **mirrored as a UI - hide**: a viewer who does not hold every listed capability gets no button, - no greyed-out control and no message, at every declared location at once. - This is intended — the client hide mirrors an enforcement the server applies - regardless, and an unentitled user is never shown a control they cannot use. - -The **Placement** section of the action inspector names the gating capabilities -and says the action is hidden rather than disabled; when the signed-in session -is itself missing one of them it says so too, so "I configured the buttons and -I see none of them" has an answer on the screen where the buttons were -configured. The read-only **action preview** beside it carries the same -capability line, so its *Where it appears* frames are not read as a promise -that everyone will see the button there. - -An unheld capability is not the same problem as a *misspelled* one: a -capability string registered nowhere is caught separately, by the advisory -capability-reference lint that runs over pending drafts during **Publish**. -It resolves a reference against the platform's built-in capabilities, the -capabilities the registry serves (a package's `defineCapability` declarations) -and every permission set's `systemPermissions`, so a declared capability that -no set grants yet is not reported. - -## Configuration - -**The console has no configuration file.** It declares no apps, objects or views of its own — -it renders whatever the server it is pointed at publishes. There are only two inputs: - -**1. `VITE_SERVER_URL` — which backend to talk to.** A build-time Vite variable, and the only -setting the console itself owns. It seeds the data adapter's base URL and the runtime-config -fetch; `apps/console/.env.development` ships it **empty**, which means same origin — the -Vite dev server proxies `/api/*` to the backend. See [Running with a Real Backend](#running-with-a-real-backend). - -**2. Server-pushed runtime config — everything else.** Before React mounts, the console -resolves `/api/v1/runtime/config` from that server and applies it: product branding, feature -flags (marketplace, AI Studio, SSO, custom domain, the storage-usage reading), the cloud URL, and a -host's request to draw no app switcher for a viewer served one app (`hideSingleAppSwitcher`). Operators configure -these on the **server**, not in the SPA, which is why changing them needs no console rebuild. - -Apps, objects and views themselves are metadata fetched over HTTP — discovered at connect -time and loaded on demand. To change what the console shows, change the metadata on the -server: author it in the ObjectStack server project (`objectstack.config.ts` lives **there**, -not here) or edit and publish it from Studio. See -[ObjectOS Integration](/docs/guide/objectos-integration) for the server-side configuration -shape. - -## Running with a Real Backend - -`VITE_SERVER_URL` is the setting that decides which backend the console talks to — the data adapter, auth, i18n and action endpoints all hang off it. - -1. In dev, leave `VITE_SERVER_URL` empty and point the dev proxy at your server instead. - Only `/api/*` is proxied, to `DEV_PROXY_TARGET` when set and `http://localhost:3000` - otherwise: - ```bash - DEV_PROXY_TARGET=https://demo.objectstack.ai pnpm dev - ``` - This keeps the page and the API on one origin. Setting `VITE_SERVER_URL` to an absolute origin still works, but it opts dev out of same-origin and into CORS — an absolute `VITE_SERVER_URL` is the right setting for a *built* console deployed apart from its backend. -2. The console will use the ObjectStack client to discover metadata and perform CRUD operations against the server. - -## Where the Code Lives - -Most of what you see in the console does not live in `apps/console`. The shell and layout, sidebar, header, command palette, object list and record detail views all ship from **`packages/app-shell`** (`@object-ui/app-shell`), so any host application can mount the same experience; the heavier view surfaces (grid, kanban, calendar, charts, designer) come from the `@object-ui/plugin-*` packages. - -`apps/console` is the assembly layer on top: it owns the route tree, registers the plugin set, wires the backend connection, and adds the surfaces specific to this app (auth pages, the docs portal, system and settings pages). So when you want to change something you *see* in the console, look in `packages/app-shell` first. - -## See Also - -- [Console Architecture](/docs/guide/console-architecture) — data flow, routing, and plugin integration -- [Schema Overview](/docs/guide/schema-overview) — the JSON protocol that drives the console -- [Data Source](/docs/guide/data-source) — how the adapter fetches and caches data diff --git a/ui/content/docs/guide/dashboard-filters.md b/ui/content/docs/guide/dashboard-filters.md deleted file mode 100644 index db0f06650fb..00000000000 --- a/ui/content/docs/guide/dashboard-filters.md +++ /dev/null @@ -1,359 +0,0 @@ ---- -title: "Dashboard-Level Filters" ---- - -A dashboard often needs one top-level filter — a date range, a region select — -that drives **several charts at once**. ObjectUI models this as a -**dashboard-level parameter**, not a shared dataset: - -- The **filter control and its value live on the dashboard** — hosted as - dashboard-level variables (the page/dashboard variables primitive). -- Each widget declares which of **its own** fields a filter binds to via - `filterBindings` — a small mapping, not a copied query. -- At render time the dashboard **broadcasts** the active values into every - bound widget's own query, `AND`-combined with the widget's own `filter`. - -Charts stay inline and self-contained; one place owns the filter; each chart -edit stays local. - -> **Working examples**: the schema catalog ships a -> `plugin-dashboard/filtered-dashboard` example plus variants for dynamic -> options, text/number/lookup filter types, widgets over two datasets, the -> `targetWidgets` allow-list, and date presets with a custom range. Every -> widget in them binds a `dataset`, exactly as the tutorial below does. They -> are **presentation** examples — the filter declarations are what they teach. -> The docs gallery draws them with no application behind it by answering each -> dataset query with placeholder rows that no filter reaches, so the numbers -> on the page are not real. - -## Tutorial: from zero to a filtered dashboard - -### Step 1 — a plain dashboard - -Start from two charts over **different** datasets. Without filters they always -show everything: - -```json -{ - "type": "dashboard", - "columns": 2, - "widgets": [ - { - "id": "invoices_by_status", - "title": "Invoices by Status", - "type": "bar", - "dataset": "invoices", - "dimensions": ["status"], - "values": ["count"] - }, - { - "id": "accounts_signed", - "title": "Accounts Signed", - "type": "line", - "dataset": "accounts", - "dimensions": ["signed_month"], - "values": ["count"] - } - ] -} -``` - -#### Where a widget's data comes from - -A widget binds a semantic-layer **dataset** (ADR-0021) — `"dataset": "invoices"` -— and selects its `dimensions` and `values` from it by name. It never carries -rows. Filters scope that dataset query: the dashboard merges the scoped filter -into it as `runtimeFilter`. - -> **Not an authoring surface: inline widget data.** `options.data` (whether an -> array of rows or a `{ "provider": "object", … }` query), `options.xField` / -> `options.yField`, a metric's `options.value` / `options.description` / -> `options.trend`, and a `component` chart's `chartType` / `xAxisKey` / -> `series` are not dashboard widget keys. `@object-ui/types`' strict authoring -> face (`StrictAnyComponentSchema`) refuses each of them by name, and -> `@objectstack/spec` requires `dataset` on every widget. The renderer still -> draws a stored widget that carries them, but that path is renderer-internal -> (ADR-0021), not something to author against: bind the widget to a dataset. -> One stored form no longer draws at all: a single-value widget (`metric`, -> `gauge`, `solid-gauge`, `kpi`, `bullet`, or a widget with no `type`) whose -> `options.data` is a `{ "provider": "object", … }` query shows the -> retired-format prompt below instead of its number (objectui#11525), as a -> `pivot` widget with that query already did. And one key no longer draws on -> any widget: `options.description`, the metric sub-caption, is retired at both -> ends (objectui#11389), so a stored one draws nothing. A widget's one authored -> description is `widget.description`, the card-header subtitle. - -> **Retired: the top-level inline analytics shape.** `object` + -> `categoryField` / `valueField` / `aggregate` on the widget itself (and the -> pivot `rowField` / `columnField` pair) was **removed** — the renderer no -> longer reads those keys, and a stored widget still carrying them renders a -> visible *"This widget uses a retired data format. Edit it to bind a dataset."* -> prompt instead of a chart. Rebind such a widget to a `dataset` (select its -> `dimensions` and `values` by name). `@objectstack/spec` refuses the retired -> shape at publish, so this is not a soft deprecation. - -### Step 2 — add the built-in date range - -Declare `dateRange` at the dashboard level. A preset/custom date-range control -appears in the filter bar above the widgets: - -```json -{ - "dateRange": { - "field": "created_at", - "defaultRange": "last_30_days", - "allowCustomRange": true - } -} -``` - -- `field` — the **default** field the range applies to on every bound widget - (falls back to `created_at` when omitted). -- `defaultRange` — the initially selected preset: `today`, `yesterday`, - `this_week`, `last_week`, `this_month`, `last_month`, `this_quarter`, - `last_quarter`, `this_year`, `last_year`, `last_7_days`, `last_30_days`, - `last_90_days`, or `custom` (starts empty and lets the user pick). When - omitted, the default `@objectstack/spec` declares for this key applies - (`this_month`), so a bare `dateRange: { "field": "created_at" }` opens - filtered to the current month, not unfiltered. -- `allowCustomRange` — offer a "Custom…" item that opens a from/to calendar - (default `true`). - -`dateRange` is the `@objectstack/spec` `DashboardSchema.dateRange` shape, taken -by reference on both the validator and the TypeScript type: a preset name -outside the list above, or any other key inside the object (`preset`, `range`, -`dateField`, …), is refused at validation rather than silently ignored. - -Presets stay **symbolic** until query time: they compile to date-macro tokens -(`{30_days_ago}`, `{current_month_start}`, …) that each widget resolves -exactly like hand-authored widget filters — so a dashboard saved today still -means "last 30 days" tomorrow. - -### Step 3 — add a global filter - -Add a `globalFilters` entry. Each entry renders one control in the filter bar: - -```json -{ - "globalFilters": [ - { - "name": "region", - "field": "region", - "label": "Region", - "type": "select", - "options": [ - { "value": "EMEA", "label": "EMEA" }, - { "value": "APAC", "label": "APAC" }, - { "value": "AMER", "label": "AMER" } - ] - } - ] -} -``` - -- `name` — the **stable filter name**: the variable key the value is published - under, and the key widgets reference in `filterBindings`. Defaults to - `field`. (`"dateRange"` is reserved for the built-in date range.) -- `field` — the default field the filter applies to on bound widgets. -- `object` — optional: the object `field` lives on. Declaring it opts the - filter into that object's translations: its field label and option labels - resolve through the same `fields..` translation-bundle - convention lists and forms use, with `label` as the fallback (see - [i18n](#i18n)). Not the same key as `optionsFrom.object` below, which names - the object dynamic options are fetched from. -- `type` — the control type: `text`, `number`, `select`, `lookup`, or `date`. - -| Type | Control | Generated condition | -| --- | --- | --- | -| `text` | input | `{ field: { "$contains": value } }` | -| `number` | numeric input | `{ field: value }` (equality) | -| `select` / `lookup` | dropdown | `{ field: value }` (or `$in` for arrays) | -| `date` | preset/custom range | `{ field: { "$gte": from, "$lte": to } }` | - -A `date` filter's `defaultValue` is a **string**, and exactly three spellings -are accepted: - -- a **preset name** from the `defaultRange` list above (`"last_7_days"`) — it - is lifted to that preset's range, the same as picking it in the control; -- an **ISO date** (`"2026-01-15"`) — equality on that day; -- a **date-macro token** (`"{today}"`, `"{7_days_ago}"`) — resolved at query - time like any other filter token. - -Anything else — a misspelled preset such as `"last_7_dayz"` — is **skipped**, -and the runtime logs a `console.warn` naming the filter and the value. It is -deliberately not compared as-is: `field = "last_7_dayz"` matches no row, and -the widget would render a perfectly healthy-looking `0`. - -Static `options` are `@objectstack/spec` object pairs — -`{ "value": "amer", "label": "AMER" }`, where `label` is a string or an inline -per-locale map (`{ "en": "AMER", "zh-CN": "美洲" }`). This is the only form the -platform accepts: a dashboard is validated against `GlobalFilterSchema` when it is -published, and anything else is refused there. objectui's own validator -(`@object-ui/types`, which `objectui validate` runs) uses the same spec schema, so -it refuses the same documents before they reach the platform. - -> **Not accepted: the bare-string shorthand.** `"options": ["EMEA", "APAC"]` is -> refused by `GlobalFilterSchema` at publish, and the runtime no longer lifts it -> either ([objectui#4356](https://github.com/objectstack-ai/objectui/issues/4356)): -> a bare-string member yields no option, so a stored dashboard authored this way -> renders that filter with an empty option list, and in development a single -> `console.warn` names the filter and the dropped members. Rewrite each string -> `X` as `{ "value": "X", "label": "X" }`. - -Options can also be fetched from an object at runtime: - - -```json -{ - "name": "industry", - "field": "industry", - "label": "Industry", - "type": "select", - "optionsFrom": { - "object": "accounts", - "valueField": "industry", - "labelField": "industry" - } -} -``` - -`object`, `valueField` and `labelField` are all required; when the options have -no separate label column, name the value field again, as above. An optional -`filter` narrows the source records and is a filter-condition object -(`{ "status": "active" }`), not an array. - -With a dataset-capable data source, `optionsFrom` resolves distinct values -**server-side** (a GROUP BY over the source object), so the option list is -complete regardless of row count. Data sources without dataset queries fall -back to a best-effort client-side dedupe over the first 200 records. - -### Step 4 — bind each widget's own fields - -By default every filter applies to its own `field` on every widget. When a -widget stores the concept under a different field — or should ignore a filter -— declare `filterBindings` on the widget: - -```json -{ - "widgets": [ - { - "id": "invoices_by_status", - "type": "bar", - "dataset": "invoices", - "dimensions": ["status"], - "values": ["count"] - }, - { - "id": "accounts_signed", - "type": "line", - "dataset": "accounts", - "dimensions": ["signed_month"], - "values": ["count"], - "filterBindings": { "dateRange": "signed_at", "region": "sales_region" } - }, - { - "id": "total_invoices", - "title": "Total Invoices (all regions)", - "type": "metric", - "dataset": "invoices", - "values": ["count"], - "filterBindings": { "region": false } - } - ] -} -``` - -Binding rules, in precedence order: - -1. `filterBindings[name]` as a **string** — apply the filter to that field. -2. `filterBindings[name]: false` — opt this widget out of that filter. -3. Legacy `targetWidgets` on the filter — when set, only listed widget ids get - the default binding (an explicit `filterBindings` entry still wins). -4. Otherwise the filter applies to its own `field` (the built-in date range - defaults to `dateRange.field ?? 'created_at'`). - -That's the whole feature: changing any filter live re-scopes every bound -widget, each against **its own** field. Here it is running in the showcase -app's *Revenue Pulse* dashboard — the date range's default field is the -invoice `issued_on`, account widgets re-map it to `signed_on`, and the -"Accounts (all time)" KPI opts out of both filters: - -![Revenue Pulse — dashboard-level date + region filters over two objects](/img/guide/dashboard-filters/revenue-pulse.png) - -Selecting **EMEA** re-scopes every bound widget live (invoices via their own -`region`, accounts via `sales_region`), while the opted-out KPI holds steady — -and a Reset button appears once any filter deviates from its default: - -![Revenue Pulse re-scoped to EMEA — bound widgets update, the opted-out KPI holds](/img/guide/dashboard-filters/revenue-pulse-emea.png) - -Bindings can also be edited visually: the Studio dashboard widget inspector -shows a **Dashboard filter bindings** section (one row per declared filter) -with an Apply toggle (opt-out) and a field picker for the override — no JSON -editing required. - -![The widget inspector's Dashboard filter bindings section](/img/guide/dashboard-filters/filter-bindings-inspector.png) - -## Reading filter values in expressions - -Filter values are hosted as dashboard variables, so any widget expression can -read them under the `page.` scope, keyed by the filter's `name`: - -```json -{ - "type": "text", - "content": "Region: ${page.region || 'All'}" -} -``` - -The built-in date range is an object under `page.dateRange` — a preset selection -is `{ "preset": "last_30_days" }`, a custom range is -`{ "from": "2026-01-01", "to": "2026-03-31" }` (either bound may be absent). - -## Dataset widgets - -Every widget is bound to a semantic-layer `dataset`, and the dashboard scopes -each one the same way: it merges the scoped filter into the widget's `filter`, -which the dataset widget forwards to the dataset query as `runtimeFilter`. -Widgets over different datasets mix freely on one filtered dashboard — the -`plugin-dashboard/filtered-dashboard-dataset-widgets` catalog entry binds two -widgets to a `sales` dataset beside one bound to `invoices`, and each is scoped -through its own query. - -## Nested variable scopes - -When a filtered dashboard is embedded inside a Page that declares its own -`variables`, the two scopes **merge**: inside the dashboard subtree, `page.*` -resolves the outer Page's variables plus the dashboard's filter values, and a -dashboard filter only shadows an outer variable that has the **same name**. -Writes route to the scope that defines the variable — setting an outer-page -variable from inside the dashboard updates the outer scope, so both subtrees -stay in sync. - -## Known limitations - -- **A binding is applied as written** — the dashboard does not know a - dataset's fields, so it cannot check a binding target for you. A default - binding whose field the widget's data does not have produces an empty - widget rather than a silent no-op, which is the visible, fixable failure: - map the filter explicitly with `filterBindings: { "": "" }`, or - opt out with `false`. (`buildWidgetScopedFilter` can skip an unknown default - field with a console warning when a host passes it the widget's known field - names; the dashboard renderer does not.) - -## i18n - -The filter bar's strings resolve from the `dashboard.filters.*` keys -(`@object-ui/i18n` ships `en` and `zh` entries). A control's label comes from -its filter's `label`, so translate that in your schema metadata — unless the -filter declares `object`. Then the app's translation bundle wins: the field -label resolves from the `fields..` entry and each option label -from `fieldOptions...`, the same convention lists and -forms use, and the authored `label` (the option's own `label`, for an option) -is only the fallback when the bundle has no entry. - -## Spec alignment - -`DashboardSchema.dateRange`, `GlobalFilterSchema` (including `name`) and -`DashboardWidgetSchema.filterBindings` are part of `@objectstack/spec` -(framework#2501). Author dashboards against the spec shapes; ObjectUI renders -them. diff --git a/ui/content/docs/guide/data-source.md b/ui/content/docs/guide/data-source.md deleted file mode 100644 index a80d57fae4f..00000000000 --- a/ui/content/docs/guide/data-source.md +++ /dev/null @@ -1,623 +0,0 @@ ---- -title: "Data Connectivity" ---- - -ObjectUI follows the **Universal Adapter Pattern**. UI components do not hardcode transport details. They receive a `DataSource` implementation from `SchemaRendererProvider` and call a stable CRUD/query contract. - -This keeps the renderer backend-agnostic: ObjectStack, REST, GraphQL, and proprietary backends can all be adapted behind the same interface. - -## The Interface - -The canonical interface lives in `@object-ui/types`: - -```typescript -import type { BatchTransactionOperation, QueryParams, QueryResult } from '@object-ui/types'; - -export interface DataSource { - find(resource: string, params?: QueryParams): Promise>; - findOne(resource: string, id: string, params?: QueryParams): Promise; - create(resource: string, data: Partial): Promise; - update( - resource: string, - id: string, - data: Partial, - opts?: { ifMatch?: string }, - ): Promise; - delete( - resource: string, - id: string, - opts?: { ifMatch?: string }, - ): Promise; - - // Optional: atomically persist an ordered set of cross-object operations - // (master-detail save). `{ $ref: }` links a child to a parent - // created earlier in the same batch. Adapters without server-side atomicity - // may emulate it — see below. - batchTransaction?( - operations: BatchTransactionOperation[], - ): Promise<{ results: any[] }>; - - getObjectSchema(objectName: string): Promise; -} -``` - -`find()` returns a `QueryResult` so components can receive both rows and pagination metadata: - -```typescript -interface QueryResult { - data: T[]; - total?: number; - page?: number; - pageSize?: number; - hasMore?: boolean; - cursor?: string; - metadata?: Record; -} -``` - -## Available Adapters - -### ObjectStack Adapter (Official) - -Use `@object-ui/data-objectstack` for ObjectStack-compatible backends. - -```bash -pnpm add @object-ui/data-objectstack -``` - -```typescript -import { createObjectStackAdapter } from '@object-ui/data-objectstack'; - -const dataSource = createObjectStackAdapter({ - baseUrl: 'https://api.your-instance.com' -}); -``` - -## Usage - -Inject the data source at the renderer boundary: - -```tsx -import '@object-ui/components'; -import '@object-ui/fields'; -import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; -import { createObjectStackAdapter } from '@object-ui/data-objectstack'; -import type { DeclaredNode } from '@object-ui/types'; - -const dataSource = createObjectStackAdapter({ - baseUrl: 'https://api.example.com' -}); - -// `object-grid` is a data-bound node: it reads `objectName` from its -// `properties` bag and queries the injected data source for the rows. -const mySchema: DeclaredNode = { type: 'object-grid', properties: { objectName: 'users' } }; - -function App() { - return ( - - - - ); -} -``` - -## Creating a Custom Adapter - -If you have a proprietary backend, wrap its SDK or client in a `DataSource` implementation. Keep transport details in the adapter, not in renderers. - -```typescript -import type { DataSource, QueryParams, QueryResult } from '@object-ui/types'; - -type User = { - id: string; - name: string; - email: string; -}; - -type BackendClient = { - listUsers(params?: QueryParams): Promise<{ rows: User[]; total?: number }>; - getUser(id: string | number): Promise; - createUser(data: Partial): Promise; - updateUser(id: string | number, data: Partial): Promise; - deleteUser(id: string | number): Promise; - describeObject(name: string): Promise; -}; - -class UserDataSource implements DataSource { - constructor(private readonly client: BackendClient) {} - - async find(resource: string, params?: QueryParams): Promise> { - if (resource !== 'users') { - return { data: [], total: 0 }; - } - - const result = await this.client.listUsers(params); - return { - data: result.rows, - total: result.total, - }; - } - - findOne(_resource: string, id: string | number): Promise { - return this.client.getUser(id); - } - - create(_resource: string, data: Partial): Promise { - return this.client.createUser(data); - } - - update(_resource: string, id: string | number, data: Partial): Promise { - return this.client.updateUser(id, data); - } - - delete(_resource: string, id: string | number): Promise { - return this.client.deleteUser(id); - } - - getObjectSchema(objectName: string): Promise { - return this.client.describeObject(objectName); - } -} -``` - -## Query Parameters - -ObjectUI uses OData-style query keys for broad compatibility: - - -```typescript -await dataSource.find('users', { - $select: ['id', 'name', 'email'], - $filter: { status: 'active' }, - $orderby: { name: 'asc' }, - $skip: 0, - $top: 25, - $count: true, -}); -``` - -Data-aware plugins may also use optional methods such as `batchTransaction`, `bulkUpdate`, `bulkDelete`, `getView`, or `listViewOverrides` when an adapter supports them. Keep the required CRUD methods implemented first, then add optional capabilities as your UI needs them. - -### Bulk writes: per-record results and one refresh per run - -`bulkUpdate` and `bulkDelete` resolve to either a plain count or a `BulkWriteResult`: the count plus one `{ id, error }` entry per record the server refused, with the server's reason. An adapter whose server reports per-row outcomes should return the `BulkWriteResult` (the `@object-ui/data-objectstack` adapter does, from the `results[].errors[]` of `updateMany` / `deleteMany`). An adapter that has only a count keeps returning the number. A caller handles both: - -```typescript -import type { DataSource } from '@object-ui/types'; - -declare const dataSource: DataSource; - -const res = await dataSource.bulkUpdate?.('order', ['o1', 'o2', 'o3'], { status: 'closed' }); -if (typeof res === 'number') { - console.log(`${res} updated`); -} else if (res) { - console.log(`${res.succeeded} updated`); - for (const { id, error } of res.errors) console.warn(`${id}: ${error}`); -} -``` - -The grid's bulk actions read both shapes. A refused record shows up in the result dialog and the error CSV under its own id and reason, and **Retry** works on it, whatever the action's `batchSize`. - -Every `create` / `update` / `delete` normally fires one `onMutation` event, and every bound view refetches once per event. A loop that writes many rows (a bulk action on its per-row path, an undo) would refetch the list once per row. Wrap such a loop in the optional `batchMutationEvents(work)`: the events its writes fire are held and delivered when `work` settles, one per touched resource and mutation type, so bound views refresh once. The grid's bulk executor does this when the adapter has the method. - -```typescript -import type { DataSource } from '@object-ui/types'; - -declare const dataSource: DataSource; - -const ids = ['o1', 'o2', 'o3']; -const write = async () => { - for (const id of ids) await dataSource.update('order', id, { status: 'closed' }); -}; -await (dataSource.batchMutationEvents ? dataSource.batchMutationEvents(write) : write()); -``` - -`listMetadataItems(type)` lists one metadata registry type as `{ name, label }` headers, for the `position-ref` / `permission-ref` field widgets (see the [Fields guide](./fields.md)). An empty array means the registry has no item of that type, so a failed load must **reject**, never resolve `[]`. - -### Cross-object atomic writes (`batchTransaction`) - -Master-detail saves (a parent record plus its child line items) go through -`dataSource.batchTransaction(operations)` — one ordered list of cross-object -create/update/delete operations, where a child's foreign key can be -`{ $ref: }` to point at a parent created in the same batch. -The `@object-ui/data-objectstack` adapter maps this to the published -`@objectstack/client` `data.batchTransaction` SDK method, which drives the -server's atomic `POST /api/v1/batch` endpoint (commit-all-or-roll-back-all). -Adapters without a -transactional endpoint don't need to hand-write orchestration: call -`emulateBatchTransaction(dataSource, operations)` from `@object-ui/core`, which -executes the operations sequentially (resolving `$ref`s) with best-effort -compensation on failure. UI components never branch on atomicity — they call -`runBatchTransaction(dataSource, operations)` (also from `@object-ui/core`), -which uses the adapter's method when present and emulates otherwise. - -The `@object-ui/data-objectstack` adapter decides whether it can trust server -atomicity **declaratively**, at connect time: it reads the -`capabilities.transactionalBatch` flag from `GET /api/v1/discovery` -(framework #3298). When the backend advertises `true`, the adapter treats any -`/batch` failure as a real error — no non-atomic client-side compensation. When -the flag is `false` or absent (a backend predating #3298), it keeps the legacy -behaviour: probe `/batch` and fall back to the non-atomic emulation on -`404`/`405`/`501`. Atomic cross-object saves are therefore guaranteed only -against backends that advertise the capability; older ones still save, but -best-effort. See the -[adapter README](https://github.com/objectstack-ai/objectui/blob/main/packages/data-objectstack/README.md#cross-object-atomic-batch-batchtransaction) -for the full capability table and minimum-backend note. - -## Per-element data binding on a page (`dataSource`) - -A metadata page component carries its own data binding — -`PageComponentSchema.dataSource`, the spec's `ElementDataSourceSchema` — so one -page can show several objects without a page-level object context: - -```json -{ - "type": "list-view", - "dataSource": { "object": "account", "view": "hot", "limit": 10 } -} -``` - -This is metadata, **not** the data-source adapter. The two share a name and are -different things: the adapter is injected by the host (`SchemaRendererProvider`), -while `dataSource` on a schema node is JSON describing *what to query*. A -renderer therefore reads the binding off `schema.dataSource` and gets its adapter -from context — never from a prop the schema could occupy. `SchemaRenderer` strips -the binding from the props it spreads for exactly this reason. - -`view` names a **saved view** of that object; its columns, filter, sort and page -size are applied to the render, so a page never has to keep a second copy of a -view's configuration. `filter` is *additional* criteria — it AND-combines with the -view's filter rather than replacing it — while `sort` and `limit` override the -view's. Only a usable `limit` overrides: a binding cap the contract refuses (`0`, -a negative, a non-integer) is treated as not authored and yields to the view's -cap exactly as an absent one would, and the view's cap is held to the same rule. A `view` name that does not resolve is reported as a configuration error; -it never degrades into an unfiltered query for the object. - -`@object-ui/react` exposes `useElementDataSource(schema, dataSource?)` for -renderers that need the same resolution, and `@object-ui/core` exposes the pure -parts (`isElementDataSourceConfig`, `resolveSavedView`, -`composeElementDataSource`). - -### Which blocks consume it, and which keys each one honours - -The binding is declared on every page component, but a component can only honour -the keys it has a read site for — a calendar has no page to cap, a metric is one -aggregated number, a form edits one record. Each block therefore maps the keys it -reads and leaves the rest alone; a key written onto a schema slot the block -ignores would be accepted and dropped, which is the defect this binding removes. - -| block | `object` | `view` | `filter` | `sort` | `limit` | -|---|---|---|---|---|---| -| `list-view` | ✅ (`data`) | ✅ | ✅ | ✅ | ✅ | -| `object-grid` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `element:record_picker` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `element:number` | ✅ | filter | ✅ | — single value | — single value | -| `element:repeater` | ✅ | filter / sort / limit | ✅ | ✅ | ✅ | -| `record:related_list` | ✅ | columns / filter / sort / limit | ✅ | ✅ | ✅ | -| `object-calendar` | ✅ | filter / sort | ✅ | ✅ | — platform ceiling | -| `object-kanban` | ✅ | filter / limit | ✅ | — no ordering | ✅ (`limit`) | -| `object-chart` | ✅ | filter | ✅ | — engine orders | — no page | -| `object-metric` | ✅ | filter | ✅ | — single value | — single value | -| `object-gantt` | ✅ | filter / sort | ✅ | ✅ | — platform ceiling | -| `object-map` | ✅ | filter / sort | ✅ | ✅ | — platform ceiling | -| `object-pivot` | ✅ | filter | ✅ | — grouping orders | — totals need all rows | -| `object-timeline` | ✅ | filter / sort / limit | ✅ | ✅ | ✅ (`limit`) | -| `object-form` | ✅ | error-checked only | — no collection query | — | — | -| `embeddable-form` | ✅ | error-checked only | — no collection query | — | — | -| `object-master-detail-form` | ✅ | error-checked only | — no collection query | — | — | -| `record:line_items` | ✅ (`childObject`) | filter / sort / limit | ✅ (AND parent scope) | ✅ | ✅ (`limit`) | -| `record:details` | ✅ (its record) | filter / sort | ✅ | ✅ (picks the first row) | — one row is read | -| `record:highlights` | ✅ (its record) | filter / sort | ✅ | ✅ (picks the first row) | — one row is read | -| `record:quick_actions` | ✅ (its record) | filter / sort | ✅ | ✅ (picks the first row) | — one row is read | - -Reading the `view` column: it lists what a named saved view actually contributes -on that block. A view name that does not resolve is reported as a configuration -error on **every** block in the table, including the ones that take nothing else -from the view — so a typo never passes silently, whatever the block. - -Reading the `object` column: on a block with an object key it lands on that key, -which is `objectName` everywhere except `list-view` and `record:line_items`. A -`list-view` node names its object in `data` (`{ "provider": "object", "object": … }`), -the spec's list view spelling, and a bound `dataSource.object` outranks -`data.object`; the node's own `objectName` is not read. On `record:line_items` the collection the panel -lists, fetches and writes is `childObject`. Its `relationshipField` is *not* part -of the binding and stays the author's — it has to name a field on the bound child -object, so rebinding `object` without updating it is an authoring error the panel -cannot paper over. A `record:line_items` node bound this way needs no `childObject` -of its own, and the page compile accepts it; a node that names its child object in -neither place shows a configuration hint naming `childObject` and loads nothing. - -Two kinds of block have no object key for the binding to land on. The -`element:*` blocks read `dataSource.object` themselves (see below). The three -one-record blocks, whose rows read "its record", take the binding's `object` as -the object of the record they show, and the bound row becomes that record. - -`record:details`, `record:highlights` and `record:quick_actions` show the -record their record context carries. Bound, a block reads the FIRST row of -the bound set by the binding's `sort`, under the caller's own identity, and that -row becomes its record, so a product page shows a record without being a -`type: 'record'` page: - -```json -{ - "type": "record:details", - "properties": { "fields": ["name", "environment_type"] }, - "dataSource": { - "object": "sys_environment", - "filter": [{ "field": "environment_type", "operator": "equals", "value": "production" }], - "limit": 1 - } -} -``` - -When no row matches — or the server refuses the read — the block draws its -authored `emptyState` in its place, when its `properties` declare one. That is -the spec's empty-state shape, the one `object-grid` takes: `{ title, message, -icon, link }`, with `title` and `message` as plain strings or inline locale maps -and `link` as `{ label, url }`. A block that declares none draws what it draws -for a record that is not there: `record:details` its "record not found" state, -`record:highlights` nothing, `record:quick_actions` an empty bar. The empty state -is never drawn while the read loads, on a block with no `dataSource`, or for a -read that fails any other way, which is reported in the block's place. `record:quick_actions` -runs the object's declared actions against the bound row's id. The bound row is -re-read whenever a write to its object is announced on the page (the data -source's mutation events, or `notifyDataChanged`). `objectui validate` warns on a -record block whose binding has no `limit: 1`, so the binding says which record it -means. On a record page, a block with no `dataSource` still shows the page's -record; a bound one shows its bound record instead, read-only. - -Because the binding lands on `objectName` (on `list-view`, it stands in for -`data`), a node bound this way needs no `objectName` (or `data`) of its own. The schema validator (`StrictAnyComponentSchema`, which -`objectui validate` runs, and the tolerant `safeValidateSchema` alike) counts the binding as a record source of `list-view`, -`object-grid`, `object-kanban`, `object-calendar`, `object-gantt` and `object-map`, -and still refuses one of those nodes that declares no other record source and names -its object in neither place. A binding with an empty `object` names nothing. - -The page compile accepts such a node too, on every row above whose object lands on -`objectName`, and on `list-view`: those registrations do not declare `objectName` -(on `list-view`, `data`) required, because the binding can supply it. The exception is `record:related_list`, whose spec row -requires `objectName`, so the page compile still refuses one of those nodes without -it. A node that names its object in neither place renders a short hint, "No object -named: set objectName or dataSource.object.", in place of an empty list, board, -form, chart, metric or pivot. On `list-view` the hint names the key a page node -writes: "No object named: set data.object or dataSource.object.". `object-grid` -shows its own "Object name required for data fetching" error instead. A block with another record source (inline `data` -rows, a form's inline fields — `customFields`, or `sections` whose every field is -inline — a chart's `dataset`, a `bind` path, or a metric's `fallbackValue`) draws -from that source and shows no hint. - -`element:record_picker` and `element:number` keep their display configuration -in the node's `properties` bag and take their query from the binding **only**: -every query key either one reads comes from `dataSource` and from nowhere else. -The flat `properties.object`, `properties.filter`, `properties.sort` and -`properties.limit` on these two elements are not read, and `@objectstack/spec` -retires them (objectstack#11509). `element:number` no longer offers its flat -`object` or `filter` (objectui#12085), so the page validator reports either one -as a prop the metric does not have. The binding's filter is AND-combined with its -view's filter, as on every block above. On `element:number`, -`{ "dataSource": { "object": "contact" }, "properties": { "aggregate": "count" } }` -is a complete metric; its `sort` and `limit` are not read, because an aggregate -has no ordering and a capped count would be a wrong number, and a filter the -converter refuses shows the configuration-error panel instead of a count. -Either of the two whose node names no `dataSource.object` issues no query: an -`element:number` that sets `aggregate` shows a short "No object named: set -dataSource.object." notice instead of a count, and an `element:record_picker` -offers no records. The Studio page designer writes the metric's object into -`dataSource`. - -`element:repeater` reads its query from the binding alone. `@objectstack/spec` -18 retired the flat `properties.object`, `properties.filter`, `properties.sort` -and `properties.limit` (objectstack#11509), and the repeater reads none of them -(objectui#12345); the spec's conversion moves them into `dataSource`. A node -that still carries one is refused by the spec's row and by the -`@object-ui/types` mirror, and the page validator reports it as a prop the -repeater does not have. The repeater requires no prop (objectui#12085): a node -whose `dataSource` names no object issues no query and shows its empty state. -With a named saved view, the binding's own `sort` and `limit` win and the -view's are the baseline, and the view's filter is AND-combined with the -binding's. The Studio page designer writes the repeater's object and its row -limit into `dataSource`, as it does the metric's object. - -```json -{ - "type": "element:repeater", - "dataSource": { - "object": "task", - "filter": [{ "field": "status", "operator": "equals", "value": "open" }], - "sort": [{ "field": "due_date", "order": "asc" }], - "limit": 5 - }, - "properties": { "titleField": "subject", "fields": ["due_date"] } -} -``` - -#### A related list's parent record: `parentDataSource` - -`record:related_list` reads two bindings. Its `dataSource` is the CHILD rows the -list draws (its row in the table above). Its parent record — the record the -list hangs off — is the record page's record, or, when the block's -`properties` carry `parentDataSource`, the record that binding reads. The key -is a member of the spec's `record:related_list` row and takes the same object -binding `dataSource` takes. It is read the way the three one-record blocks -above read their record: the FIRST row of the binding by its `sort`, under the -caller's own identity, re-read whenever a write to its object is announced. -The list then draws that record's children through `relationshipField`, and -`relationshipValueField` is read on that row. So a product page lists a -record's children without being a `type: 'record'` page: - -```json -{ - "type": "record:related_list", - "properties": { - "objectName": "sys_member", - "relationshipField": "environment_id", - "parentDataSource": { - "object": "sys_environment", - "filter": [{ "field": "environment_type", "operator": "equals", "value": "production" }], - "limit": 1 - } - } -} -``` - -When no row matches — or the server refuses the read — the block says so in its -place, naming the object, and draws no list: an empty list would read as "this -record has no related records". A read that fails any other way is reported in -the block's place. `objectui validate` warns on a `parentDataSource` without -`limit: 1`, and refuses the key written on the node instead of in `properties`. -On a record page, a related list without `parentDataSource` lists the page -record's children, as before; with it, it lists the bound record's children. - -#### Scoping a filter to the record in view: `{record_id}` - -On a `type: 'record'` page, a filter value can name the record the page shows -with `{record_id}`, in a component's own filter and in its `dataSource.filter` -alike. On a person's record page this counts that person's open tasks, and the -next person's page counts theirs: - -```json -{ - "type": "element:number", - "dataSource": { - "object": "task", - "filter": [{ "field": "assignee", "operator": "equals", "value": "{record_id}" }] - }, - "properties": { "aggregate": "count" } -} -``` - -The id is the page's mounted record context, never a URL parameter or a page -variable. Anywhere with no record in context (a list view, a dashboard, a report, -a page that is not a record page) the token is refused by name: the renderer -warns, the value is left as written rather than dropped or blanked, and the -ObjectStack server refuses the query (`FILTER_TOKEN_UNRESOLVED`), so the number -never silently becomes a count over every record. `{record_id}` scopes what a -component *shows*; it is not access control, which stays with the server's -row-level security (objectui#7297). - -On `record:related_list` and `record:line_items` the composed filter is -AND-combined with the parent relationship condition, never substituted for it: a -child panel is always scoped to the record it appears on, and an *additional* -criterion can only narrow that set further. (Until objectstack#7118 -`record:related_list` declared `filter` without reading it, so a named view -contributed its columns / sort / limit while its filter was dropped — the list -could be wider than the view it named. That gap is closed; the `filter` cell above -is what closed it.) - -`object-timeline` and `record:line_items` were the two residual gaps in this table -until objectstack#7137. Neither had a `filter` / `sort` read site at all — the -timeline's whole fetch was `find(objectName, { options: { $top: 100 } })` and the -line-items panel's was the parent FK plus a fixed `$top: 500` — so a `view` named -on either resolved (a typo reported) and then contributed nothing: the rendered -rows could be **wider than the view they named**, with no error anywhere. Both now -read `filter`, `sort` and `limit`, so the cells above are ✅. Two notes on what -came with that: - -- The timeline's default window is `limit ?? 100` and it is now a real `$top`. - The old `{ options: { $top: 100 } }` nested the cap under a key that is not a - `QueryParams` field and that no adapter in this repo reads, so the intended cap - never reached the wire; a timeline over a large object fetched whatever the - server chose to return. Authoring `limit` (or a view's `pagination.pageSize`) - now sets it. -- `record:line_items` still does **not** take a view's `columns`: they are editable - `GridColumn` objects (`{ field, type, … }`) rather than a field-name projection, - so a view's column list would be the wrong *shape*, not merely a wider answer. - -`object-kanban` carried the same nesting, and until objectui#4025 this table read -`— fixed window` in its `limit` cell. There was no window: the board's cap was -written `{ options: { $top: 100 } }` — `$filter` at the top level where the -adapters read it, the cap one level down under a key that is not a `QueryParams` -field — so a board over a large object fetched every row the server would return -and grouped all of it into lanes, client-side. The cap is now a real `$top`, -defaulting to 100, and the `limit` cell is ✅ because the read site exists: a -`limit` authored on the block, the binding's `limit`, or the named view's -`pagination.pageSize` all set it. The board's `sort` cell still reads `— no -ordering` — lanes come from `groupBy` and the fetch declares no `$orderby`, so -mapping `sort` would be the accepted-and-dropped defect one block over. - -Note the two `limit`s on a board are different keys at different levels: the row -cap above is `limit` on the **board**, while `limit` on a **column** is that -lane's WIP limit (how many cards it may hold before it warns), which is display -behaviour and never touches the query. - -Reading `— platform ceiling` on `object-calendar` / `object-gantt` / `object-map` -(and on `object-tree`, which predates this table): those four are the **non-grid** -visualisations, and objectui#7210's maintainer ruling settled what their row -behaviour is. They fetch the whole **filtered** result set — a gantt cannot -compute a truthful `min(start) → max(end)` from one page, a map fits its camera to -every marker, and a tree assembled from a page loses every node whose parent fell -outside it — but the fetch is **bounded** by `NON_GRID_ROW_CEILING` -(`@object-ui/react`, currently 2,000). Past it the view draws the first N rows and -shows a footnote naming both N and the total. Silent truncation is the failure -that ruling exists to prevent: a cut-off schedule still looks like a schedule. - -The `limit` cell stays "not ✅" for all four because the ceiling is **not -authorable and must not become one** — a named constant in the renderer, by the -same ruling. An authored `limit`, a binding's `limit` and a named view's -`pagination.pageSize` all still fail to reach these queries, which is what the -cell has always meant. What changed is only that "no cap at all" is no longer -true. - -Remaining gap, recorded rather than papered over: - -- `object-form` / `embeddable-form` / `object-master-detail-form` resolve `view` - only to report an unresolvable name; a view that does resolve contributes - nothing, because a list view's columns are not a form layout. On the - master-detail form the bound object is the **parent**; child collections come - from `details[]`, by FK. - -Blocks not in the table (`dashboard`, the other `record:*` panels) do not consume -the binding yet. - -## Binding a page element to an API endpoint (`provider: 'api'`) - -`PageComponentSchema.dataSource` has a second shape beside the object binding: -the spec's `ElementApiDataSourceSchema`. It binds one same-origin endpoint -instead of the rows of an object: - -```json -{ - "type": "stat", - "dataSource": { "provider": "api", "read": { "url": "/api/v1/cloud/home" }, "path": "data" }, - "properties": { "label": "Members", "value": "${data.members.count}" } -} -``` - -`SchemaRenderer` honours it on every node: - -- It reads `read.url` with `GET`, through the host's authenticated fetch - (`SchemaRendererProvider`'s `apiFetch`; the console passes its signed-in - fetch), and selects `path` from the answer — dotted keys, a numeric segment - indexing a list. Omit `path` to bind the whole answer. -- The selected value is `data` in the node's `${…}` holes and in its - `visibleWhen`, and in the holes of everything the node renders. A hole that is - the whole string (`"${data.members.count}"`) evaluates to the value itself — a - number, a boolean, a list — rather than to text. -- While the read is in flight the node draws a loading line; a failed read, or - a `path` that selects nothing, draws an error panel naming why. The node is - never drawn without its data. -- The binding is judged by the spec's own schema before anything is read. An - absolute or protocol-relative `url`, a credential key, or a `method` other - than `GET` is refused with the spec's message, and no request is sent. - -Elements that bind the same URL at the same time share one request. A host can -also keep one open's answers: inside `ElementApiOpenScope` (from -`@object-ui/react`), a read that has already answered in the same open is -answered again with no request. The console mounts it and counts one open per -visit of a history entry, so a navigation entry's `visible` and a page element bound to -the same URL make one request, even when the entry's read settled first: - -- following a link, a navigation entry, or Back and Forward starts a new open, - and the page reads again, even on an entry visited before; -- a redirect that replaces the address (the app root's landing on its first - entry) stays in the open; -- a failed read is not kept, and any declared data change (`notifyDataChanged`, - which every save and action reports) drops what the open kept, so a read - after a write asks the server. - -Outside such a scope nothing is kept beyond the request in flight: a page that -mounts again reads again. - -The console's product-page blocks — `hero`, `stat`, `usage-bar`, `checklist` and -`tile-grid` — accept this binding in their arms; see the `@object-ui/app-shell` -README. diff --git a/ui/content/docs/guide/deployment.md b/ui/content/docs/guide/deployment.md deleted file mode 100644 index 238d347b1c5..00000000000 --- a/ui/content/docs/guide/deployment.md +++ /dev/null @@ -1,295 +0,0 @@ ---- -title: Deployment -description: "Deploy ObjectUI applications to Docker, Vercel, Railway, Netlify, and other platforms with production-ready configurations." ---- - -# Deployment - -ObjectUI apps are standard Vite + React applications, so they can be deployed anywhere that serves static files or runs Node.js containers. This guide provides copy-paste-ready configurations for the most popular platforms. - -## Prerequisites - -- A production build: `pnpm build` (runs `turbo run build` across all packages) -- The build output lives in `apps/console/dist/` (or your app's `dist/` folder) -- Environment variables configured for your target environment - -`pnpm build` also writes `apps/console/dist/sdui.manifest.json`, the SDUI component manifest of the console it built. An ObjectStack host serving this `dist` registers that file under `@objectstack/metadata-protocol`'s `SDUI_MANIFEST_SERVICE`, and its metadata save door validates page sources against it. The console package's `build` script writes it after `vite build`, so a caller that runs `vite build` directly gets the bundle without the manifest. - -## Docker - -Create a multi-stage `Dockerfile` at the project root: - -```dockerfile -# Stage 1: Build -FROM node:22-alpine AS builder -RUN corepack enable && corepack prepare pnpm@10 --activate -WORKDIR /app - -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ -COPY packages/ packages/ -COPY apps/ apps/ -RUN pnpm install --frozen-lockfile -RUN pnpm build - -# Stage 2: Serve -FROM nginx:alpine AS runner -COPY --from=builder /app/apps/console/dist /usr/share/nginx/html -COPY <<'EOF' /etc/nginx/conf.d/default.conf -server { - listen 80; - root /usr/share/nginx/html; - index index.html; - - location / { - try_files $uri $uri/ /index.html; - } - - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { - expires 1y; - add_header Cache-Control "public, immutable"; - } - - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml; -} -EOF - -EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] -``` - -Build and run: - -```bash -docker build -t objectui-app . -docker run -p 3000:80 objectui-app -``` - -## Vercel - -Create `vercel.json` in the project root: - -```json -{ - "buildCommand": "pnpm build", - "outputDirectory": "apps/console/dist", - "installCommand": "pnpm install --frozen-lockfile", - "framework": "vite", - "rewrites": [ - { "source": "/(.*)", "destination": "/index.html" } - ], - "headers": [ - { - "source": "/assets/(.*)", - "headers": [ - { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } - ] - } - ] -} -``` - -Deploy with the Vercel CLI: - -```bash -npx vercel --prod -``` - -> **Note:** Set the **Root Directory** to the repository root so the monorepo workspace resolves correctly. - -## Railway - -Create `railway.json` in the project root: - -```json -{ - "$schema": "https://railway.com/railway.schema.json", - "build": { - "builder": "NIXPACKS", - "buildCommand": "corepack enable && pnpm install --frozen-lockfile && pnpm build" - }, - "deploy": { - "startCommand": "npx serve apps/console/dist -s -l tcp://0.0.0.0:$PORT", - "healthcheckPath": "/", - "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 3 - } -} -``` - -Push to your linked Railway project: - -```bash -railway up -``` - -## Netlify - -Create `netlify.toml` in the project root: - -```toml -[build] - command = "pnpm install --frozen-lockfile && pnpm build" - publish = "apps/console/dist" - -[build.environment] - NODE_VERSION = "22" - PNPM_VERSION = "10" - -# SPA fallback — redirect all routes to index.html -[[redirects]] - from = "/*" - to = "/index.html" - status = 200 - -[[headers]] - for = "/assets/*" - [headers.values] - Cache-Control = "public, max-age=31536000, immutable" -``` - -Deploy with the Netlify CLI: - -```bash -npx netlify deploy --prod -``` - -## Environment Variables - -ObjectUI uses Vite's `import.meta.env` for build-time configuration. Prefix all custom variables with `VITE_`. - -| Variable | Default | Description | -|----------|---------|-------------| -| `VITE_SERVER_URL` | `""` (same origin) | Absolute origin of the ObjectStack backend, e.g. `https://demo.objectstack.ai`. The one setting that matters — the data adapter, auth, i18n and action endpoints all hang off it. An empty value means same-origin, which is correct when the ObjectStack server serves the console itself; on a static host with no backend behind it, every `/api/v1/*` request then 404s. | -| `NODE_ENV` | `"development"` | Set automatically to `"production"` by `vite build`. | - -Because Vite inlines `import.meta.env` at **build time**, `VITE_SERVER_URL` has to be present when the build runs. Setting it only in a static host's runtime environment changes nothing — the value is already baked into the bundle. - -Create a `.env.production` file for production defaults: - -```bash -# Leave empty for same-origin; set an absolute origin for a split-origin deploy. -VITE_SERVER_URL=https://demo.objectstack.ai -``` - -For platform-specific configuration, set environment variables in each platform's dashboard or CLI: - -```bash -# Vercel -vercel env add VITE_SERVER_URL production - -# Railway -railway variables set VITE_SERVER_URL=https://demo.objectstack.ai - -# Netlify -netlify env:set VITE_SERVER_URL https://demo.objectstack.ai -``` - -> **Tip:** A split-origin deployment (console and backend on different origins) needs two things from the backend: CORS for the SPA origin (`Access-Control-Allow-Origin: `, `Access-Control-Allow-Credentials: true`), and auth cookies marked `SameSite=None; Secure` so they survive cross-site requests. - -## Build Optimization - -### Gzip and Brotli Compression - -Add the `vite-plugin-compression` plugin for pre-compressed assets: - -```bash -pnpm add -D vite-plugin-compression -``` - - - -```ts -// vite.config.ts -import compression from 'vite-plugin-compression'; - -export default defineConfig({ - plugins: [ - react(), - tailwindcss(), - compression({ algorithm: 'gzip' }), // .gz files - compression({ algorithm: 'brotliCompress', ext: '.br' }), // .br files - ], -}); -``` - -### Code Splitting - -Vite splits chunks automatically. For ObjectUI plugins, use dynamic imports to keep the initial bundle small: - -```tsx -import { createLazyPlugin } from '@object-ui/react'; - -const ObjectGrid = createLazyPlugin( - // The plugin package has no default export — name the component you want. - async () => ({ default: (await import('@object-ui/plugin-grid')).ObjectGrid }), - { fallback:
Loading grid...
} -); -``` - -### Bundle Analysis - -Visualize your bundle to find optimization opportunities: - -```bash -pnpm add -D rollup-plugin-visualizer -``` - - - -```ts -// vite.config.ts -import { visualizer } from 'rollup-plugin-visualizer'; - -export default defineConfig({ - plugins: [ - react(), - tailwindcss(), - visualizer({ open: true, gzipSize: true }), - ], -}); -``` - -### Production Build Command - -Bake the backend origin into the bundle and verify the output: - -```bash -VITE_SERVER_URL=https://demo.objectstack.ai pnpm build -``` - -`pnpm build` runs `turbo run build` across the workspace. Turbo runs in strict env mode, but it detects the console as a Vite package and passes `VITE_*` through automatically, so an inline `VITE_SERVER_URL` does reach the build. To build the console alone, use `pnpm build:console`. - -## Health Checks - -For containerized deployments, add a lightweight health check endpoint. Create `public/health.json` in your app: - -```json -{ "status": "ok" } -``` - -This file is copied to the build output as-is by Vite. Point your health check to `/health.json`: - -```dockerfile -# Docker HEALTHCHECK -HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ - CMD wget -qO- http://localhost:80/health.json || exit 1 -``` - -```jsonc -// railway.json (excerpt) -{ - "deploy": { - "healthcheckPath": "/health.json" - } -} -``` - -For platforms that expect an HTTP 200 on `/`, the SPA `index.html` fallback already handles this. - -## Next Steps - -- [CI workflows](https://github.com/objectstack-ai/objectstack/tree/main/.github/workflows) — The repository-root workflows that build, test and release ObjectUI -- [Architecture Overview](/docs/guide/architecture) — How ObjectUI packages fit together -- [Quick Start](/docs/guide/quick-start) — Set up a new ObjectUI project from scratch -- [Theming](/docs/guide/theming) — Customize the look-and-feel before deploying diff --git a/ui/content/docs/guide/designing-app-navigation.md b/ui/content/docs/guide/designing-app-navigation.md deleted file mode 100644 index 0439dd978ab..00000000000 --- a/ui/content/docs/guide/designing-app-navigation.md +++ /dev/null @@ -1,140 +0,0 @@ ---- -title: "Designing App Navigation" -description: "How to choose between object navigation, named views, custom pages, and dashboards when composing an app" ---- - -# Designing App Navigation - -When you compose an app, the same requirement can often be expressed three -ways: a plain object menu entry, an object entry pinned to a named view, or a -custom page that embeds views. They are **not** interchangeable — this guide -gives you the decision rules. (The canonical, agent-facing version lives in -`skills/objectui/guides/app-composition.md`; keep the two in sync.) - -## The Three Ways to Reach a List - -Say your app has a `project` object: - -| You write | User lands on | You get for free | -|---|---|---| -| `{ "type": "object", "objectName": "project" }` | `/apps/my_app/project` — the object's **default view** | The full object shell: view switcher, object actions, a create button, record detail routing, search and recent-items integration | -| `{ "type": "object", "objectName": "project", "viewName": "project.by_status" }` | `/apps/my_app/project/view/project.by_status` | The same shell, with the entry **anchored** to a named view — users can still switch | -| `{ "type": "object", "objectName": "project", "filters": { "status": "open" } }` | `/apps/my_app/project/data?filter[status]=open` — the **bare data surface** | URL-defined conditions over everything permissions allow, bound to **no saved view**. Conditions show as removable chips; the full filter/sort/group toolbar is available; "Save as view" turns the slice into a named view | -| `{ "type": "page", "pageName": "project_overview" }` | `/apps/my_app/page/project_overview` | Nothing but your page schema. View switching, actions, and record links must be assembled by hand | - -The key asymmetry: a page can imitate the other two, but it **loses the object -shell** — and every future improvement to that shell (new actions, better view -switching, permission trimming) will skip your page. - -> **Creating a view at runtime ("Add View" / "Save as view").** Both entry -> points stage the new view as a per-item **draft** (ADR-0034) — invisible to -> other users until you Publish. Its identity is the canonical qualified name -> `.` (e.g. `project.by_status`), used as the metadata row key, -> the `body.name`, and the ViewTabBar tab id alike, so the draft → preview → -> publish loop resolves to a single row. After creating, the console navigates -> you to the new view in **draft-preview mode** (`?preview=draft`) so you can -> verify it and Publish from the DraftPreviewBar in one click. The create -> dialog asks for a display label **and** a machine key; the key auto-fills -> from the label for Latin text, and must be typed for non-Latin (CJK, …) -> labels rather than falling back to a random name. - -## The Rule of Least Power - -Use the least powerful construct that expresses the requirement: - -1. **Default: one plain `object` entry per core business object.** - No `viewName`. The default view is defined by view metadata, so the - navigation layer stays decoupled from presentation. - -2. **Add `viewName` when the menu item is a named slice.** - If the label is a *perspective* — "By Status", "Due This Week", - "My Tasks" — create a named view (convention: `.`, e.g. - `project.by_status`) and anchor the entry to it. `viewName` sets the - entry point; it does not lock the user in. - -3. **Use `filters` for one-off or parameterized slices.** - A dashboard drill-through, a shared link, "records assigned to me" — put - the condition in the URL (`filters: { "owner_id": "{current_user_id}" }`) - and let it land on the bare data surface instead of authoring a view. - Promote the slice to a named view only when it's curated and reused. - Note the surface is not a security feature: it shows exactly what - row-level permissions already allow. - -4. **Create a page only for composition a single object view cannot express.** - Multiple objects side by side or in tabs, KPI cards mixed with lists, - onboarding or static content, parameterized pages. A page that wraps a - single object's single view is an anti-pattern. - -5. **Use `dashboard` for metric/chart aggregation and `report` for tabular - analysis** — don't rebuild them as pages of chart blocks. - -6. **One entry per target.** Don't offer the same object through both a plain - entry and a page that wraps its default view. If one object needs several - menu entries, make all of them named-view entries — mixing styles breaks - active-state highlighting in the sidebar. - -As a rule of thumb, in a typical business app about 80% of navigation entries -should be `object` entries (with or without `viewName`); pages are for the -home screen, onboarding, and cross-object workbenches. - -## Write Spec-Shaped Items - -Navigation is a discriminated union on `type`. Each type has its own target -field — there is no generic `path` and no `kind`: - -```json -{ - "navigation": [ - { "id": "nav_projects", "type": "object", "objectName": "project", "label": "Projects" }, - { "id": "nav_by_status", "type": "object", "objectName": "project", "viewName": "project.by_status", "label": "By Status" }, - { "id": "nav_my_open", "type": "object", "objectName": "project", "filters": { "owner_id": "{current_user_id}", "status": "open" }, "label": "My Open Projects" }, - { "id": "nav_kpis", "type": "dashboard", "dashboardName": "company_kpis", "label": "KPIs" }, - { "id": "nav_workbench", "type": "page", "pageName": "cross_object_workbench", "label": "Workbench" }, - { "id": "nav_docs", "type": "url", "url": "https://docs.example.com", "target": "_blank", "label": "Docs" } - ] -} -``` - -Requirements: - -- `id` (snake_case) and `type` are mandatory on every item. `label` is - optional: an entry without one shows its target's **current** label, resolved - when the navigation renders — the view's label for an entry that names a - labelled view, else the object's or dashboard's label, else the target's - machine name. A renamed object, view or dashboard therefore shows its new - name on the next load without touching the navigation. Entries of other types - fall back to their target's name (`pageName`, `reportName`, the `url`) and a - group to its `id`, so give those a `label`. A `label` you do write renders - verbatim. Never write an empty `label` — omit the key instead. -- The target field must match the type: `objectName`, `pageName`, - `dashboardName`, `reportName`, or `url`. Keys like `path` or `kind` are - ignored at runtime and rejected at save. -- A `doc` entry opens the package documentation portal instead of a data - surface. It names a `book` (opens the book at its first readable page), a - `doc` (opens that page — the doc's name, i.e. its source filename stem such - as `crm_lead_guide`, never `crm_lead_guide.md` or a path), or both (that page - in that book). At least one of the two is required. Name the book by its - `name`, even when it declares a `slug`: the docs portal resolves the name and - redirects to the book's canonical `slug` URL. Who may read a book is the - book's `audience`, enforced by the server — - the entry itself carries only the usual `visible` / `requiredPermissions` - gates. You do not repeat the audience on the entry: a member who may not - read what it opens — a doc outside their audience, a book whose audience - excludes them or with no page they can read — does not see the entry. The - server leaves it out of the app it serves that member, and the console's - menu hides it too, from the member's own list of readable docs and books. -- Put items under the `navigation` key. `menu` is deprecated legacy and only - kept for backward compatibility. - -Object entries also support record deep-links — `recordId` (with template -variables like `{current_user_id}`) opens a specific record, which is how -"My Profile"-style entries are built. - -## Quick Checklist - -Before publishing an app, scan the navigation for: - -- Pages that merely wrap a single object view → replace with an object entry. -- Items carrying `path`/`kind` → rewrite as typed items. -- The same object reachable twice (plain entry + wrapper page) → keep one. -- View names not following `.`, ids not snake_case → rename. diff --git a/ui/content/docs/guide/expressions.md b/ui/content/docs/guide/expressions.md deleted file mode 100644 index c4e40a7e329..00000000000 --- a/ui/content/docs/guide/expressions.md +++ /dev/null @@ -1,760 +0,0 @@ ---- -title: "Expression System" ---- - -Object UI includes a powerful expression system that enables dynamic, data-driven UIs. Expressions allow you to reference data, compute values, and create conditional logic directly in your JSON schemas. - -## Overview - -Expressions are JavaScript-like code snippets embedded in schemas using the `${}` syntax: - -```json -{ - "type": "text", - "content": "Hello, ${user.name}!" -} -``` - -With this published as the expression scope: -```tsx -const scope = { user: { name: "Alice" } } -``` - -This renders: **"Hello, Alice!"** — `user` is a root because the host published a key by that -name. [Data Context](#data-context) below shows the provider that publishes one. - -## Basic Syntax - -### Simple Property Access - -Access data properties using dot notation: - -```json -{ - "type": "text", - "content": "${user.firstName}" -} -``` - -### Nested Properties - -Access nested objects: - -```json -{ - "type": "text", - "content": "${user.address.city}" -} -``` - -### Array Access - -Access array elements: - -```json -{ - "type": "text", - "content": "${users[0].name}" -} -``` - -### String Interpolation - -Mix expressions with static text: - -```json -{ - "type": "text", - "content": "Welcome, ${user.firstName} ${user.lastName}!" -} -``` - -### Localized Copy - -Copy written once per language is an inline locale map. Under a node's `properties`, each member -of the map is evaluated exactly like a plain string on the same key, against the same scope, and -the block then draws the member for the viewer's language: - -```json -{ - "type": "element:text", - "properties": { - "content": { - "en": "Welcome back, ${current_user.name}", - "zh-CN": "欢迎回来,${current_user.name}" - } - } -} -``` - -An object counts as a locale map when every key is a locale tag (`en`, `zh-CN`, or `default`) and -every value is a string. Any other object under `properties` reaches the block as written: a -`${…}` nested inside it is not evaluated. - -## Operators - -### Arithmetic Operators - -```json -{ - "type": "text", - "content": "Total: ${price * quantity}" -} -``` - -Supported: `+`, `-`, `*`, `/`, `%` - -### Comparison Operators - -```json -{ - "type": "text", - "content": "${score >= 90 ? 'Top grade' : 'Keep going'}" -} -``` - -Supported: `>`, `<`, `>=`, `<=`, `==`, `===`, `!=`, `!==` - -### Logical Operators - -```json -{ - "type": "button", - "visibleWhen": "${user.isAdmin && user.isActive}" -} -``` - -Supported: `&&`, `||`, `!` - -### Ternary Operator - -```json -{ - "type": "text", - "content": "${count > 0 ? count + ' items' : 'No items'}" -} -``` - -## Conditional Properties - -The node-level condition keys are `visibleWhen`, `hidden` and `disabled`. The -`visibleOn` / `hiddenOn` / `disabledOn` spellings are retired: `objectui validate` -refuses them by name and points at the surviving key (same predicate, same -polarity — rename the key, the value is unchanged). A form field's `fields[].visibleOn` -is a different, view-level slot and is unaffected. - -### visibleWhen - -Show component when expression is true: - -```json -{ - "type": "button", - "label": "Admin Panel", - "visibleWhen": "${user.role === 'admin'}" -} -``` - -### hidden - -Hide component when expression is true: - -```json -{ - "type": "section", - "hidden": "${user.settings.hideSection}" -} -``` - -### disabled - -Disable component when expression is true: - -```json -{ - "type": "button", - "label": "Submit", - "disabled": "${record.status === 'submitted'}" -} -``` - -## Data Context - -### Where the names come from - -Expression scope does **not** arrive as a prop. `SchemaRenderer` declares exactly one prop, -`schema`, and forwards every other prop it is handed straight through to the component the -schema names — so a `data`, `dataSource` or `debug` written on the element is neither read nor -refused. Nothing throws, and there is one line on the console; the expression simply never -resolves, and an unresolvable template is returned as its own source text, so the characters -you typed are what the reader sees. - -The host publishes its values with `PredicateScopeProvider`, and every key it publishes becomes -a root: - -```tsx -import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react' -import type { DeclaredNode } from '@object-ui/types' - -// The page schema — your own document. -declare const schema: DeclaredNode - -// Every name here becomes a root the schema's expressions can read. -const scope = { - user: { name: 'Alice' }, - settings: { theme: 'dark' }, -} - -function App() { - return ( - - - - ) -} -``` - -```json -{ - "type": "text", - "content": "Theme: ${settings.theme}" -} -``` - -The scope the evaluator builds is what you published, plus two names the renderer supplies: - -| name | what it holds | -|---|---| -| every key of `scope` | exactly what you put there — `user`, `settings`, whatever the page needs | -| `record` | the row a record surface is bound to, when there is one | -| `page` | page-local variables, for predicates that gate on another component's state | - -`current_user` is an alias of whatever you published as `user`; an app built on -`@object-ui/app-shell` does not mount the provider itself, because the shell's -`ExpressionProvider` already feeds the same channel with the signed-in `user` and `features`. - -> **`dataSource` is not an expression root.** `SchemaRendererProvider`'s `dataSource` carries -> the host's `DataSource` *adapter* — the object renderers call `find()` on. The renderer used -> to publish that adapter under the name `data`; an adapter answers no `data.*` path, so the -> root was constant for every conformant host, and objectui#9308 removed it. A `${data.…}` -> expression now reads whatever *you* published under `data`, and nothing if you published -> none. **This changes the verdict of a gate already in the field**: `visible: "${data.x}"` -> used to resolve to `undefined` and HIDE its node on every row; with no `data` key at all it -> is unevaluable, this surface fails soft, and the node is SHOWN. At the runtime layer the row -> is `record` (ADR-0089 D3) — rewrite such a gate to `record.*` rather than re-publishing a -> `data` key. - -### Lists - -`list` has no item template, and it provides no `item` or `index` scope for an expression to -read. It draws its `items` array — or the array its `bind` path resolves to — one row per entry: -a string, or an object whose `content` is a node. - -```json -{ - "type": "list", - "bind": "users" -} -``` - -## Built-in Functions - -### String Functions - -```json -{ - "type": "text", - "content": "${user.name.toUpperCase()}" -} -``` - -Available: -- `toUpperCase()`, `toLowerCase()` -- `trim()`, `trimStart()`, `trimEnd()` -- `substring(start, end)` -- `replace(search, replace)` -- `split(separator)` -- `includes(substring)` -- `startsWith(prefix)`, `endsWith(suffix)` - -### Array Functions - -```json -{ - "type": "text", - "content": "Total users: ${users.length}" -} -``` - -```json -{ - "type": "text", - "content": "${users.map(u => u.name).join(', ')}" -} -``` - -Available: -- `length` -- `map(fn)`, `filter(fn)`, `reduce(fn, initial)` -- `join(separator)` -- `slice(start, end)` -- `includes(item)` -- `find(fn)`, `findIndex(fn)` -- `some(fn)`, `every(fn)` - -### Number Functions - -```json -{ - "type": "text", - "content": "Price: ${price.toFixed(2)}" -} -``` - -Available: -- `toFixed(decimals)` -- `toPrecision(digits)` -- `toString()` - -### Math Functions - -```json -{ - "type": "text", - "content": "${Math.round(average)}" -} -``` - -Available: All standard `Math` functions -- `Math.round()`, `Math.floor()`, `Math.ceil()` -- `Math.min()`, `Math.max()` -- `Math.abs()` -- `Math.random()` - -### Date Functions - -```json -{ - "type": "text", - "content": "${new Date().toLocaleDateString()}" -} -``` - -## Complex Expressions - -### Nested Ternary - -```json -{ - "type": "text", - "content": "${status === 'active' ? 'Active' : status === 'pending' ? 'Pending review' : status === 'error' ? 'Failed' : 'Unknown'}" -} -``` - -### Combining Operators - -```json -{ - "type": "alert", - "visibleWhen": "${(user.role === 'admin' || user.role === 'moderator') && user.isActive && !user.isSuspended}" -} -``` - -### Array Methods - -```json -{ - "type": "text", - "content": "${users.filter(u => u.isActive).map(u => u.name).join(', ')}" -} -``` - -## Practical Examples - -### User Greeting - -```json -{ - "type": "text", - "content": "${new Date().getHours() < 12 ? 'Good morning' : new Date().getHours() < 18 ? 'Good afternoon' : 'Good evening'}, ${user.firstName}!" -} -``` - -### Status Badge - -`badge` has no row in the expression carriage map, so its `label` and `variant` -are read off the node exactly as written — a `${…}` in either reaches the screen -as those characters. Resolve both in the data you hand the renderer and author -the node with the resolved values; the condition keys are evaluated on every -type and stay expressions: - -```json -{ - "type": "badge", - "label": "Completed", - "variant": "secondary", - "visibleWhen": "${status !== 'draft'}" -} -``` - -Two neighbouring traps: `text` is not a `badge` key at all — the badge's text is -`label` — and `variant` is a closed set: `default`, `secondary`, `destructive`, -`outline`. - -### Price Formatting - -```json -{ - "type": "text", - "content": "$${(price * quantity).toFixed(2)}" -} -``` - -### Empty State - -```json -{ - "type": "empty", - "visibleWhen": "${items.length === 0}", - "title": "No items to display", - "description": "Start by adding your first item" -} -``` - -### Percentage Bar - -`progress` has no row in the expression carriage map, so its `value` and `label` -are read off the node exactly as written — a `${…}` in either reaches the screen -as those characters. Compute the percentage in the data you hand the renderer; -the condition keys are evaluated on every type and stay expressions: - -```json -{ - "type": "progress", - "value": 75, - "label": "75% complete", - "visibleWhen": "${total > 0}" -} -``` - -### Conditional Styling - -`className` has no row in the carriage map either — on `card` or on any other -type — so an expression written there lands in the rendered `class` attribute as -its own source text. Resolve the class list in the data you hand the renderer, -or author each variant and gate it with a condition key: - -```json -{ - "type": "card", - "title": "${task.name}", - "className": "border-red-500 border-2", - "visibleWhen": "${task.isPriority}" -} -``` - -## Form Expressions - -A form's own values are the **row under edit**, and the row is bound as `record` and nothing -else — the bare shorthand (`country`) and the wrong-layer `data.country` were both retired on -runtime record surfaces (objectui#5330 phase 2), and `record` is the canonical runtime-layer -root (ADR-0089 D3). There is no `form` root: a predicate written against one is unevaluable, -and a fail-soft surface answers it by showing the field on every row. - -### Dependent Fields - -A form's fields are its `fields` array, and each field's `visibleOn` is evaluated against that -row: - -```json -{ - "type": "form", - "fields": [ - { - "name": "country", - "type": "select", - "label": "Country", - "options": [ - { "label": "USA", "value": "USA" }, - { "label": "Canada", "value": "Canada" }, - { "label": "Mexico", "value": "Mexico" } - ] - }, - { - "name": "state", - "type": "select", - "label": "State/Province", - "visibleWhen": "${record.country === 'USA'}", - "options": [ - { "label": "CA", "value": "CA" }, - { "label": "NY", "value": "NY" }, - { "label": "TX", "value": "TX" } - ] - } - ] -} -``` - -### Validation - -An `input` declares no `validations` bag, and nothing reads one. `inputType: "email"` renders an -email input, which the browser validates, and `required` marks it required: - -```json -{ - "type": "input", - "name": "email", - "label": "Email", - "required": true, - "inputType": "email" -} -``` - -### Computed Fields - -`input` has no row in the expression carriage map either, so a computed total -cannot be carried by its `value`. Show it with a `text` node, whose `content` is -evaluated on every component type: - -```json -{ - "type": "text", - "content": "Total: ${record.price * record.quantity}" -} -``` - -## Performance Considerations - -### Expensive Computations - -Expressions are re-evaluated when data changes. Avoid expensive operations: - -❌ Bad: Complex computation in expression - -```json -{ - "type": "text", - "content": "${users.map(u => expensiveOperation(u)).join(', ')}" -} -``` - -✅ Good: Pre-compute, and publish the result - - - -```tsx -const scope = { - processedUsers: users.map(u => expensiveOperation(u)) -} -``` - -### Caching - -The expression engine automatically caches results when data doesn't change. - -## Security - -### Sandboxed Execution - -Expressions run in a sandboxed environment and can only access: -- The names the host published as scope, plus `record` and `page` -- The globals listed in `SAFE_GLOBALS` (`packages/core/src/evaluator/SafeExpressionParser.ts`): - `Math`, `JSON`, `parseInt`, `parseFloat`, `isNaN`, `isFinite` -- Methods on the values those names hold — string, number and array methods -- `new Date(...)` and `new RegExp(...)`, the only two constructors `new` accepts - -> **A global that is not on that list fails the way every unresolved name fails.** `Date`, -> `String` and `Array` are reachable only in the two forms above — `Date` as a constructor, -> `String` and `Array` as methods on a value you already hold. Written as globals they resolve -> to nothing: `${Date.now()}`, `${String(x)}` and `${Array.isArray(x)}` each raise inside the -> parser, and this surface fails soft, so the template is returned as its own source text with -> one line on the console — the characters you typed are what the reader sees. - -They **cannot** access: -- Browser APIs (window, document, localStorage) -- Node.js APIs (fs, path, etc.) -- Global variables -- Function constructors - -### Sanitization - -All expression outputs are automatically sanitized to prevent XSS attacks. - -## Debugging Expressions - -### Expression Errors - -An expression that cannot be resolved is **not** an error the reader sees, and it is not the -same failure in both directions. Measured on the built evaluator: - -```json -{ - "type": "text", - "content": "${user.invalidProperty}" -} -``` - -| the scope | what the evaluator returns | -|---|---| -| `user` is published, `invalidProperty` is not a member of it | `undefined` — nothing is thrown | -| no `user` root at all | the template's own **source text**, and one line on the console | - -So a missing member renders as nothing, and a missing root renders as the characters you -typed. Neither raises, and neither stops the render — which is why the scope a page publishes -has to be stated rather than assumed. - -### Debug Mode - -`debug` is read off the same provider context as `dataSource` -(`context?.debug || context?.debugFlags?.enabled`), never off the element — a `debug` written -on `SchemaRenderer` is forwarded to the component the schema names, exactly like a `data` prop, -and turns nothing on. Mount the provider instead: - - - -```tsx - - - -``` - -This logs all expression evaluations to the console. It is orthogonal to the expression scope: -wrap this pair in a `PredicateScopeProvider` as well when you want both. - -## Advanced Usage - -### Custom Functions - -There is no global evaluator to extend: `SchemaRenderer` builds a fresh -`ExpressionEvaluator` for each evaluation, so a function has to reach it through -the evaluation context. Anything callable you put in the context is callable in -an expression, under exactly the name you gave it: - -```tsx -import { evaluateExpression } from '@object-ui/core' - -const formatCurrency = (value: number) => - new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value) - -// => '$1,234.50' -evaluateExpression('${formatCurrency(price)}', { formatCurrency, price: 1234.5 }) -``` - -That is the direct-evaluation path, and `formatCurrency` is a root there because this call -hands the evaluator its own context. A component expression rendered by `SchemaRenderer` -resolves against a different scope — the names the host published through -`PredicateScopeProvider`, plus `record` and `page` — so a function you registered elsewhere is -not reachable from a schema expression. Compute the value before it reaches the schema, and -bind the result. - -Hold an evaluator when you want one context reused — construct it, then call -`evaluate`: - -```tsx -import { ExpressionEvaluator } from '@object-ui/core' - -const evaluator = new ExpressionEvaluator({ user: { role: 'admin' } }) - -evaluator.evaluate('${user.role === "admin"}') -``` - -### Custom Operators - -Expressions are JavaScript, evaluated against the context — operators are the -language's own. A membership test is written with the array method: - -```json -{ - "type": "button", - "visibleWhen": "${user.permissions.includes('admin')}" -} -``` - -## Best Practices - -### 1. Keep Expressions Simple - -❌ Bad: Too complex - -```json -{ - "content": "${users.filter(u => u.age > 18).map(u => ({...u, isAdult: true})).reduce((acc, u) => acc + u.score, 0)}" -} -``` - -✅ Good: Pre-compute complex logic - -```json -{ - "content": "${adultUsersScore}" -} -``` - -### 2. Use Meaningful Variable Names - -❌ Bad - -```json -{ - "visibleWhen": "${x && y || z}" -} -``` - -✅ Good - -```json -{ - "visibleWhen": "${isAdmin && isActive || isSuperUser}" -} -``` - -### 3. Handle Null/Undefined - -❌ Bad: Might throw error - -```json -{ - "content": "${user.address.city}" -} -``` - -✅ Good: Safe access - -```json -{ - "content": "${user.address?.city || 'N/A'}" -} -``` - -### 4. Use TypeScript - -Define the type of the scope you publish: - - - -```tsx -interface AppScope { - user: { - name: string - role: 'admin' | 'user' - isActive: boolean - } -} - -const scope: AppScope = { /* ... */ } - - - -``` - -## Next Steps - -- [Schema Rendering](./schema-rendering.md) - Learn the rendering engine -- [Component Registry](./component-registry.md) - Understand components -- [Schema Overview](/docs/guide/schema-overview) - Explore schema specifications - -## Related Documentation - -- [`@object-ui/core` README](https://github.com/objectstack-ai/objectui/tree/main/packages/core) - Expression evaluator API -- [Form Plugin](/docs/plugins/plugin-form) - Form-specific expressions -- [View Plugin](/docs/plugins/plugin-view) - Data view expressions diff --git a/ui/content/docs/guide/fields.md b/ui/content/docs/guide/fields.md deleted file mode 100644 index fec1b2d290f..00000000000 --- a/ui/content/docs/guide/fields.md +++ /dev/null @@ -1,237 +0,0 @@ ---- -title: "Field Registry" ---- - -Object UI uses a **Field Registry** system to decouple the core engine from specific UI implementations of fields. This allows for rich extensibility and plugin support. - -## Concept - -The `@object-ui/fields` package serves as the "Universal Language" for rendering values. - -When a component like `` needs to render a `date` field, it doesn't import a DatePicker directly. Instead, it asks the registry: - -> *"Hey, give me the component responsible for rendering type 'date'."* - -This architecture allows you to: -1. **Override standard fields** (e.g. replace the native date picker with a fancy one). -2. **Add new field types** (e.g. add a `rating` or `signature` field). -3. **Keep bundles small** (heavy components like Code Editors are loaded only if their plugin is registered). - -## Usage - -### 1. Registering a Custom Field - -You can register a custom renderer globally, typically at your app's entry point. - -```tsx -// src/setup.tsx -import { registerFieldRenderer, type CellRendererProps } from '@object-ui/fields'; - -const MyRatingField = ({ value, onChange }: CellRendererProps) => { - return ( -
- {[1, 2, 3, 4, 5].map(star => ( - onChange?.(star)} - style={{ color: star <= value ? 'gold' : 'grey' }} - > - ★ - - ))} -
- ); -}; - -// Register it -registerFieldRenderer('rating', MyRatingField); -``` - -### 2. Using in Schema - -Once registered, you can simply use the new type in your JSON schema. - -```json -{ - "type": "form", - "fields": [ - { - "name": "customer_satisfaction", - "type": "rating", - "label": "Satisfaction" - } - ] -} -``` - -## Standard Fields - -Object UI comes with built-in support for the standard [ObjectStack Protocol](https://github.com/objectstack-ai/objectstack/tree/main/packages/spec) types: - -| Type | Description | -|---|---| -| `text` | Single line text | -| `textarea` | Multi-line text | -| `number` | Numeric input | -| `currency` | Currency formatting | -| `percent` | Percentage values | -| `date` | Date picker | -| `datetime` | Date & Time picker | -| `boolean` | Checkbox / Switch | -| `select` | Dropdown | -| `lookup` | Reference to another object | -| `master_detail` | Parent-child relationship | -| `user` | Person picker — searches the `sys_user` object (a lookup specialized to users) | -| `owner` | Record owner — a `user` field, typically read-only and stamped with the current user | - -## What a number field silently rewrites - -`number`, `currency`, `percent` and `geolocation` render a native -`type="number"` input. The browser — not ObjectUI — decides what that box will -accept, and it rewrites some entries **before any widget code runs**. Two -different things can happen, and only one of them is announced. - -### Announced: text the browser cannot read - -If the box is left holding something that is not a complete number, the browser -reports `validity.badInput` and these widgets now say so: the control is marked -`aria-invalid="true"` and a message is drawn under it — - -> Not saved: the text in this box is not a number. Enter a plain decimal (example: 1234.56). - -The sentence follows the reader's language (objectui#8148) — it resolves through -the `fields.number.badInput` locale key, so a console running in Chinese, -Japanese or Arabic refuses in that language. The example numeral is the -widget's own (`1234` for `number`, `1234.56` for `currency`, `12.5` for -`percent`, `30.2741` / `120.1551` for the two `geolocation` boxes) and stays -verbatim in every language. - -Measured in Chromium 141, typing any of `1e`, `1e-`, `1e+`, `5e`, `-`, `.`, -`+`, `-.` or `e` leaves the box **visibly displaying** what was typed while its -value reads empty. Before this was announced, the field simply stored nothing -and said nothing. - -### ⚠️ NOT announced: entries the browser silently truncates - -This is the important limitation, and it is deliberate rather than an oversight. - -| you paste / type | the field stores | -|---|---| -| `1.2.3` | `1.23` | -| `0x10` | `10` | -| `12abc` | `12` | - -**No warning is shown for these, and no widget-side check can add one.** The -browser filters the keystrokes or the pasted text as it arrives, so by the time -ObjectUI sees the field the discarded characters are already gone — there is -nothing left to detect. This is native `type="number"` behaviour; recovering it -would mean giving up the numeric keyboard on mobile and the `min`/`max`/`step` -spinner on every numeric field in the product. - -⛔ **So do not read "no warning" as "the value is correct."** A warning means the -browser could not read the box at all. Silence means the browser read -*something* — which may be less than you typed. When exact input matters -(reference codes, serial numbers, anything where `1.2.3` is meaningful), declare -a `text` field, not a numeric one. - -## Polymorphic pointer pairs (`referenceVia`) - -A `text` field that declares `referenceVia` holds a record id of the object its -sibling column names, row by row: `sys_approval_request.record_id` points at the -record `object_name` names, and `sys_activity.source_id` at the one -`source_object` names. The record details grid resolves the pair from the row -with `resolveRecordPointer(field, row)` and draws it with the renderer -registered under `RECORD_POINTER_CARD_TYPE`. The fields package's default draws -the record id as text; the console registers a record preview card there, so a -pointer field on a record page shows the record it points at. When the card -could read that record, its title links to the record's page in the current -app, built by the same record-link builder the page's lookup values use; a -record the reader cannot read is drawn with no link, and the card never says -why. Nothing is authored for this: the `referenceVia` declaration on the field -is the whole contract. List cells are not a call site yet. - -When the platform deletes the record a pointer names, it can say so on the row. -A pointer field that also declares `referenceDeletedWhen: { field, value }` -names a sibling `select` field and the option the platform writes there, as -`sys_approval_request.record_id` declares -`{ field: 'cancel_reason', value: 'record_deleted' }`. On a row holding that -option the details grid draws a tombstone in the option's own label (localized -when the console's bundle localizes it), reads nothing and links nothing. A -record the card cannot read on any other row still gets the cause-free text: -only the declaration means deleted. - -## Naming a registry item by name (`position-ref`, `permission-ref`) - -A `text` field that stores the **machine name** of one item of a metadata -registry — a position, a permission set — declares the matching widget, and the -record form lists that registry instead of asking for a typed name: - -```json -{ - "position": { - "type": "text", - "label": "Position", - "required": true, - "maxLength": 100, - "widget": "position-ref" - } -} -``` - -| Widget | Lists | Stores | -|---|---|---| -| `position-ref` | the `position` registry (`GET /api/v1/meta/position`) | the position's `name` | -| `permission-ref` | the `permission` registry (`GET /api/v1/meta/permission`) | the permission set's `name` | - -Like every `widget` value, the name is looked up as `field:`. Each item -reads as its label beside its name, and the value written is the name alone: no -label, no id. A stored name the registry does not list stays on screen, flagged -*not found*, so a renamed or removed item is visible rather than dropped. -`position-ref` leaves out the audience anchors, the spec's -`AUDIENCE_ANCHOR_POSITIONS` (`everyone`, `guest`), because the server refuses an -anchor as a grant target; a stored anchor still shows as its own option. The -list comes from the data source's `listMetadataItems(type)`. When the adapter -has none, the list is empty, or the load fails, the field is a text box that -takes the typed name; a failure is shown with its cause, and the box stays -editable. - -## A widget's face on the record heading - -A `text` field that declares `widget: 'recipient-picker'` or `widget: 'object-ref'` is drawn -in a list column through the read cell registered under that widget's key, which names what -the stored value references. When that field is also the object's `nameField`, the record -page heading draws the value through the same cell: a record share heads its page with the -recipient's name, not the user id it stores. Nothing is authored for this; the `widget` -declaration on the field is the whole contract. - -The heading lives in `@object-ui/components`, which this package depends on and so cannot be -imported from here the other way round. `@object-ui/components` therefore owns a slot, -`setFieldReadFaceResolver` / `resolveFieldReadFace`, and this package fills it when it loads with the -lookup the list cells make. The widget's key draws the stored text until a host registers a -face under it (the console registers its own), and while it does, the heading prints the -stored value as before. So does a host that does not load `@object-ui/fields`. - -## Using Renderers in Custom Components - -If you are building your own custom component (like a Kanban board card), you can leverage the registry to render fields without reinventing the wheel. - -```tsx -import { getCellRenderer } from '@object-ui/fields'; - -export const KanbanCard = ({ task }: { task: { name: string; assignee: string } }) => { - // Get the standard renderer for a 'user' type field - const UserRenderer = getCellRenderer('user'); - - return ( -
-

{task.name}

-
- -
-
- ); -}; -``` diff --git a/ui/content/docs/guide/flow-designer.md b/ui/content/docs/guide/flow-designer.md deleted file mode 100644 index 4e8618016f6..00000000000 --- a/ui/content/docs/guide/flow-designer.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -title: "Flow Designer" -description: "Authoring automation flows on the visual canvas — nodes, edges, the searchable add-node palette, the node inspector, validation, and simulation." ---- - -# Flow Designer - -The **Flow Designer** is the visual editor for `flow` metadata in the console's -metadata admin. It renders a flow's nodes and edges on a pan/zoom canvas so you -can assemble automation — create/update records, branch on conditions, wait for -events, call APIs, route for approval — without hand-editing JSON. The designer -is a thin, **spec-driven** view over `flow` metadata: everything you drop on the -canvas is a node in the flow's schema, so the same document runs unchanged on -the automation engine. - -Open it from a `flow` record's **Design** tab. The **Form** tab holds the same -flow as an editable tree; the two stay in sync. - -## The canvas - -Each node is a card showing its icon, label, `type`, and a one-line summary of -its config. Edges are the arrows between them. - -| Gesture | Result | -|---|---| -| Click a card | Select it (opens the node inspector) | -| Drag a card | Reposition it | -| Hover a card → click the bottom **+** | Append a connected child node | -| Drag a card's connect handle (the dot beside its bottom **+**) onto another node | Connect the two nodes. Refused, with the reason shown, for the node itself, a (source, target) pair another connection already joins, or a node the flow does not have | -| Zoom controls / **Fit** | Scale and re-center the graph | - -A selected connection's **From** and **To**, in its inspector, re-point it to -other nodes of the flow, under the same refusals. - -Structural problems (an undeclared cycle, an unreachable node) surface three -ways at once: a red ring on the offending card, a badge in its corner, and an -inline banner at the top-left of the canvas. Clicking a banner row selects and -pans to the element it refers to. - -## Adding nodes — the palette - -The **Add node** button (top-right, edit mode only) opens the node palette: a -searchable, grouped list of every node type the flow can use. - -### Search and keyboard - -Type in the box at the top to filter across **all** categories at once — the -match is a case-insensitive substring test over each node's **label**, **hint**, -and **type**, so `scr` finds *Screen*, `http` finds *HTTP request*, and a word -that only appears in a hint (`concurrently` → *Parallel*) still surfaces the -node. Clearing the box restores the full grouped list. - -The palette is keyboard-navigable end to end: - -| Key | Action | -|---|---| -| `↑` / `↓` | Move the highlight (wraps around) | -| `Enter` | Insert the highlighted node | -| `Esc` | Close the palette | - -The search box autofocuses when the palette opens, so you can open, type a few -letters, and press `Enter` without touching the mouse. - -### Categories - -Nodes are grouped into five sections, in this order: - -| Category | Node types | -|---|---| -| **Data** | Create / Update / Get / Delete record | -| **Logic** | Decision, Loop, Set variables, Parallel, Try / Catch | -| **Human** | Approval, Screen | -| **Integration** | HTTP request, Connector, Script | -| **Flow** | Subflow, Wait, End | - -Section headings are localized to the active console language (e.g. *数据 / -逻辑 / 人工 / 集成 / 流程* in Chinese); the underlying node types are unchanged. - -### Recently used - -When the search box is empty, a **Recently used** group tops the list with the -node types you inserted most recently (up to five, most-recent first) — so the -nodes you reach for repeatedly stop needing a scroll or a search. The list is -per-user and, when the console is connected to a backend, syncs across devices -(see [User-Scoped State Persistence](/docs/guide/user-state-persistence)); it -falls back to browser-local storage when offline. Types from a plugin that was -since uninstalled drop out of the list automatically. - -### Server-merged, plugin-extensible - -The palette is **server-driven**. Beyond the built-in node types, the running -engine publishes its registered actions at `GET /api/v1/automation/actions`, and -plugins contribute their own node types there (for example an `approval` node -from an approvals plugin, or third-party `connector_action` providers). The -designer overlays those descriptors onto the built-in list — adopting the -engine's labels and descriptions and appending engine-only types — so the -palette always matches what the connected backend actually supports. Plugin -nodes are searchable exactly like built-ins, including by their registered -`type`. When the endpoint is unreachable the designer falls back to its -built-in defaults, so authoring still works offline. - -## The node inspector - -Selecting a node opens its inspector: **ID**, **Label**, **Node Type**, and a -**Configuration** section. New nodes start with defaults where the spec allows -one: a *Wait* node already carries a timer config, and an *HTTP* node defaults to -`GET`. What the author must still supply is marked with a red `*`, the same -required marker the other metadata forms use. That covers an *HTTP* node's URL, a -record node's object, a decision branch's label and expression, and a screen -field's name. The marker follows the installed spec, so a key required only in -some configurations (a *Notify* node's title while it has no template) is marked -only while it applies. Until the value is supplied, the spec's error is shown on -that node and listed in the **Problems** panel. - -For node types whose engine executor publishes a `configSchema` (ADR-0018), the -inspector renders a **server-driven property form** from that schema — so a -plugin's node gets a real config UI without the designer hardcoding its fields. -The mapping covers scalars, enums (→ select), typed references (→ picker), -free-form maps (→ key/value), and array/object shapes: an array of objects -becomes a **repeater**, and a repeater column that is itself an array (of -strings, numbers, or objects) becomes a **nested repeater** — so an -engine-published nested-array config is editable inline rather than dropping to -the Advanced JSON block. - -An *Approval* node's approver **Value** cell takes its candidates from where the -schema's published binding for the row's **Type** says they live (`xRef.sources`): -a directory kind (`user`, `team`, `department`) is a lookup over its directory -object's records, and a kind bound to the metadata registry -(`{ source: 'registry', type }`, which is how `position` is bound on a server -carrying objectstack#22710) is a select over that type's declared items that -stores the item's name. Both offer a pencil toggle for typing a value by hand. - -A **Decision** node's Branches editor defines each branch's label, CEL -expression, **and target node** in one table: the **Target** column picks the -downstream node, wiring (creating, retargeting, or detaching) the branch's -outgoing edge with its condition, label, and default flag. The same binding can -also be edited from the edge side — select a connector and use its **Branch** -picker — and the two stay in sync, because the routing always lives on the -edges. - -An **Assignment** node's Assignments editor sets one variable per row. Each -value is either text or a CEL expression, chosen with the row's *Write as a CEL -expression* toggle. The node's `config` then reads: - -```json -{ - "assignments": { - "label": "{record.name}", - "digest": { "dialect": "cel", "source": "joinNonEmpty(names, \", \")" } - } -} -``` - -Text (`label`) is `{token}` interpolation and is stored exactly as typed. An -expression (`digest`) is stored as the `{ dialect: 'cel', source }` envelope and -evaluated by the expression engine, so the CEL stdlib is available. A malformed -envelope shows the spec's refusal under the cell. The toggle appears only on maps -whose values the spec declares may be an expression; other key/value editors -(field values, headers, inputs) have none. - -## Validate, simulate, inspect runs - -The toolbar toggles four side panels: - -| Panel | What it shows | -|---|---| -| **Variables** | The flow's declared variables | -| **Problems** | Structural + server validation issues, each clickable to reveal on canvas | -| **Debug** | A step-through **simulator** that walks the graph and highlights the active / visited nodes | -| **Runs** | Execution history for the published flow, fetched from the engine | - -## See also - -- [User-Scoped State Persistence](/docs/guide/user-state-persistence) — how the - "Recently used" list is stored and synced. -- [Console App](/docs/guide/console) — the reference app that hosts the metadata - admin and its designers. diff --git a/ui/content/docs/guide/index.md b/ui/content/docs/guide/index.md deleted file mode 100644 index c609e9fb2f0..00000000000 --- a/ui/content/docs/guide/index.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "Guide" -description: "Complete guide to using ObjectUI - from getting started to advanced concepts" ---- - -# ObjectUI Guide - -Welcome to the ObjectUI Guide! This comprehensive guide covers everything you need to know to build powerful server-driven UIs with ObjectUI. diff --git a/ui/content/docs/guide/layout.md b/ui/content/docs/guide/layout.md deleted file mode 100644 index 29f3ea22112..00000000000 --- a/ui/content/docs/guide/layout.md +++ /dev/null @@ -1,926 +0,0 @@ ---- -title: "Layout System" -description: "Understanding ObjectUI's layout components for building application shells and page structures" ---- - -# Layout System - -ObjectUI provides a comprehensive layout system through the `@object-ui/layout` package. This guide explains how to use layout components to build professional application structures. - -## Overview - -The layout system provides: - -- **AppShell** - Full application container with a top navbar, sidebar, and content areas -- **Page** - Individual page wrapper with header and body -- **PageHeader** - Consistent page headers with title and actions -- **SidebarNav** - Navigation sidebar with menu items - -## Installation - -The layout package is included in the core ObjectUI installation: - -```bash -npm install @object-ui/react -``` - -Layout components are automatically registered when you import ObjectUI. - -## AppShell Component - -The `AppShell` provides a complete application structure with a top navbar, a sidebar, and -a main content area. - -**`AppShell` is a React component, not an authorable JSON node.** Four of its seven props -are `React.ReactNode` slots — `sidebar`, `navbar`, `children` and `rightRail` — and a JSON -document has no way to put a node into any of them. Compose the shell in React, and render -your JSON pages *inside* it. `app-shell` is not a component key either — what a -`{ "type": "app-shell" }` node does now is measured under -[There is no `app-shell` node](#there-is-no-app-shell-node) below. - -### Basic Usage - - -```tsx -import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; -import { SchemaRenderer } from '@object-ui/react'; -import { LayoutDashboard, Settings, Users } from 'lucide-react'; - -const navItems: NavItem[] = [ - { title: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, - { title: 'Users', href: '/users', icon: Users }, - { title: 'Settings', href: '/settings', icon: Settings }, -]; - -My Application} - sidebar={} -> - - -``` - -Top-bar content goes in `navbar`. `AppShell` renders the sticky `
` element itself -and `{navbar}` is the only thing that fills it — there is no `header` prop. Main content is -the component's `children`; there is no `body` prop either. - -### Props - -`AppShellProps` (`packages/layout/src/AppShell.tsx`) declares exactly these seven. The -component destructures that fixed key list with **no rest element**, so anything else you -pass is built and then dropped on the floor. - -These are React props, passed in JSX. None of them is authorable in JSON — there is no -`app-shell` node to write them on. - -| Prop | Type | Required | Description | -| --- | --- | --- | --- | -| `sidebar` | `React.ReactNode` | no | Left sidebar node, a flex sibling of the content. Pass `SidebarNav`, or your own node. | -| `navbar` | `React.ReactNode` | no | Top-bar content. `AppShell` supplies the sticky `
` around it, so pass only what goes inside. | -| `children` | `React.ReactNode` | yes | Main content, rendered inside the content element, within the shell's one `
` landmark. | -| `className` | `string` | no | Tailwind overrides for the content element — **not** for the outer container. | -| `defaultOpen` | `boolean` | no | Initial open state of the underlying Shadcn `SidebarProvider`. Defaults to `true`. | -| `branding` | `AppShellBranding` | no | App branding, applied by `useAppShellBranding`: `primaryColor` / `accentColor` become CSS custom properties on the document root, `favicon` sets the icon link's `href`, and `title` sets `document.title`. | -| `rightRail` | `React.ReactNode` | no | Optional right-side rail. It reflows the content beside it rather than overlaying it; absent → unchanged single-pane layout. | - -### There is no `app-shell` node - -`app-shell` is not a component key. `registerLayout()` (`packages/layout/src/index.ts`) -does not register it, and nothing else in this repo does either, so a -`{ "type": "app-shell" }` node resolves to nothing and says so. Measured on this tree: - -- `SchemaRenderer` replaces the node with its error panel — - `Unknown component type: app-shell`, error code `OBJUI-001`. -- `sdui-parser` reports it before render, as an `error`-severity diagnostic with code - `unknown-component` and the message ` is not a known component`. - -It **was** registered until objectui#4841, and the registration could never produce a -shell. Four of the seven props are `React.ReactNode` slots that a JSON document cannot -fill, so a node had exactly two outcomes: `children` was stripped by `SchemaRenderer` -before a node's keys were spread as props — `AppShell` reads its `children` prop, never -`schema.children` — so the `
` element rendered **empty with nothing logged**; and a -schema written into `sidebar` / `navbar` / `rightRail` arrived as a plain object, which -React refuses to render, replacing the node with an error box. Only `className`, -`defaultOpen` and `branding` ever survived the JSON path, i.e. the best result JSON could -reach was a shell with no navigation, no top bar and an empty content area. The key was -retired under ADR-0049 (enforce-or-remove) so that this comes out as a named refusal -rather than a page that renders nothing. - -Two doors remain, one per capability: - -- **Compose in React** — `` as shown above, with your JSON pages rendered - *inside* it through `SchemaRenderer`. -- **The whole shell from metadata** — the `app-schema-renderer` node, which renders - `AppSchemaRenderer`. It builds the branding and the sidebar navigation from the app - document it carries under `schema`, below. It draws no page content: the node's - `children` are not rendered, and `objectui validate` refuses them on this node. Page - content reaches the shell only as React `children`, when a host renders - `AppSchemaRenderer` in JSX. - -The node takes three inputs: - -- `schema`: the app document, nested, with its own `"type": "app"`. It is validated as - the app document, so the document's own refusals apply inside it: `mobileNavMode` - there is refused, because the mode belongs on the node. -- `basePath`: the prefix of every href the shell generates. -- `mobileNavMode`: `"drawer"` (the default) or `"bottom_nav"`, which adds a fixed - bottom bar. - -```json -{ - "type": "app-schema-renderer", - "basePath": "/apps/crm", - "mobileNavMode": "bottom_nav", - "schema": { - "type": "app", - "name": "crm", - "title": "CRM", - "navigation": [ - { "id": "home", "type": "page", "pageName": "home", "label": "Home" } - ] - } -} -``` - -The document has one spelling. Its keys written flat on the node (`"navigation"`, -`"title"` beside `"type": "app-schema-renderer"`) are not drawn, and the strict authoring -face (`StrictAnyComponentSchema` in `@object-ui/types/zod`) refuses them as unrecognized -keys. A node without `schema` draws the shell with no branding and no navigation. - -### Features - -- Responsive layout that adapts to mobile/tablet/desktop -- Collapsible sidebar with state management -- Sticky header -- Scroll management for content area -- Consistent spacing and structure - -## Page Component - -The `Page` component provides a consistent wrapper for individual pages with optional headers. - -### Basic Usage - -```json -{ - "type": "page", - "pageType": "app", - "title": "User Management", - "description": "Manage users and permissions", - "children": { - "type": "container", - "children": [ - { "type": "text", "content": "User list goes here" } - ] - } -} -``` - -`pageType` decides whether the page draws its own `title` and `description`. `app`, `list`, -`utility` and `home` draw both, unless a titled `page:header` block in the page takes over -the heading. A page with no `pageType` is a `record` page, which draws neither: its heading -is a `page:header` block, as [Detail Page with Actions](#detail-page-with-actions) shows. -So without `"pageType": "app"`, the page above renders only its children. -[Content Width](#content-width) covers what else each type changes. - -### With Action Buttons - -A `page` node has no action row of its own. Buttons are NODES, and they go in `children`. -This is a list of products, so the page is a `list` page, which draws its `title` itself: - -```json -{ - "type": "page", - "pageType": "list", - "title": "Products", - "children": [ - { - "type": "flex", - "properties": { - "justify": "end", - "gap": 2, - "children": [ - { - "type": "button", - "label": "Add Product", - "variant": "default", - "icon": "plus" - }, - { - "type": "button", - "label": "Export", - "variant": "outline", - "icon": "download" - } - ] - } - }, - { - "type": "object-grid", - "properties": { "objectName": "products" } - } - ] -} -``` - -`label` is the button's text key — `text` is not a `ButtonSchema` key. `objectui validate` -refuses it by name, but nothing on the render path does: `BaseSchema` is `.passthrough()`, -so the rendering face keeps the unknown key and `button.tsx`, which reads `schema.label`, -renders a button with no text. - -> **⛔ `actions` on a `page` node is refused by name** (objectui#7926). This page used to -> teach `"actions": [ … ]` as a sibling of `title`, and it drew **nothing**: `PageRenderer` -> has never had a read point for the key, and `BaseSchema`'s `.passthrough()` kept the array -> rather than refusing it — so the author got a green validation and an empty page (before -> objectui#7933 it also reached the DOM as `actions="[object Object]"`). `PageNodeSchema` -> now declares the key as a refusal, so the same document fails with the remedy in the -> message instead of rendering silently short. Buttons in `children`, as above; on a record page, -> the `page:header` block's own `actions` — which are **action ids**, not nodes -> (see the [PageHeader reference](/docs/layout/page-header)). - -> **⛔ `breadcrumbs` on a `page` node is refused by name** (objectui#8871). This page used -> to declare it in the Schema API block below and author it in two passages, and it drew -> **nothing**: no renderer has ever read the key, and `BaseSchema`'s `.passthrough()` kept -> the array rather than refusing it — the same silent-accept shape as `actions`, retired -> under the same ADR-0049 enforce-or-remove gate. The trail is a **node**, not a key: put -> a `breadcrumb` node in `children`, as [Breadcrumbs for Deep Navigation](#2-breadcrumbs-for-deep-navigation) -> shows. ⛔ Not the `page:header` block's `breadcrumb` either — that one is a singular -> **boolean** the renderer ignores, not a list of links. - -### Schema API - - -```typescript -{ - type: 'page', - - // Header - title?: string, // Page heading; not drawn on a 'record' page, whose heading is a `page:header` block - description?: string, // Line under the heading; a 'record' page does not draw it - icon?: string, // Optional icon - // NO `actions` — refused by name (objectui#7926); put the buttons in `children` - // NO `breadcrumbs` — refused by name (objectui#8871); put a `breadcrumb` node in `children` - - // Content - children: SchemaNode, // Main page content - - // Layout - pageType?: 'record' | 'home' | 'app' | 'utility' | 'list', // default 'record'; sets the max width and who draws the heading - // NO `maxWidth` — refused by name (objectui#11318); use `pageType`, or a `container` in `children` - // NO `padding` — refused by name (objectui#11318); put a `container` with `padding` in `children` - - // Styling - className?: string, -} -``` - -### Content Width - -A `page` node has no `maxWidth` key. **`pageType`** sets how wide the page's content may -grow: `utility` is the narrowest and suits settings and forms, `record` (the default), -`list` and `app` share a wider cap, and `home` is the widest. `getPageMaxWidth` in -`packages/components/src/renderers/layout/page.tsx` holds the mapping. - -```json -{ - "type": "page", - "pageType": "utility", - "title": "Settings", - "children": { - "type": "form", - "fields": [] - } -} -``` - -A page laid out in `regions` can drop the cap altogether: a `main` region declared -`width: 'full'` makes the page full-bleed. That is the only `width` a `main` region takes. -The template decides how wide `main` is (the space `sidebar` and `aside` leave, or one grid -cell on `dashboard`), so `'small'`, `'medium'` and `'large'` on `main` are refused at that -region's `width`, as `@objectstack/spec` refuses them. Other regions keep all four values. - -`pageType` is more than a width. A `record` page leaves its heading to a `page:header` -block, so a `title` on a page with no `pageType` draws no heading. That is one more reason -the settings page above says `utility`. - -For a column narrower than the page's cap, wrap the content in a `container` and set -**its** `maxWidth`. That key is a `container` member, and the -[Container reference](/docs/components/layout/container) lists its values: - -```json -{ - "type": "page", - "pageType": "utility", - "title": "Profile", - "children": { - "type": "container", - "maxWidth": "2xl", - "children": { - "type": "form", - "fields": [] - } - } -} -``` - -> **⛔ `maxWidth` on a `page` node is refused by name** (objectui#11318). This page used to -> teach `"maxWidth": "lg"` beside `title`, with a list of sizes, and it changed **nothing**: -> `PageRenderer` has no read for the key and takes its width from `pageType`, while -> `BaseSchema`'s `.passthrough()` kept the value rather than refusing it. `PageNodeSchema` -> now declares the key as a refusal whose message names `pageType` and the `container`. - -## Page Header (`page:header`) - -`page:header` is a page's title bar: a title, an optional subtitle and, on a record page, the -record chip and the record's header actions. Its props contract is `PageHeaderProps`, the -`ComponentPropsMap['page:header']` row, and it is rendered by the record-aware -`PageHeaderRenderer` in `@object-ui/components`. The -[Page Header reference](/docs/layout/page-header) documents every key, both layouts and the -styling. - -### Usage - -```json -{ - "type": "page:header", - "properties": { - "title": "{first_name} {last_name}", - "subtitle": "View and edit customer information", - "actions": ["edit_customer", "send_email"] - } -} -``` - -The props go in `properties`, which is where `PageHeaderProps` judges them: a misspelled or -removed key there is refused by name. `className` is the one key that belongs on the node -itself, beside `type`. - -`title` and `subtitle` both interpolate `{field.path}` tokens against the surrounding -record context, so the title above resolves on a record page. Unresolvable tokens collapse to -an empty string rather than leaking the raw template. - -`actions` holds action **ids**, resolved from the object's own `actions` metadata, which -keeps the definitions in one place. They are **not** `SchemaNode` nodes: the contract refuses -an entry that is not a string. Nor does the header draw `children`, so a button written there -does not render. - -### Schema API - - -```typescript -{ - type: 'page:header', - className?: string, // on the node, beside `type` - properties?: { - title?: string | Record, // {field.path} tokens interpolated; derived from the record when omitted on a record page - subtitle?: string | Record, // secondary line; same interpolation - actions?: string[], // action ids, resolved against the object's own actions - recordChrome?: boolean, // default true — the record chip on a record page - showCopyId?: boolean, // default true - maxVisible?: number, // positive integer; inline action buttons (renderer default 3) - mobileMaxVisible?: number, // positive integer; the same on mobile (renderer default 1) - aria?: { ariaLabel?, ariaDescribedBy?, role? }, - }, -} -``` - -The favorites star beside the record title draws where the host wires a favorites toggle, and metadata does not control it. - -> **Write `subtitle`; there is no `description` and no `icon`.** The contract refuses -> `description` on this node and names `subtitle` in its place. It refuses `icon` by name as -> an ADR-0087 D2 tombstone — "`page:header` property `icon` was removed in -> @objectstack/spec 17.0.0 (ADR-0087 D2) — no renderer ever read it … Delete the key." The -> header's identity comes from the record chip (`recordChrome`), and each action carries its -> own `icon`. - -> **There is no `breadcrumbs` array, and `breadcrumb` draws nothing.** The contract still -> accepts a singular **boolean** `breadcrumb` until objectstack#20758 retires it; the renderer -> ignores it. A trail of links is a `breadcrumb` node — see -> [Breadcrumbs for Deep Navigation](#2-breadcrumbs-for-deep-navigation). - -> **`page-header` is retired; author `page:header`.** `page-header` (and its namespaced form -> `layout:page-header`) is no longer registered (objectui#10859): a node written that way -> renders the "Unknown component type" panel, and `objectui validate` refuses it at `type`. -> Write `{ "type": "page:header", "properties": { "title": "…", "subtitle": "…" } }`; the -> secondary line is `subtitle`. - -## SidebarNav Component - -The `SidebarNav` provides a collapsible navigation sidebar with menu items. - -**`SidebarNav` is a React component, and `sidebar-nav` is not a component key at all.** -`registerLayout()` (`packages/layout/src/index.ts`) registers two keys — `page:card` and -`app-schema-renderer` — and nothing in this repo registers `sidebar-nav`. What a -`{ "type": "sidebar-nav" }` node actually does is measured under -[There is no `sidebar-nav` node](#there-is-no-sidebar-nav-node) below. Compose the nav in -React, or declare it as the app's `navigation` metadata when the tree has to come from JSON. - -### Basic Usage - -`SidebarNav` renders a Shadcn `Sidebar`, so it must be inside a `SidebarProvider` — -`AppShell` supplies one. Its rows are `NavLink`s, so it also needs a router above it. - - -```tsx -import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; -import { SchemaRenderer } from '@object-ui/react'; -import { BarChart3, LayoutDashboard, Users } from 'lucide-react'; - -const navItems: NavItem[] = [ - { title: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, - { title: 'Users', href: '/users', icon: Users, badge: '12' }, - { - title: 'Reports', - href: '/reports', - icon: BarChart3, - children: [ - { title: 'Sales', href: '/reports/sales' }, - { title: 'Analytics', href: '/reports/analytics' }, - ], - }, -]; - -}> - -; -``` - -Three things that block a copy-paste: `icon` is a **component**, not an icon name — -`SidebarNav` renders it as ``. Nested rows go in `children`; `items` is a key -on `NavGroup`, never on a `NavItem`. And `href` is **required** on every item, including -one that only expands children — it is the row's React key as well as its link target. - -### Props - -`SidebarNavProps` (`packages/layout/src/SidebarNav.tsx`) declares exactly these six. - -| Prop | Type | Required | Description | -| --- | --- | --- | --- | -| `items` | `NavItem[] \| NavGroup[]` | yes | The rows. A flat `NavItem[]`, or `NavGroup[]` for labelled sections — the array's first element decides which. | -| `title` | `string` | no | Group label shown above a flat, ungrouped `items` list. Defaults to `"Application"`. | -| `className` | `string` | no | Tailwind overrides for the `Sidebar` root. | -| `collapsible` | `"offcanvas" \| "icon" \| "none"` | no | How the sidebar collapses at or above 768px. Defaults to `"icon"`. Not a boolean. | -| `searchEnabled` | `boolean` | no | Show a search input that filters rows by title, children included. Defaults to `false`. | -| `searchPlaceholder` | `string` | no | Placeholder for that input. Defaults to `"Search…"`. | - -#### `NavItem` - -| Key | Type | Required | Description | -| --- | --- | --- | --- | -| `title` | `string` | yes | The row's label. | -| `href` | `string` | yes | Link target, and the row's React key. | -| `icon` | `React.ComponentType<{ className?: string }>` | no | Rendered as `` — pass the Lucide component, not its name. | -| `badge` | `string \| number` | no | Trailing badge content. Rendered whenever it is not `null`/`undefined`, so `0` shows. | -| `badgeVariant` | `'default' \| 'destructive' \| 'outline'` | no | Badge styling. Defaults to `'default'`. | -| `children` | `NavItem[]` | no | One level of nested rows; the parent becomes a collapsible trigger. | - -#### `NavGroup` - -| Key | Type | Required | Description | -| --- | --- | --- | --- | -| `label` | `string` | yes | Section heading, shown in place of `title`. | -| `items` | `NavItem[]` | yes | The section's rows. | - -There is no `active` key — the active row is derived from the router -(`pathname === item.href`), never declared. There is no `disabled` key either, and no -`defaultOpen`: that one is `AppShell`'s prop, not this component's. - -### There is no `sidebar-nav` node - -`sidebar-nav` was never registered, so the node does not resolve to anything. Rendering -`{ "type": "sidebar-nav", "items": [...] }` produces the renderer's red error box instead -of a sidebar: - -``` -Unknown component type: sidebar-nav -💡 Ensure the component is registered via registry.register() before rendering. - Check for typos in the component type name. (OBJUI-001) -``` - -This is louder than the `app-shell` case above — nothing is silently dropped, because -nothing is parsed as props at all. The whole sidebar is replaced by the error panel. - -When the navigation tree genuinely has to come from JSON, it is application metadata: -the app document's `navigation` items (`{ "type": "app", "navigation": [...] }`, which -`objectui validate` accepts), drawn by the shell — the console's sidebar, or -`AppSchemaRenderer` (the whole shell from metadata, above). Both hand the items to the -`NavigationRenderer` component, and its items are JSON-shaped: `icon` really is a string -name there, resolved by `resolveIcon`. There is no page node for navigation: -`navigation-renderer` was retired (objectui#11441), so a node written with that type -renders the same "Unknown component type" panel. - -### Features - -- Nested menu items (2 levels) with collapsible expand/collapse -- Active state highlighting via React Router -- Icon support (Lucide icons) -- Badge/counter support with variant styling (`default`, `destructive`, `outline`) -- NavGroup support for grouped navigation sections -- Built-in search filtering (`searchEnabled`) across all items and children -- Collapse/expand animation - -## Common Layout Patterns - -### Full Application Layout - -The shell is React; the page inside it is your JSON. - - -```tsx -import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; -import { SchemaRenderer } from '@object-ui/react'; -import { SidebarTrigger } from '@object-ui/components'; -import { Home, Package, ShoppingCart } from 'lucide-react'; - -const navItems: NavItem[] = [ - { title: 'Home', href: '/', icon: Home }, - { title: 'Products', href: '/products', icon: Package }, - { title: 'Orders', href: '/orders', icon: ShoppingCart }, -]; - - - - My App - - } - sidebar={} - defaultOpen -> - - -``` - -`AppShell` adds no controls of its own to the top bar — the sidebar toggle above is one -you render yourself, in `navbar`. There is no `sidebarCollapsible` prop: the sidebar's -collapse behaviour belongs to the sidebar node you pass (`SidebarNav`'s `collapsible`), -and `defaultOpen` is the shell's own initial-state prop. - -### Landing Page (No Sidebar) - -Omit `sidebar` and the content fills the width under the top bar. - - -```tsx -Welcome}> - - -``` - -### Settings Page with Tabs - -A settings page is a `utility` page, which gives it the narrowest content width (see -[Content Width](#content-width)): - -```json -{ - "type": "page", - "pageType": "utility", - "title": "Settings", - "children": { - "type": "tabs", - "items": [ - { - "label": "General", - "value": "general", - "content": { "type": "form", "fields": [] } - }, - { - "label": "Security", - "value": "security", - "content": { "type": "form", "fields": [] } - }, - { - "label": "Notifications", - "value": "notifications", - "content": { "type": "form", "fields": [] } - } - ] - } -} -``` - -### Detail Page with Actions - -Same rule as above, and it governs the trail too: the breadcrumb and the buttons are both -**nodes in `children`** — never a `breadcrumbs` or an `actions` key on the page. - -A detail page is a `record` page. That is the default, and it is named below so the snippet -says so. A record page draws no `title` and no `description` of its own: its heading is -the [`page:header`](#page-header-pageheader) block, so the record's name goes in that -block's `properties.title`, not on the page. - -```json -{ - "type": "page", - "pageType": "record", - "children": [ - { - "type": "breadcrumb", - "items": [ - { "label": "Home", "href": "/" }, - { "label": "Customers", "href": "/customers" }, - { "label": "Acme Corporation" } - ] - }, - { - "type": "page:header", - "properties": { "title": "Acme Corporation" } - }, - { - "type": "flex", - "properties": { - "justify": "end", - "gap": 2, - "children": [ - { - "type": "action:button", - "properties": { - "name": "edit_record", - "label": "Edit", - "variant": "default", - "icon": "pencil", - "actionType": "editRecord" - } - }, - { - "type": "action:button", - "properties": { - "name": "delete_record", - "label": "Delete", - "variant": "danger", - "icon": "trash", - "actionType": "deleteRecord" - } - } - ] - } - }, - { - "type": "card", - "children": [ - { "type": "text", "content": "Record details..." } - ] - } - ] -} -``` - -A button that RUNS something is an `action:button` node, not a `button` carrying an -`onClick`. Its props — `actionType` and the rest of the block's row — go in the node's -`properties` bag, as `@objectstack/spec` declares them; the spec's strict page component -refuses them written flat on the node, and `SchemaRenderer` hoists the bag onto the node -before the renderer reads it. `ButtonSchema.onClick` is declared as a runtime slot for a host-supplied -function and the zod mirror refuses it BY NAME — JSON has no function value, and no -handler key consumes a declarative action object. The refusal is not the whole cost: -`onClick` is on `SDUI_DOM_PASS_THROUGH_KEYS`, so an authored string or object is -forwarded to the real DOM listener slot, and React throws the moment anyone clicks. -Measured, React's own error: "Expected `onClick` listener to be a function, instead got -a value of `object` type." - -The handler name goes in `actionType`, which `action:button` forwards to the action -runner as the action's type; the runner dispatches to the handler registered under it. -Same spelling as [Record Edit Modes](./record-edit-modes.md). - -The rest of the action is authored on the same node. `action:button` and `action:icon` -evaluate `visible` and `disabled` themselves (a boolean, a CEL string, or a -`{ dialect, source }` envelope), and forward the action-definition keys to the runner: -`params`, `confirmText`, `successMessage`, `errorMessage`, `toast`, `bodyExtra`, -`bodyShape`, `operation`, `onSuccess`, `objectName` and the others each -registration's `inputs` lists, with a `description` per key. For an `api` action, write -the request URL in `target`: `endpoint` is not published, because the console's `api` -handler never reads it. - -An `operation: update` node runs the declared action its `name` names: the platform -action route writes that declaration's `patch` under the collected params. A `patch` -written on the node itself is not forwarded and writes nothing. - -`undoable: true` on an `operation: update` makes the success toast offer Undo, which -writes back the prior values of the fields the update wrote: the named declaration's -`patch` fields and the collected params. `action:button` reads those values off the -record in scope: the record page's record, or the row the host binds through `data`. A -button with no record in scope offers no Undo, because there is no row to restore; -neither does one whose `recordId` names a record other than the one in scope. - -## Responsive Behavior - -The shell has exactly **one** layout breakpoint, at **768px** — Tailwind's `md`, and -`MOBILE_BREAKPOINT` in `packages/components/src/hooks/use-mobile.tsx`. There is no -separate tablet tier: nothing in `AppShell`, `SidebarNav` or the Shadcn sidebar underneath -them reads `lg` (1024px), so 800px and 1400px get the same layout. - -### Sidebar, at or above 768px - -- Rendered inline, as a flex sibling of the content — not an overlay. -- How it collapses is the sidebar node's own `collapsible` prop: `"icon"` (the `SidebarNav` - default) leaves an icon rail, `"offcanvas"` slides it fully out, `"none"` pins it open. -- `AppShell`'s `defaultOpen` picks the initial state, and defaults to `true`. - -### Sidebar, below 768px - -- It leaves the layout entirely and becomes a `Sheet` overlay (18rem) above the content, - which is why the content is full-width there. -- **Nothing opens it for you.** `AppShell`'s header renders `{navbar}` and nothing else — - it adds no controls of its own, in particular **no sidebar toggle**. Render a - `SidebarTrigger` inside `navbar` yourself; otherwise the only way in is the - `SidebarProvider` keyboard shortcut, `Cmd/Ctrl + B`, which no touch device has. - -### Header and content - -- The header is `h-12` (3rem / 48px) at **every** breakpoint — there is no compact - variant — and spans the full viewport width at every size. The only thing about it that - responds is horizontal padding, and it turns at `sm` (640px), not 768: `px-2 sm:px-4`. -- Content padding steps three ways — `p-3`, `sm:p-4` (640px), `md:p-6` (768px) — with a - taller `pb-20` below `sm` only. - -## Styling and Customization - -### Custom Classes - -Add Tailwind classes to layout components: - - -```tsx - - My App - - } - sidebar={} -> - - -``` - -`className` is the only class hook `AppShell` itself takes, and it lands on the content -element (a `
` inside the shell's one `
` landmark) — not on the outer container. There is **no** per-slot className: -`headerClassName`, `sidebarClassName` and `contentClassName` do not exist. The top bar and -the sidebar are nodes you build, so style them where you build them, as above. - -### Page Padding - -A `page` node has no padding switch. Its wrapper always insets the content: `p-3`, then -`md:p-4`, then `lg:p-6`. The padding you control is a `container`'s. Its `padding` is a -step on the container's spacing scale: one of 0 to 8, 10, 12 or 16, and `0` means none. -Any other number is refused: - -```json -{ - "type": "page", - "children": { - "type": "container", - "maxWidth": false, - "padding": 8, - "children": [] - } -} -``` - -`"maxWidth": false` keeps the container as wide as the page. Leave it out and the container -also caps its own width at its default, `xl`. The -[Container reference](/docs/components/layout/container) lists both scales. - -> **⛔ `padding` on a `page` node is refused by name** (objectui#11318). This page used to -> teach `"padding": false` here, and it removed **nothing**: the page wrapper kept its inset, -> because `PageRenderer` has no read for the key and `BaseSchema`'s `.passthrough()` kept the -> value rather than refusing it. The refusal's message names the `container` and its `padding`. - -## Best Practices - -### 1. Consistent Structure - -Compose the shell once and let the page JSON change per route: - - -```tsx -// One shell for the whole app; `pageSchema` is whatever the route resolves to. -}> - - -``` - -### 2. Breadcrumbs for Deep Navigation - -Add a breadcrumb trail to help users navigate. It is a **node in `children`**, not a key on the -page — `breadcrumb`, singular, is the registered renderer: - -```json -{ - "type": "breadcrumb", - "items": [ - { "label": "Home", "href": "/" }, - { "label": "Products", "href": "/products" }, - { "label": "Electronics", "href": "/products/electronics" }, - { "label": "Laptops" } - ], - "separator": "/", - "maxItems": 3 -} -``` - -`separator` defaults to `/`, and `maxItems` collapses the middle of a long trail behind an -ellipsis while keeping the first crumb and the current page. See the -[Breadcrumb reference](/docs/components/data-display/breadcrumb) for the per-key face. - -⛔ Not `"breadcrumbs"` on the `page` node — that key has no reader and is refused by name -(objectui#8871), the same way `actions` is. ⛔ Nor the `page:header` block's `breadcrumb`, -a **boolean** the renderer ignores rather than a list of links. - -### 3. Action Buttons at the Top of the Body - -Place primary actions in the first `children` node, so they sit above the content. A list -of orders is a `list` page, which draws its `title` as the heading above them: - -```json -{ - "type": "page", - "pageType": "list", - "title": "Orders", - "children": [ - { - "type": "flex", - "properties": { - "justify": "end", - "gap": 2, - "children": [ - { "type": "button", "label": "New Order", "variant": "default" } - ] - } - } - ] -} -``` - -⛔ Not `"actions"` on the `page` node — that key has no reader and is refused by name -(objectui#7926). A record page has a second door: the `page:header` block, whose `actions` -are **action ids** resolved from the object's own actions metadata, not nodes -(see the [PageHeader reference](/docs/layout/page-header)). - -### 4. Constrained Width for Forms - -Give forms and reading content a narrow column: a `container` with its own `maxWidth`, -inside the page. ⛔ Not `maxWidth` on the `page` node, which is refused by name -(objectui#11318); the page's own cap comes from `pageType` (see [Content Width](#content-width)). - -```json -{ - "type": "page", - "pageType": "utility", - "children": { - "type": "container", - "maxWidth": "2xl", - "children": { - "type": "form", - "fields": [] - } - } -} -``` - -### 5. Sidebar Organization - -Group related items with `NavGroup`. Pass groups instead of a flat list and `SidebarNav` -labels each section and draws the separator between them itself — there is no `divider` -item, and a row is either a link or a group, never both: - - -```tsx -import { SidebarNav, type NavGroup } from '@object-ui/layout'; -import { DollarSign, Home, Settings } from 'lucide-react'; - -const navGroups: NavGroup[] = [ - { - label: 'Overview', - items: [{ title: 'Dashboard', href: '/', icon: Home }], - }, - { - label: 'Sales', - items: [ - { - title: 'Sales', - href: '/sales', - icon: DollarSign, - children: [ - { title: 'Orders', href: '/orders' }, - { title: 'Invoices', href: '/invoices' }, - ], - }, - ], - }, - { - label: 'System', - items: [{ title: 'Settings', href: '/settings', icon: Settings }], - }, -]; - -; -``` - -## Related Documentation - -- [Components Overview](/docs/components) - All available components -- [Schema Rendering](/docs/guide/schema-rendering) - How schemas work -- [Architecture Overview](/docs/guide/architecture) - System architecture diff --git a/ui/content/docs/guide/meta.json b/ui/content/docs/guide/meta.json deleted file mode 100644 index e2b11ad2c23..00000000000 --- a/ui/content/docs/guide/meta.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "title": "Guide", - "pages": [ - "quick-start", - "agent-skills", - "architecture", - "architecture-overview", - "schema-rendering", - "schema-overview", - "schema-playground", - "schema-catalog", - "layout", - "expressions", - "data-source", - "fields", - "component-registry", - "theming", - "plugins", - "plugin-development", - "building-crud-app", - "designing-app-navigation", - "public-forms", - "record-edit-modes", - "notifications", - "dashboard-filters", - "slotted-pages", - "react-pages", - "print-pages", - "console", - "console-architecture", - "flow-designer", - "metadata-diagnostics", - "user-state-persistence", - "objectos-integration", - "troubleshooting", - "deployment", - "release-notes" - ] -} diff --git a/ui/content/docs/guide/metadata-diagnostics.md b/ui/content/docs/guide/metadata-diagnostics.md deleted file mode 100644 index 9613eaadcc7..00000000000 --- a/ui/content/docs/guide/metadata-diagnostics.md +++ /dev/null @@ -1,322 +0,0 @@ ---- -title: Metadata Diagnostics -description: How ObjectStack surfaces load-time validation problems in Studio. ---- - -# Metadata Diagnostics - -Every metadata item shipped by a package — `object`, `view`, `report`, -`dashboard`, `flow`, `app`, … — is validated against its Zod schema when -the framework loads it. The validation result travels alongside the -item as a `_diagnostics` envelope, and Studio surfaces it at four levels -so authors and operators can fix problems without grepping logs. - -> **Backend agnostic.** The shape and the REST endpoint described below -> are part of the ObjectStack protocol. Studio is one consumer; any -> custom UI built on `@object-ui/data-objectstack` can render the same -> envelope. - -## The `_diagnostics` envelope - -```ts -interface MetadataDiagnostics { - valid: boolean; - errors?: Array<{ path: string; message: string; code?: string }>; - warnings?: Array<{ path: string; message: string; code?: string }>; -} -``` - -* `valid === false` means **at least one error** — features that depend - on the item (rendering, queries, automation) are unsafe to use. -* `warnings[]` is advisory — items remain `valid: true` but operators - should review (deprecations, performance hints, missing-but-defaultable - fields). -* `path` is dot-delimited, matching the same convention Zod uses - (`fields.email.type`, `columns.0.bind`). - -The envelope is attached to: - -| Endpoint | Where the envelope lives | -|:---------------------------------------|:----------------------------------| -| `GET /api/v1/meta/items/:type` | Each list entry (`item._diagnostics`) | -| `GET /api/v1/meta/items/:type/:name` | Top-level (`item._diagnostics`) | -| `GET /api/v1/meta/items/:type/:name?layered=true` | `effective._diagnostics` | -| `GET /api/v1/meta/diagnostics` | Sweep — see next section | - -## The diagnostics sweep endpoint - -`GET /api/v1/meta/diagnostics` runs validation across **every metadata -type and item** in one round-trip. It powers the governance overview -page and the per-type tile badges. - -```http -GET /api/v1/meta/diagnostics?severity=error -``` - -| Query | Default | Effect | -|:-------------|:----------|:------------------------------------------------| -| `severity` | `error` | `error` returns invalid items only; `warning` also returns items with only warnings. | -| `type` | — | Limit to a single metadata type. | -| `package` | — | Limit to one package id. | - -Response: - -```ts -import type { MetadataDiagnostics } from '@object-ui/data-objectstack'; - -interface MetadataDiagnosticsSummary { - entries: Array<{ - type: string; - name: string; - diagnostics: MetadataDiagnostics; - }>; - total: number; // entries.length - scannedTypes: number; // how many metadata types were checked - scannedItems: number; // how many items were checked in total - /** - * Per-type aggregate stats — count of items and the list of - * packages contributing to each type. Computed in the same sweep so - * directory tiles render counts and a package filter without - * additional round-trips. Empty `{}` on framework versions older - * than the 7.x line. - */ - stats: Record; -} -``` - -Use this as a CI gate too — `total === 0` is the green-build condition. - -## Studio UI surfaces - -### 1. Directory page badges - -`/apps//metadata` — the directory is **scoped to the active project -software package** (the sidebar `active_package` selector, published as -`?package=`). Only metadata types that the selected project package -contributes are listed — system/cloud types never appear, and there is no -in-page "All packages" dropdown. If the URL holds no valid project -package the page repairs it to the first available one. Each visible type -tile shows: - -* A neutral count badge with the total items of that type. (Note: this - total spans all packages — the per-type *list* page it links to is - strictly scoped to the active project package.) -* A red ⚠ + count when any items fail validation (errors). -* An amber ⚠ + count when items have warnings but no errors. - -Tiles deep-link into the list page carrying the active `?package=`, so the -scope survives navigation. - -The "View all issues (N)" link in the filter row jumps straight to the -governance page. - -### 2. Resource list rows - -`/apps//metadata/` — invalid rows get a red ⚠ icon next to -the name and a destructive-tinted background; warning-only rows get an -amber ⚠ and amber tint. The list header shows aggregate "Invalid N" and -"Warnings N" chips. Hover the ⚠ for the first three messages. - -The list page is **always scoped to a single project software package**. -Studio's sidebar exposes a mandatory **Package** scope selector (the app's -`active_package` context selector) whose options are the installed -*project* packages — system/cloud packages are never offered and there is -no "All" choice. The selection is published as the `?package=` URL -parameter, which every metadata list reads to filter rows by their -`_packageId`. If the URL holds no valid project package the list repairs -it to the first available one, so system metadata never leaks into the -view. (The page no longer renders its own per-type package dropdown — -scope is owned solely by the sidebar selector.) - -### 3. Resource edit banners - -`/apps//metadata//` — a destructive banner at the top -of the edit page lists the first three errors with their paths; the -same errors are also threaded into the form so the offending fields -get inline messages **without** the user having to click Save first. -Warnings, when present, render as a parallel amber banner. - -Edits clear the matching diagnostic immediately — the inline error on a -field disappears as soon as you start typing in it, then re-validates -on save. - -For **object** drafts the live validation goes beyond the Zod shape check: -every field conditional rule (`visibleWhen` / `readonlyWhen` / `requiredWhen`) -is linted as a CEL predicate with the same `@objectstack/formula` validators -the server uses. A predicate -that parses but references an unknown field, or references a field bare -instead of as `record.`, surfaces under its `fields..` -path in the banner. The field inspector's *Conditional rules* editors give -the same verdict inline as you type — with autocomplete for the object's -fields (after `record.` / `previous.`), the runtime-bound scope roots -(`record`, `previous`, `parent`), and the CEL stdlib. - -Formula fields get the same treatment for their value `expression`: the -inline editor lints it in `role: 'value'` mode and shows the **inferred -result type** (only a proven-Number formula is offered as a dataset -measure), while the draft-wide pass surfaces a broken formula on any field -under its `fields..expression` path. - -A predicate that slips past authoring is still not silent at **runtime**: -when a conditional rule (`visibleWhen` / `readonlyWhen` / `requiredWhen`, -view-level `visibleOn`, per-option `visibleWhen`, list conditional -formatting) fails to evaluate, the renderer applies the rule's safe default -(fail-open — a broken predicate never hides a field) and -logs **one `console.warn` per predicate** with the predicate source, the -engine's failure reason, and the field it was attached to. A rule that never -fires while its field stays visible is the classic symptom — open the -browser console and the broken predicate identifies itself (most often a -bare field name where `record.` was meant). - -One slot is the opposite: an object **field group**'s `visibleWhen` is -fail-CLOSED, as `@objectstack/spec` declares for -`ObjectFieldGroupSchema.visibleWhen`. A group whose predicate cannot be -evaluated is hidden whole, header and members, on the form and on the -record detail page. The same one-time warning names `safe default (false)` -and "the group is hidden". A form-view section's own `visibleWhen` keeps -failing open (objectui#13055). - -At **submit**, a broken field rule is refused rather than passed (ADR-0137 -D2). A field-level `visibleWhen` that cannot be evaluated refuses the submit -on the client — the record form, the `/forms/:name` and `/f/:slug` page, and a -wizard's final step — with a message naming the field and the rule, because no -server evaluates `visibleWhen` and its fail-open render would otherwise be a -silent grant. A broken `requiredWhen` / `readonlyWhen` is refused by the server, -and the form shows that refusal beside the input. A `visibleWhen` that reads -`previous` cannot be evaluated on a create form, so such a form is always -refused (and on an edit wizard too, whose final cross-step check binds no -`previous`). - -A **blank** field rule (`''`, whitespace, or an envelope whose `source` is -blank) is not "no rule" — it is refused, at the first place that can see it -(ADR-0137 D1 / D2). A new one is refused at authoring: the form schema's -`visibleWhen` / `readonlyWhen` / `requiredWhen` reject a blank predicate at -parse, as the protocol's field schema does. One already stored is refused at -submit, like any rule that cannot be evaluated: the client refuses a blank -`visibleWhen`, the server a blank `requiredWhen` / `readonlyWhen`. A blank -**gate** is different: at runtime it is read as "no gate" and never refused — -a blank `visible`, `hidden` or `disabled`, a page node's `visibleWhen`, or a -form view's own field `visibleWhen` — but it is not silent either: it is -reported once in the console with the `[blank]` reason (ADR-0137 D4). Where the -protocol already refuses a blank gate at authoring, the form schema does too: a -form field's view-level `visibleOn` and a select option's `visibleWhen` reject -a blank predicate at parse, as their protocol counterparts do. `visible`, -`hidden` and `disabled` have no protocol counterpart, so a blank there parses -and is only diagnosed. - -The same is now true of a **component node's own gate** — `visibleWhen` on a -page component (and its `visible` / `hidden` siblings; the `visibility`, -`visibleOn` and `hiddenOn` spellings are retired and not read), plus a `page:tabs` item's `visibleWhen`. These used to -report in a development build only, so a gate that stopped biting in -production left nothing on the console at all. They now warn in **both** -builds, with the node type, the node id, the gate key, the predicate source -and the engine's reason: - -```text -[ObjectUI] A visibility predicate could not be evaluated - node "record:alert" (id: "a1") - visibleWhen: "nosuchroot.status == 'draft'" - Reason: Failed to evaluate expression "nosuchroot.status == 'draft'": nosuchroot is not defined -... -``` - -The rest of the line says what the fault did to the node: on `visibleWhen` and -`visible` the node was **withheld**, on `hidden` it was removed (see below). - -The line is **rate limited to one per distinct predicate source**, so a broken -predicate rendered down two hundred rows of a list is one line, not two -hundred — while a second, differently-broken predicate still gets its own. - -Page visibility gates fail **closed**: a page node's `visibleWhen` (and its -`visible` / `hidden` siblings, objectui#13299) and a -`page:tabs` item's `visibleWhen` (objectui#13286). A declared predicate that -cannot be evaluated — over the CEL engine's AST bound, a type error, an unbound -root such as `record` on a surface with no row, a missing function — withholds -what it gates rather than drawing it for a viewer the predicate was written to -exclude: the node is not rendered, and a tab is not drawn, header and panel. A -related list withheld this way is not counted in its tab's badge either. The -console line says the node or tab was **withheld**. If a withheld tab was the -active one, the strip falls back to the first visible tab. A node or item with -no gate, or a blank one (`''`, whitespace, an envelope whose `source` is blank), -declares nothing and is still shown; a predicate that evaluates decides as it -always did. Form field rules (`visibleWhen` / `readonlyWhen` on a field, and -the other rules listed above) are not page visibility gates and keep their -fail-open. - -The **app shell's own `visible` gate** joins the same reporter and the same -rate limit: a `visible` predicate on a navigation item, on an area's -navigation, or on an object field rendered by the record form page. This one -was silent in **both** builds before — including the bare-string dialect, -which printed nothing at all — so a menu entry whose role gate had stopped -working rendered for everyone, silently, with nothing to grep for. It now -reports under the surface label `app-shell:visible`: - -```text -[ObjectUI] A visibility predicate could not be evaluated - node "app-shell:visible" - visible: "'org_admin' in current_user.postions" - Reason: ... -``` - -The dedupe key is the predicate **source**, not the menu entry — one broken -role gate copy-pasted across eight entries is one authoring mistake and prints -one line, while a second, differently-broken predicate still gets its own. -Fail-open is unchanged here too: the item still renders for everyone, -including the role the predicate was written to exclude. That is what the line -exists to tell you. - -### 4. Governance overview page - -`/apps//metadata/_diagnostics` — a single sortable table of every -invalid item across every type, grouped by type, with deep-links to the -offending edit page. Toggle the severity tab to include -warning-only items. This is the page to open during a release readiness -review. - -## Authoring metadata that validates cleanly - -Validation rules are defined by the Zod schemas in `@objectstack/spec`. -A few high-leverage patterns: - -* **Use `defineObject`, `defineView`, … helpers** from `@objectstack/spec` — - TypeScript catches most shape issues at compile time before they ever - reach the diagnostics path. -* **Run `os check`** locally before publishing. It calls the same - validators the server uses on load. -* **Treat warnings like errors in CI.** Pass `severity=warning` to the - sweep endpoint and assert `total === 0`. -* **Layered overlays merge first, then validate.** If only your runtime - overlay fails, the source artifact is fine — the bad value is in the - overlay. The edit banner reflects the *effective* item, so what you - see is what features will get. - -## Client SDK - -```ts -import { MetadataClient } from '@object-ui/data-objectstack'; - -const client = new MetadataClient({ baseUrl: 'https://api.example.com' }); -const summary = await client.diagnostics({ severity: 'error' }); -console.log(summary.total, 'invalid item(s)'); -``` - -The hook used by the Studio surfaces: - -```ts -import { useGlobalDiagnostics, useMetadataClient } from '@object-ui/app-shell'; - -const client = useMetadataClient(); -const { - loading, - error, - summary, - byType, // Record - warnByType, // Record (severity='warning' only) - countsByType, // Record - packagesByType, // Record - allPackages, // packageId[] — deduped union for filter dropdowns - reload, -} = useGlobalDiagnostics(client, 'warning'); -``` - -Pass `severity: 'warning'` when you need `warnByType` populated — the -server omits warning-only entries when the default `'error'` severity -is in effect. diff --git a/ui/content/docs/guide/notifications.md b/ui/content/docs/guide/notifications.md deleted file mode 100644 index f86e93895f5..00000000000 --- a/ui/content/docs/guide/notifications.md +++ /dev/null @@ -1,237 +0,0 @@ ---- -title: Notifications -description: The five spec display types and the surface that renders each ---- - -# Notifications - -ObjectUI's notification system implements the spec `NotificationSchema` -(`@objectstack/spec` → `ui/notification.zod.ts`). A notification carries two -independent axes: - -- **`severity`** — `info` / `success` / `warning` / `error`. Picks the icon and tone. -- **`displayType`** — `toast` / `snackbar` / `banner` / `alert` / `inline`. Picks the - **surface**: where and how it appears. - -`displayType` used to be stored and never read, so every type surfaced as a -toast — an author asking for a `banner` got a transient overlay. Each type now -has a presentation of its own. - -## The five presentations - -| `displayType` | Presentation | Rendered by | Persists | -|---|---|---|---| -| `toast` | Transient overlay | the host's `onToast` delegate (sonner in the console) | no — auto-dismiss | -| `snackbar` | Bottom-anchored bar, one at a time, at most one action | `` | no — auto-dismiss | -| `banner` | Page-width strip **in the content flow** | `` | yes — until dismissed | -| `alert` | Blocking acknowledgement dialog, FIFO queue | `` | yes — until acknowledged | -| `inline` | In place, at the surface that raised it | `` | yes — until dismissed | - -Auto-dismiss follows the presentation, not a single global timer: `toast` and -`snackbar` are transient (`config.defaultDuration`, 5s by default), the other -three stay until dismissed. An explicit `duration` always wins — including -`duration: 0`, which makes a toast persistent. - -## Mounting the surfaces - -`toast` is delegated to the host; the other four are React components that -subscribe to the provider. Placement is deliberately yours: a banner needs a -slot in the content area and an inline notification belongs next to the thing -that raised it, so neither can be positioned by a global overlay. - - - -```tsx -import { - NotificationAlerts, - NotificationBanners, - NotificationInline, - NotificationSnackbar, -} from '@object-ui/components'; -import { NotificationProvider } from '@object-ui/react'; -import { toast } from 'sonner'; - - toast[n.severity](n.title, { description: n.message })} -> -
- {/* top of the content area */} - -
- - -
-``` - -A provider with **no** `onToast` is a supported "notification centre" mode: items -are collected in `notifications` / `unreadCount` for a bell or list, and nothing -is overlaid. Raising one of the other four types with its surface unmounted is a -mistake, though — dev builds warn, naming the component to mount. - -### In the console - -`@object-ui/app-shell` already does this wiring — a console route can call -`useNotifications()` and every display type presents correctly with no setup: - -| Surface | Mounted by | -|---|---| -| `toast` | `ConsoleShell`, via `presentNotificationToast` → sonner (`ConsoleToaster`) | -| `snackbar`, `alert` | `ConsoleShell` — both have a single global home | -| `banner` | `ConsoleLayout`, at the top of the content area, beside the draft / unpublished bars | -| `inline` | nothing, by contract — the raising surface mounts its own `` | - -Assembling a shell by hand? Both pieces are exported: `presentNotificationToast` -for the `onToast` delegate, and `ConsoleNotificationBanners` — `NotificationBanners` -guarded by `useHasNotificationProvider()`, so a layout rendered without the -provider above it renders no banners instead of throwing. - -## Raising notifications - -```tsx -import { useNotifications } from '@object-ui/react'; - -declare const undo: () => void; - -const { notify } = useNotifications(); - -notify({ title: 'Saved', severity: 'success' }); // toast (spec default) -notify({ title: 'Row deleted', severity: 'info', displayType: 'snackbar', - actions: [{ label: 'Undo', onClick: undo }] }); // one action -notify({ title: 'Viewing a draft', severity: 'warning', displayType: 'banner' }); -notify({ title: 'Session expired', severity: 'error', displayType: 'alert' }); -``` - -### `actions` - -An action's `variant` is the spec vocabulary — `primary` (default) / `secondary` -/ `link` — describing the action's **role**. Each surface maps it onto its own -button styling; it is not the shadcn Button vocabulary, which is a look. - - - -```tsx -notify({ - title: 'Storage almost full', severity: 'warning', displayType: 'banner', - actions: [ - { label: 'Upgrade', onClick: upgrade }, // primary by default - { label: 'Learn more', onClick: openDocs, variant: 'link' }, - ], -}); -``` - -A `snackbar` and a `toast` render only the **first** action — both have one -action slot by nature. A `banner` and an `inline` render all of them; an `alert` -renders them beside its acknowledge button. - -### `inline` and `scope` - -An inline notification is rendered by the surface that raised it. `scope` is the -routing key that pairs the two, so two forms on one page don't show each other's -messages: - - - -```tsx -notify({ - title: 'Fix 2 fields', severity: 'error', - displayType: 'inline', scope: 'contact-form', -}); - - -``` - -Omit `scope` on both ends for a page-level inline outlet. `scope` is renderer-local -routing metadata, not a spec field — the spec describes what a notification *is*, -not which React subtree hosts it. - -### `icon` - -Every surface — including the console's sonner toast — resolves `icon` through -the same rule: a declared Lucide name (kebab-case or PascalCase) replaces the -severity icon; anything else falls back to it. - - - -```tsx -notify({ title: 'Deploy finished', severity: 'success', displayType: 'banner', icon: 'rocket' }); -``` - -A name Lucide doesn't have costs the author their override and nothing more — -deliberately *not* the generic `Database` glyph `getLazyIcon` returns for -data-shaped schema slots, which on an error notification would replace a -meaningful icon with a meaningless one. - -### `position` - -Honored by the **floating** presentations — `toast` and `snackbar`. `banner`, -`inline` and `alert` are anchored by what they are (content top / in place / -centred modal) and ignore it. - -Resolution is `notification.position ?? config.defaultPosition ?? nothing`, and -"nothing" is a real answer rather than a missing one: - -- **declared** → the surface pins itself there, always; -- **undeclared** → the surface keeps its own anchor (a snackbar's bottom edge) - or defers to the host's toast chrome. - -That asymmetry is deliberate. The host's toast container is shared with toasts -that are *not* spec notifications (in the console, the action runtime's own -`toast.*` calls), so it stays the fallback authority for placement — but never a -competing one. A declared position that a component prop could silently override -would be the same "validates, then does nothing" shape this whole area is about. - -## Configuring the system - -`NotificationProvider`'s `config` is `NotificationSystemConfig`, declared by -`@object-ui/react` (`packages/react/src/context/NotificationContext.tsx`) and -normalized by `resolveNotificationConfig`: - -| Key | Default | Effect | -|---|---|---| -| `defaultPosition` | — | Fallback position for the floating presentations. Deliberately unset: see above. | -| `defaultDuration` | `5000` | Auto-dismiss for the transient presentations. | -| `maxVisible` | `5` | Cap on a stacking surface (banner, inline); the newest survive. | -| `stackDirection` | `down` | Which way a stack grows — `down` puts the newest below. | -| `pauseOnHover` | `true` | Hold a transient notification's timer while it is hovered. | - -The legacy spellings `position` and `stacking` are still accepted: -`defaultPosition` wins over `position`, and `stacking: false` reads as -`maxVisible: 1` ("show only the newest") rather than being ignored. - -### `dismissible` - -Defaults to `true`. On the persistent presentations, `dismissible: false` removes -the dismiss control (a banner you must resolve rather than wave away). An `alert` -always keeps its acknowledge button — `dismissible: false` only closes the Escape -route, never the way out. - -## Notes - -- **`alert` is not the action system's modal.** `ModalHandler` resolves a page or - object, renders it, and reports an `ActionResult` back to the `ActionRunner`. A - notification alert has no schema, no target and no result — it is a message and - an acknowledgement, so it renders through the `AlertDialog` primitive instead. -- **`snackbar` is not a toast variant.** It supersedes rather than stacks, anchors - to the bottom regardless of the toast position config, and carries at most one - action. -- Adding a member to the spec `NotificationTypeSchema` fails type-check in - `NOTIFICATION_PRESENTATIONS` (`@object-ui/react`) until its presentation is - decided — new types cannot silently fall back to a toast. - -## Server-side notifications - -Server-side notifications are the ADR-0030 inbox rows: `sys_inbox_message`, -with read-state in `sys_notification_receipt`. `@object-ui/app-shell` reads them -through **one shared inbox feed** (`sharedUserFeeds`), and every surface that -shows them reads that feed. So however many of those surfaces mount, there is -one read on one cadence, and the surfaces cannot disagree about read-state: - -| Surface | How to get it | -|---|---| -| Header bell: Notifications tab and unread badge | `AppHeader` from `@object-ui/app-shell`; the console's own layouts already mount it | -| A bell placed on a page | a `global:notifications` node in the page schema | - -The feed itself is not exported. To show these rows, mount one of the surfaces -above rather than polling them yourself: a second poller beside the shell is -the duplicate-read shape the shared feed exists to remove. diff --git a/ui/content/docs/guide/objectos-integration.mdx b/ui/content/docs/guide/objectos-integration.mdx deleted file mode 100644 index aedc4c8f139..00000000000 --- a/ui/content/docs/guide/objectos-integration.mdx +++ /dev/null @@ -1,690 +0,0 @@ ---- -title: "ObjectOS Integration Guide" -description: "Complete guide for integrating ObjectUI with ObjectOS platform" ---- - -# ObjectOS Integration Guide - -ObjectUI is designed to be the official frontend renderer for the **ObjectOS** ecosystem. This guide provides comprehensive instructions for integrating ObjectUI components with ObjectOS, ObjectStack, and building enterprise applications. - -## Overview - -ObjectUI serves as the **UI Layer** in the ObjectOS architecture: - -``` -┌─────────────────────────────────────────────────┐ -│ ObjectUI (UI Layer) │ -│ React Components + Tailwind + Schema Rendering │ -└──────────────────┬──────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────┐ -│ ObjectStack (Runtime Layer) │ -│ Kernel + Plugins + ObjectQL + Data Drivers │ -└──────────────────┬──────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────┐ -│ ObjectOS (Platform Layer) │ -│ Multi-tenant + RBAC + System Objects + APIs │ -└─────────────────────────────────────────────────┘ -``` - -## Quick Start Integration - -### 1. Install Dependencies - -```bash -# Core ObjectUI packages -pnpm add @object-ui/react @object-ui/components @object-ui/fields - -# ObjectStack runtime -pnpm add @objectstack/core @objectstack/runtime @objectstack/objectql - -# Data adapter -pnpm add @object-ui/data-objectstack - -# Optional: Plugins as needed -pnpm add @object-ui/plugin-form @object-ui/plugin-grid -``` - -### 2. Set Up ObjectStack Kernel - -{/* doc-snippet: fragment — this file belongs to the ObjectStack SERVER project, not to an ObjectUI app. `@objectstack/runtime`, `@objectstack/objectql`, `@objectstack/plugin-app` and `@objectstack/plugin-hono-server` are dependencies of that project and are not declared by any package in this repository, so they do not resolve in this gate's program (measured: TS2307 x4). Correct documentation the gate cannot reach — the authoritative reference is the ObjectStack side, linked in the section below */} - -```typescript -// src/kernel.ts -import { Kernel } from '@objectstack/runtime'; -import { ObjectQLPlugin } from '@objectstack/objectql'; -import { AppPlugin } from '@objectstack/plugin-app'; -import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; - -export async function createKernel() { - const kernel = new Kernel(); - - // Register essential plugins - kernel.registerPlugin(new ObjectQLPlugin()); - kernel.registerPlugin(new AppPlugin()); - kernel.registerPlugin(new HonoServerPlugin({ - port: 3000, - cors: true - })); - - await kernel.start(); - return kernel; -} -``` - -### 3. Create ObjectStack Configuration - -`objectstack.config.ts` belongs to the **ObjectStack server project**, not to an -ObjectUI app. It is authored with `defineStack()` and compiled by the `os` CLI; -the packages it imports (`@objectstack/spec`, `@objectstack/runtime`, plugins) -are dependencies of that project, not of this one. ObjectUI is a pure consumer -of whatever the server publishes over the metadata API, so the authoritative -reference for this file lives on the ObjectStack side: - -- [Your First Project](https://objectstack.ai/docs/getting-started/your-first-project) — the generated `objectstack.config.ts` walked through key by key: `manifest`, `plugins`, `objects` -- [Command Line Interface](https://objectstack.ai/docs/deployment/cli) — how the file is discovered, validated against `ObjectStackDefinitionSchema`, and compiled to a deployable `dist/objectstack.json` -- [Data Flow Diagrams](https://objectstack.ai/docs/api/data-flow) — how `defineStack()` becomes a running application - -The rest of this guide is ObjectUI's half of the integration: rendering the -objects, views and apps that the server exposes. - -### 4. Set Up Frontend with Console - -```typescript -// src/App.tsx -import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; -import { ObjectStackAdapter } from '@object-ui/data-objectstack'; - -// Import required plugins -import '@object-ui/components'; -import '@object-ui/fields'; -import '@object-ui/plugin-form'; -import '@object-ui/plugin-grid'; - -// Initialize ObjectStack adapter -const dataSource = new ObjectStackAdapter({ - baseUrl: 'http://localhost:3000/api' -}); - -export function App() { - // The adapter is installed ONCE, by `SchemaRendererProvider` above the - // renderer: every node in the tree, the nested `object-view` included, reads - // it from that context. A `dataSource` prop on `SchemaRenderer` itself would - // only be forwarded to the root component. - return ( - -
- -
-
- ); -} -``` - -Mount `App` from your project's own React entry point, the way any React -application is bootstrapped — `createRoot` out of the DOM renderer package, -inside a `StrictMode`. That entry point is yours, not ObjectUI's: no -`@object-ui/*` package installs a DOM renderer for you, it is asked for as a -peer dependency, so the snippet above stops at the component boundary rather -than showing a line that would only resolve inside this repository. - -## ObjectOS-Specific Features - -### Multi-Tenancy Support - -The adapter's constructor takes no `headers` option. Per-request headers go -through its `fetch` hook, which it uses for every call it makes: - -```typescript -// Configure tenant isolation -import { ObjectStackAdapter } from '@object-ui/data-objectstack'; - -const adapter = new ObjectStackAdapter({ - baseUrl: 'http://localhost:3000/api', - fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set('X-Tenant-ID', 'tenant-123'); - headers.set('X-Workspace-ID', 'workspace-456'); - return globalThis.fetch(input, { ...init, headers }); - } -}); -``` - -### Role-Based Access Control (RBAC) - -{/* doc-snippet: fragment — a SHAPE excerpt of the ObjectStack SERVER's object metadata (the `objects` map of `defineStack()`), not an expression: a bare object literal at statement position parses as a block with labels (measured: TS1005 x4, TS1109 x1, TS1128 x1). This repo declares no type for it — ObjectUI is a pure consumer of what the server publishes */} - -```typescript -// Define permissions in object schema -{ - objects: { - contact: { - name: 'contact', - label: 'Contact', - permissions: { - create: ['admin', 'sales'], - read: ['admin', 'sales', 'support'], - update: ['admin', 'sales'], - delete: ['admin'] - }, - fields: { - salary: { - name: 'salary', - label: 'Salary', - type: 'currency', - permissions: { - read: ['admin', 'hr'], - update: ['admin', 'hr'] - } - } - } - } - } -} -``` - -### System Objects Integration - -ObjectOS provides system objects like `sys_user`, `sys_organization`, `sys_role`, etc. Integrate them in your UI: - -```typescript -import { ObjectStackAdapter } from '@object-ui/data-objectstack'; -import type { ObjectViewSchema } from '@object-ui/types'; - -const objectStackAdapter = new ObjectStackAdapter({ - baseUrl: 'http://localhost:3000/api' -}); - -// The adapter is installed above the renderer by `SchemaRendererProvider` -// (`dataSource={objectStackAdapter}`, from `@object-ui/react`), never written on -// the node: a node's `dataSource` is the spec's per-element binding, not an adapter. -const sysUserView: ObjectViewSchema = { - type: 'object-view', - objectName: 'sys_user', - defaultViewType: 'grid', - // The grid's own configuration rides the `table` slot. - table: { columns: ['username', 'email', 'role', 'status', 'last_login'] } -}; -``` - -### Workflow Integration - -{/* doc-snippet: fragment — a SHAPE excerpt of the ObjectStack SERVER's object metadata, not an expression: a bare object literal at statement position parses as a block with labels (measured: TS1005 x4, TS1128 x1). `workflow` is a server-side object concern; this repo declares no type for it */} - -```typescript -// Define workflow-enabled object -{ - objects: { - opportunity: { - name: 'opportunity', - label: 'Opportunity', - workflow: { - enabled: true, - states: ['lead', 'qualified', 'proposal', 'negotiation', 'closed_won', 'closed_lost'], - transitions: [ - { from: 'lead', to: 'qualified', label: 'Qualify', role: ['sales'] }, - { from: 'qualified', to: 'proposal', label: 'Create Proposal', role: ['sales'] }, - { from: 'proposal', to: 'negotiation', label: 'Negotiate', role: ['sales', 'manager'] }, - { from: 'negotiation', to: 'closed_won', label: 'Close Won', role: ['manager'] }, - { from: 'negotiation', to: 'closed_lost', label: 'Close Lost', role: ['manager'] } - ] - }, - fields: { - // ... field definitions - } - } - } -} -``` - -## Data Layer Integration - -### Using ObjectQL for Queries - -```typescript -import { ObjectStackAdapter } from '@object-ui/data-objectstack'; -import type { ObjectGridBlockNode } from '@object-ui/types'; -import type { ObjectGridProps } from '@objectstack/spec/ui'; - -const dataSource = new ObjectStackAdapter({ - baseUrl: 'http://localhost:3000/api' -}); - -// The adapter is injected ONCE, at the renderer boundary — -// `` from `@object-ui/react` — -// never as a schema key. A node's `dataSource` is the per-element BINDING -// (`{ object, view?, filter?, sort?, limit? }`), not the adapter, and -// `SchemaRenderer` strips it from the props it spreads. The grid's own props go -// in its `properties` bag — the spec's `ComponentPropsMap['object-grid']` row. -const schema: ObjectGridBlockNode = { - type: 'object-grid', - properties: { - objectName: 'contact', - // Grid filter, JSON-rules form: an array of `{ field, operator, value }` - // entries, AND-ed together. `operator` takes the canonical view-filter - // vocabulary (`equals`, `greater_than_or_equal`, ...). - filter: [ - { field: 'status', operator: 'equals', value: 'active' }, - { field: 'created_date', operator: 'greater_than_or_equal', value: '2024-01-01' } - ], - // ObjectQL sorting - sort: [ - { field: 'created_date', order: 'desc' } - ], - } satisfies ObjectGridProps, -}; -``` - -### Custom Data Hooks - -`@object-ui/data-objectstack` ships the *adapter*, not hooks. Reads and writes -go through `useViewData` from `@object-ui/react`, which resolves the adapter -from context and hands back both the rows and the `DataSource` to write with. - -```typescript -import { SchemaRenderer, useViewData } from '@object-ui/react'; - -function ContactList() { - const { data, loading, error, dataSource, refresh } = useViewData({ - resource: 'contact', - params: { - $filter: { status: 'active' }, - $orderby: [{ field: 'name', order: 'asc' }], - $top: 20, - }, - }); - - const handleCreate = async (formData: Record) => { - await dataSource?.create('contact', formData); - await refresh(); - }; - - if (loading) return
Loading...
; - if (error) return
Error: {error.message}
; - - // The grid's props go in its `properties` bag. The rows `useViewData` - // fetched are handed over as inline `ViewData` (there is no `rowData` key). - return ( - - ); -} -``` - -## Deployment Strategies - -### Strategy 1: Monolithic Deployment - -Deploy ObjectUI and ObjectStack together in a single Node.js process: - -{/* doc-snippet: fragment — `./kernel` and `./console-plugin` are the READER's own project files (the kernel module is the one built in step 2 above), so the relative specifiers resolve nowhere in this gate's program (measured: TS2307 x2) */} - -```typescript -// server.ts -import { createKernel } from './kernel'; -import { ConsolePlugin } from './console-plugin'; - -async function start() { - const kernel = await createKernel(); - - // Register console UI plugin - kernel.registerPlugin(new ConsolePlugin()); - - console.log('🚀 Server started at http://localhost:3000'); - console.log('📊 Console UI at http://localhost:3000/console'); -} - -start(); -``` - -### Strategy 2: Microservices Deployment - -Deploy ObjectUI (frontend) and ObjectStack (backend) separately: - -**Backend (ObjectStack API):** - -{/* doc-snippet: fragment — `./kernel` is the READER's own project file (the kernel module built in step 2 above), so the relative specifier resolves nowhere in this gate's program (measured: TS2307 x1) */} - -```typescript -// backend/server.ts -import { createKernel } from './kernel'; - -async function start() { - const kernel = await createKernel(); - console.log('🚀 API Server started at http://localhost:3000'); -} - -start(); -``` - -**Frontend (ObjectUI):** - -{/* doc-snippet: fragment — two files in one block, and it is `import.meta.env` that cannot compile here: the `env` member is a Vite ambient declaration carried by the READER's `vite/client` types, which this repository's gate program does not load (measured: TS2339 x1, plus TS2304 x1 for the adapter the second file imports in the reader's own entry point) */} - -```typescript -// frontend/src/config.ts -export const config = { - apiBaseUrl: import.meta.env.VITE_API_URL || 'http://localhost:3000/api' -}; - -// frontend/src/index.tsx -const dataSource = new ObjectStackAdapter({ - baseUrl: config.apiBaseUrl -}); -``` - -### Strategy 3: Cloud-Native Deployment - -Deploy on Kubernetes with separate services: - -```yaml -# k8s/deployment.yaml -apiVersion: v1 -kind: Service -metadata: - name: objectstack-api -spec: - selector: - app: objectstack-api - ports: - - port: 3000 ---- -apiVersion: v1 -kind: Service -metadata: - name: objectui-frontend -spec: - selector: - app: objectui-frontend - ports: - - port: 80 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: objectstack-api -spec: - replicas: 3 - template: - spec: - containers: - - name: api - image: myregistry/objectstack-api:latest - env: - - name: DATABASE_URL - value: postgresql://... ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: objectui-frontend -spec: - replicas: 2 - template: - spec: - containers: - - name: frontend - image: myregistry/objectui-frontend:latest - env: - - name: API_URL - value: http://objectstack-api:3000 -``` - -## Advanced Integration Patterns - -### Custom Component Registration - -{/* doc-snippet: fragment — `MyCustomWidget` is the READER's own component, and the block closes with a bare `{ type: 'my-custom-widget' }` SHAPE excerpt showing where the registered name is then used, which at statement position parses as a block with labels (measured: TS1005 x1) */} - -```typescript -// Register custom components with ObjectUI -import { ComponentRegistry } from '@object-ui/core'; - -ComponentRegistry.register('my-custom-widget', MyCustomWidget, { - namespace: 'custom', - lazy: true -}); - -// Use in schema -{ - type: 'my-custom-widget', - config: { - // custom props - } -} -``` - -### Event Handling & Callbacks - -A metadata document cannot carry a function, so a callback is never a schema -key: the grid's row callbacks (`onRowClick`, `onCellChange`, …) are **component -props**, passed to the renderer. The grid's own props go in its `properties` -bag, and with `editable` an edited cell saves through the adapter the -`SchemaRendererProvider` above it supplies, with no callback to wire. - -```tsx -import { SchemaRenderer } from '@object-ui/react'; - -export const ContactGrid = () => ( - { - // Navigate to detail page - window.location.href = `/contact/${row.id}`; - }} - /> -); -``` - -### Reacting to Data Changes - -⚠️ `@object-ui/data-objectstack` has **no WebSocket transport and no server-push -subscription**. What it offers is `onMutation`: a notification of the writes -*this adapter instance* performed, which is what a view needs to refresh itself -after its own create/update/delete. It returns its own unsubscribe function. - -```typescript -import { ObjectStackAdapter } from '@object-ui/data-objectstack'; - -const adapter = new ObjectStackAdapter({ - baseUrl: 'http://localhost:3000/api' -}); - -// Fires for writes this adapter performed. Filter by `resource` for one object. -const unsubscribe = adapter.onMutation((event) => { - if (event.resource !== 'contact') return; - if (event.type === 'create' || event.type === 'update') { - // Refresh the view holding this object's rows - } -}); - -// Later, when the view unmounts: -unsubscribe(); -``` - -## Migration from Other Platforms - -### From Retool - -{/* doc-snippet: fragment — a SHAPE excerpt showing the ObjectUI half of a side-by-side migration comparison; a bare object literal at statement position parses as a block with labels */} - -```typescript -// Retool table → ObjectUI Grid. The props go in the node's `properties` bag -// (the spec's `ComponentPropsMap['object-grid']` row): declaring bulk actions -// turns multi-row selection on, the export menu is `exportOptions`, and the -// adapter comes from `SchemaRendererProvider`, never from the node. -{ - type: 'object-grid', - properties: { - objectName: 'users', - editable: true, - bulkActions: ['delete', 'export'], - exportOptions: { formats: ['csv', 'xlsx'] }, - }, -} -``` - -### From Appsmith - -{/* doc-snippet: fragment — a SHAPE excerpt showing the ObjectUI half of a side-by-side migration comparison; a bare object literal at statement position parses as a block with labels */} - -```typescript -// Appsmith form → ObjectUI form. The props go in the node's `properties` -// bag (the spec's `ComponentPropsMap['object-form']` row). The form saves -// through the adapter `SchemaRendererProvider` supplies, so no submit -// handler is written here. -{ - type: 'object-form', - properties: { - objectName: 'contact', - mode: 'create', - fields: ['name', 'email', 'phone', 'company'], - }, -} -``` - -### From Mendix - -{/* doc-snippet: fragment — a SHAPE excerpt showing the ObjectUI half of a side-by-side migration comparison, with every slot body elided as a prose comment, so the object literal cannot parse as TypeScript (measured: TS1005 x2, TS1128 x2) */} - -```typescript -// Mendix page → ObjectUI page -{ - type: 'page', - template: 'header-sidebar-main', - header: { /* ... */ }, - sidebar: { /* ... */ }, - main: { - type: 'tabs', - items: [ - { label: 'Overview', content: { /* ... */ } }, - { label: 'Details', content: { /* ... */ } } - ] - } -} -``` - -## Performance Optimization - -### Bundle Optimization - -```typescript -// Lazy load plugins -const plugins = { - grid: () => import('@object-ui/plugin-grid'), - charts: () => import('@object-ui/plugin-charts'), - kanban: () => import('@object-ui/plugin-kanban') -}; - -// Load on demand -await plugins.grid(); -``` - -### Caching Strategy - -```typescript -import { ObjectStackAdapter } from '@object-ui/data-objectstack'; - -const adapter = new ObjectStackAdapter({ - baseUrl: 'http://localhost:3000/api', - cache: { - ttl: 60000, // 1 minute - maxSize: 500 // entries retained - } -}); -``` - -## Testing & Quality Assurance - -### Unit Tests - -{/* doc-snippet: fragment — a test body: `test` / `expect` are the RUNNER's globals (Vitest or Jest, injected by the reader's own test config, not imported here) and `mockDataSource` is the reader's own fixture, so none of the three resolves in this gate's program (measured: TS2593 x1, TS2304 x2) */} - -```typescript -import { render } from '@testing-library/react'; -import { SchemaRenderer } from '@object-ui/react'; - -test('renders contact grid', () => { - const { getByText } = render( - - ); - expect(getByText('Contact')).toBeInTheDocument(); -}); -``` - -### Integration Tests - -End-to-end tests drive the running app, so they need a browser runner the app itself -does not depend on. Install it in your project first — `npm install -D @playwright/test` -(then `npx playwright install` once, for the browsers) — and run these specs with -`npx playwright test`. - -{/* doc-snippet: fragment — an end-to-end spec for the READER's own Playwright installation: `@playwright/test` is a test runner the reader installs themselves (the sentence above says so), and no package documented here declares it, so it does not resolve in this gate's program (measured: TS2307 x1). It used to compile only because THIS repository happens to carry `@playwright/test` as a root devDependency, which is what objectui#7463 item 2 bounded */} - -```typescript -import { test, expect } from '@playwright/test'; - -test('create contact workflow', async ({ page }) => { - await page.goto('http://localhost:3000/console/contacts'); - await page.click('button:has-text("New Contact")'); - await page.fill('[name="name"]', 'John Doe'); - await page.fill('[name="email"]', 'john@example.com'); - await page.click('button:has-text("Save")'); - await expect(page.locator('text=John Doe')).toBeVisible(); -}); -``` - -## Resources - -- [ObjectStack Documentation](https://docs.objectstack.ai) -- [ObjectUI Components Reference](/docs/components) -- [ObjectQL Schemas](/docs/api/schema-reference#objectql-schemas) -- [Schema Catalog](/docs/guide/schema-catalog) - every schema rendered in these docs - -## Support - -- GitHub Issues: https://github.com/objectstack-ai/objectui/issues -- Discord Community: https://discord.gg/objectui -- Email: hello@objectui.org diff --git a/ui/content/docs/guide/plugin-development.md b/ui/content/docs/guide/plugin-development.md deleted file mode 100644 index e4b33382be7..00000000000 --- a/ui/content/docs/guide/plugin-development.md +++ /dev/null @@ -1,561 +0,0 @@ ---- -title: "Custom Plugin Development" ---- - -This guide walks you through creating custom ObjectUI plugins — from scaffolding to publishing. Plugins extend ObjectUI with new view types, field widgets, or complex interactive components while keeping your application bundle lean through lazy loading. - -## What Is an ObjectUI Plugin? - -A plugin is a self-contained package that registers one or more components into the [Component Registry](./component-registry.md). When a JSON schema references a plugin's component type, the renderer resolves and renders it automatically. - -Plugins differ from regular components in two ways: - -- **Lazy-loaded** — heavy dependencies are code-split and fetched on demand. -- **Self-registering** — importing the package is enough; no manual wiring required. - -Official plugins (`@object-ui/plugin-grid`, `@object-ui/plugin-kanban`, `@object-ui/plugin-charts`, etc.) all follow this pattern, and your custom plugins should too. - -## Plugin Anatomy - -Every plugin has three key parts: - -``` -packages/plugin-board/ -├── src/ -│ ├── index.tsx # Entry point: lazy wrapper + ComponentRegistry.register() -│ ├── BoardImpl.tsx # Heavy implementation (imported lazily) -│ ├── BoardImpl.test.tsx # Tests -│ └── types.ts # TypeScript interfaces & schema types -├── package.json -├── vite.config.ts -├── tsconfig.json -└── README.md -``` - -| File | Role | -|------|------| -| `index.tsx` | Lightweight entry — sets up `React.lazy()`, `Suspense` fallback, and calls `ComponentRegistry.register()`. | -| `BoardImpl.tsx` | The actual renderer. All heavy dependencies live here so they are tree-shaken from the initial bundle. | -| `types.ts` | Schema interfaces extending `BaseSchema` from `@object-ui/types`. | - -## Scaffolding With the CLI - -The fastest way to start is the `create-plugin` generator: - -```bash -npx @object-ui/create-plugin board --description "Kanban-style board view" - -# Or with pnpm / npm create aliases: -pnpm create @object-ui/plugin board -npm create @object-ui/plugin board -``` - -This produces a ready-to-build plugin under `packages/plugin-board/` with the correct `package.json`, Vite config, test file, and registry call already in place. That directory — and the anatomy shown above — is what the generator writes into **your** workspace (`/packages/plugin-`, see `packages/create-plugin/src/index.ts`); it is not a package that ships in this repository, so do not expect to find it in a fresh ObjectUI checkout. - -After scaffolding, install dependencies: - -```bash -pnpm install -``` - -## Implementing a Custom View Plugin - -Let's build a **board** view plugin that renders items in columns (similar to a Kanban but simplified). - -### 1. Define the Schema Types - -```typescript -// src/types.ts -import type { BaseSchema } from '@object-ui/types'; - -export interface BoardColumn { - id: string; - title: string; -} - -export interface BoardItem { - id: string; - columnId: string; - title: string; - description?: string; -} - -export interface BoardSchema extends BaseSchema { - type: 'board'; - columns: BoardColumn[]; - items: BoardItem[]; - onItemMove?: (itemId: string, toColumnId: string) => void; -} - -export interface BoardProps { - schema: BoardSchema; - className?: string; -} -``` - -### 2. Build the Implementation - - -```tsx -// src/BoardImpl.tsx -import React from 'react'; -import { Card, CardHeader, CardTitle, CardContent } from '@object-ui/components'; -import { cn } from '@object-ui/components'; -import type { BoardProps } from './types'; - -export default function BoardImpl({ schema, className }: BoardProps) { - const { columns, items } = schema; - - return ( -
- {columns.map((col) => ( -
-

{col.title}

- {items - .filter((item) => item.columnId === col.id) - .map((item) => ( - - - {item.title} - - {item.description && ( - - {item.description} - - )} - - ))} -
- ))} -
- ); -} -``` - -### 3. Create the Entry Point - - -```tsx -// src/index.tsx -import React, { Suspense } from 'react'; -import { ComponentRegistry } from '@object-ui/core'; -import { Skeleton } from '@object-ui/components'; - -const LazyBoard = React.lazy(() => import('./BoardImpl')); - -export const BoardRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ - schema, - ...props -}) => ( - }> - - -); - -// Auto-register on import -ComponentRegistry.register('board', BoardRenderer, { - namespace: 'plugin-board', - label: 'Board View', - category: 'plugin', - inputs: [ - { name: 'columns', type: 'array', required: true }, - { name: 'items', type: 'array', required: true }, - ], - defaultProps: { - columns: [ - { id: 'todo', title: 'To Do' }, - { id: 'done', title: 'Done' }, - ], - items: [], - }, -}); - -export { default as BoardImpl } from './BoardImpl'; -export type { BoardSchema, BoardProps, BoardColumn, BoardItem } from './types'; -``` - -Now any schema with `"type": "board"` will resolve to your component. - -## What Reaches the DOM - -`SchemaRenderer` hands a registered component far more than the keys it -declares: the node itself as `schema`, every other key the author wrote on the -node, the contents of its `props` bag, the runtime props it injects (`bind`, -`events`, the camelCase `ariaLabel` the author wrote, the data-source adapter) -and whatever its own host passed. React writes unknown lowercase props straight -onto an element and stringifies objects, so a component that ends in a bare -`{...props}` spread onto an element puts all of it on the page — -`schema="[object Object]"`, `datasource="[object Object]"`, a `name` on a `div`, -and every authored key the widget never declared. Nothing warns: React stays -silent for lowercase names. - -**The contract for a registered widget's host element** (objectui#4425, -phase-2 ruling, comment 5270759246): it receives only what `toDomProps` from `@object-ui/core` -passes — the SDUI contract's declared DOM keys (`id`, `className`, `role`, -`tabIndex` and the rest of `SDUI_DOM_PASS_THROUGH_KEYS`) and the open `aria-*` -and `data-*` families. Everything else the renderer hands a widget is either -**consumed** — read by name and turned into behaviour — or dropped. It is a -whitelist rather than a list of keys to strip, because the set of keys an author -may write is unbounded and a strip list can only name the ones that exist today. - -```tsx -import type { HTMLAttributes, ReactNode } from 'react'; -import { toDomProps, type SduiDomPassThroughKey } from '@object-ui/core'; - -// The DOM pass-through this widget declares is exactly what the whitelist -// forwards — named from the whitelist's own key type, so the two cannot drift. -interface BoardFrameProps extends Pick, SduiDomPassThroughKey> { - children?: ReactNode; -} - -export function BoardFrame({ className, children, ...props }: BoardFrameProps) { - return ( - // Spread FIRST, so the attributes the widget computes stay authoritative. -
- {children} -
- ); -} -``` - -A widget that really means to forward something beyond that set **declares** -it on its props type and forwards it **by name** — the way `style` reaches the -layout containers. Never reopen the spread to get it. A component that is both -registered and exported for direct React use may keep a wider declared -`HTMLAttributes` pass-through on its direct door, as `@object-ui/plugin-dashboard`'s -metric cards do; what the renderer hands it still goes through the whitelist. - -Field widgets follow the same rule with their own declared set: `@object-ui/fields` -exports a `toDomProps` that adds the form-control keys `name` and `disabled` -(see [Field Widget Props](/docs/fields/widget-props)). - -**The gate.** `packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx` -renders every registered widget of the packages it covers through the real -`SchemaRenderer`, with planted canary keys from every family above, and fails -on any attribute HTML does not define. A known leak is a ledger row in that -file naming the issue that owns its fix, and the ledger is a two-way ratchet: a -new leak fails the gate, and so does a fixed one until its row is deleted. - -## Implementing a Custom Field Widget - -Field widgets follow the `FieldWidgetComponentProps` interface from `@object-ui/fields`. - -```typescript -// FieldWidgetComponentProps shape (from packages/fields/src/widgets/types.ts) -import type { FieldMetadata } from '@object-ui/types'; - -type FieldWidgetComponentProps = { - value: T; - onChange: (val: T) => void; - field: FieldMetadata; - readonly?: boolean; - disabled?: boolean; - className?: string; - error?: string; -}; -``` - -The validation slot is named `error`, matching `FieldWidgetPropsSchema` in -`@objectstack/spec/ui` — the published contract a widget is written against. -The form renderer supplies it from the active validation message. - -### `field` is the only metadata carrier (v17, breaking) - -Before v17 a widget could receive its metadata under **either** `field` or -`schema`, depending on which host rendered it, so widgets written in that era -resolve their config as `field || schema`. `schema` has been removed from -`FieldWidgetComponentProps` in v17: **read `props.field`, full stop.** Reading -`props.schema` now yields `undefined`. - -`schema` itself is not going anywhere — it is the universal SDUI node -`SchemaRenderer` hands to *every* registered component (`element:*`, `page:*`, -grids, reports). That is precisely why a **field widget** needs an adapter when -it is rendered from a schema node instead of from a form. Wrap it once, at -registration, and the widget only ever implements one contract: - - -```tsx -import { ComponentRegistry } from '@object-ui/core'; -import { withFieldCarrier } from '@object-ui/fields'; - -ComponentRegistry.register('color', withFieldCarrier(ColorPickerField), { - namespace: 'field', -}); -``` - -`withFieldCarrier` forwards the node by reference (nothing is copied or -dropped) and consumes `schema` so it never reaches the DOM. Every built-in -field widget is registered through it. - -### Who renders what - -The widget and the form renderer split validation display, and the split is -not optional: - -| Concern | Owner | -|---|---| -| `aria-invalid` on the input | **the widget** — only it renders the input element | -| the required marker (`*`) | **the form renderer** (``) | -| the message TEXT | **the form renderer** (``) | - -So consume `error` as a **boolean signal** — `aria-invalid={!!error}` — and do -not render the message yourself. The form already prints it below the control; -a widget that prints it too shows the user the same sentence twice. For the -same reason `required` is not in the props: the marker has one author. - -### Example: Color Picker Field - -```tsx -// src/ColorPickerField.tsx -import React from 'react'; -import { Input } from '@object-ui/components'; -import type { FieldWidgetComponentProps } from '@object-ui/fields'; - -export function ColorPickerField({ - value, - onChange, - field, - readonly, - disabled, - error, -}: FieldWidgetComponentProps) { - if (readonly) { - return ( -
- - {value || '—'} -
- ); - } - - return ( -
- onChange(e.target.value)} - disabled={disabled} - className="h-8 w-8 cursor-pointer rounded border-0 p-0" - /> - onChange(e.target.value)} - placeholder={field?.placeholder || '#000000'} - disabled={disabled} - className="font-mono text-sm" - // The whole job of `error` here: tell assistive tech the field failed. - // The message text is rendered by the form, not by this widget. - aria-invalid={!!error} - /> -
- ); -} -``` - -Register it as a field widget: - - -```tsx -// src/index.tsx -import { ComponentRegistry } from '@object-ui/core'; -import { withFieldCarrier } from '@object-ui/fields'; -import { ColorPickerField } from './ColorPickerField'; - -ComponentRegistry.register('field-color', withFieldCarrier(ColorPickerField), { - namespace: 'plugin-board', - label: 'Color Picker', - category: 'field', - inputs: [ - { name: 'value', type: 'string', label: 'Value' }, - { name: 'placeholder', type: 'string', label: 'Placeholder' }, - ], -}); - -export { ColorPickerField }; -``` - -## Using the ComponentRegistry - -### Namespaced Registration - -Namespaces prevent type collisions between plugins: - - -```tsx -import { ComponentRegistry } from '@object-ui/core'; - -// Register with a namespace — accessible as 'plugin-board:board' AND 'board' -ComponentRegistry.register('board', BoardRenderer, { - namespace: 'plugin-board', -}); - -// Explicit lookup by namespace -ComponentRegistry.get('board', 'plugin-board'); - -// Fallback lookup (works when the type is unambiguous) -ComponentRegistry.get('board'); -``` - -Use `skipFallback: true` in the metadata if you do **not** want the component to be available without a namespace prefix. - -### Querying Registered Components - -```tsx -import { ComponentRegistry } from '@object-ui/core'; - -ComponentRegistry.has('board'); // boolean -ComponentRegistry.getAllTypes(); // string[] -ComponentRegistry.getNamespaceComponents('plugin-board'); // RegistryComponentConfig[] -``` - -## Plugin Configuration & Schema Types - -Define your schema interface in `types.ts` and extend `BaseSchema`: - - -```typescript -import type { BaseSchema } from '@object-ui/types'; - -export interface BoardSchema extends BaseSchema { - type: 'board'; - columns: BoardColumn[]; - items: BoardItem[]; -} -``` - -Declare `ComponentInput` entries when registering: they are what the published manifest (`sdui.manifest.json`) and the JSX-page compiler's diagnostics read. Each entry carries the seven keys the manifest forwards — `name`, `type`, `of`, `required`, `enum`, `binding`, `description`; a default belongs in the renderer's own fallback read and, for the author, in `description` (`label`, `defaultValue` and `advanced` are retired keys — nothing ever read them). At the component level the manifest carries one marker of its own: `tier: 'html'` on the intrinsic HTML elements a `kind:'html'` page may author (`HTML_TIER_INTRINSICS` in `@object-ui/core`). An entry without it is a curated block; a reader that means the curated vocabulary filters on it, a reader that whitelists tags takes every key. - -`of` is the coarse kind of an `array`'s elements, or of the values of an `object` used as a map. Declare it whenever the contract admits exactly one kind there: `type: 'array'` alone says a value is a list and stops, so a member of the wrong shape reaches the renderer with nothing anywhere reporting it. With `of` declared, the parser reports a member no declared kind accepts, and the generated `sdui-intrinsics.d.ts` types the elements (`string[]` rather than `unknown[]`). Like `type`, it names a KIND and never a domain: `of: 'object'` says the elements are objects, not which keys they carry — spell that out in `description`, and let `os validate` judge the values. A member contract that genuinely accepts several kinds takes the array form (`of: ['string', 'object']`), or no `of` at all: - - -```tsx -ComponentRegistry.register('board', BoardRenderer, { - inputs: [ - { name: 'columns', type: 'array', of: 'object', required: true }, - { name: 'items', type: 'array', of: 'object', required: true }, - { - name: 'layout', - type: 'enum', - enum: ['horizontal', 'vertical'], - description: 'Defaults to "horizontal" — the renderer\'s own fallback', - }, - ], -}); -``` - -## Testing Plugins - -ObjectUI uses **Vitest + React Testing Library**. Place tests next to the implementation. - - -```tsx -// src/BoardImpl.test.tsx -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; -// `toBeInTheDocument` is a jest-dom matcher, not a Vitest one — without this -// import the assertions below do not type-check and do not run. -import '@testing-library/jest-dom'; -import BoardImpl from './BoardImpl'; - -const schema = { - type: 'board' as const, - columns: [ - { id: 'todo', title: 'To Do' }, - { id: 'done', title: 'Done' }, - ], - items: [ - { id: '1', columnId: 'todo', title: 'Write tests' }, - { id: '2', columnId: 'done', title: 'Ship plugin' }, - ], -}; - -describe('BoardImpl', () => { - it('renders all columns', () => { - render(); - expect(screen.getByText('To Do')).toBeInTheDocument(); - expect(screen.getByText('Done')).toBeInTheDocument(); - }); - - it('renders items in correct columns', () => { - render(); - expect(screen.getByText('Write tests')).toBeInTheDocument(); - expect(screen.getByText('Ship plugin')).toBeInTheDocument(); - }); - - it('handles empty items gracefully', () => { - render(); - expect(screen.getByText('To Do')).toBeInTheDocument(); - }); -}); -``` - -Run tests: - -```bash -pnpm vitest run packages/plugin-board -``` - -## Publishing Guidelines - -### Package Checklist - -Before publishing, verify: - -- [ ] `package.json` has correct `name`, `version`, `exports`, and `peerDependencies`. -- [ ] `react` and `react-dom` are **peer** dependencies, not direct dependencies. -- [ ] `@object-ui/core` and `@object-ui/components` are in `devDependencies` (or `peerDependencies`). -- [ ] `vite.config.ts` marks React and ObjectUI packages as **external**. -- [ ] Types are exported via `"types"` field in `package.json`. -- [ ] All tests pass (`pnpm vitest run`). -- [ ] The entry point is lightweight — heavy code lives in `*Impl.tsx` files. - -### Build & Verify - -```bash -pnpm build --filter @object-ui/plugin-board -ls -lh packages/plugin-board/dist/ -``` - -The entry chunk should be under 1 KB; the lazy chunk carries the bulk. - -### Publish - -```bash -cd packages/plugin-board -npm publish --access public -``` - -### Consumers Install & Use - -```bash -pnpm add @object-ui/plugin-board -``` - - -```tsx -// app/main.tsx — import once, auto-registers -import '@object-ui/plugin-board'; -``` - -```json -{ - "type": "board", - "columns": [ - { "id": "todo", "title": "To Do" }, - { "id": "done", "title": "Done" } - ], - "items": [ - { "id": "1", "columnId": "todo", "title": "Write docs" } - ] -} -``` - -## Related Documentation - -- [Component Registry](./component-registry.md) — registry internals and advanced usage -- [Plugins Overview](./plugins.md) — official plugin catalog -- [Schema Rendering](./schema-rendering.md) — how schemas become UI -- [Fields Guide](./fields.md) — built-in field widgets and `FieldWidgetComponentProps` diff --git a/ui/content/docs/guide/plugins.md b/ui/content/docs/guide/plugins.md deleted file mode 100644 index 879b343a0a8..00000000000 --- a/ui/content/docs/guide/plugins.md +++ /dev/null @@ -1,553 +0,0 @@ ---- -title: "Plugins" ---- - -Object UI supports a powerful plugin system that allows you to extend the framework with additional components. Plugins are separate packages that load on-demand, keeping your main application bundle small while providing rich functionality. - -## Overview - -Plugins are lazy-loaded component packages that: - -- **Auto-register** components when imported -- **Lazy-load** heavy dependencies on-demand -- **Keep bundles small** - only load when needed -- **Are type-safe** with full TypeScript support -- **Follow best practices** with built-in loading states - -## Official Plugins - -Object UI provides 14+ official plugins for common use cases: - -### Data Visualization & Dashboards - -#### [@object-ui/plugin-charts](../plugins/plugin-charts.mdx) - -Data visualization components powered by Recharts. - -- Bar, line, area, and pie charts -- Responsive design -- Customizable colors -- Lazy-loaded (~80 KB) - -[Read full documentation →](../plugins/plugin-charts.mdx) - ---- - -#### [@object-ui/plugin-dashboard](../plugins/plugin-dashboard.mdx) - -Dashboard layouts with metric cards and widgets. - -- Dashboard grid layouts -- Metric/KPI cards with trends -- Widget system -- Lazy-loaded (~22 KB) - -[Read full documentation →](../plugins/plugin-dashboard.mdx) - ---- - -#### [@object-ui/plugin-timeline](../plugins/plugin-timeline.mdx) - -Timeline component with multiple layout variants. - -- Vertical, horizontal layouts -- Customizable markers -- Date formatting -- Lazy-loaded (~20 KB) - -[Read full documentation →](../plugins/plugin-timeline.mdx) - ---- - -#### [@object-ui/plugin-gantt](../plugins/plugin-gantt.mdx) - -Gantt chart for project visualization. - -- Task dependencies -- Progress tracking -- ObjectQL integration -- Lazy-loaded (~40 KB) - -[Read full documentation →](../plugins/plugin-gantt.mdx) - ---- - -#### [@object-ui/plugin-calendar](../plugins/plugin-calendar.mdx) - -Calendar visualization for events. - -- Month/week/day views -- Event management -- ObjectQL integration -- Lazy-loaded (~25 KB) - -[Read full documentation →](../plugins/plugin-calendar.mdx) - ---- - -#### [@object-ui/plugin-map](../plugins/plugin-map.mdx) - -Map visualization with markers. - -- Interactive maps -- Location markers -- ObjectQL integration -- Lazy-loaded (~60 KB) - -[Read full documentation →](../plugins/plugin-map.mdx) - ---- - -### Data Management - -#### [@object-ui/plugin-grid](../plugins/plugin-grid.mdx) - -Advanced data grid with sorting, filtering, and pagination. - -- Column sorting and filtering -- Pagination controls -- Row selection -- Lazy-loaded (~45 KB) - -[Read full documentation →](../plugins/plugin-grid.mdx) - ---- - -#### [@object-ui/plugin-form](../plugins/plugin-form.mdx) - -Advanced form builder with validation. - -- Multi-step forms -- Field validation -- Custom field types -- Lazy-loaded (~28 KB) - -[Read full documentation →](../plugins/plugin-form.mdx) - ---- - -#### [@object-ui/plugin-view](../plugins/plugin-view.mdx) - -ObjectQL-integrated views for automatic CRUD. - -- Auto-generated forms and grids -- CRUD operations -- Field mapping -- Lazy-loaded (~35 KB) - -[Read full documentation →](../plugins/plugin-view.mdx) - ---- - -### Content & Editing - -#### [@object-ui/plugin-editor](../plugins/plugin-editor.mdx) - -Code editor component powered by Monaco Editor. - -- Syntax highlighting for 100+ languages -- IntelliSense and code completion -- Multiple themes -- Lazy-loaded (~120 KB) - -[Read full documentation →](../plugins/plugin-editor.mdx) - ---- - -#### [@object-ui/plugin-markdown](../plugins/plugin-markdown.mdx) - -Markdown renderer with GitHub Flavored Markdown support. - -- GitHub Flavored Markdown -- XSS protection -- Code syntax highlighting -- Lazy-loaded (~30 KB) - -[Read full documentation →](../plugins/plugin-markdown.mdx) - ---- - -#### [@object-ui/plugin-chatbot](../plugins/plugin-chatbot.mdx) - -Chat interface component. - -- Message history -- User and assistant roles -- Timestamps and avatars -- Responsive floating panel for console assistants -- Inline responding, stop, and retry states -- Lazy-loaded (~35 KB) - -[Read full documentation →](../plugins/plugin-chatbot.mdx) - ---- - -### Workflows & Tasks - -#### [@object-ui/plugin-kanban](../plugins/plugin-kanban.mdx) - -Kanban board component with drag-and-drop powered by @dnd-kit. - -- Drag and drop cards between columns -- Column limits (WIP limits) -- Card badges for status/priority -- Lazy-loaded (~100 KB) - -[Read full documentation →](../plugins/plugin-kanban.mdx) - ---- - -## How Plugins Work - -### Stylesheets - -A plugin's JavaScript is only half of what it renders with. `@object-ui/plugin-grid` and `@object-ui/plugin-kanban` publish a `style.css` of their own, and an app that installs one must import it after the base sheets: - -```css -/* src/index.css */ -@import "tailwindcss"; -@import "@object-ui/components/style.css"; -@import "@object-ui/fields/style.css"; -@import "@object-ui/plugin-grid/style.css"; -@import "@object-ui/plugin-kanban/style.css"; -``` - -Each plugin sheet is compiled against the components theme and then has every rule that sheet already ships subtracted from it, so it carries only what the plugin adds. That includes the themed utilities (`bg-muted/10`, `bg-card/60`, `ring-primary/40`) which **no consumer-side configuration can produce** — the `@theme` block declaring their tokens lives in package source that is not published, so scanning `node_modules` cannot reach it ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)). Skip the import and the view renders unstyled. - -Add a line only for the plugins you install. The other `@object-ui/plugin-*` packages do not publish a stylesheet yet; the build step above is the pattern each of them will adopt when it needs one. - -### Lazy Loading Architecture - -Plugins use React's `lazy()` and `Suspense` to load heavy dependencies on-demand: - - -```typescript -// The plugin structure -import React, { Suspense } from 'react' -import { Skeleton } from '@object-ui/components' - -// Lazy load the heavy implementation -const LazyEditor = React.lazy(() => import('./MonacoImpl')) - -export const CodeEditorRenderer = (props) => ( - }> - - -) -``` - -**Benefits:** -- **Smaller initial bundle**: Main app loads faster -- **Progressive loading**: Components load when needed -- **Better UX**: Loading skeletons while chunks download -- **Automatic code splitting**: Vite handles chunking - -### Bundle Impact - -| Plugin | Initial Load | Lazy Load | Description | -|--------|-------------|-----------|-------------| -| plugin-editor | ~0.2 KB | ~120 KB | Monaco editor | -| plugin-charts | ~0.2 KB | ~80 KB | Recharts visualization | -| plugin-kanban | ~0.2 KB | ~100 KB | Drag-and-drop board | -| plugin-markdown | ~0.2 KB | ~30 KB | Markdown rendering | -| plugin-dashboard | ~0.2 KB | ~22 KB | Dashboard layouts | -| plugin-form | ~0.2 KB | ~28 KB | Form builder | -| plugin-grid | ~0.2 KB | ~45 KB | Data grid | -| plugin-view | ~0.2 KB | ~35 KB | ObjectQL views | -| plugin-timeline | ~0.2 KB | ~20 KB | Timeline layouts | -| plugin-chatbot | ~0.2 KB | ~35 KB | Chat interface | -| plugin-calendar | ~0.2 KB | ~25 KB | Calendar views | -| plugin-gantt | ~0.2 KB | ~40 KB | Gantt charts | -| plugin-map | ~0.2 KB | ~60 KB | Map visualization | - -Without lazy loading, all this code would be in your main bundle! - -### Auto-Registration - -Plugins automatically register their components when imported: - - -```typescript -// In the plugin's index.tsx -import { ComponentRegistry } from '@object-ui/core' - -ComponentRegistry.register('code-editor', CodeEditorRenderer) -``` - -You just need to import the plugin once: - -```typescript -// In your App.tsx or main.tsx -import '@object-ui/plugin-editor' -import '@object-ui/plugin-charts' -import '@object-ui/plugin-kanban' -import '@object-ui/plugin-markdown' -import '@object-ui/plugin-dashboard' -import '@object-ui/plugin-form' -import '@object-ui/plugin-grid' -// ... import other plugins as needed -``` - -Now all plugin components are available in your schemas! - -## Creating Custom Plugins - -You can create your own plugins following the same pattern: - -### 1. Create Package Structure - -```bash -mkdir -p packages/plugin-myfeature/src -cd packages/plugin-myfeature -``` - -### 2. Create Heavy Implementation - - -```typescript -// src/MyFeatureImpl.tsx -import HeavyLibrary from 'heavy-library' - -export default function MyFeatureImpl(props) { - return -} -``` - -### 3. Create Lazy Wrapper - - -```typescript -// src/index.tsx -import React, { Suspense } from 'react' -import { ComponentRegistry } from '@object-ui/core' -import { Skeleton } from '@object-ui/components' - -// Lazy load implementation -const LazyFeature = React.lazy(() => import('./MyFeatureImpl')) - -// Create renderer with Suspense -export const MyFeatureRenderer = (props) => ( - }> - - -) - -// Auto-register -ComponentRegistry.register('my-feature', MyFeatureRenderer) - -// Export for manual use -export const myFeatureComponents = { - 'my-feature': MyFeatureRenderer -} -``` - -### 4. Add TypeScript Types - -```typescript -// src/types.ts -import type { BaseSchema } from '@object-ui/types' - -export interface MyFeatureSchema extends BaseSchema { - type: 'my-feature' - customProp?: string -} -``` - -### 5. Configure Build - - -```typescript -// vite.config.ts -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -import { resolve } from 'path' - -export default defineConfig({ - plugins: [react()], - build: { - lib: { - entry: resolve(__dirname, 'src/index.tsx'), - name: 'ObjectUIPluginMyFeature', - fileName: (format) => `index.${format}.js` - }, - rollupOptions: { - external: [ - 'react', - 'react-dom', - '@object-ui/components', - '@object-ui/core' - ], - output: { - globals: { - react: 'React', - 'react-dom': 'ReactDOM' - } - } - } - } -}) -``` - -### 6. Add Package.json - -```json -{ - "name": "@object-ui/plugin-myfeature", - "version": "1.0.0", - "type": "module", - "main": "./dist/index.umd.js", - "module": "./dist/index.es.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.es.js", - "require": "./dist/index.umd.js", - "types": "./dist/index.d.ts" - } - }, - "files": ["dist"], - "scripts": { - "build": "vite build && tsc --emitDeclarationOnly" - }, - "peerDependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0" - }, - "dependencies": { - "heavy-library": "^1.0.0" - }, - "devDependencies": { - "@object-ui/components": "workspace:*", - "@object-ui/core": "workspace:*", - "@object-ui/types": "workspace:*", - "@vitejs/plugin-react": "^6.0.5", - "typescript": "^6.0.3", - "vite": "^8.2.1" - } -} -``` - -## Best Practices - -### 1. Keep Entry Point Light - -The main index file should only contain: -- Lazy loading wrapper -- Component registration -- Type exports - -Heavy imports go in the `*Impl.tsx` file. - -### 2. Provide Good Loading States - -Always show a meaningful skeleton while loading: - - -```typescript - -}> - - -``` - -### 3. Export Types - -Make your plugin type-safe: - - -```typescript -export type { MyFeatureSchema } from './types' -``` - -### 4. Document Your Plugin - -Include a README with: -- Installation instructions -- Usage examples -- Schema API reference -- Bundle size information - -### 5. Test Lazy Loading - -Verify that: -- The main bundle is small (~200 bytes) -- The lazy chunk is separate -- Components load correctly when rendered - -```bash -pnpm build -ls -lh dist/ -``` - -## Plugin vs Component Package - -**Use a Plugin when:** -- The component depends on large libraries (>50 KB) -- Not all apps will use this component -- You want on-demand loading - -**Use regular Components when:** -- The component is lightweight -- Most apps will use it -- It's part of core functionality - -## Troubleshooting - -### Plugin not loading - -Check that you imported it in your app: - - -```typescript -import '@object-ui/plugin-myfeature' -``` - -### TypeScript errors - -Make sure types are exported: - - -```typescript -export type { MyFeatureSchema } from '@object-ui/plugin-myfeature' -``` - -### Bundle size too large - -Check that the implementation is in a separate file: - -``` -✅ src/index.tsx (light, uses React.lazy) -✅ src/MyFeatureImpl.tsx (heavy, imported lazily) -``` - -### Component not registering - -Check that ComponentRegistry.register() is called at the module level: - - -```typescript -// ✅ Good - runs on import -ComponentRegistry.register('my-feature', MyFeatureRenderer) - -// ❌ Bad - never runs -export function registerComponents() { - ComponentRegistry.register('my-feature', MyFeatureRenderer) -} -``` - -## Related Documentation - -- [Component Registry](./component-registry.md) - Understanding the registry -- [Schema Rendering](./schema-rendering.md) - How schemas become UI -- [Custom Plugin Development](/docs/guide/plugin-development) - Component development -- **[Create Plugin Utility](/docs/utilities/create-plugin)** - Scaffold new plugins quickly -- **[CLI Tool](/docs/utilities/cli)** - Test plugins with the CLI -- **[All Utilities](/docs/utilities)** - Complete toolkit for development - -## Next Steps - -1. Install official plugins you need -2. Try creating a custom plugin -3. Share your plugins with the community -4. Contribute new plugins to Object UI diff --git a/ui/content/docs/guide/print-pages.md b/ui/content/docs/guide/print-pages.md deleted file mode 100644 index f771bad9070..00000000000 --- a/ui/content/docs/guide/print-pages.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -title: Print Pages -description: A page that declares `print` is a printable document — an invoice, a delivery order, a letter — printed through the browser's print preview. ---- - -# Print Pages - -A printable document is an ordinary page with one more key, `print`. There is -no separate template type: the page's own blocks are the document body, and its -own `header` and `footer` regions are the letterhead and the footer -(`@objectstack/spec`, ruling B′ on objectstack#8346). - -```json -{ - "name": "invoice_document", - "label": "Invoice", - "type": "record", - "object": "invoice", - "print": { - "paperSize": "A4", - "orientation": "portrait", - "margins": { "top": 18, "bottom": 18, "left": 15, "right": 15 }, - "repeatHeader": true, - "pageNumbers": true, - "avoidBreakInside": true - }, - "regions": [ - { "name": "header", "components": [{ "type": "element:text", "properties": { "content": "ACME Ltd" } }] }, - { - "name": "main", - "components": [ - { "type": "record:details" }, - { - "type": "record:line_items", - "properties": { "childObject": "invoice_line", "relationshipField": "invoice", "columns": [{ "name": "qty" }], "readonly": true } - } - ] - }, - { "name": "footer", "components": [{ "type": "element:text", "properties": { "content": "Thank you for your business" } }] } - ] -} -``` - -`print` is accepted on a `kind: 'full'` page of `type: 'record'`, `'home'` or -`'app'` with its blocks in `regions`. Every block in it must come from the -spec's printable block subset (`PRINTABLE_PAGE_COMPONENT_TYPES` in -`@objectstack/spec/ui`); `os validate`, `os build` and the metadata save door -refuse any other block inside a print page. - -## What each key does on paper - -`PageRenderer` mounts a print stylesheet for a page that declares `print`, and -only for it. Every rule sits inside `@media print`, so the page on screen is -drawn exactly as before — the console's page is the preview. - -| Key | Print CSS | -| --- | --- | -| `paperSize`, `orientation` | `@page { size }` — A4 and portrait when omitted | -| `margins` | `@page` margin, one side per key, in millimetres; a side left out keeps the console default | -| `pageNumbers` | "page / pages" in the bottom-right margin box | -| `repeatHeader`, `repeatFooter` | the page's `header` / `footer` region repeated on every sheet | -| `repeatTableHeaders` | a table's column headings repeated on every sheet it runs onto — on when omitted | -| `avoidBreakInside` | `break-inside: avoid` on each block of a region | - -Only the print page reaches paper: the console chrome and the record view's own -panels around the page are hidden, and the page runs onto as many sheets as it -needs. - -## Blocks print whole - -- `record:line_items` loads at most its row cap (500 unless `limit` is set). On - a print page, when the child object has more lines than that, the panel draws - the cap's lines and a note under them naming the cap and, when the server - reports one, the total — a printed copy never drops lines in silence. -- A collapsible `record:details` section draws open on a print page. -- A `record:details` section prints every declared field, empty ones included, - with no "Show N empty fields" row. A section `hideEmpty` hides still prints - nothing. - -A block reads whether it is on a print page with `usePrintPage()` from -`@object-ui/react`, which returns the page's `print` declaration or `null`. - -## The record page's Print action - -When the record page bound to a record declares `print`, the record view shows -a **Print** button that opens the browser's print preview of that page. Saving -a PDF is the browser's own "Save as PDF" destination. - -## Printing a page that is not the default - -The record route, `/apps/APP/OBJECT/record/ID`, shows a record on its object's -default record page. Any other record page of the object renders for the same -record at: - -``` -/apps/APP/OBJECT/record/ID/page/PAGE -``` - -So an invoice keeps its record view as the default page, and its print page -(`invoice_document` above) opens at `…/invoice/record/ID/page/invoice_document`, -with the same print stylesheet, print root and Print action. PAGE must be a -record page bound to OBJECT (`type: 'record'`, `object: OBJECT`). Any other -name, a page that is not a record page or a page of another object, shows -"Page not found": the console never falls back to the default page, so a print -of that URL never puts a different document on paper. diff --git a/ui/content/docs/guide/public-forms.md b/ui/content/docs/guide/public-forms.md deleted file mode 100644 index 5cfdce67172..00000000000 --- a/ui/content/docs/guide/public-forms.md +++ /dev/null @@ -1,289 +0,0 @@ ---- -title: "Public Forms" -description: "Embed hardened public-facing forms — contact, lead capture, signup — with built-in anti-spam, GDPR consent, prefill whitelist, and open-redirect protection." ---- - -# Public Forms - -`@object-ui/plugin-form` ships an `EmbeddableForm` component that renders a -public-facing form (contact us, lead capture, signup, RSVP) with the security -defaults you'd expect from Airtable Forms, Typeform or HubSpot Forms — without -asking app authors to bolt them on themselves. - -The console exposes a turnkey route at `/f/:slug` (`FormPage`) that loads -a `FormView` spec from the server's `GET /api/v1/forms/:slug` resolver and -renders the merged form. The same component also serves authed internal -forms at `/forms/:name` (`mode="internal"`), reading the FormView spec -from `/api/v1/meta/view/:name` and posting to `/api/v1/data/:object`. - -## Quick start - -```tsx -import { EmbeddableForm } from '@object-ui/plugin-form'; -import { createObjectStackAdapter } from '@object-ui/data-objectstack'; - -const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' }); - - -``` - -## Security defaults (at a glance) - -| Defence | Default | Config key | -| --- | --- | --- | -| **Honeypot field** (silent fake-success on bots) | On | `honeypot` (string to rename, `false` to disable) | -| **Min-fill-time guard** | 1500 ms | `minFillTime` (ms, `0` to disable) | -| **URL prefill whitelist** | _none_ — prefill is fully off | `allowedPrefillFields: string[]` | -| **Open-redirect guard** | Same-origin only | `allowedRedirectHosts: string[]` (supports `*.example.com`) | -| **Default `maxLength`** | text 200 · email 254 · url 2048 · phone 32 · textarea/markdown/html 5000 | Per-field `maxLength` overrides | -| **GDPR consent gate** | Off (opt-in) | `consent: { required, label }` + `privacyPolicyUrl` | -| **CAPTCHA token** | Off (opt-in) | `captchaToken` (string sent as `_captcha`) | -| **Demo mode (`?demo=1`)** | DEV only | gated by `import.meta.env.DEV` in `EmbeddableForm` consumers | - -All gates run **before** the network call. If the consent gate or min-fill -timer trips, the backend is never contacted. The honeypot silently shows the -thank-you page so bots can't tell they failed. - -## Configuration reference - -```ts -import type { FormField } from '@object-ui/types'; -import type { EmbeddableFormTexts } from '@object-ui/plugin-form'; - -interface EmbeddableFormConfig { - formId: string; - objectName: string; - title?: string; - description?: string; - - // Fields — either from a registered schema or inline - fields?: string[]; - customFields?: FormField[]; - - // Anti-spam - honeypot?: string | false; // field name (default '_company_website_2'), false disables - minFillTime?: number; // ms before submit allowed (default 1500) - captchaToken?: string; // forwarded as payload._captcha - - // Prefill & redirect hardening - allowedPrefillFields?: string[]; // empty/undefined → no URL prefill - allowedRedirectHosts?: string[]; // supports '*.example.com' - - // GDPR - consent?: { required?: boolean; label?: string }; - privacyPolicyUrl?: string; - - // UI - branding?: { logo?: string; primaryColor?: string; coverImage?: string }; - thankYouPage?: { title?: string; message?: string; redirectUrl?: string; redirectDelay?: number }; - texts?: EmbeddableFormTexts; // i18n-friendly string overrides -} -``` - -### URL prefill (safe-by-default) - -Public form URLs are user-controlled, so prefill is **off** unless you -explicitly opt fields in: - - - -```tsx - -``` - -With the snippet above, visiting `/f/contact?email=alice@x.com&secret=foo` -fills the `email` field and silently ignores `secret`. The -`prefillParams` prop (used by trusted hosts such as the console) -bypasses this whitelist. - -### Open-redirect guard - -`thankYouPage.redirectUrl` is validated against the current origin **plus** -`allowedRedirectHosts` before navigation. Wildcards like `*.example.com` -match subdomains; the apex itself must be listed explicitly. Dangerous -schemes (`javascript:`, `data:`) are always rejected. - -The thank-you panel follows that verdict rather than the declaration: the -`Redirecting in N seconds…` line appears only for a destination that was -**accepted**, and reads its countdown from the delay that destination was -accepted with. When the destination is refused the line is omitted, and -`texts.redirectBlocked` — if you declared it — is shown in the panel instead; -leave it undeclared and the panel stays silent. Either way the refusal is -logged with `console.warn`, which is where to look if a redirect you expected -never happens. - -### Who performs the redirect (mounted hosts) - -The guard decides **whether** a destination is followed; who travels to it -depends on the shape of the destination: - -| Destination | Travelled by | -|---|---| -| App-relative — `/thanks`, `thanks`, `?ok=1`, `#done` | the host's navigate, when one is supplied; otherwise a browser navigation | -| External — a host you listed in `allowedRedirectHosts` | a browser navigation, always | -| Same-origin but **absolute** — `https://your.app/thanks` | a browser navigation, always | - -This matters when your application is mounted at a sub-path (the console runs -at basename `/_console`). A browser navigation resolves `/thanks` against the -**origin root**, which leaves the application — so an in-app thank-you page -needs the host to place the path. Supply one with `HostNavigationProvider` -from `@object-ui/react` and the form hands app-relative destinations to it: - -```tsx -import { HostNavigationProvider } from '@object-ui/react'; -import { EmbeddableForm, type EmbeddableFormConfig } from '@object-ui/plugin-form'; -import type { DataSource } from '@object-ui/types'; - -export function MountedPublicForm(props: { - /** Your own router's navigate — it already knows the basename. */ - navigate: (to: string) => void; - config: EmbeddableFormConfig; - dataSource: DataSource; -}) { - return ( - - - - ); -} -``` - -Supplying it is optional and changes nothing for an unmounted host: with no -provider — or with no router at all, where there is no basename to miss — -every destination keeps the browser navigation it always had. - -An external destination is never handed to your navigate. A host navigate is a -client-side router transition, so it takes an application-relative path, and a -form holding a cross-origin address must not route it through your router. A -same-origin **absolute** URL is treated the same way for a different reason: -you spelled out the whole address, so that is the address the submitter gets — -write the destination relatively if you want it placed inside your mount. - -### GDPR consent - - - -```tsx -consent: { required: true, label: 'I agree to the privacy policy.' } -privacyPolicyUrl: '/legal/privacy' -``` - -When `required: true`, submitting before the box is ticked shows -`texts.consentRequired` and the network call is suppressed. - -### Honeypot - -The hidden input is rendered off-screen with `tabIndex={-1}` and -`autocomplete="off"`. Bots that blindly fill every field trigger a -**silent fake-success** — the visitor sees the thank-you screen, but -`dataSource.create()` is never called. Honeypot data is also stripped -from the payload defensively. - -### Min-fill-time - -Genuine users take more than ~1.5 s to read and fill a form. Faster -submissions are soft-rejected with `texts.rateLimited` rather than a -hard error, so legit speed-typers can simply retry. - -### CAPTCHA hook - -`EmbeddableForm` doesn't bundle a specific provider. Mount your -preferred widget (hCaptcha, Turnstile, reCAPTCHA) and feed the token in: - - - -```tsx -const [token, setToken] = useState(); - -``` - -The token is forwarded as `payload._captcha` for server-side validation. - -## i18n - -`EmbeddableForm` is i18n-agnostic — every user-visible string is -overridable via `config.texts: EmbeddableFormTexts`. The console wires -this up through `@object-ui/i18n`: - - - -```tsx -const { t } = useObjectTranslation(); - -``` - -## Console route — `/f/:slug` and `/forms/:name` - -The console's `FormPage` component renders both modes from the same -spec-merging code path: - -- **`/f/:slug`** (public, anonymous) — loads `GET /api/v1/forms/:slug` which - resolves the `FormView` whose `sharing.publicLink` matches the slug, then - submits to `POST /api/v1/forms/:slug/submit`. -- **`/forms/:name`** (internal, authed) — loads `GET /api/v1/meta/view/:name` - for the FormView spec plus `GET /api/v1/meta/object/:object` for field - metadata, then submits to `POST /api/v1/data/:object` with the - authenticated session cookie. -- **`/apps/:appName/forms/:name`** (internal, authed) — the same form inside - the app the URL names, with that app's sidebar and branding. A - `type: 'form'` action opens here when it is fired from an app route (a - record page, an object list or a page). The app-less `/forms/:name` serves - every other link, cold deep links included. - -URL parameters of the form `?prefill_=` populate the matching -fields on mount; the rest of the chrome (label, section columns, post-submit -behaviour) comes from the `FormView` spec itself. The heading is the view's -label or the form's `title`; a form with neither is headed by its object's -label, and the object's API name is used only when no label is available at -all. The internal form also has a Cancel, which goes back to the page the form -was opened from, or, on a cold link, to the record being edited, else to the -app's home (Home on the app-less route). - -For richer public forms with anti-spam, GDPR consent, prefill whitelisting -and open-redirect protection, host `EmbeddableForm` directly inside your own -route — see the Quick start above. - -## Testing - -Pure helpers (`isRedirectUrlSafe`, `applyDefaultMaxLengths`) are exported -from `@object-ui/plugin-form` for unit testing. See -`packages/plugin-form/src/__tests__/EmbeddableForm.test.tsx` for the -reference test suite covering all gates. diff --git a/ui/content/docs/guide/quick-start.md b/ui/content/docs/guide/quick-start.md deleted file mode 100644 index 1d843026887..00000000000 --- a/ui/content/docs/guide/quick-start.md +++ /dev/null @@ -1,333 +0,0 @@ ---- -title: "Quick Start" -description: "Get up and running with ObjectUI in 5 minutes - install, configure, and render your first server-driven UI" ---- - -# Quick Start - -Get up and running with ObjectUI in a small Vite app. This guide installs the core renderer, registers the built-in component packages, and renders a first JSON schema. - -## Prerequisites - -- **Node.js** and **pnpm** (or npm/yarn) — ObjectUI is tested on Node 22.x with pnpm 10.x. -- Basic knowledge of **React** and **TypeScript** - -## Step 1: Create a React Project - -If you don't have an existing React project, create one with Vite: - -```bash -pnpm create vite my-app --template react-ts -cd my-app -``` - -## Step 2: Install ObjectUI - -Install the core ObjectUI packages: - -```bash -pnpm add @object-ui/react @object-ui/core @object-ui/types @object-ui/components @object-ui/fields -``` - -Install Tailwind CSS for styling: - -```bash -pnpm add -D tailwindcss @tailwindcss/vite -``` - -## Step 3: Configure Tailwind CSS - -Add Tailwind to your `vite.config.ts`: - - -```ts -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import tailwindcss from '@tailwindcss/vite'; - -export default defineConfig({ - plugins: [react(), tailwindcss()], -}); -``` - -Add to your `src/index.css`: - -```css -@import "tailwindcss"; -@import "@object-ui/components/style.css"; -@import "@object-ui/fields/style.css"; -``` - -Each `style.css` is a stylesheet the package compiles from its own sources at build time, and between them they carry every utility ObjectUI renders with — the themed ones (`bg-primary`, `border-input`) included. - -**Import them in that order.** `@object-ui/components/style.css` is the complete sheet: Tailwind's base layer, the `@theme` tokens and the utilities its components use. `@object-ui/fields/style.css` is a small supplement on top of it — only the ~155 utilities the field widgets add and the components sheet does not already carry, which is why it is a few kB rather than another 170. It is not a standalone stylesheet, and on its own it will not style anything. - -**Plugin packages that publish a stylesheet need one line each.** `@object-ui/plugin-grid` and `@object-ui/plugin-kanban` ship the same kind of supplement, built the same way, so add whichever of them you install: - -```css -@import "@object-ui/plugin-grid/style.css"; -@import "@object-ui/plugin-kanban/style.css"; -``` - -Without that line the plugin renders with no themed styling at all — its `bg-muted/10`, `bg-card/60` and `text-muted-foreground/60` have no other source in a published app, because the `@theme` block they resolve lives in package source that is never published ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)). The remaining `@object-ui/plugin-*` packages ship no stylesheet yet; importing one that does not exist breaks the build, so add only the lines above. - -That is the whole styling setup: you do not add `@source` lines for the ObjectUI packages, and pointing Tailwind at them inside `node_modules` only regenerates utilities these imports already gave you. - -## Step 4: Render Your First Schema - -Replace `src/App.tsx` with: - -```tsx -import '@object-ui/components'; -import '@object-ui/fields'; -import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; -import type { CardSchema } from '@object-ui/types'; - -const schema: CardSchema = { - type: 'card', - title: 'Team Directory', - description: 'Rendered from JSON metadata', - className: 'mx-auto max-w-3xl', - children: { - type: 'data-table', - caption: 'Users', - columns: [ - { header: 'Name', accessorKey: 'name', sortable: true }, - { header: 'Email', accessorKey: 'email' }, - { header: 'Role', accessorKey: 'role' }, - ], - data: [ - { name: 'Ada Lovelace', email: 'ada@example.com', role: 'Admin' }, - { name: 'Grace Hopper', email: 'grace@example.com', role: 'Editor' }, - { name: 'Katherine Johnson', email: 'katherine@example.com', role: 'Viewer' }, - ], - pagination: false, - searchable: false, - }, -} as const; - -function App() { - return ( -
- - - -
- ); -} - -export default App; -``` - -Importing `@object-ui/components` and `@object-ui/fields` registers their renderers with the shared `ComponentRegistry`. `SchemaRendererProvider` injects the host's data adapter, and everything below it — expressions, smart fields, data-aware plugins — reads it back from there. - -This app renders inline `data`, so it has no adapter to inject and says so with `undefined`. That is a real state of the contract, not a placeholder: `dataSource` is typed `DataSource | null | undefined` (`@object-ui/types`), so a host either hands over an adapter or states that it has none. It used to be typed `any` and this example passed an empty object, which no renderer can do anything with (objectui#7912). - -## Step 5: Run the App - -```bash -pnpm dev -``` - -Open [http://localhost:5173](http://localhost:5173). You should see a card and data table rendered from JSON. - -## What Just Happened? - -1. **Schema** - the UI was described as JSON with `type`, visual props, and nested `children`. -2. **Registry** - importing the component packages registered renderers for `card` and `data-table`. -3. **Renderer** - `SchemaRenderer` resolved each `type` and rendered React components. -4. **Provider** - `SchemaRendererProvider` is where a host injects its `DataSource`; this app has none, so it passes `undefined`. - -## Expression Scope - -A `${…}` expression in a schema reads a root the host publishes. Publish those roots with -`PredicateScopeProvider`: - -### Basic Usage - -```tsx -import React from 'react' -import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react' -import type { DeclaredNode } from '@object-ui/types' -// Importing the package registers every default renderer as a side effect — -// there is no separate registration call. -import '@object-ui/components' - -const schema: DeclaredNode = { - type: "page", - // An app page draws `title` as its heading; a page with no `pageType` is a - // record page, which leaves the heading to a `page:header` block. - pageType: "app", - title: "Dashboard", - children: { - type: "grid", - columns: 3, - children: [ - { type: "statistic", label: "Total Users", value: "${stats.users}" }, - { type: "statistic", label: "Revenue", value: "${stats.revenue}" }, - { type: "statistic", label: "Orders", value: "${stats.orders}" } - ] - } -} - -function App() { - // Every key of this object becomes a root the schema's expressions can read — - // `stats` here is what answers `${stats.users}`. - const scope = { - stats: { users: 1234, revenue: "$56,789", orders: 432 } - } - - return ( - - - - ) -} - -export default App -``` - -Expression scope reaches the renderer through the provider, never through a prop on the -element. `SchemaRenderer` declares exactly one prop, `schema`, and forwards every other prop -it is handed to the component the schema names — so a value passed as `data={…}` is neither -read nor refused, and the expression that wanted it is returned as its own source text, with -nothing thrown and one line on the console. - -⛔ `dataSource` is the **adapter** — the object data renderers call `find()` on — and not an -expression root. It is a different channel from the expression scope: publish the values your -`${…}` expressions read with `PredicateScopeProvider`, and inject the adapter your data -components query with `SchemaRendererProvider`. - -## Copy-Paste Schemas - -A `${name.…}` in any of these reads the root `name` off the scope the host published — see -[Expression Scope](#expression-scope) for the provider that publishes one. A head name nothing -published is not an error: the expression is returned as its own source text. - -### Contact Form - -```json -{ - "type": "form", - "fields": [ - { "name": "name", "type": "text", "label": "Full Name", "required": true }, - { "name": "email", "type": "email", "label": "Email", "required": true }, - { "name": "subject", "type": "select", "label": "Subject", "options": [ - { "label": "General Inquiry", "value": "general" }, - { "label": "Bug Report", "value": "bug" }, - { "label": "Feature Request", "value": "feature" } - ]}, - { "name": "message", "type": "textarea", "label": "Message", "required": true } - ], - "submitLabel": "Send Message" -} -``` - -### Data Grid - -```json -{ - "type": "object-grid", - "properties": { - "objectName": "user", - "title": "Users", - "columns": [ - { "field": "name", "label": "Name", "sortable": true }, - { "field": "email", "label": "Email" }, - { "field": "role", "label": "Role" }, - { "field": "status", "label": "Status" }, - { "field": "created_at", "label": "Joined" } - ], - "showSearch": true, - "operations": { "create": true, "update": true, "delete": true, "export": true } - } -} -``` - -### Dashboard - -A row of live KPI tiles. A `dashboard` node's `widgets[]` takes `metric-card` entries and -dataset-bound charts instead; see [Dashboard Plugin](/docs/plugins/plugin-dashboard). - -```json -{ - "type": "grid", - "columns": 4, - "children": [ - { "type": "statistic", "label": "Revenue", "value": "${stats.revenue}", "trend": "up", "description": "+12%" }, - { "type": "statistic", "label": "Orders", "value": "${stats.orders}", "trend": "up", "description": "+8%" }, - { "type": "statistic", "label": "Customers", "value": "${stats.customers}", "trend": "up", "description": "+5%" }, - { "type": "statistic", "label": "Conversion", "value": "${stats.conversion}", "trend": "down", "description": "-2%" } - ] -} -``` - -### Kanban Board - -```json -{ - "type": "object-kanban", - "objectName": "tasks", - "groupBy": "status", - "cardTitle": "title", - "cardFields": ["assignee", "priority", "due_date"], - "columns": [ - { "id": "todo", "title": "To Do", "className": "border-t-2 border-indigo-500" }, - { "id": "in_progress", "title": "In Progress", "className": "border-t-2 border-amber-500" }, - { "id": "review", "title": "In Review", "className": "border-t-2 border-blue-500" }, - { "id": "done", "title": "Done", "className": "border-t-2 border-green-500" } - ] -} -``` - -More schemas: [`ui/examples/schema-catalog`](https://github.com/objectstack-ai/objectstack/tree/main/ui/examples/schema-catalog) -is the canonical catalog — the JSON schemas the docs render, a smoke test mounts, and AI agents -use as a few-shot corpus. - -## Next Steps - -### Add Actions - -Actions are data, not inline functions. Declare one as an `action:button` -node whose `properties` bag carries the block's props, as `@objectstack/spec` -declares them: `actionType` names the built-in executor the action runner -dispatches to, and the bag's other keys carry that executor's arguments — -`target` is the location a `url` action navigates to. Written flat on the node, -`actionType` and `target` are refused by the spec's strict page component: - -```json -{ - "type": "action:button", - "properties": { - "label": "Open details", - "actionType": "url", - "target": "/users/ada" - } -} -``` - -Learn the full action model in [Enhanced Actions](/docs/core/enhanced-actions). - -### Connect a Data Source - -```bash -pnpm add @object-ui/data-objectstack -``` - -```tsx -import { createObjectStackAdapter } from '@object-ui/data-objectstack'; - -const dataSource = createObjectStackAdapter({ - baseUrl: 'https://api.example.com' -}); -``` - -Pass the adapter to `SchemaRendererProvider` and let data-aware renderers call the `DataSource` interface. See [Data Connectivity](/docs/guide/data-source). - -### Learn More - -- [Architecture Overview](/docs/guide/architecture) — Understand how ObjectUI works -- [Schema Rendering](/docs/guide/schema-rendering) — Deep dive into schema rendering -- [Component Registry](/docs/guide/component-registry) — Customize and extend components -- [Plugins](/docs/guide/plugins) — Add views like Grid, Kanban, Charts -- [Fields Guide](/docs/guide/fields) — Field widgets and cell renderers diff --git a/ui/content/docs/guide/react-pages.md b/ui/content/docs/guide/react-pages.md deleted file mode 100644 index c6de06996f9..00000000000 --- a/ui/content/docs/guide/react-pages.md +++ /dev/null @@ -1,413 +0,0 @@ ---- -title: React Pages -description: Author a page body as real React (kind:'react') or as constrained JSX that is parsed and never executed (kind:'html') — and how to choose between them. ---- - -# React Pages - -Most pages in ObjectUI are a **schema tree** — `regions[].components[]` of JSON -nodes. Two page kinds let you write the body as **source** instead, for layouts -that are awkward to express as nested JSON: - -| `kind` | Source is | Executed? | Author trust | -|---|---|---|---| -| `"html"` | Constrained JSX/HTML | **No** — parsed into a schema tree | Untrusted OK | -| `"react"` | Real React (hooks, handlers, arbitrary JS) | **Yes** — in the main React tree | Trusted only | - -Both set `source` and leave `regions` unused. The former alias `"jsx"` was retired -in `@objectstack/spec` 18 and is refused; author `"html"`. - -> Page `kind` also carries the record-page override values `"full"` (default) -> and `"slotted"` — a different axis, covered in [Slotted Pages](./slotted-pages.md). - -## Choosing between them - -Reach for **`kind:'html'`** by default. It is parsed, whitelisted against the -public block manifest, and never executed, so it is safe for AI-generated and -customer-authored pages. It covers layout, blocks, and styling — styling through -the blocks' own structured props plus a JSON `style` object, **not** Tailwind -(see *Styling*, below; the rule holds on both tiers). - -Reach for **`kind:'react'`** only when you need real behaviour the schema tree -cannot express — local state, computed lists, event handlers wiring one block to -another, custom data fetching. It runs **without a sandbox**. - -## `kind:'react'` - -```json -{ - "type": "home", - "name": "project_console", - "kind": "react", - "source": "function Page() {\n const [selected, setSelected] = React.useState(null);\n return (\n
\n setSelected(r._id)} />\n {selected && }\n
\n );\n}" -} -``` - -Written out, that `source` is: - -```jsx -function Page() { - const [selected, setSelected] = React.useState(null); - return ( -
- setSelected(r._id)} - /> - {selected && } -
- ); -} -``` - -### The security gate - -A react page's source is transpiled and evaluated directly in the application — -no isolation, full access to the page's React tree. So the host capability -`react-pages` defaults **OFF**, and a deployment turns it on only when its page -authors are trusted and reviewed. - -On ObjectStack the operator turns it on server-side with -`OS_PAGE_REACT_ENABLED=true`; the server then writes the enable global -(`window.__OBJECTUI_CAPABILITIES__`) into the Console document, and with the -tier off it writes the disable global instead. A host that embeds the renderer -itself calls `enableCapability('react-pages')` before rendering. While the -capability is off, a react page renders a notice that names the switch instead -of executing. `kind:'html'` pages are unaffected either way. - -### What is in scope - -Nothing is imported. These identifiers are injected as closure variables: - -| In scope | What it is | -|---|---| -| `React` | The host's React — call hooks with it (`React.useState`). | -| The public data blocks | Every public non-container block, as a PascalCase tag *on this tier* — but *what resolves* and *what you author against* are two different sets, below. | -| `Block` | Escape hatch for anything not injected. | -| `useAdapter` | The live data source — query/create/update. | -| `useDataInvalidation` | The data-invalidation bus reader: `useDataInvalidation('object')` returns a number that moves when a write to that object is reported. Name it in the dependencies of an effect that reads through `useAdapter` — see *Live data*, below. | -| `data`, `variables`, `page` | The page's own data, local variables, and schema. | - -#### Two tiers: what resolves, and what you author against - -**The runtime scope** is every block in the curated public contract -(`PUBLIC_BLOCKS`) that is not a layout container. **On this tier** tags are -derived by splitting the registry type on `-`, `_` and `:` and PascalCasing each -part: `object-grid` → ``, `record:details` → ``. A -`kind:'html'` page writes the registry type itself instead — ``, -``. Blocks registered lazily are in scope too — you never wait -on a plugin chunk to reference one. The intrinsic HTML elements the `html` tier -declares (`h1`, `p`, `a`, the sectioning tags, … — `HTML_TIER_INTRINSICS`, carried -in `sdui.manifest.json` as `tier: 'html'`) are **not** injected here: on this tier -a lowercase `

` is React's own element, and no `

` wrapper exists. - -**The authored contract** is the much smaller set that has *published props* — -checked by `os validate` and generated into the reference an author, human or AI, -writes against: **``, ``, ``, ``**. -That set is `REACT_BLOCKS` in `@objectstack/spec`, and the generated per-prop -table is `skills/objectstack-ui/references/react-blocks.md` in the framework -repo. **Treat that table as the prop authority, not this page.** - -Everything in the runtime scope but outside the contract still resolves and -renders — its props simply are not part of the react-tier contract. Reach those -through the contract instead: a kanban / calendar / gantt / timeline / map of an -object is ``, or ``. - -#### The `record:*` family is excluded from this tier - -The tag derivation above is real — `` and `` -*are* defined in the scope — but every `record:*` block reads its record from the -record context a **record page** mounts, and a `kind:'react'` page never mounts -one. The block renders empty however you bind it: its `objectName`/`recordId` are -not read by the renderer. `os validate` rejects them at publish time: - -``` - ✗ Author-time rules failed (1 issue) - • page "showcase_renewals_pipeline" › RecordHighlights: RecordHighlights renders - "record:highlights", which reads its record from the record context a record page - mounts — a kind:'react' page never mounts one, so the block renders empty no matter - how it is bound (its objectName/recordId are not read by the renderer). - rule: react-block-needs-record-context -``` - -The rule matches by **type**, so `` is rejected -the same way. On a react page, bind the record yourself: - -| Instead of | Write | -|---|---| -| `` | `` — it binds by its own props. | -| `` | ``, or read the record with `useAdapter().findOne` and lay the strip out in JSX. | -| `` | ``, ``, -`` and friends have no injected wrapper. In react mode you compose layout -with real HTML, which React is better at than a schema-children renderer — styled -inline, not with Tailwind: `

`. - -`isContainer` means exactly this: *layout containment*, the reason a block is -kept out of the JSX scope. It does not mean "accepts children". Whether a block -renders an authored child list is declared on its registration as -`{ name: 'children', type: 'slot' }` in `inputs`, and that declaration is what -the html tier's `not-a-container` diagnostic reads — so `