From f8ad4e302adea691d3c9b17c69fe0caace487107 Mon Sep 17 00:00:00 2001 From: James Bradford Date: Wed, 21 Oct 2026 11:23:23 -0700 Subject: [PATCH 01/15] initial copy-paste of coreai-starter --- .../skills/coreai-ds-fluentui-react/SKILL.md | 21 + .../.agents/skills/css-styling/SKILL.md | 253 ++++ .../.agents/skills/features-variants/SKILL.md | 306 +++++ starter-coreai/.agents/skills/icons/SKILL.md | 30 + .../.agents/skills/interpreting-html/SKILL.md | 23 + starter-coreai/.agents/skills/lint/SKILL.md | 21 + .../.agents/skills/new-variant/SKILL.md | 74 ++ .../.agents/skills/react-components/SKILL.md | 72 ++ .../.agents/skills/routing/SKILL.md | 30 + .../.agents/skills/start-app/SKILL.md | 22 + .../.agents/skills/state-management/SKILL.md | 347 ++++++ starter-coreai/.gitignore | 2 + starter-coreai/AGENTS.md | 89 ++ starter-coreai/README.md | 91 ++ .../components/common/Card/Card.module.css | 17 + .../app/components/common/Card/Card.tsx | 238 ++++ .../DataGridCell/ShimmerCell.module.css | 11 + .../DataGridCell/ShimmerCell.tsx | 23 + .../DataGridCell/createMenuColumn.module.css | 40 + .../DataGridCell/createMenuColumn.tsx | 90 ++ .../DataGridWithFilters.module.css | 157 +++ .../DataGridWithFilters.tsx | 1082 +++++++++++++++++ .../common/DataGridWithFilters/README.md | 54 + .../DataGridWithFilters/createColumn.tsx | 110 ++ .../common/DetailsList/DetailsList.module.css | 22 + .../common/DetailsList/DetailsList.tsx | 58 + .../components/common/Icons/CoreAILogo.tsx | 26 + .../components/common/Icons/DiscordIcon.tsx | 22 + .../components/common/Icons/GitHubIcon.tsx | 25 + .../components/common/Icons/MCPToolIcon.tsx | 25 + .../app/components/common/Illicon/Illicon.tsx | 99 ++ .../app/components/common/Image/Image.tsx | 48 + .../app/components/common/Link/Link.tsx | 133 ++ .../common/Pages/CollectionDetailsPage.tsx | 26 + .../common/Pages/CollectionPage.tsx | 23 + .../components/common/Pages/DetailsLayout.tsx | 66 + .../components/common/Pages/GridContent.tsx | 21 + .../common/Pages/GridDetailsContent.tsx | 65 + .../components/common/Pages/PageBackLink.tsx | 29 + .../app/components/common/Pages/PageBody.tsx | 39 + .../common/Pages/PageHeader.module.css | 83 ++ .../components/common/Pages/PageHeader.tsx | 66 + .../common/Pages/PageLayout.module.css | 62 + .../components/common/Pages/PageLayout.tsx | 37 + .../common/Pages/PanelLayouts.module.css | 98 ++ .../app/components/common/Pages/README.md | 243 ++++ .../common/Pages/ResponsivePanel.tsx | 103 ++ .../components/common/Pages/SidebarLayout.tsx | 104 ++ .../common/Pages/TabbedPageLayout.tsx | 36 + .../PaginationControls.module.css | 13 + .../PaginationControls/PaginationControls.tsx | 52 + .../ResizeHandle/ResizeHandle.module.css | 53 + .../common/ResizeHandle/ResizeHandle.tsx | 144 +++ .../ResourceNotFound.module.css | 51 + .../ResourceNotFound/ResourceNotFound.tsx | 79 ++ .../common/TabLink/TabLink.module.css | 11 + .../app/components/common/TabLink/TabLink.tsx | 119 ++ .../common/TabLinkList/TabLinkList.module.css | 14 + .../common/TabLinkList/TabLinkList.tsx | 65 + .../common/TabRadio/TabRadio.module.css | 30 + .../components/common/TabRadio/TabRadio.tsx | 99 ++ .../TabRadioGroup/TabRadioGroup.module.css | 17 + .../common/TabRadioGroup/TabRadioGroup.tsx | 100 ++ .../common/hooks/ContainerQueryContext.tsx | 17 + .../hooks/ContainerQueryProvider.module.css | 21 + .../common/hooks/ContainerQueryProvider.tsx | 58 + .../common/hooks/useContainerQuery.ts | 67 + .../components/common/hooks/useMediaQuery.ts | 26 + starter-coreai/app/components/common/index.ts | 72 ++ .../components/shell/AppContent.module.css | 19 + .../app/components/shell/AppContent.tsx | 23 + .../app/components/shell/NavLayout.module.css | 116 ++ .../app/components/shell/NavLayout.tsx | 61 + .../shell/feedback/FeedbackButton.tsx | 12 + .../shell/nav/AgentHelperToggleButton.tsx | 15 + .../components/shell/nav/LeftNav.module.css | 183 +++ .../app/components/shell/nav/LeftNav.tsx | 63 + .../components/shell/nav/LeftNavLinkList.tsx | 91 ++ .../shell/nav/MobileLeftNav.module.css | 37 + .../components/shell/nav/MobileLeftNav.tsx | 124 ++ .../shell/nav/ProjectSwitcher.module.css | 39 + .../components/shell/nav/ProjectSwitcher.tsx | 57 + .../components/shell/nav/TopNav.module.css | 266 ++++ .../app/components/shell/nav/TopNav.tsx | 129 ++ .../AzureOpenAISettingsDialog.module.css | 106 ++ .../nav/profile/AzureOpenAISettingsDialog.tsx | 373 ++++++ .../nav/profile/ProfileButton.module.css | 52 + .../shell/nav/profile/ProfileButton.tsx | 89 ++ .../shell/nav/profile/ThemePicker.tsx | 62 + .../notifications/NotificationButton.tsx | 12 + .../components/shell/search/SearchButton.tsx | 12 + .../shell/search/SearchDialog.module.css | 23 + .../components/shell/search/SearchDialog.tsx | 50 + .../app/core/datafetching/QueryProvider.tsx | 33 + starter-coreai/app/core/utils/azureOpenAI.ts | 50 + .../app/core/utils/setCookie.client.ts | 9 + starter-coreai/app/data/agents.json | 552 +++++++++ starter-coreai/app/data/home.json | 265 ++++ starter-coreai/app/data/index.ts | 215 ++++ starter-coreai/app/data/models.json | 262 ++++ starter-coreai/app/data/tools.json | 317 +++++ starter-coreai/app/dev/reactGrab.ts | 165 +++ starter-coreai/app/features.json | 3 + starter-coreai/app/features/FeatureContext.ts | 33 + .../app/features/FeatureFloatingUI.module.css | 60 + .../app/features/FeatureFloatingUI.tsx | 67 + .../app/features/FeatureMenu.module.css | 18 + starter-coreai/app/features/FeatureMenu.tsx | 96 ++ .../app/features/FeatureProvider.tsx | 140 +++ starter-coreai/app/features/featureConfig.ts | 120 ++ .../app/features/useFloatingPosition.ts | 182 +++ starter-coreai/app/hooks/useAgents.ts | 54 + .../app/hooks/useAzureOpenAISettings.ts | 121 ++ starter-coreai/app/hooks/useChat.ts | 161 +++ .../app/hooks/useComplexityAnalyzer.ts | 126 ++ starter-coreai/app/hooks/useHomeData.ts | 22 + starter-coreai/app/hooks/useTools.ts | 66 + starter-coreai/app/hooks/useUserInfo.ts | 43 + starter-coreai/app/root.tsx | 96 ++ starter-coreai/app/routes.ts | 153 +++ .../build/agent/ChatMessages.module.css | 110 ++ .../app/routes/build/agent/ChatMessages.tsx | 147 +++ .../app/routes/build/agent/evaluation.tsx | 23 + .../app/routes/build/agent/monitor.tsx | 23 + .../routes/build/agent/placeholder.module.css | 15 + .../routes/build/agent/playground.module.css | 173 +++ .../app/routes/build/agent/playground.tsx | 470 +++++++ .../app/routes/build/agent/route.tsx | 127 ++ .../app/routes/build/agent/traces.tsx | 23 + .../app/routes/build/agents.module.css | 5 + starter-coreai/app/routes/build/agents.tsx | 31 + .../app/routes/build/agents/AgentList.tsx | 116 ++ .../app/routes/build/agents/all.tsx | 13 + .../app/routes/build/agents/drafts.tsx | 14 + .../app/routes/build/agents/published.tsx | 14 + starter-coreai/app/routes/build/route.tsx | 12 + .../app/routes/build/tools.module.css | 10 + starter-coreai/app/routes/build/tools.tsx | 129 ++ .../routes/discover/catalog/Catalog.types.ts | 28 + .../discover/catalog/CatalogGrid.module.css | 77 ++ .../routes/discover/catalog/CatalogGrid.tsx | 125 ++ .../catalog/CatalogItemDetails.module.css | 47 + .../discover/catalog/CatalogItemDetails.tsx | 79 ++ .../discover/catalog/CatalogView.module.css | 88 ++ .../routes/discover/catalog/CatalogView.tsx | 615 ++++++++++ .../discover/catalog/FilterSidebar.module.css | 43 + .../routes/discover/catalog/FilterSidebar.tsx | 248 ++++ .../catalog/hooks/useCatalogPagination.ts | 65 + .../discover/catalog/hooks/useFilterGroups.ts | 86 ++ starter-coreai/app/routes/discover/model.tsx | 66 + starter-coreai/app/routes/discover/models.tsx | 54 + starter-coreai/app/routes/discover/route.tsx | 15 + starter-coreai/app/routes/discover/tool.tsx | 53 + .../app/routes/discover/tools.module.css | 17 + starter-coreai/app/routes/discover/tools.tsx | 65 + starter-coreai/app/routes/docs.tsx | 14 + starter-coreai/app/routes/home.module.css | 369 ++++++ starter-coreai/app/routes/home.tsx | 453 +++++++ .../app/routes/operate/agents.module.css | 10 + starter-coreai/app/routes/operate/agents.tsx | 95 ++ .../app/routes/operate/dashboard.tsx | 14 + starter-coreai/app/routes/operate/route.tsx | 15 + starter-coreai/app/styles/fonts.css | 7 + starter-coreai/app/styles/globals.css | 65 + starter-coreai/app/theme/AppThemeProvider.tsx | 14 + starter-coreai/app/theme/ThemeContext.ts | 21 + starter-coreai/app/theme/ThemeProvider.tsx | 46 + .../app/theme/createLayeredRenderer.ts | 29 + starter-coreai/app/theme/themes.ts | 110 ++ starter-coreai/app/theme/useTheme.ts | 11 + starter-coreai/app/theme/useThemeContext.ts | 16 + starter-coreai/app/theme/useThemeProvider.ts | 100 ++ starter-coreai/eslint.config.js | 12 + starter-coreai/eslint.rules.js | 106 ++ starter-coreai/package.json | 41 + starter-coreai/react-router.config.ts | 8 + starter-coreai/tsconfig.json | 16 + starter-coreai/vite.config.ts | 29 + 178 files changed, 16140 insertions(+) create mode 100644 starter-coreai/.agents/skills/coreai-ds-fluentui-react/SKILL.md create mode 100644 starter-coreai/.agents/skills/css-styling/SKILL.md create mode 100644 starter-coreai/.agents/skills/features-variants/SKILL.md create mode 100644 starter-coreai/.agents/skills/icons/SKILL.md create mode 100644 starter-coreai/.agents/skills/interpreting-html/SKILL.md create mode 100644 starter-coreai/.agents/skills/lint/SKILL.md create mode 100644 starter-coreai/.agents/skills/new-variant/SKILL.md create mode 100644 starter-coreai/.agents/skills/react-components/SKILL.md create mode 100644 starter-coreai/.agents/skills/routing/SKILL.md create mode 100644 starter-coreai/.agents/skills/start-app/SKILL.md create mode 100644 starter-coreai/.agents/skills/state-management/SKILL.md create mode 100644 starter-coreai/.gitignore create mode 100644 starter-coreai/AGENTS.md create mode 100644 starter-coreai/README.md create mode 100644 starter-coreai/app/components/common/Card/Card.module.css create mode 100644 starter-coreai/app/components/common/Card/Card.tsx create mode 100644 starter-coreai/app/components/common/DataGridWithFilters/DataGridCell/ShimmerCell.module.css create mode 100644 starter-coreai/app/components/common/DataGridWithFilters/DataGridCell/ShimmerCell.tsx create mode 100644 starter-coreai/app/components/common/DataGridWithFilters/DataGridCell/createMenuColumn.module.css create mode 100644 starter-coreai/app/components/common/DataGridWithFilters/DataGridCell/createMenuColumn.tsx create mode 100644 starter-coreai/app/components/common/DataGridWithFilters/DataGridWithFilters.module.css create mode 100644 starter-coreai/app/components/common/DataGridWithFilters/DataGridWithFilters.tsx create mode 100644 starter-coreai/app/components/common/DataGridWithFilters/README.md create mode 100644 starter-coreai/app/components/common/DataGridWithFilters/createColumn.tsx create mode 100644 starter-coreai/app/components/common/DetailsList/DetailsList.module.css create mode 100644 starter-coreai/app/components/common/DetailsList/DetailsList.tsx create mode 100644 starter-coreai/app/components/common/Icons/CoreAILogo.tsx create mode 100644 starter-coreai/app/components/common/Icons/DiscordIcon.tsx create mode 100644 starter-coreai/app/components/common/Icons/GitHubIcon.tsx create mode 100644 starter-coreai/app/components/common/Icons/MCPToolIcon.tsx create mode 100644 starter-coreai/app/components/common/Illicon/Illicon.tsx create mode 100644 starter-coreai/app/components/common/Image/Image.tsx create mode 100644 starter-coreai/app/components/common/Link/Link.tsx create mode 100644 starter-coreai/app/components/common/Pages/CollectionDetailsPage.tsx create mode 100644 starter-coreai/app/components/common/Pages/CollectionPage.tsx create mode 100644 starter-coreai/app/components/common/Pages/DetailsLayout.tsx create mode 100644 starter-coreai/app/components/common/Pages/GridContent.tsx create mode 100644 starter-coreai/app/components/common/Pages/GridDetailsContent.tsx create mode 100644 starter-coreai/app/components/common/Pages/PageBackLink.tsx create mode 100644 starter-coreai/app/components/common/Pages/PageBody.tsx create mode 100644 starter-coreai/app/components/common/Pages/PageHeader.module.css create mode 100644 starter-coreai/app/components/common/Pages/PageHeader.tsx create mode 100644 starter-coreai/app/components/common/Pages/PageLayout.module.css create mode 100644 starter-coreai/app/components/common/Pages/PageLayout.tsx create mode 100644 starter-coreai/app/components/common/Pages/PanelLayouts.module.css create mode 100644 starter-coreai/app/components/common/Pages/README.md create mode 100644 starter-coreai/app/components/common/Pages/ResponsivePanel.tsx create mode 100644 starter-coreai/app/components/common/Pages/SidebarLayout.tsx create mode 100644 starter-coreai/app/components/common/Pages/TabbedPageLayout.tsx create mode 100644 starter-coreai/app/components/common/PaginationControls/PaginationControls.module.css create mode 100644 starter-coreai/app/components/common/PaginationControls/PaginationControls.tsx create mode 100644 starter-coreai/app/components/common/ResizeHandle/ResizeHandle.module.css create mode 100644 starter-coreai/app/components/common/ResizeHandle/ResizeHandle.tsx create mode 100644 starter-coreai/app/components/common/ResourceNotFound/ResourceNotFound.module.css create mode 100644 starter-coreai/app/components/common/ResourceNotFound/ResourceNotFound.tsx create mode 100644 starter-coreai/app/components/common/TabLink/TabLink.module.css create mode 100644 starter-coreai/app/components/common/TabLink/TabLink.tsx create mode 100644 starter-coreai/app/components/common/TabLinkList/TabLinkList.module.css create mode 100644 starter-coreai/app/components/common/TabLinkList/TabLinkList.tsx create mode 100644 starter-coreai/app/components/common/TabRadio/TabRadio.module.css create mode 100644 starter-coreai/app/components/common/TabRadio/TabRadio.tsx create mode 100644 starter-coreai/app/components/common/TabRadioGroup/TabRadioGroup.module.css create mode 100644 starter-coreai/app/components/common/TabRadioGroup/TabRadioGroup.tsx create mode 100644 starter-coreai/app/components/common/hooks/ContainerQueryContext.tsx create mode 100644 starter-coreai/app/components/common/hooks/ContainerQueryProvider.module.css create mode 100644 starter-coreai/app/components/common/hooks/ContainerQueryProvider.tsx create mode 100644 starter-coreai/app/components/common/hooks/useContainerQuery.ts create mode 100644 starter-coreai/app/components/common/hooks/useMediaQuery.ts create mode 100644 starter-coreai/app/components/common/index.ts create mode 100644 starter-coreai/app/components/shell/AppContent.module.css create mode 100644 starter-coreai/app/components/shell/AppContent.tsx create mode 100644 starter-coreai/app/components/shell/NavLayout.module.css create mode 100644 starter-coreai/app/components/shell/NavLayout.tsx create mode 100644 starter-coreai/app/components/shell/feedback/FeedbackButton.tsx create mode 100644 starter-coreai/app/components/shell/nav/AgentHelperToggleButton.tsx create mode 100644 starter-coreai/app/components/shell/nav/LeftNav.module.css create mode 100644 starter-coreai/app/components/shell/nav/LeftNav.tsx create mode 100644 starter-coreai/app/components/shell/nav/LeftNavLinkList.tsx create mode 100644 starter-coreai/app/components/shell/nav/MobileLeftNav.module.css create mode 100644 starter-coreai/app/components/shell/nav/MobileLeftNav.tsx create mode 100644 starter-coreai/app/components/shell/nav/ProjectSwitcher.module.css create mode 100644 starter-coreai/app/components/shell/nav/ProjectSwitcher.tsx create mode 100644 starter-coreai/app/components/shell/nav/TopNav.module.css create mode 100644 starter-coreai/app/components/shell/nav/TopNav.tsx create mode 100644 starter-coreai/app/components/shell/nav/profile/AzureOpenAISettingsDialog.module.css create mode 100644 starter-coreai/app/components/shell/nav/profile/AzureOpenAISettingsDialog.tsx create mode 100644 starter-coreai/app/components/shell/nav/profile/ProfileButton.module.css create mode 100644 starter-coreai/app/components/shell/nav/profile/ProfileButton.tsx create mode 100644 starter-coreai/app/components/shell/nav/profile/ThemePicker.tsx create mode 100644 starter-coreai/app/components/shell/notifications/NotificationButton.tsx create mode 100644 starter-coreai/app/components/shell/search/SearchButton.tsx create mode 100644 starter-coreai/app/components/shell/search/SearchDialog.module.css create mode 100644 starter-coreai/app/components/shell/search/SearchDialog.tsx create mode 100644 starter-coreai/app/core/datafetching/QueryProvider.tsx create mode 100644 starter-coreai/app/core/utils/azureOpenAI.ts create mode 100644 starter-coreai/app/core/utils/setCookie.client.ts create mode 100644 starter-coreai/app/data/agents.json create mode 100644 starter-coreai/app/data/home.json create mode 100644 starter-coreai/app/data/index.ts create mode 100644 starter-coreai/app/data/models.json create mode 100644 starter-coreai/app/data/tools.json create mode 100644 starter-coreai/app/dev/reactGrab.ts create mode 100644 starter-coreai/app/features.json create mode 100644 starter-coreai/app/features/FeatureContext.ts create mode 100644 starter-coreai/app/features/FeatureFloatingUI.module.css create mode 100644 starter-coreai/app/features/FeatureFloatingUI.tsx create mode 100644 starter-coreai/app/features/FeatureMenu.module.css create mode 100644 starter-coreai/app/features/FeatureMenu.tsx create mode 100644 starter-coreai/app/features/FeatureProvider.tsx create mode 100644 starter-coreai/app/features/featureConfig.ts create mode 100644 starter-coreai/app/features/useFloatingPosition.ts create mode 100644 starter-coreai/app/hooks/useAgents.ts create mode 100644 starter-coreai/app/hooks/useAzureOpenAISettings.ts create mode 100644 starter-coreai/app/hooks/useChat.ts create mode 100644 starter-coreai/app/hooks/useComplexityAnalyzer.ts create mode 100644 starter-coreai/app/hooks/useHomeData.ts create mode 100644 starter-coreai/app/hooks/useTools.ts create mode 100644 starter-coreai/app/hooks/useUserInfo.ts create mode 100644 starter-coreai/app/root.tsx create mode 100644 starter-coreai/app/routes.ts create mode 100644 starter-coreai/app/routes/build/agent/ChatMessages.module.css create mode 100644 starter-coreai/app/routes/build/agent/ChatMessages.tsx create mode 100644 starter-coreai/app/routes/build/agent/evaluation.tsx create mode 100644 starter-coreai/app/routes/build/agent/monitor.tsx create mode 100644 starter-coreai/app/routes/build/agent/placeholder.module.css create mode 100644 starter-coreai/app/routes/build/agent/playground.module.css create mode 100644 starter-coreai/app/routes/build/agent/playground.tsx create mode 100644 starter-coreai/app/routes/build/agent/route.tsx create mode 100644 starter-coreai/app/routes/build/agent/traces.tsx create mode 100644 starter-coreai/app/routes/build/agents.module.css create mode 100644 starter-coreai/app/routes/build/agents.tsx create mode 100644 starter-coreai/app/routes/build/agents/AgentList.tsx create mode 100644 starter-coreai/app/routes/build/agents/all.tsx create mode 100644 starter-coreai/app/routes/build/agents/drafts.tsx create mode 100644 starter-coreai/app/routes/build/agents/published.tsx create mode 100644 starter-coreai/app/routes/build/route.tsx create mode 100644 starter-coreai/app/routes/build/tools.module.css create mode 100644 starter-coreai/app/routes/build/tools.tsx create mode 100644 starter-coreai/app/routes/discover/catalog/Catalog.types.ts create mode 100644 starter-coreai/app/routes/discover/catalog/CatalogGrid.module.css create mode 100644 starter-coreai/app/routes/discover/catalog/CatalogGrid.tsx create mode 100644 starter-coreai/app/routes/discover/catalog/CatalogItemDetails.module.css create mode 100644 starter-coreai/app/routes/discover/catalog/CatalogItemDetails.tsx create mode 100644 starter-coreai/app/routes/discover/catalog/CatalogView.module.css create mode 100644 starter-coreai/app/routes/discover/catalog/CatalogView.tsx create mode 100644 starter-coreai/app/routes/discover/catalog/FilterSidebar.module.css create mode 100644 starter-coreai/app/routes/discover/catalog/FilterSidebar.tsx create mode 100644 starter-coreai/app/routes/discover/catalog/hooks/useCatalogPagination.ts create mode 100644 starter-coreai/app/routes/discover/catalog/hooks/useFilterGroups.ts create mode 100644 starter-coreai/app/routes/discover/model.tsx create mode 100644 starter-coreai/app/routes/discover/models.tsx create mode 100644 starter-coreai/app/routes/discover/route.tsx create mode 100644 starter-coreai/app/routes/discover/tool.tsx create mode 100644 starter-coreai/app/routes/discover/tools.module.css create mode 100644 starter-coreai/app/routes/discover/tools.tsx create mode 100644 starter-coreai/app/routes/docs.tsx create mode 100644 starter-coreai/app/routes/home.module.css create mode 100644 starter-coreai/app/routes/home.tsx create mode 100644 starter-coreai/app/routes/operate/agents.module.css create mode 100644 starter-coreai/app/routes/operate/agents.tsx create mode 100644 starter-coreai/app/routes/operate/dashboard.tsx create mode 100644 starter-coreai/app/routes/operate/route.tsx create mode 100644 starter-coreai/app/styles/fonts.css create mode 100644 starter-coreai/app/styles/globals.css create mode 100644 starter-coreai/app/theme/AppThemeProvider.tsx create mode 100644 starter-coreai/app/theme/ThemeContext.ts create mode 100644 starter-coreai/app/theme/ThemeProvider.tsx create mode 100644 starter-coreai/app/theme/createLayeredRenderer.ts create mode 100644 starter-coreai/app/theme/themes.ts create mode 100644 starter-coreai/app/theme/useTheme.ts create mode 100644 starter-coreai/app/theme/useThemeContext.ts create mode 100644 starter-coreai/app/theme/useThemeProvider.ts create mode 100644 starter-coreai/eslint.config.js create mode 100644 starter-coreai/eslint.rules.js create mode 100644 starter-coreai/package.json create mode 100644 starter-coreai/react-router.config.ts create mode 100644 starter-coreai/tsconfig.json create mode 100644 starter-coreai/vite.config.ts diff --git a/starter-coreai/.agents/skills/coreai-ds-fluentui-react/SKILL.md b/starter-coreai/.agents/skills/coreai-ds-fluentui-react/SKILL.md new file mode 100644 index 00000000..4cceadf3 --- /dev/null +++ b/starter-coreai/.agents/skills/coreai-ds-fluentui-react/SKILL.md @@ -0,0 +1,21 @@ +--- +name: coreai-ds-fluentui-react +description: 'Use only when editing ./apps/prototype/* with @coreai-ds/react-components; requires the local CoreAI documentation MCP before choosing components, props, templates, or patterns.' +--- + +Read and follow the canonical [coreai-ds-fluentui-react skill](../../../../../packages/react-components/agent/SKILL.md). + +## Prototype-specific exceptions + +- Use `coreai-ds-docs-localhost` at `http://localhost:6006/mcp`, configured in + [the workspace MCP configuration](../../../../../.vscode/mcp.json), instead of the hosted + endpoint. If it is unavailable, troubleshoot the local server and retry before proceeding. +- Discover the local server's actual schemas. Its current equivalents are + `list-all-documentation` for `docs-list`, `get-documentation` for `docs-show`, and + `get-documentation-for-story` for `docs-show-story`. +- Invoke `react-components` to classify the repository boundary. It owns + prototype adapter and composition guidance; the canonical package skill owns CoreAI API + questions. +- Resolve app-relative paths from `apps/prototype/`. Inspect local adapters and compositions + under `app/components/common/**`; use the app-owned `Card`, `Image`, and `Link` adapters + instead of importing those components directly from the CoreAI package. diff --git a/starter-coreai/.agents/skills/css-styling/SKILL.md b/starter-coreai/.agents/skills/css-styling/SKILL.md new file mode 100644 index 00000000..56daa356 --- /dev/null +++ b/starter-coreai/.agents/skills/css-styling/SKILL.md @@ -0,0 +1,253 @@ +--- +name: css-styling +description: 'CSS Modules conventions, design tokens, container queries, and CSS-vs-component boundaries. USE WHEN: editing any `*.css` / `*.module.css` file in this repo, or writing styles for a new component.' +--- + +# CSS Editing Instructions + +## CRITICAL: Before Editing Any CSS File + +**REQUIRED STEP**: Before making any changes to a `.css` file, you MUST read the corresponding `.tsx` file(s) that use it: + +1. Look for `.tsx` files with the same base name (e.g., `ComponentName.tsx` for `ComponentName.module.css`) +2. Search for imports of the CSS file using grep with pattern: `import.*from.*'\.\/.*\.module\.css'` +3. Read ALL `.tsx` files that import the CSS file before proceeding with edits + +You may also need to edit the matching `.tsx` file and its structure to properly edit CSS. + +## CSS Architecture + +This project uses **CSS Modules**. Component CSS files should be named `*.module.css` and placed alongside their corresponding `.tsx` component files. Existing app-level stylesheets in `apps/prototype/app/styles/` are an exception for foundational styles such as resets and font registration; do not put component styles there. + +Do not write inline css in the style attribute (unless there is a javascript value being attached). + +### Custom Fluent Components + +Do not use CSS to recreate components that already exist in CoreAI or Fluent. Before editing components, invoke the `react-components` skill for proper component usage guidelines. Most appearance and interaction styles are already handled by documented CoreAI components and props. If an appropriate prop is available, use it instead of writing CSS. + +### CSS @layers + +There are 3 named CSS `@layer`s in this app, ordered `base`, `griffel`, and `common`, plus unlayered CSS (which is not a named layer). The `base` layer contains the foundational box-sizing reset in `apps/prototype/app/styles/globals.css`. The `griffel` layer encapsulates Fluent's styles through the app-owned layered renderer - NEVER write CSS into the `griffel` layer. The `common` layer is for common components inside the `apps/prototype/app/components/common` folder and existing app-level resets; all components in this folder should use this layer, and component styles outside it should not. All other component styles should be written outside of these layers. + +## General Guidelines + +**No Global CSS**: Do not write global CSS selectors (e.g., `body`, `h1`) outside of a scoped CSS class for component styling. All component styles must be scoped using CSS Modules. Existing app-level stylesheets in `apps/prototype/app/styles/` are a narrow exception for foundational resets, font registration, and app-wide variable defaults, not component styling. +**Use Nested Classes**: Scope styles to specific elements within the component using nested class selectors. +**Avoid `!important`**: Do not use `!important` in CSS rules. Instead, refactor the CSS or component structure to achieve the desired specificity. + +## Responsive Breakpoints — Container Queries + +**CRITICAL**: Do NOT use `@media (width ...)` for responsive width breakpoints. Use `@container app (width ...)` instead. + +This app uses **CSS container queries** so that responsive behavior responds to the actual available content width, not the viewport. Panels that reduce the content container's available width affect its breakpoints; the app-local floating feature selector is fixed-position and does not consume layout width. + +The `ContainerQueryProvider` component (wrapping the app children with `styles.fullscreenContainer` in `apps/prototype/app/components/shell/AppContent.tsx`) establishes `container-type: inline-size` with `container-name: app`. + +### Correct — Container Query + +```css +@container app (width <= 768px) { + .sidebar { + display: none; + } +} +``` + +### Wrong — Viewport Media Query + +```css +/* ❌ DO NOT use @media for width breakpoints */ +@media (width <= 768px) { + .sidebar { + display: none; + } +} +``` + +### When to still use `@media` + +- **Height queries**: `@media (height <= 480px)` — container queries only support inline-size +- **User preferences**: `@media (prefers-color-scheme: dark)`, `@media (prefers-reduced-motion: reduce)` +- **Accessibility**: `@media (forced-colors: active)` +- **Elements in portals/top-layer**: Dialogs, popovers, and other portal-rendered elements that exist outside the container tree (these still need viewport `@media` queries) + +## Layout + +CSS should primarily be used for layout, spacing, borders, and backgrounds. Avoid using CSS for text styles, component styles, or interaction styles. Most of these are handled by React components and props. Prefer using flexbox with gap for most layouts instead of block with margin. Grid is acceptable for more complex layouts. + +Reduce nesting of HTML elements where possible - prefer a flatter structure. + +## Text Styling + +**Text Properties**: Avoid writing these CSS properties directly in CSS files. The components handle most of these by default. + +- `font-size` +- `font-weight` +- `font-family` +- `line-height` +- `color` (for text color) + +**EXCEPTION**: Text properties inside `@container` queries for responsive typography (e.g., scaling a heading on mobile) should be kept in CSS — the `Text` component doesn't handle container-based sizing. + +**INSTEAD**: Where these properties are applied to a `Text`, `span`, or other text element, refactor to use the documented `Text` component from `@coreai-ds/react-components` and replace any text CSS properties with `preset` and `foreground` props. + +```tsx +import { Text } from '@coreai-ds/react-components'; + + + Your text here +; +``` + +### Text Component Presets & Foreground + +**Presets** (size from smallest to largest): + +- `bodyXS` (10px) / `bodyXSStrong` (10px semibold) +- `bodyS` (12px) / `bodySStrong` (12px semibold); `heading6` is an alias of `bodySStrong` +- `body` (14px) / `bodyStrong` (14px semibold); `heading5` is an alias of `bodyStrong` +- `heading4` (16px semibold) +- `heading3` (20px semibold) +- `heading2` (28px semibold) +- `heading1` (40px semibold) + +**Foreground** (text color intensity): + +- `1` - Headings +- `2` - Body text +- `3` - Subtle/muted/secondary +- `4` - Disabled/placeholder +- `'disabled'` - Disabled text + +Additional properties: + +- `block` - makes the text a block element +- `italic` - makes the text italic +- `font` - sets the font family - `base` | `monospace` +- `as` - html element to render as (e.g., 'p', 'span', 'h1', etc.) + +Don't use Fluent's fixed typography components (like `` or ``) directly. Always use CoreAI `` with `preset` and `foreground` props. + +**When you encounter these properties in existing CSS**: Refactor the code to remove them from CSS and use the `Text` component in the `.tsx` file instead. + +## Combining CSS Classes + +When applying multiple CSS classes, use the `clsx` utility: + +```tsx +import clsx from 'clsx'; +import styles from './Component.module.css'; + +
; +``` + +## CSS Variables + +Use Fluent semantic color tokens directly. + +### Background + +- `--colorNeutralBackground1` - Lighter primary background +- `--colorNeutralBackground2` - Middle background (uncommon) +- `--colorNeutralBackground3` - Darker background +- `--colorNeutralBackground4` - Very dark background (rarely used) + +### Border/Stroke + +- `--colorNeutralBackground4` - Existing panel-divider color +- `--colorNeutralStroke1` - Standard border color for elements + +Usage: `border: 1px solid var(--colorNeutralStroke1);` + +### Foreground/Text + +Prefer using the `Text` component's `foreground` prop instead of setting text color in CSS. + +- `--colorNeutralForeground1` - Intense (primary text/headings) +- `--colorNeutralForeground2` - Default body text color +- `--colorNeutralForeground3` - Subtle/muted (secondary text) +- `--colorNeutralForeground4` - Dim (disabled/placeholder text) +- `--colorBrandForeground1` - Brand color +- `--colorBrandForegroundLink` - Link color + +### Interactive/States + +Components own their hover, pressed, and selected states. Use the component API instead of +introducing application-level interaction-color aliases. + +### Spacing - REQUIRED + +**NEVER use hardcoded pixel values or rem values for spacing values less than 32px**. Always use these CSS variables: + +- `--spacingNone` (0) +- `--spacingXXS` (2px) +- `--spacingXS` (4px) +- `--spacingSNudge` (6px) +- `--spacingS` (8px) +- `--spacingMNudge` (10px) +- `--spacingM` (12px) +- `--spacingL` (16px) +- `--spacingXL` (20px) +- `--spacingXXL` (24px) +- `--spacingXXXL` (32px) +- `--spacingPagePadding` - Special variable for horizontal page padding (adjusts by app container width, with a viewport-based fallback in the global stylesheet) + +**DO NOT** use Fluent spacing variables that include "Horizontal" or "Vertical" in their names. + +For pixel values 32px and above, you may use hardcoded values if necessary, but prefer using spacing variables where possible for consistency. + +### Border Radius + +- `--borderRadiusNone` (0) +- `--borderRadiusSmall` (2px) +- `--borderRadiusMedium` (4px) +- `--borderRadiusLarge` (6px) +- `--borderRadiusXLarge` (8px) +- `--borderRadius2XLarge` (12px) +- `--borderRadius3XLarge` (16px) +- `--borderRadius4XLarge` (24px) +- `--borderRadius5XLarge` (32px) +- `--borderRadius6XLarge` (40px) +- `--borderRadiusCircular` (9999px) + +## Example CSS Patterns + +### Good - Uses Variables + +```css +.container { + padding: var(--spacingL); + background-color: var(--colorNeutralBackground1); + border: 1px solid var(--colorNeutralStroke1); + gap: var(--spacingM); +} +``` + +### Bad - Hardcoded Values + +```css +.container { + padding: 16px; /* WRONG - use var(--spacingL) */ + background-color: #f5f5f5; /* WRONG - use var(--colorNeutralBackground1) */ + font-size: 14px; /* WRONG - use Text component instead */ + color: #333; /* WRONG - use Text component instead */ +} +``` + +## Summary Checklist + +Before submitting CSS edits, verify: + +- [ ] Read corresponding `.tsx` file(s) that use this CSS +- [ ] No text-styling properties (`font-*`, `color`) in CSS +- [ ] All colors use Fluent semantic tokens +- [ ] All spacing uses `--spacing*` variables +- [ ] Width breakpoints use `@container app (width ...)` not `@media (width ...)` +- [ ] Component CSS file is named `*.module.css`; any app-level stylesheet edit stays within the foundational-style exception +- [ ] Classes combined with `clsx` when multiple are needed diff --git a/starter-coreai/.agents/skills/features-variants/SKILL.md b/starter-coreai/.agents/skills/features-variants/SKILL.md new file mode 100644 index 00000000..3d9714c7 --- /dev/null +++ b/starter-coreai/.agents/skills/features-variants/SKILL.md @@ -0,0 +1,306 @@ +--- +name: features-variants +description: 'Definitive guide to the app-local prototype feature/variant system: configuration, `useFeature()`, URL params, and anti-patterns. USE WHEN: editing `apps/prototype/app/features.json`, using `useFeature()` in routes, creating design alternatives, A/B comparisons, layout options, alternate versions, or switching between design proposals. Trigger keywords: feature, variant, version, option, alternate, prototype, design option, A/B, comparison, switch between, try a different, another way, layout option, explore, side by side.' +--- + +# Feature & Variant System Instructions + +Paths below are relative to `apps/prototype/`. + +## What Is the Feature System? + +CoreAI Sandbox has a **built-in feature/variant system** for tracking prototyped features and presenting multiple UI alternatives on the same page. It uses a neutral floating selector with small, elevated tab links and a brand-colored Prototype InteractionTag menu trigger to distinguish itself from the designs being prototyped. + +A **feature** represents a prototyped change from main. A feature may: + +- Have **variants** — multiple design alternatives that can be switched between (e.g., different layouts) +- Be **variant-less** — a single prototyped change with no alternatives (just a link to the location of the change) + +**Multiple features can be active simultaneously** on the same page, each with their own independent variant selection. + +**You MUST use this system** whenever a user asks to: + +- Try a different layout, design, or approach for a page or section +- Create an "option B" or "version 2" of something +- Compare two or more designs side by side +- Add an A/B test or design alternative +- Explore an alternate version of a feature + +**Do NOT** build a custom toggle, tab switcher, or parallel component tree. The feature system already handles all of this. + +> For a guided walk-through tailored to designer-style requests, invoke the `new-variant` skill. + +--- + +## How It Works (Summary) + +1. **Features** are defined in `app/features.json`. Each feature may have variant options. The registry is currently empty; add definitions to its top-level `features` array. +2. Active variants are encoded in the URL as repeated `?variant=feature-id--variant-id` params — every combination is a shareable deep link. +3. When a user navigates to a page with applicable features, the system **automatically populates** selections from valid URL values, then remembered client-navigation selections, then the first configured variant. +4. A **draggable floating switcher strip** appears bottom-center by default showing each feature's variants as small, elevated router-backed tab links, when applicable variants exist and **Show feature selector** is enabled in the Prototype menu. +5. Route components use the `useFeature(featureId)` hook to conditionally render based on the active variant. +6. **Multiple features** can be active on the same page simultaneously with independent variant selections. + +--- + +## Architecture — Key Files + +| File | Purpose | +| ----------------------------------------------- | ---------------------------------------------------------------------- | +| `app/features.json` | App configuration: all features, routes, and variant labels | +| `app/features/featureConfig.ts` | Feature schema, configuration validation, and route/URL helpers | +| `app/features/FeatureContext.ts` | Feature context and public context types | +| `app/features/FeatureProvider.tsx` | URL state, route matching, defaults, and floating selector composition | +| `app/features/FeatureContext.ts` (`useFeature`) | Public hook for reading the active variant | +| `app/features/FeatureFloatingUI.tsx` | Floating variant selector | +| `app/features/FeatureMenu.tsx` | Prototype feature menu used by the app shell | + +--- + +## URL Structure + +Variants are encoded as repeated `variant` query params: + +``` +# Single feature with one variant active +?variant=agent-playground-layout--accordion + +# Two features active simultaneously +?variant=agent-playground-layout--accordion&variant=code-tab--visible + +# Variant-less features have no variant query param (they apply only on matching routes) +``` + +Each `variant` param is a `feature-id--variant-id` pair using kebab-case string identifiers. + +--- + +## Step-by-Step: Adding a New Variant to an Existing Feature + +If a feature already exists in `app/features.json` and has variants, add a new variant: + +### 1. Update `app/features.json` + +Add a new entry to the `variants` array of the existing feature: + +```json +{ + "id": "your-new-variant", + "label": "Your New Design Name" +} +``` + +Variant `id` values are **kebab-case strings**. Choose a descriptive, URL-friendly name. + +### 2. Update the Route Component + +In the route component that uses this feature, add a conditional branch for the new variant: + +```tsx +import { useFeature } from '~/features/FeatureContext'; + +function MyPage() { + const variant = useFeature('agent-playground-layout'); + + if (variant === 'accordion') return ; + if (variant === 'your-new-variant') return ; // ← new + return ; // first variant or null +} +``` + +That's it. The floating UI automatically shows the new tab link when the selector is visible on an applicable route. + +--- + +## Step-by-Step: Creating a New Feature + +### Feature WITH Variants + +When the feature explores multiple design alternatives: + +#### 1. Add the feature to `app/features.json` + +Add this object to the registry's top-level `features` array: + +```json +{ + "id": "agent-playground-layout", + "label": "Agent Playground Layout", + "notes": "What design question this feature explores.", + "defaultRoute": "/build/agents/:agent/playground", + "defaultParams": { "agent": "agent-001" }, + "routes": ["/build/agents/:agent/playground"], + "variants": [ + { "id": "side-panel", "label": "Side Panel (Current)" }, + { "id": "accordion", "label": "Accordion Layout" } + ] +} +``` + +#### 2. Add `useFeature()` to the Route Component + +```tsx +import { useFeature } from '~/features/FeatureContext'; + +function MyPage() { + const variant = useFeature('agent-playground-layout'); + + if (variant === 'accordion') return ; + return ; // 'side-panel' or null +} +``` + +### Feature WITHOUT Variants + +When the feature is a single prototyped change with no alternatives: + +#### 1. Add the feature to `app/features.json` + +Add this object to the registry's top-level `features` array: + +```json +{ + "id": "dark-mode-revamp", + "label": "Dark Mode Revamp", + "notes": "Updated dark mode colors across settings page.", + "defaultRoute": "/settings/appearance", + "routes": ["/settings/appearance"] +} +``` + +No `variants` array needed. The feature appears in the Prototype InteractionTag menu as a navigation link. No `useFeature()` call is needed in the route component. + +--- + +**Field reference:** + +| Field | Required | Description | +| --------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `id` | Yes | Kebab-case string identifier (used in URL params) | +| `label` | Yes | Human-readable name shown in the floating UI | +| `notes` | Yes | Design context — what question does this feature explore? | +| `defaultRoute` | Yes | The primary route to navigate to from the Prototype menu; must be in `routes` | +| `defaultParams` | No | Fallback values for `:param` segments (e.g., `{ "agent": "agent-001" }`); required when `defaultRoute` has required parameters | +| `routes` | Yes | Route patterns this feature applies to. Supports `:param` wildcards | +| `variants` | No | Array of `{ id: string, label: string }`. Omit for variant-less features | + +**Route pattern matching:** A pattern like `/build/agents/:agent/playground` matches any URL of that shape. The `:param` segments are wildcards matched segment-by-segment. + +--- + +## `useFeature()` Return Values + +```tsx +const result = useFeature('feature-id'); +// Returns: +// string — active variant id (e.g., 'accordion') if feature has variants +// true — if feature is variant-less and applies to current route +// null — if feature doesn't apply to current route +``` + +--- + +## Multi-Feature Pages + +A single page can have **multiple features** active simultaneously. Each component on the page calls `useFeature()` with its own feature ID independently. + +```tsx +const layout = useFeature('agent-playground-layout'); +const codeTab = useFeature('code-tab-visibility'); + +// Both return their own variant independently +// URL: ?variant=agent-playground-layout--accordion&variant=code-tab-visibility--visible +``` + +The floating strip shows variant tab links for all applicable features with variants, separated by dividers. + +--- + +## Variant Scope — What Can Change? + +A variant can change **anything** within its route component: + +- **Full page replacement**: Return entirely different component trees per variant +- **Section swap**: Replace just one panel, card, or section +- **Prop changes**: Same component with different props +- **Style changes**: Different CSS classes per variant +- **Data presentation**: Same data shown in different formats +- **Conditional features**: Show/hide features like buttons, tabs, or panels + +### Example — Small Change (text variant) + +```tsx +const variant = useFeature('hero-text'); +return ( + + {variant === 'version-b' + ? 'Start Building with AI' + : variant === 'version-c' + ? 'Your AI Development Platform' + : 'Welcome to CoreAI Sandbox'} + +); +``` + +### Example — Full Layout Swap + +```tsx +const variant = useFeature('playground-layout'); +if (variant === 'accordion') return ; +if (variant === 'dialog') return ; +return ; +``` + +--- + +## Variant-Exclusive Routes + +Some variants introduce routes that only exist for that variant (e.g., a "Code" tab route that only exists when `go-to-code--tab` is active). If a user lands on a variant-exclusive route with the wrong variant selected, the route component should redirect: + +```tsx +const variant = useFeature('go-to-code'); + +// This route only exists for the "tab" variant — redirect otherwise +if (variant !== 'tab') return ; +``` + +Keep this guard **in the route component itself**, colocated with the route that knows it's conditional. Do not add redirect logic to `FeatureProvider.tsx`. + +--- + +## Anti-Patterns — Do NOT Do These + +| ❌ Don't | ✅ Do Instead | +| ------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | +| Build a custom toggle/switch for design alternatives | Use `useFeature()` + `app/features.json` | +| Store variant state in localStorage or React state | URL query params (`?variant=...`) handle everything | +| Create separate routes for each design alternative | One route, conditional rendering via `useFeature()` | +| Import feature implementation internals from `app/features/featureConfig.ts` or `app/features/FeatureProvider.tsx` in app components | Use `useFeature()` from `~/features/FeatureContext` | +| Hard-code variant IDs as numbers | Feature and variant IDs are **kebab-case strings** | +| Add a new variant switching UI element into the product design | The floating tab-link UI is the switcher — it's meta-UI | +| Modify app-local feature-system internals for an app feature | Only modify `app/features.json` and the consuming component | + +--- + +## Troubleshooting + +**Floating strip doesn't appear on my page:** + +- Check that the route pattern in `app/features.json` matches your page's URL exactly (segment-by-segment) +- `:param` segments match any value, but the number of segments must match +- Ensure the applicable feature has variants and **Show feature selector** is enabled in the Prototype menu +- Check browser console for errors + +**`useFeature()` returns null:** + +- Verify you're passing the **feature ID** (string), not the variant ID +- Verify the feature's `routes` array includes a pattern matching the current page + +**Variant tab links show but clicking doesn't change anything:** + +- Ensure your component reads `useFeature()` and conditionally renders based on it +- Make sure you're checking the correct variant IDs (kebab-case strings) + +**URL params get cleared when navigating:** + +- This is expected for off-route `variant` params: they are removed. When returning during client navigation, valid URL selections take priority, then remembered selections, then the first configured variant. A reload uses the URL or the first variant, not remembered client-navigation state. Unrelated query params are preserved. diff --git a/starter-coreai/.agents/skills/icons/SKILL.md b/starter-coreai/.agents/skills/icons/SKILL.md new file mode 100644 index 00000000..f78256db --- /dev/null +++ b/starter-coreai/.agents/skills/icons/SKILL.md @@ -0,0 +1,30 @@ +--- +name: icons +description: 'Conventions for picking, sizing, and bundling Fluent icons. USE WHEN: adding or changing an icon in a `*.tsx` file, or picking an icon name.' +--- + +# Icon Usage + +When looking for icon names, + +- **Prefer searching the installed `@fluentui/react-icons` exports** so the names match the version the app actually uses. Run this from `apps/prototype/` or the repository root, replacing the search expression with the concept you need: + +```sh +node -e "const icons = require('@fluentui/react-icons'); console.log(Object.keys(icons).filter(name => /document.*add/i.test(name) && !/\d/.test(name) && /(?:Regular|Filled)$/.test(name)).join('\n'));" +``` + +The example searches document-add icons, excludes numbered-size exports, and keeps filled/regular styles. + +- As an alternate, use the Fluent MCP tools: `search_fluent_icons` and `validate_fluent_icon` if available, especially for semantic suggestions. +- As an alternate, search the icon repo names for discovery or visual previews: `https://github.com/microsoft/fluentui-system-icons/blob/main/icons_regular.md` or `https://github.com/microsoft/fluentui-system-icons/blob/main/icons_filled.md`. +- After choosing an icon through an alternate method, verify that its exact export name exists in the installed `@fluentui/react-icons` package; the online catalog may include icons from newer versions. + +Prefer using the unsized icons without the number suffix, e.g. `TextBulletListTreeFilled` instead of `TextBulletListTree20Filled`. This allows the icon to scale dynamically within buttons and other components. + +When adding icons to Fluent components, use the `bundleIcon` utility from `@fluentui/react-icons` to combine filled and regular icons. For example: + +```tsx +const TraceIcon = bundleIcon(TextBulletListTreeFilled, TextBulletListTreeRegular); + + + ); + + return disabledTooltip ? ( + + {content} + + ) : ( + content + ); + }; + } + + return result; +} diff --git a/starter-coreai/app/components/common/DetailsList/DetailsList.module.css b/starter-coreai/app/components/common/DetailsList/DetailsList.module.css new file mode 100644 index 00000000..95e28cfc --- /dev/null +++ b/starter-coreai/app/components/common/DetailsList/DetailsList.module.css @@ -0,0 +1,22 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +@layer common { + .detailsList { + display: flex; + flex-direction: column; + gap: var(--spacingVerticalL); + margin: 0; + } + + .label { + margin-bottom: var(--spacingVerticalXS); + } + + .value { + margin: 0; + overflow-wrap: break-word; + } +} diff --git a/starter-coreai/app/components/common/DetailsList/DetailsList.tsx b/starter-coreai/app/components/common/DetailsList/DetailsList.tsx new file mode 100644 index 00000000..25c7c928 --- /dev/null +++ b/starter-coreai/app/components/common/DetailsList/DetailsList.tsx @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { JSX, Key, ReactNode } from 'react'; + +import { Text } from '@coreai-ds/react-components'; +import { clsx } from 'clsx'; + +import styles from './DetailsList.module.css'; + +interface DetailsListProps { + items: { + key?: Key; + label: string | ReactNode; + value: string | ReactNode; + }[]; + className?: string; + itemClassName?: string; + labelClassName?: string; + valueClassName?: string; +} + +export function DetailsList({ + items, + className, + itemClassName, + labelClassName, + valueClassName, +}: DetailsListProps): JSX.Element { + return ( +
+ {items.map((item, index) => ( +
+
+ + {item.label} + +
+
+ + {item.value} + +
+
+ ))} +
+ ); +} diff --git a/starter-coreai/app/components/common/Icons/CoreAILogo.tsx b/starter-coreai/app/components/common/Icons/CoreAILogo.tsx new file mode 100644 index 00000000..d5ef42f7 --- /dev/null +++ b/starter-coreai/app/components/common/Icons/CoreAILogo.tsx @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { wrapIcon } from '@fluentui/react-icons'; +import type { FluentIconsProps } from '@fluentui/react-icons'; + +export const CoreAILogo = wrapIcon((props: FluentIconsProps) => { + return ( + + + + + + + + ); +}, 'CoreAILogo'); diff --git a/starter-coreai/app/components/common/Icons/DiscordIcon.tsx b/starter-coreai/app/components/common/Icons/DiscordIcon.tsx new file mode 100644 index 00000000..3573aadd --- /dev/null +++ b/starter-coreai/app/components/common/Icons/DiscordIcon.tsx @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { wrapIcon } from '@fluentui/react-icons'; +import type { FluentIconsProps } from '@fluentui/react-icons'; + +export const DiscordIcon = wrapIcon((props: FluentIconsProps) => { + return ( + + + + ); +}, 'DiscordIcon'); diff --git a/starter-coreai/app/components/common/Icons/GitHubIcon.tsx b/starter-coreai/app/components/common/Icons/GitHubIcon.tsx new file mode 100644 index 00000000..13220b90 --- /dev/null +++ b/starter-coreai/app/components/common/Icons/GitHubIcon.tsx @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { wrapIcon } from '@fluentui/react-icons'; +import type { FluentIconsProps } from '@fluentui/react-icons'; + +export const GitHubIcon = wrapIcon((props: FluentIconsProps) => { + return ( + + + + ); +}, 'GitHubIcon'); diff --git a/starter-coreai/app/components/common/Icons/MCPToolIcon.tsx b/starter-coreai/app/components/common/Icons/MCPToolIcon.tsx new file mode 100644 index 00000000..33f6645d --- /dev/null +++ b/starter-coreai/app/components/common/Icons/MCPToolIcon.tsx @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { FluentIconsProps } from '@fluentui/react-icons'; +import { wrapIcon } from '@fluentui/react-icons'; + +export const MCPToolIcon = wrapIcon((props: FluentIconsProps) => { + return ( + + + + ); +}, 'MCPToolIcon'); diff --git a/starter-coreai/app/components/common/Illicon/Illicon.tsx b/starter-coreai/app/components/common/Illicon/Illicon.tsx new file mode 100644 index 00000000..404238d5 --- /dev/null +++ b/starter-coreai/app/components/common/Illicon/Illicon.tsx @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { JSX } from 'react'; + +import type { FluentIcon } from '@fluentui/react-icons'; +import { + Agents24Regular, + ArrowFlowSquareMultiple24Regular, + Book24Regular, + BrainCircuit24Regular, + Code24Regular, + Cube24Regular, + Database24Regular, + ErrorCircle24Regular, + FlowArrow24Regular, + LockClosed24Regular, + Options24Regular, + Settings24Regular, + ShieldCheckmark24Regular, + TaskListSquareLtr24Regular, +} from '@fluentui/react-icons'; + +export type IlliconName = + | 'agents' + | 'azureAI' + | 'code' + | 'data' + | 'error' + | 'evaluations' + | 'fineTune' + | 'guardrails' + | 'knowledge' + | 'modelFlows' + | 'models' + | 'noAccess' + | 'tools' + | 'workflows'; + +export interface IlliconProps { + icon?: IlliconName; + width?: number; + alt?: string; + className?: string; +} + +function getIcon(icon: IlliconName): FluentIcon { + switch (icon) { + case 'agents': + return Agents24Regular; + case 'azureAI': + return BrainCircuit24Regular; + case 'code': + return Code24Regular; + case 'data': + return Database24Regular; + case 'error': + return ErrorCircle24Regular; + case 'evaluations': + return TaskListSquareLtr24Regular; + case 'fineTune': + return Options24Regular; + case 'guardrails': + return ShieldCheckmark24Regular; + case 'knowledge': + return Book24Regular; + case 'modelFlows': + return FlowArrow24Regular; + case 'models': + return Cube24Regular; + case 'noAccess': + return LockClosed24Regular; + case 'tools': + return Settings24Regular; + case 'workflows': + return ArrowFlowSquareMultiple24Regular; + } +} + +/** Illustration icon. */ +export function Illicon({ + icon = 'azureAI', + // width = 24 * 3, + alt, + className, +}: IlliconProps): JSX.Element { + const Icon = getIcon(icon); + const isDecorative = !alt; + + return ( + + ); +} diff --git a/starter-coreai/app/components/common/Image/Image.tsx b/starter-coreai/app/components/common/Image/Image.tsx new file mode 100644 index 00000000..ed797e37 --- /dev/null +++ b/starter-coreai/app/components/common/Image/Image.tsx @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { ImageProps as CoreAIImageProps } from '@coreai-ds/react-components'; +import type { JSX } from 'react'; + +import { Image as CoreAIImage } from '@coreai-ds/react-components'; +import { useThemeContext } from '~/theme/useThemeContext'; + +type ThemeImageSource = { + darkMode: string; + lightMode: string; +}; + +export type ImageProps = Omit & { + src?: CoreAIImageProps['src'] | ThemeImageSource; +}; + +export function resolveImageSrc(path: string): string { + try { + void new URL(path); + return path; + } catch { + // Relative asset paths need the deployment base URL. + } + + const baseUrl = import.meta.env.BASE_URL; + const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + const pathWithoutBase = path.startsWith(normalizedBase) + ? path.slice(normalizedBase.length) + : path; + const relativePath = pathWithoutBase.startsWith('/') ? pathWithoutBase.slice(1) : pathWithoutBase; + + return `${normalizedBase}${relativePath}`; +} + +export function Image({ src, ...props }: ImageProps): JSX.Element { + const { isDarkMode } = useThemeContext(); + const selectedSrc = + typeof src === 'string' || src === undefined ? src : isDarkMode ? src.darkMode : src.lightMode; + + return ( + + ); +} diff --git a/starter-coreai/app/components/common/Link/Link.tsx b/starter-coreai/app/components/common/Link/Link.tsx new file mode 100644 index 00000000..dee39fe2 --- /dev/null +++ b/starter-coreai/app/components/common/Link/Link.tsx @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { ComponentPropsWithoutRef } from 'react'; +import type { + LinkProps as RouterLinkProps, + NavLinkProps as RouterNavLinkProps, +} from 'react-router'; +import type { LinkProps as CoreAILinkProps } from '@coreai-ds/react-components'; + +import { Link as CoreAILink } from '@coreai-ds/react-components'; +import { forwardRef, useMemo } from 'react'; +import { Link as RouterLink, NavLink as RouterNavLink } from 'react-router'; + +interface LinkBaseProps { + className?: string; + isNavLink?: boolean; + /** + * Inline appearance adds underline, required for accessibility so links are distinguishable from regular text when in a paragraph. + */ + appearance?: 'normal' | 'inline'; + ref?: React.Ref; +} + +export type NavLinkProps = LinkBaseProps & + Omit & { + isNavLink: true; + }; + +export type RegularLinkProps = LinkBaseProps & + Omit & { + isNavLink?: false; + }; + +export type LinkProps = NavLinkProps | RegularLinkProps; + +export type ExternalLinkProps = Omit, 'href' | 'ref'> & + Pick & { + href: string; + }; + +export function ExternalLink({ href, ...props }: ExternalLinkProps): React.ReactElement { + return ; +} + +export function Link({ + isNavLink = false, + prefetch = 'intent', + viewTransition = true, + onClick, + className, + appearance = 'normal', + ref, + ...rest +}: LinkProps): React.ReactElement { + // Check for external links in development mode + if (import.meta.env.DEV) { + const to = 'to' in rest ? rest.to : ''; + + if (typeof to === 'string' && (to.startsWith('http://') || to.startsWith('https://'))) { + throw new Error( + 'External links should use CoreAI Link with external={true} instead of the Router Link adapter', + ); + } + } + + const { to, replace, state, relative, preventScrollReset, end, caseSensitive, ...linkProps } = + rest as NavLinkProps; + + // CoreAI's `Link` (like Fluent's) filters unrecognized props down to plain DOM + // attributes before they reach its rendered element, so React Router props (`to`, + // `replace`, etc.) can't be passed through as top-level props - they'd be stripped. + // Instead we bake them into a small `as` component via closure, so only ordinary DOM + // props (className, onClick, aria-*, children) need to survive that filtering. + const RouterLinkSlot = useMemo(() => { + return forwardRef>( + (slotProps, forwardedRef) => + isNavLink ? ( + + ) : ( + + ), + ); + }, [ + isNavLink, + to, + replace, + state, + relative, + preventScrollReset, + viewTransition, + prefetch, + end, + caseSensitive, + ]); + + return ( + )} + ref={ref} + // Fluent's `as` is typed as 'a' | 'button' | 'span', but the runtime slot resolver + // accepts any component - cast past the type restriction rather than patching Fluent. + as={RouterLinkSlot as unknown as 'a'} + inline={appearance === 'inline'} + className={className} + onClick={onClick} + > + {linkProps.children} + + ); +} diff --git a/starter-coreai/app/components/common/Pages/CollectionDetailsPage.tsx b/starter-coreai/app/components/common/Pages/CollectionDetailsPage.tsx new file mode 100644 index 00000000..816027df --- /dev/null +++ b/starter-coreai/app/components/common/Pages/CollectionDetailsPage.tsx @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { GridDetailsContentProps } from './GridDetailsContent'; +import type { PageHeaderProps } from './PageHeader'; + +import { GridDetailsContent } from './GridDetailsContent'; +import { PageLayout } from './PageLayout'; +import { PageHeader } from './PageHeader'; + +/** Standalone collection configuration with row-driven details. */ +export interface CollectionDetailsPageProps extends GridDetailsContentProps { + headerProps: PageHeaderProps; +} + +/** Composes a page header, single-selection grid, and responsive details panel. */ +export function CollectionDetailsPage({ + headerProps, + ...contentProps +}: CollectionDetailsPageProps) { + return ( + } scrollMode="panes"> + + + ); +} diff --git a/starter-coreai/app/components/common/Pages/CollectionPage.tsx b/starter-coreai/app/components/common/Pages/CollectionPage.tsx new file mode 100644 index 00000000..3cb0ace5 --- /dev/null +++ b/starter-coreai/app/components/common/Pages/CollectionPage.tsx @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { GridContentProps } from './GridContent'; +import type { PageHeaderProps } from './PageHeader'; + +import { GridContent } from './GridContent'; +import { PageLayout } from './PageLayout'; +import { PageHeader } from './PageHeader'; + +/** Standalone collection header and grid configuration. */ +export interface CollectionPageProps extends GridContentProps { + headerProps: PageHeaderProps; +} + +/** Composes a fixed page header with a padded, independently scrolling grid. */ +export function CollectionPage({ headerProps, dataGridProps }: CollectionPageProps) { + return ( + } scrollMode="panes"> + + + ); +} diff --git a/starter-coreai/app/components/common/Pages/DetailsLayout.tsx b/starter-coreai/app/components/common/Pages/DetailsLayout.tsx new file mode 100644 index 00000000..6adf900a --- /dev/null +++ b/starter-coreai/app/components/common/Pages/DetailsLayout.tsx @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { ReactNode, RefObject } from 'react'; + +import { useState } from 'react'; + +import { PageBody } from './PageBody'; +import { ResponsivePanel } from './ResponsivePanel'; +import styles from './PanelLayouts.module.css'; + +/** Caller-owned content for a labelled details panel. */ +export interface PanelDetails { + title: string; + content: ReactNode; + headerActions?: ReactNode; + /** Details body inset; defaults to default. Use none for edge-to-edge content. */ + padding?: 'default' | 'none'; + width?: number; +} + +/** Main content and optional right details; undefined details closes the panel. */ +export interface DetailsLayoutProps { + children: ReactNode; + details?: PanelDetails; + onClose: () => void; + /** Opener to refocus after dismissal. */ + returnFocusRef?: RefObject; + contentPadding?: 'default' | 'none'; +} + +/** Composes a scrolling body and responsive right panel, retaining outgoing content + * during dismissal animations but not persisting state within drawer subtrees. + */ +export function DetailsLayout({ + children, + details, + onClose, + returnFocusRef, + contentPadding = 'default', +}: DetailsLayoutProps) { + const [lastDetails, setLastDetails] = useState(details); + if (details !== undefined && lastDetails !== details) setLastDetails(details); + + return ( +
+ + {children} + + { + if (!open) onClose(); + }} + returnFocusRef={returnFocusRef} + headerActions={lastDetails?.headerActions} + padding={lastDetails?.padding} + width={lastDetails?.width} + > + {lastDetails?.content} + +
+ ); +} diff --git a/starter-coreai/app/components/common/Pages/GridContent.tsx b/starter-coreai/app/components/common/Pages/GridContent.tsx new file mode 100644 index 00000000..648494c3 --- /dev/null +++ b/starter-coreai/app/components/common/Pages/GridContent.tsx @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { DataGridWithFiltersProps } from '../DataGridWithFilters/DataGridWithFilters'; + +import { DataGridWithFilters } from '../DataGridWithFilters/DataGridWithFilters'; +import { PageBody } from './PageBody'; + +/** Grid configuration shared by standalone collections and nested content. */ +export interface GridContentProps { + dataGridProps: DataGridWithFiltersProps; +} + +/** Renders a padded, scrollable grid without adding a page header. */ +export function GridContent({ dataGridProps }: GridContentProps) { + return ( + + + + ); +} diff --git a/starter-coreai/app/components/common/Pages/GridDetailsContent.tsx b/starter-coreai/app/components/common/Pages/GridDetailsContent.tsx new file mode 100644 index 00000000..11a1d7a5 --- /dev/null +++ b/starter-coreai/app/components/common/Pages/GridDetailsContent.tsx @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { TableRowId } from '@coreai-ds/react-components'; +import type { DataGridWithFiltersProps } from '../DataGridWithFilters/DataGridWithFilters'; +import type { PanelDetails } from './DetailsLayout'; + +import { useRef, useState } from 'react'; + +import { DataGridWithFilters } from '../DataGridWithFilters/DataGridWithFilters'; +import { DetailsLayout } from './DetailsLayout'; + +/** Single-selection grid configuration with caller-rendered details. */ +export interface GridDetailsContentProps { + /** Supply stable getRowId values; selection is owned by this composition. */ + dataGridProps: Omit< + DataGridWithFiltersProps, + 'selectionMode' | 'selectedItems' | 'onSelectionChange' + >; + /** Returns details for the selected row, or undefined to hide the panel. */ + renderDetails: (itemId: TableRowId) => PanelDetails | undefined; +} + +/** Owns row selection, responsive details, and focus restoration when details close. */ +export function GridDetailsContent({ + dataGridProps, + renderDetails, +}: GridDetailsContentProps) { + const [selectedId, setSelectedId] = useState(null); + const returnFocusRef = useRef(null); + const gridRef = useRef(null); + const selectedExists = + selectedId !== null && + dataGridProps.items.some( + (item, index) => (dataGridProps.getRowId?.(item) ?? index) === selectedId, + ); + + if (selectedId !== null && !selectedExists) setSelectedId(null); + + return ( + setSelectedId(null)} + returnFocusRef={returnFocusRef} + > +
+ { + const target = event.target instanceof HTMLElement ? event.target : null; + returnFocusRef.current = + target + ?.closest('[role="row"]') + ?.querySelector('input[type="radio"]') ?? + target ?? + gridRef.current; + setSelectedId(data.selectedItems.values().next().value ?? null); + }} + /> +
+
+ ); +} diff --git a/starter-coreai/app/components/common/Pages/PageBackLink.tsx b/starter-coreai/app/components/common/Pages/PageBackLink.tsx new file mode 100644 index 00000000..9a4d848e --- /dev/null +++ b/starter-coreai/app/components/common/Pages/PageBackLink.tsx @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { To } from 'react-router'; + +import { LinkButton } from '@coreai-ds/react-components'; +import { useHref, useLinkClickHandler } from 'react-router'; +import { ArrowLeftFilled, ArrowLeftRegular, bundleIcon } from '@fluentui/react-icons'; + +import styles from './PageHeader.module.css'; + +const BackIcon = bundleIcon(ArrowLeftFilled, ArrowLeftRegular); + +export function PageBackLink({ to, label }: { to: To; label: string }) { + const href = useHref(to); + const onClick = useLinkClickHandler(to); + + return ( + } + href={href} + onClick={onClick} + /> + ); +} diff --git a/starter-coreai/app/components/common/Pages/PageBody.tsx b/starter-coreai/app/components/common/Pages/PageBody.tsx new file mode 100644 index 00000000..2005eabd --- /dev/null +++ b/starter-coreai/app/components/common/Pages/PageBody.tsx @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { ReactNode } from 'react'; + +import { clsx } from 'clsx'; + +import styles from './PageLayout.module.css'; + +/** Explicit body inset and scrolling. */ +export interface PageBodyProps { + children?: ReactNode; + /** Defaults to default; use none when a child owns the inset. */ + padding?: 'default' | 'none'; + /** Defaults to false; enable when this body owns scrolling under a pane frame. */ + scrollable?: boolean; + className?: string; +} + +/** Supplies page content padding and optionally owns a scrollable region. */ +export function PageBody({ + children, + padding = 'default', + scrollable = false, + className, +}: PageBodyProps) { + return ( +
+ {children} +
+ ); +} diff --git a/starter-coreai/app/components/common/Pages/PageHeader.module.css b/starter-coreai/app/components/common/Pages/PageHeader.module.css new file mode 100644 index 00000000..edf0fdd2 --- /dev/null +++ b/starter-coreai/app/components/common/Pages/PageHeader.module.css @@ -0,0 +1,83 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +@layer common { + .header { + display: flex; + flex-shrink: 0; + flex-direction: column; + gap: var(--spacingM); + min-width: 0; + padding: var(--spacingXL) var(--spacingPagePadding) var(--spacingM); + border-bottom: var(--strokeWidthThin) solid var(--colorNeutralBackground4); + } + + .withNavigation { + padding-bottom: 0; + } + + .titleRow { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--spacingM); + min-width: 0; + } + + .titleGroup { + display: flex; + flex: 1 1 240px; + align-items: center; + gap: var(--spacingS); + min-width: 0; + + > :not(.title) { + flex-shrink: 0; + } + } + + .linkButton { + margin-inline: -5px; + } + + .title { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + + :global(.fui-Badge) { + vertical-align: middle; + } + } + + .actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacingS); + min-width: 0; + } + + .slot { + min-width: 0; + overflow-wrap: anywhere; + } + + .navigation { + min-width: 0; + } + + @container app (width <= 480px) { + .titleRow { + flex-direction: column; + align-items: stretch; + } + + .titleGroup { + flex-basis: auto; + } + } +} diff --git a/starter-coreai/app/components/common/Pages/PageHeader.tsx b/starter-coreai/app/components/common/Pages/PageHeader.tsx new file mode 100644 index 00000000..b09967d2 --- /dev/null +++ b/starter-coreai/app/components/common/Pages/PageHeader.tsx @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { ReactNode } from 'react'; +import type { To } from 'react-router'; + +import { Text } from '@coreai-ds/react-components'; +import { clsx } from 'clsx'; + +import { PageBackLink } from './PageBackLink'; +import styles from './PageHeader.module.css'; + +/** Named header regions; callers own actions, banners, and navigation state. */ +export interface PageHeaderProps { + /** Page heading rendered as an h1. */ + title: string; + /** Caller-rendered status; supply Badge shape explicitly. */ + titleBadge?: ReactNode; + /** Explicit collection count, including zero; never inferred from content. */ + count?: number; + description?: ReactNode; + headerActions?: ReactNode; + toolbar?: ReactNode; + navigation?: ReactNode; + /** Optional router destination for the title-row back link. */ + backTo?: To; + /** Accessible name for the icon-only back link; defaults to Back. */ + backLabel?: string; +} + +/** Renders the page heading, actions, optional content slots, and navigation inset. */ +export function PageHeader({ + title, + titleBadge, + count, + description, + headerActions, + toolbar, + navigation, + backTo, + backLabel = 'Back', +}: PageHeaderProps) { + return ( +
+
+
+ {backTo !== undefined ? : null} + + {title} + {count === undefined ? null : ( + + {' '} + ({count}) + + )} + {titleBadge == null ? null : {titleBadge}} + +
+ {headerActions == null ? null :
{headerActions}
} +
+ {description == null ? null :
{description}
} + {toolbar == null ? null :
{toolbar}
} + {navigation == null ? null :
{navigation}
} +
+ ); +} diff --git a/starter-coreai/app/components/common/Pages/PageLayout.module.css b/starter-coreai/app/components/common/Pages/PageLayout.module.css new file mode 100644 index 00000000..dbf5faf9 --- /dev/null +++ b/starter-coreai/app/components/common/Pages/PageLayout.module.css @@ -0,0 +1,62 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +@layer common { + .frame { + display: flex; + flex-direction: column; + overflow: hidden; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + } + + .content { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + min-height: 0; + } + + .pageScroll { + overflow: auto; + + > .content { + flex: 1 0 auto; + } + } + + .contentScroll > .content { + overflow: auto; + } + + .panesScroll > .content { + overflow: hidden; + } + + .body { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + min-height: 0; + } + + .padded { + padding: var(--spacingL) var(--spacingPagePadding) var(--spacingXXXL); + } + + .scrollable { + overflow: auto; + } + + .footer { + flex-shrink: 0; + padding: var(--spacingL) var(--spacingPagePadding); + border-top: var(--strokeWidthThin) solid var(--colorNeutralBackground4); + } +} diff --git a/starter-coreai/app/components/common/Pages/PageLayout.tsx b/starter-coreai/app/components/common/Pages/PageLayout.tsx new file mode 100644 index 00000000..2b019a1b --- /dev/null +++ b/starter-coreai/app/components/common/Pages/PageLayout.tsx @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { ReactNode } from 'react'; + +import { clsx } from 'clsx'; + +import styles from './PageLayout.module.css'; + +/** Frame regions and scroll ownership; the host must provide a bounded height. */ +export interface PageLayoutProps { + /** Fixed above content unless scrollMode is page. */ + header?: ReactNode; + /** Persistent actions below content unless scrollMode is page. */ + footer?: ReactNode; + children: ReactNode; + /** Defaults to content; panes delegates scrolling to descendants. */ + scrollMode?: 'page' | 'content' | 'panes'; + className?: string; +} + +/** Composes an unpadded page frame with independently supplied header and footer. */ +export function PageLayout({ + header, + footer, + children, + scrollMode = 'content', + className, +}: PageLayoutProps) { + return ( +
+ {header} +
{children}
+ {footer == null ? null :
{footer}
} +
+ ); +} diff --git a/starter-coreai/app/components/common/Pages/PanelLayouts.module.css b/starter-coreai/app/components/common/Pages/PanelLayouts.module.css new file mode 100644 index 00000000..4991a7ae --- /dev/null +++ b/starter-coreai/app/components/common/Pages/PanelLayouts.module.css @@ -0,0 +1,98 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +@layer common { + .layout { + display: flex; + flex: 1; + overflow: hidden; + width: 100%; + min-width: 0; + min-height: 0; + } + + .main { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + min-height: 0; + } + + .toolbar { + flex-shrink: 0; + padding: var(--spacingM) var(--spacingPagePadding) 0; + } + + .panel { + min-width: 0; + background-color: var(--colorNeutralBackground1); + } + + .inline { + flex-shrink: 0; + width: 420px; + max-width: 40%; + border-color: var(--colorNeutralBackground4); + } + + .overlay { + width: min(400px, 100vw); + max-width: 100vw; + } + + .inlineSidebar { + background-color: transparent; + } + + .fixedWidth { + box-sizing: border-box; + width: var(--panelWidth); + max-width: 100%; + } + + .overlay.fixedWidth { + max-width: 100vw; + } + + .panelHeader { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: var(--spacingS); + min-width: 0; + padding: var(--spacingL) var(--spacingPagePadding); + } + + .panelTitle { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + } + + .panelActions { + display: flex; + flex-shrink: 0; + align-items: center; + gap: var(--spacingXS); + } + + .panelBody { + overflow: auto; + min-width: 0; + min-height: 0; + padding: 0; + margin: 0; + } + + .panelPadding { + padding: 0 var(--spacingPagePadding) var(--spacingL); + + &:first-child { + padding-top: var(--spacingL); + } + } +} diff --git a/starter-coreai/app/components/common/Pages/README.md b/starter-coreai/app/components/common/Pages/README.md new file mode 100644 index 00000000..aecadc69 --- /dev/null +++ b/starter-coreai/app/components/common/Pages/README.md @@ -0,0 +1,243 @@ +# Modular Page Layouts Guide + +Use these components to compose page headers, padded content, collections, tabs, and responsive panels. Layout components own spacing and scrolling; callers own application data, actions, and persistent state. + +Route screens should compose these page components instead of creating their own page frames, headers, or sidebar drawers. Keep domain-specific content with its owning routes; the application shell should contain shared navigation and chrome. + +## Quick Start + +Import layouts from `~/components/common` and design-system controls from `@coreai-ds/react-components`. A basic page needs a frame, a header, and a body: + +```tsx +import { PageBody, PageHeader, PageLayout } from '~/components/common'; + +export function ProjectPage() { + return ( + }> + Project content + + ); +} +``` + +By default, the header stays in place while the content region scrolls. `PageBody` supplies the content padding. Use the existing app shell, or another host with a bounded height, for this behavior. + +## Choose a Composition + +| Need | Use | +| ----------------------------------------- | ----------------------------------------------------------------------------- | +| Plain page or full-page form | `PageLayout` + `PageHeader` + `PageBody`; add `footer` for persistent actions | +| Standalone collection | `CollectionPage`, or `CollectionDetailsPage` for row-driven details | +| Collection inside an existing page or tab | `GridContent` or `GridDetailsContent` | +| Left filters or configuration | `SidebarLayout` inside a `PageLayout` | +| Right details for custom content | `DetailsLayout` | +| Route-driven page tabs | `TabbedPageLayout` with child routes | +| Custom inline/overlay panel | `ResponsivePanel` | + +Start with the smallest composition that fits the screen. See the +[page layout migration](../../../../.github/docs/migrations/coreai/page-layout-migration.md) +for historical records and legacy component mappings. + +## Architecture + +```text +PageLayout frame, scroll ownership, optional footer + PageHeader optional, supplied through header prop + PageBody padded content OR padding="none" + +SidebarLayout left inline panel / button-opened drawer + ResponsivePanel position=start + PageBody configurable content padding + +DetailsLayout main content + right details + PageBody configurable content padding + ResponsivePanel position=end + +GridContent PageBody + DataGridWithFilters +GridDetailsContent DetailsLayout + grid selection behavior + +CollectionPage PageLayout + PageHeader + GridContent +CollectionDetailsPage PageLayout + PageHeader + GridDetailsContent +TabbedPageLayout PageLayout + PageHeader/TabLinkList + Outlet +``` + +These are compositions, not an inheritance hierarchy. There is no universal component with flags for every screen type, and no separate family of duplicated tab-subpage wrappers. + +## Source Reference + +| File | Responsibility | +| ------------------------------------------------------ | -------------------------------------------------------------- | +| [PageLayout.tsx](PageLayout.tsx) | Page frame, scroll ownership, and optional footer | +| [PageBody.tsx](PageBody.tsx) | Explicit body padding and optional scrolling | +| [PageLayout.module.css](PageLayout.module.css) | Explicit body padding, frame constraints, scroll modes, footer | +| [PageHeader.tsx](PageHeader.tsx) | Title and named content slots | +| [PageBackLink.tsx](PageBackLink.tsx) | Internal router-aware header back link | +| [PageHeader.module.css](PageHeader.module.css) | Header alignment and narrow-title wrapping | +| [ResponsivePanel.tsx](ResponsivePanel.tsx) | Controlled inline/overlay panel presentation | +| [SidebarLayout.tsx](SidebarLayout.tsx) | Left sidebar and mobile trigger | +| [DetailsLayout.tsx](DetailsLayout.tsx) | Right details layout and `PanelDetails` | +| [PanelLayouts.module.css](PanelLayouts.module.css) | Split regions, panel dimensions, portal-safe spacing | +| [GridContent.tsx](GridContent.tsx) | Padded, scrollable grid content | +| [GridDetailsContent.tsx](GridDetailsContent.tsx) | Grid selection, details, and focus restoration | +| [CollectionPage.tsx](CollectionPage.tsx) | Standalone collection header and grid | +| [CollectionDetailsPage.tsx](CollectionDetailsPage.tsx) | Standalone collection with row-driven details | +| [TabbedPageLayout.tsx](TabbedPageLayout.tsx) | Route-driven tabs and outlet context | +| [../index.ts](../index.ts) | Public modular layout exports | + +## Spacing + +1. `PageLayout` adds **no body padding**. It provides a bounded flex frame. +2. `PageHeader` owns its header inset and divider. +3. `PageBody` defaults to `padding="default"`: top `spacingL`, horizontal `spacingPagePadding`, bottom `spacingXXXL`. +4. `PageBody padding="none"` contributes no padding. Use it only when a child owns the inset or is intentionally edge-to-edge. +5. `SidebarLayout` and `DetailsLayout` each supply their own main `PageBody`. Their `contentPadding` defaults to `default`. +6. When nesting a details/grid layout inside a sidebar, set the sidebar's `contentPadding="none"`. The nested content owns the inset. +7. Drawer headers and default body insets use responsive `spacingPagePadding` horizontally. Headers have `spacingL` vertical padding; bodies have `spacingL` bottom padding and top padding only when no header renders. Set `SidebarLayout.sidebarPadding="none"` or `PanelDetails.padding="none"` for unpadded body content; header padding is independent. + +There are no `data-pagecontent` / `data-subpagecontent` attributes or ancestor-padding overrides in the page system. When porting code from an older branch, replace its legacy wrappers rather than restoring those overrides. + +### Drawer Portal Spacing + +Use `--spacingPagePadding` for horizontal panel and accordion insets. Inline content inherits the app-container override; portaled drawers inherit the viewport-responsive root definition (24px above 768px, 16px at or below it). The root definition includes explicit fallbacks because Fluent spacing tokens are defined on providers, not necessarily on `:root`. Without those fallbacks the custom property computes to an invalid value before reaching the portal. + +For full-width accordion dividers, disable the panel body inset and put responsive horizontal padding inside each section: the item owns the full-width border, while its header and expanded content own their insets. The drawer body still owns scrolling. `contentPadding` controls only the main content area, independently of panel padding. + +## Scrolling + +| `PageLayout.scrollMode` | Owner | Recommended content | +| ----------------------- | --------------------------------------------------- | ----------------------------------------------------------- | +| `content` (default) | Frame's content region; header/footer stay in place | `PageBody` without `scrollable` | +| `page` | Entire frame, including header/footer | Long document content in `PageBody` | +| `panes` | Descendant regions; frame itself clips overflow | Sidebar/details/workspace layouts, or `PageBody scrollable` | + +`PageBody.scrollable` defaults to false. `SidebarLayout.scrollable` defaults to true and applies to its main body. Set it to false when the child is another pane layout or owns its own scrolling, such as the workspace example. + +All shrinkable flex boundaries use `min-width: 0` and `min-height: 0`. A host must still supply a bounded height, as the existing app shell does. Do not place a pane layout in an unbounded document and expect independent scrolling to work. + +## Headers + +`PageHeader` accepts: + +- `title`: string rendered as the page's single `h1`. +- `titleBadge`: caller-provided node. Use a Badge with `shape="rounded"` for title statuses such as Active or Draft. The caller supplies the shape explicitly; PageHeader does not override the Badge component's defaults. Use Tags for chosen values such as categories or filters, not lifecycle status indicators. +- `count`: a subtle numeric suffix, including zero; never inferred from grid data. +- `headerActions`: caller-provided actions or navigation links. +- `description`, `navigation`: optional regions below the title row, in that order. +- `backTo`, `backLabel`: optional React Router destination and accessible label (default `Back`). The header renders an icon-only CoreAI `LinkButton` beside the title. Router resolution respects relative destinations and the deployment basename; normal clicks use client-side navigation while modified clicks retain native link behavior. Omit `backTo` to hide the control. + +Use the back control for destination navigation, not arbitrary actions. A cancel, dismiss, or confirmation action belongs in `headerActions` or the caller's flow controls. The header does not expose an action-only `onGoBack` callback. + +At app-container widths of 480px or less, header actions move below the title group. Badges do not shrink into the title text. + +Render any banner in `description`, with visibility managed by the caller. + +## Responsive Panels + +### Shared Presentation + +`ResponsivePanel` takes `position="start" | "end"`, a required string `title`, `open`, `onOpenChange`, and `children`. Optional props include `headerActions`, `showInlineHeader`, `padding`, `returnFocusRef`, `id`, and `collapseAt`. `showInlineHeader` defaults to true here; overlay headers always render. When the inline header is hidden, the region uses `title` as its accessible label. + +- Inline presentation uses a labelled complementary region. +- Overlay presentation uses a labelled modal dialog provided by CoreAI `Drawer`. +- Every rendered header has a close button labelled `Close ${title}`. Overlay Escape and backdrop dismissal reach the same close callback; inline panels do not dismiss on Escape. +- `returnFocusRef` restores focus to the opener. Supply it for direct usage with custom content. +- Without `width`, the standard inline width is 420px, capped at 40% of the containing layout. Overlay width is capped at the viewport and 400px. Supply `width` as a positive pixel number for a fixed width in either presentation and either position. Explicit inline widths do not use the 40% cap; they are bounded by the containing layout. Overlay widths are bounded by the viewport. +- `collapseAt` defaults to 768px and is evaluated using the existing app `ContainerQueryProvider`, not a viewport media query. Supply a nonnegative integer pixel threshold. + +### Left Sidebar + +`SidebarLayout` adds the always-visible desktop sidebar and the mobile trigger at a fixed **1024px** app-container threshold. + +The sidebar requires `sidebarTitle` and `sidebar`; main content is `children`. Optional props are `sidebarWidth`, `sidebarPadding`, `triggerLabel`, `triggerIcon`, `contentPadding`, and `scrollable`. `sidebarWidth` forwards the fixed pixel width to the panel; the Discover catalog uses `sidebarWidth={240}` to leave more space for cards. `sidebarPadding` defaults to `"default"`; use `"none"` when sidebar content owns its insets. `triggerIcon` uses the documented Button icon-slot type. + +Mobile open state is internal. Returning to desktop clears that mobile-open state, so a subsequent resize to mobile does not unexpectedly reopen a modal. The visible trigger carries its dialog relationship and expanded state. + +For a trigger inside an existing toolbar, supply a render function as `children`: `{sidebarTrigger => }`. Place the supplied node exactly once. It is null in inline mode, and the layout omits its default trigger toolbar when a render function is supplied. The layout still owns open state, dialog attributes, and the trigger ref used for focus restoration. Ordinary React-node children retain the default toolbar. + +The Discover [CatalogView](../../../routes/discover/catalog/CatalogView.tsx) is a card-catalog example: `PageLayout` with `scrollMode="page"`, `PageHeader`, and `SidebarLayout` with `scrollable={false}`, filters on the left, and results on the right. The header and both columns scroll together. The sidebar's `header` render function places search, sorting, and the mobile filter trigger in a full-width row below the page header and above both columns. Selected filters use dismissible interaction tags above the cards in the results column. With filters hidden, the controls remain above a `PageBody` without independent scrolling. The mobile overlay retains its own scrolling. Filter/search state remains in the catalog, above the responsive panel. + +Supplying `triggerIcon` makes the trigger icon-only. `triggerLabel` remains its accessible name and hover title; without an icon it renders the text label. The playground places Configuration immediately before its Chat/YAML/Code tabs. + +The inline sidebar always hides its header and has a transparent background. Overlay drawers retain their surface background, title, and close button. `sidebarTitle` supplies the accessible region name and default trigger label. Details-panel headers and backgrounds are unchanged. + +### Right Details + +`DetailsLayout.details` is either undefined (closed) or a `PanelDetails` object with `title`, `content`, and optional `headerActions`, `padding`, and `width`. For example, `details={{ title: 'Details', content, width: 320 }}` sets a fixed 320px panel. Collection `renderDetails` callbacks can return the same `width` option. `padding` defaults to `"default"`; return `padding: "none"` for edge-to-edge details, including from collection `renderDetails` callbacks. It takes `onClose` and an optional `returnFocusRef`. Details use the shared panel's 768px threshold. + +The last defined details object is retained during closing animations so the outgoing panel does not become blank. This is not a persistence mechanism for form state. + +### State Ownership + +Drawer subtrees can unmount on close and when inline/overlay presentation changes. Keep values that must survive those transitions above the drawer and pass them as controlled props. The workspace and filter samples demonstrate this. Do not rely on uncontrolled inputs inside a drawer to preserve drafts. + +The sidebar provides a fixed-width responsive layout, not drag-to-resize behavior or filtering logic. Keep screen-specific behavior in the caller; resizing can be layered in separately if a screen needs it. + +## Collection APIs and Loading + +`GridContent` and `GridDetailsContent` both receive a `dataGridProps` object. Their page counterparts add a `headerProps` object; the shape of grid configuration does not change depending on whether it is a page or tab content. + +`GridDetailsContent` adds a sibling `renderDetails(itemId)` callback. It owns single selection and focus restoration, so its grid props omit `selectionMode`, `selectedItems`, and `onSelectionChange`. Use a stable `getRowId` whenever rows can reorder; index identity is only a fallback. + +Removing the selected ID from the supplied `items` clears selection and closes details. Closing details returns focus to the selected row radio. This membership check concerns supplied items, not a second independent implementation of the grid's internal search/pagination. + +Loading is explicit: + +- Render a caller-side `Spinner` inside `PageBody` while loading; the body has no loading API. +- `dataGridProps.isLoading` is grid loading and is passed to the existing grid implementation. +- Collection wrappers have no ambiguous outer `isLoading` prop. + +## Composition Recipes + +These snippets assume the named content and data are supplied by the caller. Import the layouts from `~/components/common` and design-system controls from `@coreai-ds/react-components`. + +### Plain Page + +```tsx +}> + {isLoading ? : content} + +``` + +### Collection Page or Tab Content + +```tsx + agent.id }} + renderDetails={renderDetails} +/> + + agent.id }} + renderDetails={renderDetails} +/> +``` + +The tab-content form has no extra header and needs no `BasicSubpage` wrapper. + +### Sidebar with Nested Details + +```tsx +} scrollMode="panes"> + + + + +``` + +### Workspace or Creation Flow + +```tsx +} footer={submitActions} scrollMode="panes"> + + {workspaceWithItsOwnScrollRegion} + + +``` + +The caller owns submit validation, pending state, and persistence. A layout is not a form controller. diff --git a/starter-coreai/app/components/common/Pages/ResponsivePanel.tsx b/starter-coreai/app/components/common/Pages/ResponsivePanel.tsx new file mode 100644 index 00000000..03661f1e --- /dev/null +++ b/starter-coreai/app/components/common/Pages/ResponsivePanel.tsx @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { CSSProperties, ReactNode, RefObject } from 'react'; + +import { Button, Drawer, DrawerBody, DrawerHeader, Text } from '@coreai-ds/react-components'; +import { DismissFilled, DismissRegular, bundleIcon } from '@fluentui/react-icons'; +import { clsx } from 'clsx'; +import { useId } from 'react'; + +import { useContainerQuery } from '../hooks/useContainerQuery'; +import styles from './PanelLayouts.module.css'; + +const CloseIcon = bundleIcon(DismissFilled, DismissRegular); + +/** Controlled inline/overlay panel presentation within the app container. */ +export interface ResponsivePanelProps { + id?: string; + title: string; + children: ReactNode; + position: 'start' | 'end'; + open: boolean; + onOpenChange: (open: boolean) => void; + headerActions?: ReactNode; + /** Show the inline header; defaults to true. Overlay headers always show. */ + showInlineHeader?: boolean; + /** Element to refocus after close, Escape, or overlay dismissal. */ + returnFocusRef?: RefObject; + padding?: 'default' | 'none'; + width?: number; + /** Nonnegative integer app-container width in pixels; defaults to 768. */ + collapseAt?: number; +} + +/** Renders a labelled complementary region or modal drawer at the collapse threshold. + * Panel padding uses portal-safe tokens; callers own state that must survive unmounts. + */ +export function ResponsivePanel({ + id, + title, + children, + position, + open, + onOpenChange, + headerActions, + showInlineHeader = true, + returnFocusRef, + padding = 'default', + width, + collapseAt = 768, +}: ResponsivePanelProps) { + const titleId = useId(); + const isOverlay = useContainerQuery(`(max-width: ${collapseAt}px)`); + const showHeader = isOverlay || showInlineHeader; + + const close = () => { + onOpenChange(false); + requestAnimationFrame(() => returnFocusRef?.current?.focus()); + }; + + return ( + { + if (!data.open) close(); + }} + separator + > + {showHeader ? ( + + + {title} + +
+ {headerActions} +
+
+ ) : null} + + {children} + +
+ ); +} diff --git a/starter-coreai/app/components/common/Pages/SidebarLayout.tsx b/starter-coreai/app/components/common/Pages/SidebarLayout.tsx new file mode 100644 index 00000000..32797e0a --- /dev/null +++ b/starter-coreai/app/components/common/Pages/SidebarLayout.tsx @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { ReactNode } from 'react'; +import type { ButtonProps } from '@coreai-ds/react-components'; + +import { Button } from '@coreai-ds/react-components'; +import { useId, useRef, useState } from 'react'; + +import { useContainerQuery } from '../hooks/useContainerQuery'; +import { PageBody } from './PageBody'; +import { ResponsivePanel } from './ResponsivePanel'; +import styles from './PanelLayouts.module.css'; + +/** Left-panel content and explicit main-body spacing and scrolling. */ +export interface SidebarLayoutProps { + sidebarTitle: string; + sidebar: ReactNode; + header?: (sidebarTrigger: ReactNode) => ReactNode; + /** Render a supplied mobile trigger once within custom content, or use the default toolbar. */ + children: ReactNode | ((sidebarTrigger: ReactNode) => ReactNode); + /** Sidebar body inset; use none when sections own padding and full-width dividers. */ + sidebarPadding?: 'default' | 'none'; + sidebarWidth?: number; + triggerLabel?: string; + triggerIcon?: ButtonProps['icon']; + triggerAppearance?: ButtonProps['appearance']; + /** Use none when nesting a grid, details layout, or other inset-owning content. */ + contentPadding?: 'default' | 'none'; + /** Defaults to true; disable when descendants own scrolling. */ + scrollable?: boolean; +} + +/** Presents an inline left sidebar or a button-opened drawer at narrow widths. + * Keep persistent sidebar state in the caller because drawer content can unmount. + */ +export function SidebarLayout({ + sidebarTitle, + sidebar, + header, + children, + sidebarPadding = 'default', + sidebarWidth, + triggerLabel = sidebarTitle, + triggerIcon, + triggerAppearance, + contentPadding = 'default', + scrollable = true, +}: SidebarLayoutProps) { + const isOverlay = useContainerQuery('(max-width: 1024px)'); + const [mobileOpen, setMobileOpen] = useState(false); + const triggerRef = useRef(null); + const panelId = useId(); + + if (!isOverlay && mobileOpen) setMobileOpen(false); + + const iconOnly = Boolean(triggerIcon); + const sidebarTrigger = isOverlay ? ( + + ) : null; + const hasCustomTriggerPlacement = typeof children === 'function'; + + return ( + <> + {header?.(sidebarTrigger)} +
+ + {sidebar} + +
+ {isOverlay && !header && !hasCustomTriggerPlacement ? ( +
{sidebarTrigger}
+ ) : null} + + {hasCustomTriggerPlacement ? children(sidebarTrigger) : children} + +
+
+ + ); +} diff --git a/starter-coreai/app/components/common/Pages/TabbedPageLayout.tsx b/starter-coreai/app/components/common/Pages/TabbedPageLayout.tsx new file mode 100644 index 00000000..af95cadf --- /dev/null +++ b/starter-coreai/app/components/common/Pages/TabbedPageLayout.tsx @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { PageHeaderProps } from './PageHeader'; +import type { TabLinkListProps } from '../TabLinkList/TabLinkList'; + +import { Outlet } from 'react-router'; + +import { TabLinkList } from '../TabLinkList/TabLinkList'; +import { PageHeader } from './PageHeader'; +import { PageLayout } from './PageLayout'; + +/** Header, linked tabs, and optional context for nested route content. */ +export interface TabbedPageLayoutProps { + headerProps: Omit; + tabLinkListProps: TabLinkListProps; + outletContext?: unknown; +} + +/** Composes a fixed header with route-driven tabs and a pane-scrolling outlet. + * Child routes supply a scrollable PageBody or a layout that owns its scrolling. + */ +export function TabbedPageLayout({ + headerProps, + tabLinkListProps, + outletContext, +}: TabbedPageLayoutProps) { + return ( + } />} + scrollMode="panes" + > + + + ); +} diff --git a/starter-coreai/app/components/common/PaginationControls/PaginationControls.module.css b/starter-coreai/app/components/common/PaginationControls/PaginationControls.module.css new file mode 100644 index 00000000..63781b26 --- /dev/null +++ b/starter-coreai/app/components/common/PaginationControls/PaginationControls.module.css @@ -0,0 +1,13 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +.paginationControls { + display: flex; + align-items: center; + + & button { + min-width: auto; + } +} diff --git a/starter-coreai/app/components/common/PaginationControls/PaginationControls.tsx b/starter-coreai/app/components/common/PaginationControls/PaginationControls.tsx new file mode 100644 index 00000000..5e0924d5 --- /dev/null +++ b/starter-coreai/app/components/common/PaginationControls/PaginationControls.tsx @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { JSX } from 'react'; + +import { Button } from '@coreai-ds/react-components'; +import { ChevronLeft24Regular, ChevronRight24Regular } from '@fluentui/react-icons'; +import { clsx } from 'clsx'; + +import styles from './PaginationControls.module.css'; + +export interface PaginationControlsProps { + onPrevClicked: () => void; + onNextClicked: () => void; + isPrevDisabled: boolean; + isNextDisabled: boolean; + className?: string; +} + +export function PaginationControls({ + onPrevClicked, + onNextClicked, + isPrevDisabled, + isNextDisabled, + className, +}: PaginationControlsProps): JSX.Element { + return ( +
+ + +
+ ); +} diff --git a/starter-coreai/app/components/common/ResizeHandle/ResizeHandle.module.css b/starter-coreai/app/components/common/ResizeHandle/ResizeHandle.module.css new file mode 100644 index 00000000..4b3f27cc --- /dev/null +++ b/starter-coreai/app/components/common/ResizeHandle/ResizeHandle.module.css @@ -0,0 +1,53 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +@layer common { + .resizeHandle { + transition: background-color 0.1s ease; + } + + .vertical { + --handle-width: var(--spacingL); + + position: absolute; + top: 0; + right: calc(-1 * var(--handle-width)); + + width: var(--handle-width); + height: 100%; + border-left: var(--strokeWidthThickest) solid transparent; + + &:hover, + &.dragging { + cursor: col-resize; + border-left-color: var(--colorBrandBackground); + } + + &.disabled { + display: none; + } + } + + .horizontal { + --handle-height: var(--spacingXS); + + position: relative; + + width: 100%; + height: var(--handle-height); + border-top: var(--strokeWidthThickest) solid transparent; + + &:hover, + &.dragging { + cursor: ns-resize; + border-top-color: var(--colorBrandBackground); + } + + &.disabled { + pointer-events: none; + opacity: 0.5; + } + } +} diff --git a/starter-coreai/app/components/common/ResizeHandle/ResizeHandle.tsx b/starter-coreai/app/components/common/ResizeHandle/ResizeHandle.tsx new file mode 100644 index 00000000..a8f3ec65 --- /dev/null +++ b/starter-coreai/app/components/common/ResizeHandle/ResizeHandle.tsx @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { JSX } from 'react'; + +import { clsx } from 'clsx'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import styles from './ResizeHandle.module.css'; + +interface ResizeHandleCommonProps { + disabled?: boolean; + onDragStateChange?: (isDragging: boolean) => void; +} + +type VerticalOrientationProps = ResizeHandleCommonProps & { + orientation: 'vertical'; + minWidth: number; + maxWidth: number; + initialWidth: number; + direction?: 'left' | 'right'; + onWidthChange: (width: number) => void; +}; + +type HorizontalOrientationProps = ResizeHandleCommonProps & { + orientation: 'horizontal'; + minHeight: number; + maxHeight: number; + initialHeight: number; + onHeightChange: (height: number) => void; +}; + +export type ResizeHandleProps = VerticalOrientationProps | HorizontalOrientationProps; + +export function ResizeHandle(props: ResizeHandleProps): JSX.Element { + const { disabled, onDragStateChange, orientation } = props; + const isVertical = orientation === 'vertical'; + const minSize = isVertical ? props.minWidth : props.minHeight; + const maxSize = isVertical ? props.maxWidth : props.maxHeight; + const initialSize = isVertical ? props.initialWidth : props.initialHeight; + const onSizeChange = isVertical ? props.onWidthChange : props.onHeightChange; + const direction = isVertical ? (props.direction ?? 'right') : 'right'; + const [isDragging, setIsDragging] = useState(false); + const [currentSize, setCurrentSize] = useState(initialSize); + + const dragStartPositionRef = useRef(0); + const dragStartSizeRef = useRef(0); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDragging) { + return; + } + + const currentPosition = isVertical ? e.clientX : e.clientY; + const delta = + direction === 'right' + ? currentPosition - dragStartPositionRef.current + : dragStartPositionRef.current - currentPosition; + const newSize = Math.max(minSize, Math.min(maxSize, dragStartSizeRef.current + delta)); + setCurrentSize(newSize); + onSizeChange(newSize); + }, + [direction, isDragging, isVertical, maxSize, minSize, onSizeChange], + ); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + onDragStateChange?.(false); + document.body.style.setProperty('cursor', ''); + document.body.style.setProperty('user-select', ''); + }, [onDragStateChange]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (disabled) { + return; + } + + setIsDragging(true); + onDragStateChange?.(true); + dragStartPositionRef.current = isVertical ? e.clientX : e.clientY; + dragStartSizeRef.current = currentSize; + document.body.style.setProperty('cursor', isVertical ? 'col-resize' : 'ns-resize'); + document.body.style.setProperty('user-select', 'none'); + }, + [currentSize, disabled, isVertical, onDragStateChange], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (disabled) { + return; + } + + const increaseKey = isVertical ? 'ArrowRight' : 'ArrowDown'; + const decreaseKey = isVertical ? 'ArrowLeft' : 'ArrowUp'; + if (e.key !== increaseKey && e.key !== decreaseKey) { + return; + } + + e.preventDefault(); + const newSize = + e.key === increaseKey + ? Math.min(maxSize, currentSize + 10) + : Math.max(minSize, currentSize - 10); + setCurrentSize(newSize); + onSizeChange(newSize); + }, + [currentSize, disabled, isVertical, maxSize, minSize, onSizeChange], + ); + + useEffect(() => { + if (isDragging) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + } + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging, handleMouseMove, handleMouseUp]); + + return ( +
+ ); +} diff --git a/starter-coreai/app/components/common/ResourceNotFound/ResourceNotFound.module.css b/starter-coreai/app/components/common/ResourceNotFound/ResourceNotFound.module.css new file mode 100644 index 00000000..1c8a4551 --- /dev/null +++ b/starter-coreai/app/components/common/ResourceNotFound/ResourceNotFound.module.css @@ -0,0 +1,51 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +@layer common { + .container { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + + width: 100%; + } + + .content { + display: flex; + flex-direction: column; + gap: var(--spacingS); + align-items: center; + justify-content: center; + + max-width: 400px; + + text-align: center; + } + + .title { + color: var(--colorNeutralForeground1); + text-align: center; + } + + .description { + color: var(--colorNeutralForeground2); + text-align: center; + } + + .actions { + display: flex; + gap: var(--spacingM); + align-items: center; + justify-content: center; + + padding-top: var(--spacingS); + } + + .popoverButton { + margin-bottom: var(--spacingS); + } +} diff --git a/starter-coreai/app/components/common/ResourceNotFound/ResourceNotFound.tsx b/starter-coreai/app/components/common/ResourceNotFound/ResourceNotFound.tsx new file mode 100644 index 00000000..5e80fd07 --- /dev/null +++ b/starter-coreai/app/components/common/ResourceNotFound/ResourceNotFound.tsx @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { IlliconProps } from '../Illicon/Illicon'; +import type { JSX, PropsWithChildren, ReactNode } from 'react'; + +import { Button, Popover, PopoverSurface, PopoverTrigger, Text } from '@coreai-ds/react-components'; +import { Info20Regular } from '@fluentui/react-icons'; +import { clsx } from 'clsx'; +import { Illicon } from '../Illicon/Illicon'; + +import styles from './ResourceNotFound.module.css'; + +export type ResourceNotFoundProps = PropsWithChildren<{ + /** The illustration icon to use. */ + illicon?: IlliconProps['icon']; + title: string; + description: ReactNode; + /** + * The heading level to use for the title. Defaults to "h2". + */ + headingLevel?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + className?: string; + contentClassName?: string; + topContent?: ReactNode; + /** + * Optional info content to display in a Popover below the description + */ + infoContent?: ReactNode; + /** + * Optional accessible label for the info button + */ + infoButtonLabel?: string; +}>; + +export function ResourceNotFound({ + illicon, + title, + description, + children, + headingLevel = 'h2', + className, + contentClassName, + topContent, + infoContent, + infoButtonLabel, +}: ResourceNotFoundProps): JSX.Element { + return ( +
+
+ + {topContent ?? null} + + {title} + +
+ + {description} + + {infoContent == null ? null : ( + + +
+ {children == null ? null :
{children}
} +
+
+ ); +} diff --git a/starter-coreai/app/components/common/TabLink/TabLink.module.css b/starter-coreai/app/components/common/TabLink/TabLink.module.css new file mode 100644 index 00000000..44fd09e6 --- /dev/null +++ b/starter-coreai/app/components/common/TabLink/TabLink.module.css @@ -0,0 +1,11 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +@layer common { + .tab { + max-width: 100%; + text-decoration: none; + } +} diff --git a/starter-coreai/app/components/common/TabLink/TabLink.tsx b/starter-coreai/app/components/common/TabLink/TabLink.tsx new file mode 100644 index 00000000..f35444ef --- /dev/null +++ b/starter-coreai/app/components/common/TabLink/TabLink.tsx @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { ComponentPropsWithoutRef, JSX, MouseEventHandler, ReactNode, Ref } from 'react'; + +import { Tab } from '@coreai-ds/react-components'; +import { clsx } from 'clsx'; +import { forwardRef, useMemo } from 'react'; +import { NavLink as RouterNavLink, useLocation } from 'react-router'; + +import styles from './TabLink.module.css'; + +export interface TabLinkProps { + to: string; + end?: boolean; + preventScrollReset?: boolean; + onClick?: MouseEventHandler; + children?: ReactNode; +} + +/** + * Renders Fluent's real `Tab` with a router `NavLink` slotted in as its root, via the + * same slot-injection/type-cast technique as `Link.tsx`. Fluent only types/documents + * Tab's `as` as `"button"` - this relies on the runtime slot resolver accepting any + * component, unverified against edge cases in Tabster's roving-tabindex/keydown handling + * or a future `disabled` tab (anchors have no native `disabled`; see `LinkButton.tsx` for + * how that's hand-emulated elsewhere). + */ +export function TabLink({ + to, + end, + preventScrollReset, + onClick, + children, +}: TabLinkProps): JSX.Element { + const RouterTabSlot = useMemo(() => { + return forwardRef>( + function RouterTabRoot(slotProps, forwardedRef) { + const location = useLocation(); + const hasQuery = to.includes('?'); + const queryActive = isLinkSelected(to, end, location.pathname, location.search); + const anchorProps = slotProps as unknown as ComponentPropsWithoutRef<'a'>; + + return ( + } + end={end} + to={to} + preventScrollReset={preventScrollReset} + {...anchorProps} + onClick={(event) => { + onClick?.(event); + if (!event.defaultPrevented) { + anchorProps.onClick?.(event); + } + }} + aria-current={hasQuery ? (queryActive ? 'page' : false) : slotProps['aria-current']} + className={({ isActive }) => + clsx(slotProps.className, (hasQuery ? queryActive : isActive) && 'active') + } + /> + ); + }, + ); + }, [to, end, preventScrollReset, onClick]); + + return ( + + {children} + + ); +} + +/** + * Matches NavLink-style pathnames and, when supplied, the destination's query values. + * Repeated query values are compared without order so variant links can be active + * independently on the same pathname. Absolute paths (leading `/`) are compared + * against the full pathname with the same segment-boundary check `NavLink` uses - + * `matchPath`'s `end: false` treats `"/"` as a prefix of *every* path, which would make + * a root link permanently "active". Relative paths (e.g. sibling tab routes like + * `"playground"`) are resolved by `NavLink` against the *current route*, not + * `location.pathname` directly, so a plain `matchPath(to, location.pathname)` never + * matches them - approximate that resolution by comparing `to` against the matching + * number of trailing path segments instead. + */ +export function isLinkSelected( + to: string, + end: boolean | undefined, + pathname: string, + search = '', +): boolean { + const targetPath = to.split(/[?#]/, 1)[0]; + const toSegments = targetPath.split('/').filter(Boolean); + const pathSegments = pathname.split('/').filter(Boolean); + const pathMatches = targetPath.startsWith('/') + ? pathname === targetPath || + (!(end ?? false) && + pathname.startsWith(targetPath) && + pathname.charAt(targetPath.length) === '/') + : pathSegments.slice(-toSegments.length).join('/') === targetPath; + if (!pathMatches) return false; + + if (!to.includes('?')) return true; + const targetQuery = new URLSearchParams(to.slice(to.indexOf('?') + 1).split('#', 1)[0]); + const currentQuery = new URLSearchParams(search); + return [...new Set(targetQuery.keys())].every((key) => { + const expected = targetQuery.getAll(key).sort(); + const actual = currentQuery.getAll(key).sort(); + return ( + expected.length === actual.length && expected.every((value, index) => value === actual[index]) + ); + }); +} diff --git a/starter-coreai/app/components/common/TabLinkList/TabLinkList.module.css b/starter-coreai/app/components/common/TabLinkList/TabLinkList.module.css new file mode 100644 index 00000000..57c156d9 --- /dev/null +++ b/starter-coreai/app/components/common/TabLinkList/TabLinkList.module.css @@ -0,0 +1,14 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +@layer common { + .tabList { + flex-wrap: wrap; + } + + .alignContent { + margin: 0 calc(-1 * var(--spacingS)); + } +} diff --git a/starter-coreai/app/components/common/TabLinkList/TabLinkList.tsx b/starter-coreai/app/components/common/TabLinkList/TabLinkList.tsx new file mode 100644 index 00000000..d1f910e4 --- /dev/null +++ b/starter-coreai/app/components/common/TabLinkList/TabLinkList.tsx @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { TabListProps as CoreAITabListProps } from '@coreai-ds/react-components'; +import type { JSX, MouseEventHandler } from 'react'; + +import { TabList } from '@coreai-ds/react-components'; +import { clsx } from 'clsx'; +import { useLocation } from 'react-router'; + +import { isLinkSelected, TabLink } from '../TabLink/TabLink'; + +import styles from './TabLinkList.module.css'; + +export interface TabLinkListProps extends Omit< + CoreAITabListProps, + 'children' | 'onTabSelect' | 'selectedValue' +> { + 'aria-label': string; + alignContent?: boolean; + preventScrollReset?: boolean; + className?: string; + links: { + label: string; + to: string; + hidden?: boolean; + end?: boolean; + onClick?: MouseEventHandler; + }[]; +} + +export function TabLinkList({ + alignContent = true, + preventScrollReset = false, + className, + links, + ...rest +}: TabLinkListProps): JSX.Element { + const location = useLocation(); + const visibleLinks = links.filter((link) => !link.hidden); + const selectedValue = visibleLinks.find((link) => + isLinkSelected(link.to, link.end, location.pathname, location.search), + )?.to; + + return ( + + {visibleLinks.map((link) => ( + + {link.label} + + ))} + + ); +} diff --git a/starter-coreai/app/components/common/TabRadio/TabRadio.module.css b/starter-coreai/app/components/common/TabRadio/TabRadio.module.css new file mode 100644 index 00000000..d3ebf9f0 --- /dev/null +++ b/starter-coreai/app/components/common/TabRadio/TabRadio.module.css @@ -0,0 +1,30 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +@layer common { + .input { + position: absolute; + + overflow: hidden; + + width: 1px; + height: 1px; + + white-space: nowrap; + + clip-path: inset(50%); + } + + .tab { + max-width: 100%; + + color: var(--colorNeutralForeground1); + + &:focus-within:has(input:focus-visible) { + outline: var(--strokeWidthThick) solid var(--colorStrokeFocus2); + outline-offset: var(--spacingXXS); + } + } +} diff --git a/starter-coreai/app/components/common/TabRadio/TabRadio.tsx b/starter-coreai/app/components/common/TabRadio/TabRadio.tsx new file mode 100644 index 00000000..3ee7feb0 --- /dev/null +++ b/starter-coreai/app/components/common/TabRadio/TabRadio.tsx @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { ComponentPropsWithoutRef, JSX, ReactNode } from 'react'; + +import { Tab } from '@coreai-ds/react-components'; +import { clsx } from 'clsx'; +import { forwardRef, useMemo, useRef } from 'react'; + +import styles from './TabRadio.module.css'; + +export interface TabRadioProps { + value: string; + name: string; + checked: boolean; + onChange: (value: string) => void; + disabled?: boolean; + children?: ReactNode; +} + +type RadioTabSlotProps = ComponentPropsWithoutRef<'button'> & { + as?: unknown; + appearance?: unknown; +}; + +/** + * Renders Fluent's real `Tab` with a native radio `input` slotted in as its root, via + * the same slot-injection/type-cast technique `TabLink` uses for a router `NavLink`. + * The slot itself is a `label` (not the `input` directly) because `Tab`'s `content` + * slot is rendered as a *child* of the root, and `input` is a void element that can't + * have children - the `label` wraps the hidden `input` alongside Tab's own rendered + * label text, exactly like `Radio` does internally. `checked` is passed in rather than + * derived from Tab's own `aria-selected` styling (an internal state Tab doesn't expose) - + * `TabRadioGroup` derives both this and the `TabList`'s `selectedValue` from the same + * source of truth so the native radio state and Tab's own selected styling stay in sync. + * + * This is used as a form control, so the native input remains the focusable semantic + * radio and owns the browser's built-in radio keyboard behavior. `Tab`'s generated + * `role="tab"`/`aria-selected` are cleared from the wrapping label; Tab and TabList are + * retained for their visual styling and selected-state appearance only. + */ +export function TabRadio({ + value, + name, + checked, + onChange, + disabled, + children, +}: TabRadioProps): JSX.Element { + const inputState = useRef({ checked, disabled, name, onChange, value }); + inputState.current = { checked, disabled, name, onChange, value }; + + const RadioTabSlot = useMemo(() => { + return forwardRef( + ( + { + as: _as, + appearance: _appearance, + children: content, + disabled: _disabled, + type: _type, + value: _value, + ...labelProps + }, + forwardedRef, + ) => ( + + ), + ); + }, []); + + return ( + + {children} + + ); +} diff --git a/starter-coreai/app/components/common/TabRadioGroup/TabRadioGroup.module.css b/starter-coreai/app/components/common/TabRadioGroup/TabRadioGroup.module.css new file mode 100644 index 00000000..70edfd79 --- /dev/null +++ b/starter-coreai/app/components/common/TabRadioGroup/TabRadioGroup.module.css @@ -0,0 +1,17 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +@layer common { + .tabList { + flex-wrap: wrap; + } + + .elevated { + /* was .fui-TabList.elevated .fui-Tab[aria-selected="true"] */ + :global(.fui-Tab:has(input:checked)) { + background-color: var(--colorInteractiveBackgroundSelected); + } + } +} diff --git a/starter-coreai/app/components/common/TabRadioGroup/TabRadioGroup.tsx b/starter-coreai/app/components/common/TabRadioGroup/TabRadioGroup.tsx new file mode 100644 index 00000000..38182414 --- /dev/null +++ b/starter-coreai/app/components/common/TabRadioGroup/TabRadioGroup.tsx @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { + TabListAppearance, + TabListProps as CoreAITabListProps, +} from '@coreai-ds/react-components'; +import type { ComponentPropsWithoutRef, JSX, ReactNode } from 'react'; + +import { TabList as FluentTabList } from '@coreai-ds/react-components'; +import { clsx } from 'clsx'; +import { forwardRef, useId } from 'react'; + +import { TabRadio } from '../TabRadio/TabRadio'; +import styles from './TabRadioGroup.module.css'; + +type RadioGroupSlotProps = ComponentPropsWithoutRef<'div'> & { + as?: unknown; + appearance?: unknown; + reserveSelectedTabSpace?: unknown; + size?: unknown; +}; + +const RadioGroupSlot = forwardRef( + ( + { + as: _as, + appearance: _appearance, + reserveSelectedTabSpace: _reserveSelectedTabSpace, + size: _size, + ...divProps + }, + forwardedRef, + ) =>
, +); + +export interface TabRadioGroupProps extends Omit< + CoreAITabListProps, + 'children' | 'onChange' | 'onTabSelect' | 'selectedValue' +> { + 'aria-label': string; + appearance?: TabListAppearance; + className?: string; + options: { value: T; label: ReactNode; disabled?: boolean }[]; + selectedValue: T; + onChange: (value: T) => void; +} + +/** + * A row of tab-styled radio options: renders Fluent's real `TabList`/`Tab`, each `Tab` + * with a native radio `input` slotted in via `TabRadio` (see there for why `Tab` can't + * be reused the way `TabLink` reuses it without a slotted native control). Selection is + * driven by `selectedValue`/`onChange`, which feed both the `TabList`'s own + * `selectedValue` (so Tab's built-in selected/hover/appearance styling applies + * unmodified) and each `TabRadio`'s `checked` state (so the underlying radios stay in + * sync, sharing one generated `name` so they behave as a real mutually exclusive group). + * + * Used as a form control, so `TabList`'s own `role="tablist"` is overridden to + * `role="radiogroup"` (Fluent merges consumer-passed `role` over its own default, so no + * internal `TabList` code needs to change). Each `TabRadio` clears Tab semantics from + * its wrapping label and leaves focus, radio semantics, and keyboard behavior to the + * slotted native input. + */ +export function TabRadioGroup({ + 'aria-label': ariaLabel, + appearance, + className, + options, + selectedValue, + onChange, + ...props +}: TabRadioGroupProps): JSX.Element { + const name = useId(); + + return ( + + {options.map((option) => ( + onChange(value as T)} + value={option.value} + > + {option.label} + + ))} + + ); +} diff --git a/starter-coreai/app/components/common/hooks/ContainerQueryContext.tsx b/starter-coreai/app/components/common/hooks/ContainerQueryContext.tsx new file mode 100644 index 00000000..1d25f71c --- /dev/null +++ b/starter-coreai/app/components/common/hooks/ContainerQueryContext.tsx @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { createContext, useContext } from 'react'; + +/** + * Context providing the container's inline size (width). + * Used by useContainerQuery to evaluate width breakpoints against + * the actual available space rather than the viewport. + */ +export const ContainerWidthContext = createContext( + typeof window !== 'undefined' ? window.innerWidth : 1024, +); + +export function useContainerWidth(): number { + return useContext(ContainerWidthContext); +} diff --git a/starter-coreai/app/components/common/hooks/ContainerQueryProvider.module.css b/starter-coreai/app/components/common/hooks/ContainerQueryProvider.module.css new file mode 100644 index 00000000..e9e124eb --- /dev/null +++ b/starter-coreai/app/components/common/hooks/ContainerQueryProvider.module.css @@ -0,0 +1,21 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +.container { + container-type: inline-size; + container-name: app; +} + +/* Target children so @container app resolves against .container (the nearest ancestor container). + Custom properties inherit, so all descendants receive the value. */ +.container > * { + --spacingPagePadding: var(--spacingXXL); +} + +@container app (width <= 768px) { + .container > * { + --spacingPagePadding: var(--spacingL); + } +} diff --git a/starter-coreai/app/components/common/hooks/ContainerQueryProvider.tsx b/starter-coreai/app/components/common/hooks/ContainerQueryProvider.tsx new file mode 100644 index 00000000..cfe618e8 --- /dev/null +++ b/starter-coreai/app/components/common/hooks/ContainerQueryProvider.tsx @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { useCallback, useEffect, useRef, useState, type JSX, type PropsWithChildren } from 'react'; +import { clsx } from 'clsx'; +import { ContainerWidthContext } from './ContainerQueryContext'; +import styles from './ContainerQueryProvider.module.css'; + +export interface ContainerQueryProviderProps { + /** CSS class to apply to the container div (this IS your layout element) */ + className?: string; +} + +/** + * Renders a div that observes its own inline size via ResizeObserver and + * provides the measured width to descendant useContainerQuery hooks via context. + * + * Use this as your main content wrapper — it replaces the div you'd otherwise + * use for layout. This ensures width-based responsive logic responds to + * available space rather than the viewport (which may include sidebars). + */ +export function ContainerQueryProvider({ + className, + children, +}: PropsWithChildren): JSX.Element { + const ref = useRef(null); + const [width, setWidth] = useState(() => + typeof window !== 'undefined' ? window.innerWidth : 1024, + ); + + const handleResize = useCallback((entries: ResizeObserverEntry[]) => { + for (const entry of entries) { + const inlineSize = entry.contentBoxSize[0]?.inlineSize ?? entry.contentRect.width; + setWidth(inlineSize); + } + }, []); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const observer = new ResizeObserver(handleResize); + observer.observe(el); + + // Set initial width + setWidth(el.clientWidth); + + return () => observer.disconnect(); + }, [handleResize]); + + return ( + +
+ {children} +
+
+ ); +} diff --git a/starter-coreai/app/components/common/hooks/useContainerQuery.ts b/starter-coreai/app/components/common/hooks/useContainerQuery.ts new file mode 100644 index 00000000..cddb74a4 --- /dev/null +++ b/starter-coreai/app/components/common/hooks/useContainerQuery.ts @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { useContainerWidth } from './ContainerQueryContext'; + +/** + * Parse a media-query-style string into a comparison. + * Supports: (max-width: Npx), (min-width: Npx), (width <= N), (width >= N), (width < N), (width > N) + */ +function parseWidthQuery(query: string): ((width: number) => boolean) | null { + // Modern syntax: (width <= 768px), (width < 768px), (width >= 768px), (width > 768px) + const modernMatch = query.match(/\(\s*width\s*([<>]=?)\s*(\d+)(?:px)?\s*\)/); + if (modernMatch) { + const [, op, value] = modernMatch; + const px = Number(value); + switch (op) { + case '<=': + return (w) => w <= px; + case '<': + return (w) => w < px; + case '>=': + return (w) => w >= px; + case '>': + return (w) => w > px; + } + } + + // Legacy syntax: (max-width: 768px), (min-width: 768px) + const legacyMatch = query.match(/\(\s*(max|min)-width:\s*(\d+)px\s*\)/); + if (legacyMatch) { + const [, type, value] = legacyMatch; + const px = Number(value); + return type === 'max' ? (w) => w <= px : (w) => w >= px; + } + + return null; +} + +/** + * Container-aware replacement for useMediaQuery for width-based queries. + * + * Evaluates width breakpoints against the actual container size (provided by + * ContainerWidthContext) rather than the viewport. This ensures responsive + * behavior is correct when sidebars (like Vellum) consume viewport space. + * + * For non-width queries (e.g., prefers-color-scheme, height), use useMediaQuery instead. + * + * @param query - A CSS media query string with a width condition + * @returns Whether the container width matches the query + */ +export function useContainerQuery(query: string): boolean { + const containerWidth = useContainerWidth(); + const evaluate = parseWidthQuery(query); + + if (!evaluate) { + // Fallback: if we can't parse the query, warn in dev + if (import.meta.env.DEV) { + console.warn( + `[useContainerQuery] Could not parse width query: "${query}". ` + + `Use useMediaQuery for non-width queries (height, color-scheme, etc.)`, + ); + } + return false; + } + + return evaluate(containerWidth); +} diff --git a/starter-coreai/app/components/common/hooks/useMediaQuery.ts b/starter-coreai/app/components/common/hooks/useMediaQuery.ts new file mode 100644 index 00000000..f554fdd5 --- /dev/null +++ b/starter-coreai/app/components/common/hooks/useMediaQuery.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { useEffect, useState } from 'react'; + +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState( + () => !import.meta.env.SSR && window.matchMedia(query).matches, + ); + + useEffect(() => { + const mediaQuery = window.matchMedia(query); + const updateMatches = () => { + setMatches(mediaQuery.matches); + }; + + mediaQuery.addEventListener('change', updateMatches); + + updateMatches(); + return () => { + mediaQuery.removeEventListener('change', updateMatches); + }; + }, [query]); + + return matches; +} diff --git a/starter-coreai/app/components/common/index.ts b/starter-coreai/app/components/common/index.ts new file mode 100644 index 00000000..92b21a2a --- /dev/null +++ b/starter-coreai/app/components/common/index.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export { Card } from './Card/Card'; +export type { + CardButtonProps, + CardLinkProps, + CardProps, + CardSelectableProps, + CardStaticProps, +} from './Card/Card'; +export { createColumn } from './DataGridWithFilters/createColumn'; +export { DataGridWithFilters } from './DataGridWithFilters/DataGridWithFilters'; +export type { + BasePaginationOptions, + ControlledPaginationOptions, + DataGridWithFiltersProps, + PaginationOptions, + SortOrder, + TableColumnDefinition, +} from './DataGridWithFilters/DataGridWithFilters'; +export { createMenuColumn } from './DataGridWithFilters/DataGridCell/createMenuColumn'; +export { ShimmerCell } from './DataGridWithFilters/DataGridCell/ShimmerCell'; +export { DetailsList } from './DetailsList/DetailsList'; +export { useMediaQuery } from './hooks/useMediaQuery'; +export { useContainerQuery } from './hooks/useContainerQuery'; +export { ContainerQueryProvider } from './hooks/ContainerQueryProvider'; +export { ContainerWidthContext, useContainerWidth } from './hooks/ContainerQueryContext'; +export { DiscordIcon } from './Icons/DiscordIcon'; +export { CoreAILogo } from './Icons/CoreAILogo'; +export { GitHubIcon } from './Icons/GitHubIcon'; +export { Illicon } from './Illicon/Illicon'; +export type { IlliconProps } from './Illicon/Illicon'; +export { MCPToolIcon } from './Icons/MCPToolIcon'; +export { Image } from './Image/Image'; +export type { ImageProps } from './Image/Image'; +export { ExternalLink, Link } from './Link/Link'; +export type { ExternalLinkProps, LinkProps, RegularLinkProps, NavLinkProps } from './Link/Link'; +export { PageLayout } from './Pages/PageLayout'; +export type { PageLayoutProps } from './Pages/PageLayout'; +export { PageBody } from './Pages/PageBody'; +export type { PageBodyProps } from './Pages/PageBody'; +export { PageHeader } from './Pages/PageHeader'; +export type { PageHeaderProps } from './Pages/PageHeader'; +export { ResponsivePanel } from './Pages/ResponsivePanel'; +export type { ResponsivePanelProps } from './Pages/ResponsivePanel'; +export { SidebarLayout } from './Pages/SidebarLayout'; +export type { SidebarLayoutProps } from './Pages/SidebarLayout'; +export { DetailsLayout } from './Pages/DetailsLayout'; +export type { DetailsLayoutProps, PanelDetails } from './Pages/DetailsLayout'; +export { CollectionPage } from './Pages/CollectionPage'; +export type { CollectionPageProps } from './Pages/CollectionPage'; +export { CollectionDetailsPage } from './Pages/CollectionDetailsPage'; +export type { CollectionDetailsPageProps } from './Pages/CollectionDetailsPage'; +export { GridContent } from './Pages/GridContent'; +export type { GridContentProps } from './Pages/GridContent'; +export { GridDetailsContent } from './Pages/GridDetailsContent'; +export type { GridDetailsContentProps } from './Pages/GridDetailsContent'; +export { TabbedPageLayout } from './Pages/TabbedPageLayout'; +export type { TabbedPageLayoutProps } from './Pages/TabbedPageLayout'; +export { PaginationControls } from './PaginationControls/PaginationControls'; +export type { PaginationControlsProps } from './PaginationControls/PaginationControls'; +export { ResizeHandle } from './ResizeHandle/ResizeHandle'; +export type { ResizeHandleProps } from './ResizeHandle/ResizeHandle'; +export { ResourceNotFound } from './ResourceNotFound/ResourceNotFound'; +export type { ResourceNotFoundProps } from './ResourceNotFound/ResourceNotFound'; +export { TabLinkList } from './TabLinkList/TabLinkList'; +export type { TabLinkListProps } from './TabLinkList/TabLinkList'; +export { TabRadio } from './TabRadio/TabRadio'; +export type { TabRadioProps } from './TabRadio/TabRadio'; +export { TabRadioGroup } from './TabRadioGroup/TabRadioGroup'; +export type { TabRadioGroupProps } from './TabRadioGroup/TabRadioGroup'; diff --git a/starter-coreai/app/components/shell/AppContent.module.css b/starter-coreai/app/components/shell/AppContent.module.css new file mode 100644 index 00000000..338c78b4 --- /dev/null +++ b/starter-coreai/app/components/shell/AppContent.module.css @@ -0,0 +1,19 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +.fullscreenContainer { + flex-direction: column; + gap: var(--spacingVerticalXXL); + align-items: center; + justify-content: center; + height: 100vh; +} + +/* Scroll full screen on short viewports */ +@media (height <= 480px) { + .fullscreenContainer { + height: auto; + } +} diff --git a/starter-coreai/app/components/shell/AppContent.tsx b/starter-coreai/app/components/shell/AppContent.tsx new file mode 100644 index 00000000..8479006b --- /dev/null +++ b/starter-coreai/app/components/shell/AppContent.tsx @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { JSX, PropsWithChildren } from 'react'; +import { ContainerQueryProvider } from '~/components/common'; +import { AppThemeProvider } from '~/theme/AppThemeProvider'; +import { FeatureProvider } from '~/features/FeatureProvider'; + +import styles from './AppContent.module.css'; + +export function AppContent({ children }: PropsWithChildren): JSX.Element { + return ( + + + + {children} + + + + ); +} + +export default AppContent; diff --git a/starter-coreai/app/components/shell/NavLayout.module.css b/starter-coreai/app/components/shell/NavLayout.module.css new file mode 100644 index 00000000..29a9f258 --- /dev/null +++ b/starter-coreai/app/components/shell/NavLayout.module.css @@ -0,0 +1,116 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +.layout { + overflow: hidden; + display: flex; + flex-direction: column; + + width: 100%; + height: 100vh; + + /* stylelint-disable-next-line declaration-property-value-allowed-list */ + background-color: var(--section-background, var(--colorNeutralBackground4)); +} + +.content { + /* This silly variable is used to make sure that + .leftNav and .main box-shadows are not clipped by overflow:hidden; */ + --spacingRoomForBoxShadow: 3px; + + overflow: hidden; + display: flex; + flex: 1; + gap: var(--spacingXXS); + + margin-top: calc(-1 * var(--spacingRoomForBoxShadow)); + padding-top: var(--spacingRoomForBoxShadow); +} + +.main { + overflow: hidden auto; + display: flex; + flex: 1; + flex-direction: column; + + width: 100%; + min-width: 0; /* Allow shrinking below content size in flex to accomodate side panels */ + border-top-left-radius: var(--borderRadius2XLarge); + + background-color: var(--colorNeutralBackground1); + box-shadow: var(--shadow2); + + &.mainInMiddle { + border-top-right-radius: var(--borderRadius2XLarge); + } +} + +/* Discover section specific styles */ +[data-discover-page] .main { + background-color: var(--colorNeutralBackground4); + box-shadow: none; +} + +/* Docs page specific styles */ +[data-docs-page] .main { + box-shadow: none; +} + +/* Home page specific styles */ +.layout[data-home-page] .main { + /* no left nav means no top-left radius */ + border-top-left-radius: unset; + background-color: var(--colorNeutralBackground4); + background-image: linear-gradient( + to bottom, + lch(from var(--colorNeutralBackground3) l c h / 100%) 10%, + lch(from var(--colorNeutralBackground3) l c h / 0%) 30%, + lch(from var(--colorBrandBackground2) l c h / 0%) 70%, + lch(from var(--colorBrandBackground2) l c h / var(--homeBgGradientBrandIntensity)) 80%, + lch(from var(--colorBrandBackground2) l c h / calc(var(--homeBgGradientBrandIntensity) * 2)) + 100% + ); + background-size: 100% auto; + + [data-theme='Light'] & { + --homeBgGradientBrandIntensity: 15%; + } + + [data-theme='Dark'] & { + --homeBgGradientBrandIntensity: 35%; + } +} + +@container app (width > 768px) { + .rightPanel { + border-top-left-radius: var(--borderRadius2XLarge); + box-shadow: var(--shadow2); + } +} + +@container app (width <= 900px) { + .leftNav { + display: none; /* TopNav will show button that opens MobileLeftNav instead */ + } +} + +/* Scroll full screen on short viewports */ +@media (height <= 480px) { + .layout { + height: auto; + } + + .leftNav { + position: sticky; + top: 48px; + height: calc(100vh - 48px); + } + + .main, + .content, + .layout { + overflow: visible; /* Needed for sticky left nav */ + } +} diff --git a/starter-coreai/app/components/shell/NavLayout.tsx b/starter-coreai/app/components/shell/NavLayout.tsx new file mode 100644 index 00000000..18a316ad --- /dev/null +++ b/starter-coreai/app/components/shell/NavLayout.tsx @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import clsx from 'clsx'; +import styles from './NavLayout.module.css'; +import type { JSX, PropsWithChildren } from 'react'; +import { Outlet, useLocation } from 'react-router'; +import { TopNav } from './nav/TopNav'; +import { LeftNav } from './nav/LeftNav'; + +function NavLayoutContent({ children }: PropsWithChildren): JSX.Element { + const location = useLocation(); + // Add data attribute for route-specific styling (case-insensitive) + const isDiscoverPage = /\/discover(?:\/|$)/i.test(location.pathname); + const isHomePage = location.pathname === '/'; + const isDocsPage = /\/docs(?:\/|$)/i.test(location.pathname); + + // const showRightPanel = rightPanelContext.rightPanelId !== null; + + return ( +
+ +
+ +
+ {children} +
+ {/* {showRightPanel ? ( + + ) : null} */} +
+ {/* {showFooter ?
: null} */} +
+ ); +} + +export default function NavLayout(): JSX.Element { + return ( + + + + ); +} + +// export function ErrorBoundary({ error }: IShellErrorProps): JSX.Element { +// return ( +// +// +// +// ); +// } diff --git a/starter-coreai/app/components/shell/feedback/FeedbackButton.tsx b/starter-coreai/app/components/shell/feedback/FeedbackButton.tsx new file mode 100644 index 00000000..da410104 --- /dev/null +++ b/starter-coreai/app/components/shell/feedback/FeedbackButton.tsx @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { bundleIcon, EmojiSmileSlightFilled, EmojiSmileSlightRegular } from '@fluentui/react-icons'; +import React from 'react'; +import { Button, type ButtonProps } from '@coreai-ds/react-components'; + +const FeedbackIcon = bundleIcon(EmojiSmileSlightFilled, EmojiSmileSlightRegular); + +export const FeedbackButton: React.FC = ({ ...props }) => { + return
+
+ + ) : null; +} diff --git a/starter-coreai/app/components/shell/nav/LeftNavLinkList.tsx b/starter-coreai/app/components/shell/nav/LeftNavLinkList.tsx new file mode 100644 index 00000000..dd0d18a6 --- /dev/null +++ b/starter-coreai/app/components/shell/nav/LeftNavLinkList.tsx @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { JSX } from 'react'; +import { useId } from 'react'; +import { Text, Tooltip } from '@coreai-ds/react-components'; +import { clsx } from 'clsx'; +import { Link } from '~/components/common'; +import type { LeftNavRoute } from '~/routes'; +import styles from './LeftNav.module.css'; + +export interface LeftNavLinkListProps { + routes: LeftNavRoute[]; + isCollapsed?: boolean; + onNavigate?: () => void; + className?: string; +} + +export function LeftNavLinkList({ + routes, + isCollapsed = false, + onNavigate, + className, +}: LeftNavLinkListProps): JSX.Element { + const headingId = useId(); + const groups: { title?: string; routes: LeftNavRoute[] }[] = []; + + for (const route of routes) { + if (route.sectionTitle || groups.length === 0) { + groups.push({ title: route.sectionTitle, routes: [] }); + } + groups[groups.length - 1].routes.push(route); + } + + return ( +
+ {groups.map((group, groupIndex) => ( +
+ {group.title && ( + + {group.title} + + )} +
    + {group.routes.map((route) => { + const { regular: RegularIcon, selected: SelectedIcon } = route.icon; + const link = ( + + + + {route.content} + + ); + + return ( +
  • + {isCollapsed ? ( + + {link} + + ) : ( + link + )} +
  • + ); + })} +
+
+ ))} +
+ ); +} diff --git a/starter-coreai/app/components/shell/nav/MobileLeftNav.module.css b/starter-coreai/app/components/shell/nav/MobileLeftNav.module.css new file mode 100644 index 00000000..b527157f --- /dev/null +++ b/starter-coreai/app/components/shell/nav/MobileLeftNav.module.css @@ -0,0 +1,37 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +.drawer { + /* stylelint-disable-next-line custom-property-pattern */ + --fui-Drawer--size: 380px; +} + +.body { + display: flex; + min-height: 0; + flex-direction: column; + gap: var(--spacingXS); + padding: var(--spacingM); +} + +.topNav { + display: flex; + flex-flow: row wrap; +} + +.linkList { + padding: var(--spacingXL) 0 0 0; +} + +.iconsAboveNav { + display: none; + flex-direction: row; +} + +@container app (width <= 620px) { + .iconsAboveNav { + display: flex; + } +} diff --git a/starter-coreai/app/components/shell/nav/MobileLeftNav.tsx b/starter-coreai/app/components/shell/nav/MobileLeftNav.tsx new file mode 100644 index 00000000..6f543a0c --- /dev/null +++ b/starter-coreai/app/components/shell/nav/MobileLeftNav.tsx @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { JSX } from 'react'; + +import { + Button, + Drawer, + DrawerBody, + useRestoreFocusSource, + useRestoreFocusTarget, +} from '@coreai-ds/react-components'; +import { TabLinkList } from '~/components/common'; +import { NavigationRegular, PanelLeftContractRegular } from '@fluentui/react-icons'; +import { useMemo } from 'react'; +import { useLocation } from 'react-router'; + +import { getLeftNavRoutes } from '~/routes'; +import { LeftNavLinkList } from './LeftNavLinkList'; + +import leftNavStyles from './LeftNav.module.css'; +import styles from './MobileLeftNav.module.css'; + +export interface MobileLeftNavProps { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + topNavRoutes: { + path: string; + content: string; + }[]; + buttonClassname?: string; + buttonsAboveNav?: JSX.Element; +} + +export function MobileLeftNav({ + isOpen, + setIsOpen, + topNavRoutes, + buttonClassname, + buttonsAboveNav, +}: MobileLeftNavProps): JSX.Element { + const location = useLocation(); + + const leftNavRoutes = useMemo(() => getLeftNavRoutes(location.pathname), [location.pathname]); + + const restoreFocusTargetAttributes = useRestoreFocusTarget(); + const restoreFocusSourceAttributes = useRestoreFocusSource(); + + return ( + <> +
+ + + + ); +} diff --git a/starter-coreai/app/components/shell/nav/ProjectSwitcher.module.css b/starter-coreai/app/components/shell/nav/ProjectSwitcher.module.css new file mode 100644 index 00000000..76413d2f --- /dev/null +++ b/starter-coreai/app/components/shell/nav/ProjectSwitcher.module.css @@ -0,0 +1,39 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +.menuButton { + min-width: 0; + padding: 0; + background-color: transparent; +} + +.menuButton:hover { + background-color: transparent; +} + +.menuButton:active { + background-color: transparent; +} + +.menuButton:disabled { + color: var(--colorNeutralForeground2); +} + +.menuButtonText { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.menuPopover { + border: var(--strokeWidthThin) solid var(--colorNeutralStrokeDisabled); +} + +.workspaceName { + overflow: hidden; + max-width: 142px; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/starter-coreai/app/components/shell/nav/ProjectSwitcher.tsx b/starter-coreai/app/components/shell/nav/ProjectSwitcher.tsx new file mode 100644 index 00000000..57878607 --- /dev/null +++ b/starter-coreai/app/components/shell/nav/ProjectSwitcher.tsx @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { JSX } from 'react'; + +import { + Menu, + MenuButton, + MenuDivider, + MenuGroupHeader, + MenuItem, + MenuItemRadio, + MenuList, + MenuPopover, + MenuTrigger, +} from '@coreai-ds/react-components'; +import { ChevronUpDownRegular, Open12Regular } from '@fluentui/react-icons'; +import styles from './ProjectSwitcher.module.css'; + +export function ProjectSwitcher(): JSX.Element { + const currentWorkspaceName = 'My Project'; + + return ( + <> + + + } + > + {currentWorkspaceName} + + + + + + Recent Projects + + Project Alpha + + + Project Beta + + + View All Projects + Create New Project + + }> + View All Resources + + + + + + ); +} diff --git a/starter-coreai/app/components/shell/nav/TopNav.module.css b/starter-coreai/app/components/shell/nav/TopNav.module.css new file mode 100644 index 00000000..2d2127ce --- /dev/null +++ b/starter-coreai/app/components/shell/nav/TopNav.module.css @@ -0,0 +1,266 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +.container { + display: grid; + + /* minmax(0, 1fr) ensures the two columns are always the same size */ + grid-template-columns: + minmax(auto, 1fr) + auto + minmax(auto, 1fr); + flex-shrink: 0; + gap: var(--spacingS); + + height: 48px; + padding: 0 var(--spacingXL); +} + +.brand { + display: flex; + align-items: center; +} + +.logoWrapper { + display: flex; + align-items: center; + justify-content: center; + + margin-right: var(--spacingXS); + margin-left: calc(-1 * var(--spacingXS)); + padding: var(--spacingXS); + + color: var(--colorNeutralForeground1); +} + +.appName { + margin-right: var(--spacingS); + font-weight: 400; + white-space: nowrap; +} + +.actions { + display: flex; + gap: var(--spacingXS); + align-items: center; + margin-left: auto; +} + +.brandLink { + display: flex; + align-items: center; + color: var(--colorNeutralForeground1); +} + +.brandLink:hover { + color: var(--colorNeutralForeground1); + text-decoration: none; +} + +.brandLink:focus-visible { + color: var(--colorNeutralForeground1); + text-decoration: none; + outline: var(--strokeWidthThick) solid var(--colorStrokeFocus1); + outline-offset: var(--spacingXXS); +} + +.rightSection { + display: flex; + flex-shrink: 0; + gap: var(--spacingXXL); + align-items: center; + justify-self: end; +} + +.buttonsContainer { + display: flex; + flex-shrink: 0; + align-items: center; + + /* visually align to icons */ + margin-left: calc(-1 * var(--spacingS)); +} + +.leftSection { + display: flex; + gap: var(--spacingS); + align-items: center; +} + +.centerSection { + position: relative; + display: flex; + align-items: center; +} + +.largeSearchButton { + width: 200px; + .searchButtonText { + display: flex; + justify-content: space-between; + width: 100%; + gap: var(--spacingS); + align-items: baseline; + } +} + +.smallSearchButton { + display: none; +} + +.linkContainer { + display: flex; + gap: var(--spacingS); + align-items: center; + + margin: calc(-1 * var(--spacingXS)); + + text-overflow: ellipsis; + white-space: nowrap; +} + +.breadCrumbDivider { + flex-shrink: 0; + color: var(--colorNeutralForegroundDisabled); +} + +.navLink { + position: relative; + + padding: var(--spacingS) var(--spacingXS); + + font-size: 0.9rem; + color: var(--colorNeutralForeground3); + text-decoration: none; + + transition: color 0.2s ease; +} + +.navLink:hover { + color: var(--colorNeutralForeground1); +} + +.navLink:global(.active) { + font-weight: 600; + color: var(--colorNeutralForeground2LinkSelected); +} + +.navLink:global(.active):hover { + color: var(--colorNeutralForeground1); +} + +.nextGenToggle, +.notificationButton, +.feedbackButton { + display: flex; +} + +.userMarkButton { + display: flex; +} + +.menuButton { + display: none; +} + +.agentHelperButtonWithGlobalSearch { + display: none; +} + +@container app (width <= 1470px) { + .container { + display: flex; + justify-content: space-between; + } +} + +@container app (width <= 1280px) { + .centerSection { + position: absolute; + z-index: 1; + top: 0; + right: 0; + left: 0; + + display: flex; + align-items: center; + + margin-inline: auto; + + opacity: 1; + + transition: + opacity 0.4s ease, + display 0.4s ease allow-discrete; + } + + .centerSection:not(:focus-within) { + display: none; + opacity: 0; + } + + .agentHelperButtonWithGlobalSearch { + display: flex; + } + + .searchAgentHelperButton { + display: none; + } + + .smallSearchButton { + display: flex; + } +} + +@container app (width <= 970px) { + .appName { + display: none; + } +} + +@container app (width <= 900px) { + .linkContainer { + display: none; + } + + .feedbackButton { + margin-right: 0; + margin-left: 0; + } + + .logoWrapper { + padding-right: 0; + } + + .leftSection { + gap: var(--spacingXS); + } + + .menuButton { + display: flex; + } +} + +@container app (width <= 620px) { + .notificationButton, + .feedbackButton, + .agentHelperButtonWithGlobalSearch, + .smallSearchButton { + display: none; + } +} + +@container app (width <= 520px) { + .nextGenToggle { + display: none; + } +} + +@container app (width <= 481px) { + .container { + column-gap: var(--spacingXS); + padding: 0 0 0 var(--spacingXS); + } +} diff --git a/starter-coreai/app/components/shell/nav/TopNav.tsx b/starter-coreai/app/components/shell/nav/TopNav.tsx new file mode 100644 index 00000000..a3f20852 --- /dev/null +++ b/starter-coreai/app/components/shell/nav/TopNav.tsx @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { JSX } from 'react'; +import { useEffect, useState } from 'react'; +import { Text } from '@coreai-ds/react-components'; +import { CoreAILogo, Link } from '~/components/common'; +import styles from './TopNav.module.css'; +import { ProfileButton } from './profile/ProfileButton'; +import { ProjectSwitcher } from './ProjectSwitcher'; +import { SlashForward12Regular } from '@fluentui/react-icons'; +import { FeedbackButton } from '../feedback/FeedbackButton'; +import { NotificationButton } from '../notifications/NotificationButton'; +import { SearchButton } from '../search/SearchButton'; +import { SearchDialog } from '../search/SearchDialog'; +import { AgentHelperToggleButton } from './AgentHelperToggleButton'; +import { topNavRoutes } from '~/routes'; +import { MobileLeftNav } from './MobileLeftNav'; +import { FeatureMenu } from '~/features/FeatureMenu'; + +export function TopNav(): JSX.Element { + const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false); + const [isMobileLeftNavOpen, setIsMobileLeftNavOpen] = useState(false); + + useEffect(() => { + const handleSearchShortcut = (event: KeyboardEvent) => { + if ( + event.defaultPrevented || + event.repeat || + event.altKey || + event.shiftKey || + !(event.metaKey || event.ctrlKey) || + event.key.toLowerCase() !== 'k' + ) { + return; + } + event.preventDefault(); + setIsSearchDialogOpen(true); + }; + window.addEventListener('keydown', handleSearchShortcut); + return () => window.removeEventListener('keydown', handleSearchShortcut); + }, []); + + return ( +
+
+
+ +
+ +
+ + CoreAI Sandbox + + + +
+ + +
+ +
+ setIsSearchDialogOpen(true)} + appearance="secondary" + > + + + Search with AI + + + ⌘ K + + + + {' '} +
+ +
+
+ {topNavRoutes.map((route) => ( + + {route.content} + + ))} +
+ +
+ setIsSearchDialogOpen(true)} + /> + + + + + + + + + + + setIsSearchDialogOpen(true)} + />{' '} + + + + + } + isOpen={isMobileLeftNavOpen} + setIsOpen={setIsMobileLeftNavOpen} + topNavRoutes={topNavRoutes} + /> +
+
+ + setIsSearchDialogOpen(false)} /> +
+ ); +} diff --git a/starter-coreai/app/components/shell/nav/profile/AzureOpenAISettingsDialog.module.css b/starter-coreai/app/components/shell/nav/profile/AzureOpenAISettingsDialog.module.css new file mode 100644 index 00000000..a713762d --- /dev/null +++ b/starter-coreai/app/components/shell/nav/profile/AzureOpenAISettingsDialog.module.css @@ -0,0 +1,106 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +.dialogSurface { + width: 36%; + max-width: 570px; + padding: var(--spacingL) var(--spacingXXL); + border: var(--strokeWidthThin) solid var(--colorNeutralStroke3); + background-color: var(--colorNeutralBackground1); +} + +.dialogTitle { + min-height: var(--spacingXXXL); + overflow-wrap: break-word; +} + +.dialogContent { + margin: 0 calc(-1 * var(--spacingXXL)); + padding: 0 var(--spacingXXL) var(--spacingL); +} + +.description { + margin-bottom: var(--spacingL); +} + +.inheritForeground { + color: inherit; +} + +.divider { + grid-area: 2 / 1 / 2 / 4; + align-self: end; + width: calc(100% + var(--spacingXXL) * 2); + margin-right: calc(-1 * var(--spacingXXL)); + margin-left: calc(-1 * var(--spacingXXL)); + border-bottom: var(--strokeWidthThin) solid var(--colorNeutralBackground4); +} + +.dialogActions { + flex-wrap: wrap; + justify-content: end; + margin-top: var(--spacingS); +} + +@media (width < 1312px) { + .dialogSurface { + width: 42%; + } +} + +@media (width < 1056px) { + .dialogSurface { + width: 60%; + } +} + +@media (width <= 768px), (height <= 480px) { + .dialogSurface { + width: 100%; + max-width: 100%; + height: 100%; + margin: 0; + } +} + +.fieldStack { + display: flex; + flex-direction: column; + gap: var(--spacingVerticalM); +} + +.statusRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacingHorizontalM); +} + +.statusMessage { + display: flex; + align-items: center; + gap: var(--spacingHorizontalS); +} + +.configuredIcon { + color: var(--colorPaletteGreenForeground1); +} + +.notConfiguredIcon { + color: var(--colorPaletteRedForeground1); +} + +.errorMessage { + padding: var(--spacingVerticalS) var(--spacingHorizontalM); + background-color: var(--colorPaletteRedBackground1); + border-radius: var(--borderRadiusMedium); +} + +.testButton { + display: flex; + align-items: center; + gap: var(--spacingHorizontalXS); + flex-shrink: 0; +} diff --git a/starter-coreai/app/components/shell/nav/profile/AzureOpenAISettingsDialog.tsx b/starter-coreai/app/components/shell/nav/profile/AzureOpenAISettingsDialog.tsx new file mode 100644 index 00000000..0bac65ab --- /dev/null +++ b/starter-coreai/app/components/shell/nav/profile/AzureOpenAISettingsDialog.tsx @@ -0,0 +1,373 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { JSX } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; +import { + Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + DialogTrigger, + Field, + InfoLabel, + InfoLabelProps, + Input, + MessageBar, + MessageBarBody, + Spinner, + Text, +} from '@coreai-ds/react-components'; +import { + Checkmark16Regular, + Dismiss16Regular, + Dismiss24Regular, + ChatSettingsFilled, + ChatSettingsRegular, + bundleIcon, +} from '@fluentui/react-icons'; +import { useAzureOpenAISettings, DEFAULT_API_VERSION } from '~/hooks/useAzureOpenAISettings'; + +const ChatSettingsIcon = bundleIcon(ChatSettingsFilled, ChatSettingsRegular); +import { createAzureOpenAIClient } from '~/core/utils/azureOpenAI'; + +import styles from './AzureOpenAISettingsDialog.module.css'; + +type TestStatus = 'idle' | 'testing' | 'success' | 'error'; + +interface AzureOpenAISettingsDialogProps { + className?: string; +} + +export function AzureOpenAISettingsDialog({ + className, +}: AzureOpenAISettingsDialogProps): JSX.Element { + const { settings, setSettings, isConfigured } = useAzureOpenAISettings(); + + // Local state for form inputs + const [endpoint, setEndpoint] = useState(settings.endpoint); + const [deployment, setDeployment] = useState(settings.deployment); + const [apiKey, setApiKey] = useState(settings.apiKey); + + // Test connection state + const [testStatus, setTestStatus] = useState('idle'); + const [testError, setTestError] = useState(null); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const isDialogOpenRef = useRef(false); + const testGeneration = useRef(0); + const testTimeoutRef = useRef>(); + const dialogTriggerRef = useRef(null); + + const cancelTest = useCallback(() => { + testGeneration.current += 1; + clearTimeout(testTimeoutRef.current); + testTimeoutRef.current = undefined; + }, []); + + const resetTest = useCallback(() => { + cancelTest(); + setTestStatus('idle'); + setTestError(null); + }, [cancelTest]); + + const closeDialog = useCallback(() => { + isDialogOpenRef.current = false; + resetTest(); + setEndpoint(settings.endpoint); + setDeployment(settings.deployment); + setApiKey(settings.apiKey); + setIsDialogOpen(false); + dialogTriggerRef.current?.focus(); + }, [resetTest, settings]); + + // Sync local state when settings change (e.g., on mount) + useEffect(() => { + resetTest(); + setEndpoint(settings.endpoint); + setDeployment(settings.deployment); + setApiKey(settings.apiKey); + }, [settings, resetTest]); + + const canTest = endpoint.trim() !== '' && deployment.trim() !== '' && apiKey.trim() !== ''; + + const runTest = useCallback(async () => { + if (!isDialogOpenRef.current || !canTest) return; + + cancelTest(); + const generation = testGeneration.current; + const isCurrentTest = () => isDialogOpenRef.current && generation === testGeneration.current; + + setTestStatus('testing'); + setTestError(null); + + try { + const { client, deployment: model } = createAzureOpenAIClient({ + endpoint: endpoint.trim(), + deployment: deployment.trim(), + apiKey: apiKey.trim(), + apiVersion: DEFAULT_API_VERSION, + }); + + const completion = await client.chat.completions.create({ + messages: [ + { + role: 'user', + content: 'Say "Hello, connection successful!" in exactly those words.', + }, + ], + model, + }); + + if (!isCurrentTest()) return; + + const response = completion.choices[0]?.message?.content; + if (response) { + setTestStatus('success'); + } else { + setTestStatus('error'); + setTestError('Received empty response from the API'); + } + } catch (err) { + if (!isCurrentTest()) return; + + setTestStatus('error'); + setTestError(err instanceof Error ? err.message : 'Unknown error occurred'); + } + }, [canTest, endpoint, deployment, apiKey, cancelTest]); + + // Auto-test when inputs change (debounced) + useEffect(() => { + if (!isDialogOpen || !canTest) return cancelTest; + + // Debounce the auto-test - only run after user stops typing + testTimeoutRef.current = setTimeout(() => { + runTest(); + }, 800); + + return cancelTest; + }, [isDialogOpen, canTest, runTest, cancelTest]); + + const handleTest = useCallback(() => { + runTest(); + }, [runTest]); + + const handleSave = useCallback(() => { + setSettings({ + endpoint: endpoint.trim(), + deployment: deployment.trim(), + apiKey: apiKey.trim(), + apiVersion: DEFAULT_API_VERSION, + }); + }, [endpoint, deployment, apiKey, setSettings]); + + const handleClear = useCallback(() => { + resetTest(); + setEndpoint(''); + setDeployment(''); + setApiKey(''); + setSettings({ + endpoint: '', + deployment: '', + apiKey: '', + apiVersion: DEFAULT_API_VERSION, + }); + }, [setSettings, resetTest]); + + return ( + { + if (data.open) { + resetTest(); + setEndpoint(settings.endpoint); + setDeployment(settings.deployment); + setApiKey(settings.apiKey); + isDialogOpenRef.current = true; + setIsDialogOpen(true); + } else { + closeDialog(); + } + }} + open={isDialogOpen} + > + + + + + + } + onClick={closeDialog} + /> + } + className={styles.dialogTitle} + > + Azure OpenAI Settings + + + + Configure your Azure OpenAI connection to enable the chat simulation feature. These + settings are stored locally in your browser. + +
+
+
+ {testStatus === 'testing' ? ( + <> + + + Testing connection... + + + ) : testStatus === 'success' ? ( + <> + + + Connection test successful + + + ) : testStatus === 'error' ? ( + <> + + + Connection test failed + + + ) : isConfigured ? ( + <> + + + Settings saved (test to verify) + + + ) : ( + <> + + + Azure OpenAI is not configured + + + )} +
+ +
+ + {testStatus === 'error' && testError && ( + + + + {testError} + + + + )} + + ( + + Azure OpenAI endpoint + + ), + }} + > + { + resetTest(); + setEndpoint(data.value); + }} + /> + + + ( + + Deployment + + ), + }} + > + { + resetTest(); + setDeployment(data.value); + }} + /> + + + ( + + API Key + + ), + }} + > + { + resetTest(); + setApiKey(data.value); + }} + /> + +
+
+
+ + + + + + + +
+ ); +} diff --git a/starter-coreai/app/components/shell/nav/profile/ProfileButton.module.css b/starter-coreai/app/components/shell/nav/profile/ProfileButton.module.css new file mode 100644 index 00000000..cdcc0952 --- /dev/null +++ b/starter-coreai/app/components/shell/nav/profile/ProfileButton.module.css @@ -0,0 +1,52 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +.content { + display: flex; + flex-direction: column; + gap: var(--spacingM); + width: 274px; +} + +.persona { + overflow: hidden; +} + +.dropdownContainer { + display: flex; + flex-direction: column; +} + +.settingsButton { + justify-content: flex-start; +} + +.tenantId { + color: var(--colorNeutralForeground3); +} + +.signOut { + display: flex; + gap: var(--spacingXS); + align-items: center; + padding: var(--spacingSNudge) var(--spacingXS); +} + +.projectDetails { + display: flex; + gap: var(--spacingXS); + align-items: center; + padding: var(--spacingSNudge) 0; +} + +.tenant { + display: flex; + flex-direction: row; + align-items: center; +} + +.tenantName { + flex-grow: 1; +} diff --git a/starter-coreai/app/components/shell/nav/profile/ProfileButton.tsx b/starter-coreai/app/components/shell/nav/profile/ProfileButton.tsx new file mode 100644 index 00000000..9c5edf46 --- /dev/null +++ b/starter-coreai/app/components/shell/nav/profile/ProfileButton.tsx @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { JSX } from 'react'; +import { + Avatar, + Button, + Divider, + Persona, + Popover, + PopoverSurface, + PopoverTrigger, + Text, +} from '@coreai-ds/react-components'; +import { Link } from '~/components/common'; + +import styles from './ProfileButton.module.css'; +import { ArrowExit20Regular } from '@fluentui/react-icons'; +import { ThemePicker } from './ThemePicker'; +import { AzureOpenAISettingsDialog } from './AzureOpenAISettingsDialog'; +import { useUserInfo } from '~/hooks/useUserInfo'; + +export function ProfileButton(): JSX.Element { + const { data: userInfo, isLoading } = useUserInfo(); + return ( + + +